4 Commits

20 changed files with 476 additions and 63 deletions
+15
View File
@@ -0,0 +1,15 @@
Mood-Board
Copyright (c) 2026 HNZIO
Licensed under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software only for permitted
noncommercial purposes under the terms of that license.
Commercial use is not allowed without a separate written agreement from the
copyright holder.
Required Notice: Copyright (c) 2026 HNZIO
Full license text:
https://polyformproject.org/licenses/noncommercial/1.0.0/
+7
View File
@@ -32,3 +32,10 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
- Nicht-kommerzielle Nutzung ist erlaubt.
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+75
View File
@@ -128,6 +128,34 @@ body {
color: var(--text);
}
.pull-refresh-indicator {
position: fixed;
top: max(0.85rem, env(safe-area-inset-top));
left: 50%;
z-index: 30;
padding: 0.72rem 1rem;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.9rem) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease;
color: var(--muted);
font-size: 0.92rem;
letter-spacing: 0.01em;
}
body.is-pull-refreshing .pull-refresh-indicator,
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
@@ -216,6 +244,26 @@ button:disabled {
gap: 1rem;
}
.site-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.9rem;
margin-top: auto;
padding: 0.9rem 1.1rem;
border-radius: var(--radius-lg);
}
.site-footer__link {
color: var(--muted);
font-size: 0.92rem;
transition: color 180ms ease, opacity 180ms ease;
}
.site-footer__link:hover {
color: var(--text);
}
.topbar {
display: flex;
justify-content: space-between;
@@ -1273,6 +1321,17 @@ input[type="range"] {
gap: 0.7rem;
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
line-height: 1.45;
}
.checkbox-row--panel {
padding: 0.95rem 1rem;
border-radius: 18px;
@@ -1281,6 +1340,12 @@ input[type="range"] {
min-height: 100%;
}
.checkbox-row--tall {
align-items: flex-start;
padding-top: 1.05rem;
padding-bottom: 1.05rem;
}
.checkbox-row input {
width: auto;
}
@@ -1455,6 +1520,10 @@ input[type="range"] {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
}
.site-footer {
margin-bottom: 0.5rem;
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1535,4 +1604,10 @@ input[type="range"] {
.mobile-nav a span {
font-size: 0.72rem;
}
.site-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
}
+133 -3
View File
@@ -264,6 +264,7 @@
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const painEnabled = Boolean(settings.tracking?.pain_enabled);
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -275,9 +276,14 @@
walk_minutes: walkMode === "steps"
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0,
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
if (painEnabled) {
components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 0);
}
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings);
@@ -316,11 +322,13 @@
mood: "Stimmung",
energy: "Energie",
stress: "Stress",
pain: "Schmerzen",
sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport",
sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang",
alcohol: "Alkohol",
note: "Notiz",
};
@@ -328,6 +336,7 @@
mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value),
pain: Number(form.elements.pain?.value || 1),
sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0),
@@ -335,6 +344,7 @@
walk_mode: form.elements.walk_mode?.value || "time",
walk_minutes: Number(form.elements.walk_minutes?.value || 0),
walk_steps: Number(form.elements.walk_steps?.value || 0),
alcohol: Boolean(form.elements.alcohol?.checked),
note: form.elements.note.value || "",
});
@@ -384,7 +394,7 @@
}
const seriesName = container.dataset.series || "";
const invertScale = seriesName === "stress";
const invertScale = seriesName === "stress" || seriesName === "pain";
const values = items.map(item => Number(item.value));
const width = 760;
const height = 196;
@@ -392,7 +402,7 @@
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5);
} else {
@@ -402,7 +412,7 @@
if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5);
} else {
@@ -992,6 +1002,125 @@
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isAppleTouchDevice() {
return /iPhone|iPad|iPod/i.test(window.navigator.userAgent)
|| (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1);
}
function initPullToRefresh() {
if (!isStandaloneMode() || !isAppleTouchDevice()) {
return;
}
const indicator = document.querySelector("[data-pull-refresh-indicator]");
const body = document.body;
const threshold = 96;
let isTracking = false;
let isReady = false;
let startY = 0;
const setIndicator = message => {
if (indicator) {
indicator.textContent = message;
}
};
const resetState = () => {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isTracking = false;
isReady = false;
startY = 0;
setIndicator("Zum Aktualisieren ziehen");
};
const scrollTop = () => Math.max(
window.scrollY || 0,
document.documentElement.scrollTop || 0,
document.body.scrollTop || 0
);
const canStart = target => {
if (scrollTop() > 0) {
return false;
}
if (!(target instanceof Element)) {
return true;
}
return !target.closest("input, textarea, select, button");
};
window.addEventListener("touchstart", event => {
if (event.touches.length !== 1 || !canStart(event.target)) {
resetState();
return;
}
isTracking = true;
startY = event.touches[0].clientY;
}, { passive: true });
window.addEventListener("touchmove", event => {
if (!isTracking || event.touches.length !== 1) {
return;
}
if (scrollTop() > 0) {
resetState();
return;
}
const delta = event.touches[0].clientY - startY;
if (delta <= 0) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isReady = false;
setIndicator("Zum Aktualisieren ziehen");
return;
}
if (delta > 18) {
body.classList.add("is-pull-refreshing");
event.preventDefault();
}
if (delta >= threshold) {
if (!isReady) {
body.classList.add("is-pull-refresh-ready");
setIndicator("Loslassen zum Aktualisieren");
isReady = true;
}
return;
}
if (isReady) {
body.classList.remove("is-pull-refresh-ready");
isReady = false;
}
setIndicator("Zum Aktualisieren ziehen");
}, { passive: false });
window.addEventListener("touchend", () => {
if (!isTracking) {
return;
}
if (isReady) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
body.classList.add("is-pull-refresh-reloading");
setIndicator("Wird aktualisiert ...");
window.location.reload();
return;
}
resetState();
}, { passive: true });
window.addEventListener("touchcancel", resetState, { passive: true });
}
function initPushControls() {
const panel = document.querySelector("[data-push-panel]");
if (!panel) {
@@ -1147,5 +1276,6 @@
initDashboardCharts();
initSportTypeManager();
initPwaShell();
initPullToRefresh();
initPushControls();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6 -6
View File
@@ -12,15 +12,15 @@
"theme_color": "#0b1e2e",
"icons": [
{
"src": "/assets/branding/logo-mark.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/assets/branding/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/branding/apple-touch-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/assets/branding/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
+117 -51
View File
@@ -31,6 +31,7 @@ final class App
$path = request_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
@@ -233,6 +234,7 @@ final class App
'pageTitle' => 'Dashboard',
'page' => 'dashboard',
'authUser' => $user,
'settings' => $settings,
'summary' => $summary,
'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData),
@@ -252,6 +254,8 @@ final class App
'mood' => 6,
'energy' => 6,
'stress' => 4,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 3,
'sport_minutes' => 0,
@@ -260,9 +264,11 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'alcohol' => false,
'note' => '',
];
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
$entry = $this->scoring->normalize($entry);
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
@@ -297,6 +303,8 @@ final class App
'mood' => $_POST['mood'] ?? 5,
'energy' => $_POST['energy'] ?? 5,
'stress' => $_POST['stress'] ?? 5,
'pain' => $_POST['pain'] ?? 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
@@ -304,6 +312,7 @@ final class App
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'alcohol' => $_POST['alcohol'] ?? false,
'note' => $_POST['note'] ?? '',
]);
@@ -344,6 +353,7 @@ final class App
'authUser' => $user,
'entries' => $archive,
'selectedEntry' => $selectedEntry,
'settings' => $settings,
]);
}
@@ -390,6 +400,8 @@ final class App
'mood' => 10,
'energy' => 10,
'stress' => 1,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 5,
'sport_minutes' => 999,
@@ -400,6 +412,7 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999,
'walk_steps' => 10000,
'alcohol' => false,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -532,6 +545,12 @@ final class App
'value' => $entry['stress'],
];
}, $recent),
'pain' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['pain'],
];
}, $recent),
'sport' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
@@ -598,8 +617,12 @@ final class App
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
];
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
@@ -663,6 +686,10 @@ final class App
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['tracking'] = array_replace(
Defaults::settings()['tracking'],
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
@@ -855,60 +882,15 @@ final class App
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
}
$now = new DateTimeImmutable('now');
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => date(DATE_ATOM),
]);
}
}
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
json_response([
'ok' => true,
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
'processed' => $stats['processed'],
'sent_users' => $stats['sent_users'],
'already_tracked' => $stats['already_tracked'],
'skipped' => $stats['skipped'],
'removed_subscriptions' => $stats['removed_subscriptions'],
]);
}
@@ -968,4 +950,88 @@ final class App
return (string) ($state['last_sent_date'] ?? '') !== $today;
}
private function triggerReminderCheckFromTraffic(string $method, string $path): void
{
if ($method !== 'GET' || $path === '/reminders/run') {
return;
}
$lockPath = storage_path('system/reminder-traffic.lock');
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return;
}
try {
$this->runDueReminders(new DateTimeImmutable('now'));
} catch (Throwable) {
// Reminder checks should never break normal page delivery.
}
flock($handle, LOCK_UN);
fclose($handle);
}
private function runDueReminders(DateTimeImmutable $now): array
{
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => $now->format(DATE_ATOM),
]);
}
}
return [
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
];
}
}
+9
View File
@@ -80,12 +80,16 @@ final class EntryRepository
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'pain' => $painRaw !== null ? (int) $painRaw : 1,
'pain_enabled' => $painRaw !== null,
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
@@ -94,6 +98,7 @@ final class EntryRepository
'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content),
];
@@ -140,12 +145,14 @@ final class EntryRepository
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
@@ -155,11 +162,13 @@ final class EntryRepository
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
+27
View File
@@ -13,6 +13,8 @@ final class ScoringService
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
@@ -21,6 +23,7 @@ final class ScoringService
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
'note' => trim((string) ($input['note'] ?? '')),
];
}
@@ -33,6 +36,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
@@ -43,14 +47,20 @@ final class ScoringService
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings),
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
if ($painEnabled) {
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
}
$total = round(array_sum($components), 1);
$maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
@@ -353,4 +363,21 @@ final class ScoringService
{
return $mode === 'steps' ? 'steps' : 'time';
}
private function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int) $value === 1;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
}
}
+5
View File
@@ -19,6 +19,9 @@ final class Defaults
'walk' => [
'mode' => 'time',
],
'tracking' => [
'pain_enabled' => false,
],
'sport_types' => [
[
'id' => 'running',
@@ -115,6 +118,7 @@ final class Defaults
'mood_multiplier' => 3,
'energy_multiplier' => 2,
'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [
'lt4' => 0,
@@ -149,6 +153,7 @@ final class Defaults
['steps' => 20000, 'points' => 0],
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
+11 -3
View File
@@ -27,9 +27,11 @@ $brandSubtitle = match ($page) {
<meta name="mood-push-public-key" content="<?= e((string) $pushPublicKey) ?>">
<?php endif; ?>
<title><?= e($pageTitle) ?> · Mood</title>
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
<link rel="apple-touch-icon" href="/assets/branding/apple-touch-icon.svg">
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/branding/favicon-32.png?v=20260412">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/branding/favicon-16.png?v=20260412">
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
@@ -37,6 +39,7 @@ $brandSubtitle = match ($page) {
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell">
<?php if ($authUser !== null): ?>
<aside class="sidebar glass-panel">
@@ -109,6 +112,11 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?>
<?= $content ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.0</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
</main>
<?php if ($authUser !== null): ?>
+4
View File
@@ -54,6 +54,9 @@
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
@@ -76,6 +79,7 @@
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
<div class="note-box">
+13
View File
@@ -73,6 +73,19 @@
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Körper</p>
<h3>Schmerzverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php endif; ?>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
+24
View File
@@ -23,6 +23,30 @@
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
</div>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span>
<strong>Schmerzen aktivieren</strong>
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
</span>
</label>
<label>
<span>Schmerzfaktor</span>
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
</label>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
+30
View File
@@ -37,6 +37,34 @@
</label>
</div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div class="field-grid field-grid--two">
<label class="range-card">
<span>Schmerzen</span>
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php endif; ?>
<div class="field-grid field-grid--two">
<label>
<span>Schlafdauer in Stunden</span>
@@ -127,11 +155,13 @@
'mood' => 'Stimmung',
'energy' => 'Energie',
'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz',
];
?>