2 Commits

Author SHA1 Message Date
hnzio 4a884dd166 Add dashboard pain chart and version footer 2026-04-13 10:30:51 +02:00
hnzio 5ea1b56649 Add optional pain tracking and fix reminder delivery 2026-04-13 10:22:41 +02:00
11 changed files with 294 additions and 54 deletions
+47
View File
@@ -244,6 +244,26 @@ button:disabled {
gap: 1rem; 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 { .topbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1301,6 +1321,17 @@ input[type="range"] {
gap: 0.7rem; 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 { .checkbox-row--panel {
padding: 0.95rem 1rem; padding: 0.95rem 1rem;
border-radius: 18px; border-radius: 18px;
@@ -1309,6 +1340,12 @@ input[type="range"] {
min-height: 100%; min-height: 100%;
} }
.checkbox-row--tall {
align-items: flex-start;
padding-top: 1.05rem;
padding-bottom: 1.05rem;
}
.checkbox-row input { .checkbox-row input {
width: auto; width: auto;
} }
@@ -1483,6 +1520,10 @@ input[type="range"] {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom)); padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
} }
.site-footer {
margin-bottom: 0.5rem;
}
.bar-chart { .bar-chart {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
@@ -1563,4 +1604,10 @@ input[type="range"] {
.mobile-nav a span { .mobile-nav a span {
font-size: 0.72rem; font-size: 0.72rem;
} }
.site-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
} }
+13 -3
View File
@@ -264,6 +264,7 @@
const ratings = sortedRatings(settings.ratings || []); const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {}; const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time"; const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const painEnabled = Boolean(settings.tracking?.pain_enabled);
const components = { const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0), mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0), energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -275,9 +276,14 @@
walk_minutes: walkMode === "steps" walk_minutes: walkMode === "steps"
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || []) ? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), : 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), 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; const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings); let label = labelForScore(total, ratings);
@@ -316,11 +322,13 @@
mood: "Stimmung", mood: "Stimmung",
energy: "Energie", energy: "Energie",
stress: "Stress", stress: "Stress",
pain: "Schmerzen",
sleep_hours: "Schlafdauer", sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl", sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport", sport_minutes: "Sport",
sport_bonus: "Sportbonus", sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang", walk_minutes: "Spaziergang",
alcohol: "Alkohol",
note: "Notiz", note: "Notiz",
}; };
@@ -328,6 +336,7 @@
mood: Number(form.elements.mood.value), mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value), energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value), stress: Number(form.elements.stress.value),
pain: Number(form.elements.pain?.value || 1),
sleep_hours: Number(form.elements.sleep_hours.value || 0), sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value), sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0), sport_minutes: Number(form.elements.sport_minutes.value || 0),
@@ -335,6 +344,7 @@
walk_mode: form.elements.walk_mode?.value || "time", walk_mode: form.elements.walk_mode?.value || "time",
walk_minutes: Number(form.elements.walk_minutes?.value || 0), walk_minutes: Number(form.elements.walk_minutes?.value || 0),
walk_steps: Number(form.elements.walk_steps?.value || 0), walk_steps: Number(form.elements.walk_steps?.value || 0),
alcohol: Boolean(form.elements.alcohol?.checked),
note: form.elements.note.value || "", note: form.elements.note.value || "",
}); });
@@ -384,7 +394,7 @@
} }
const seriesName = container.dataset.series || ""; const seriesName = container.dataset.series || "";
const invertScale = seriesName === "stress"; const invertScale = seriesName === "stress" || seriesName === "pain";
const values = items.map(item => Number(item.value)); const values = items.map(item => Number(item.value));
const width = 760; const width = 760;
const height = 196; const height = 196;
@@ -392,7 +402,7 @@
let minValue = Math.min(...values); let minValue = Math.min(...values);
let maxValue = Math.max(...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); minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5); maxValue = Math.min(10, maxValue + 1.5);
} else { } else {
@@ -402,7 +412,7 @@
if ((maxValue - minValue) < 3) { if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2; const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress") { if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5); minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5); maxValue = Math.min(10, center + 1.5);
} else { } else {
+117 -51
View File
@@ -31,6 +31,7 @@ final class App
$path = request_path(); $path = request_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers(); $hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check(); $isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run']; $systemPaths = ['/reminders/run'];
@@ -233,6 +234,7 @@ final class App
'pageTitle' => 'Dashboard', 'pageTitle' => 'Dashboard',
'page' => 'dashboard', 'page' => 'dashboard',
'authUser' => $user, 'authUser' => $user,
'settings' => $settings,
'summary' => $summary, 'summary' => $summary,
'entries' => array_reverse($evaluatedEntries), 'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData), 'chartPayload' => encode_payload($chartData),
@@ -252,6 +254,8 @@ final class App
'mood' => 6, 'mood' => 6,
'energy' => 6, 'energy' => 6,
'stress' => 4, 'stress' => 4,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7, 'sleep_hours' => 7,
'sleep_feeling' => 3, 'sleep_feeling' => 3,
'sport_minutes' => 0, 'sport_minutes' => 0,
@@ -260,9 +264,11 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0, 'walk_minutes' => 0,
'walk_steps' => 0, 'walk_steps' => 0,
'alcohol' => false,
'note' => '', 'note' => '',
]; ];
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
$entry = $this->scoring->normalize($entry); $entry = $this->scoring->normalize($entry);
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1)); $previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
@@ -297,6 +303,8 @@ final class App
'mood' => $_POST['mood'] ?? 5, 'mood' => $_POST['mood'] ?? 5,
'energy' => $_POST['energy'] ?? 5, 'energy' => $_POST['energy'] ?? 5,
'stress' => $_POST['stress'] ?? 5, 'stress' => $_POST['stress'] ?? 5,
'pain' => $_POST['pain'] ?? 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => $_POST['sleep_hours'] ?? 0, 'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_minutes' => $_POST['sport_minutes'] ?? 0,
@@ -304,6 +312,7 @@ final class App
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'), 'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0, 'walk_steps' => $_POST['walk_steps'] ?? 0,
'alcohol' => $_POST['alcohol'] ?? false,
'note' => $_POST['note'] ?? '', 'note' => $_POST['note'] ?? '',
]); ]);
@@ -344,6 +353,7 @@ final class App
'authUser' => $user, 'authUser' => $user,
'entries' => $archive, 'entries' => $archive,
'selectedEntry' => $selectedEntry, 'selectedEntry' => $selectedEntry,
'settings' => $settings,
]); ]);
} }
@@ -390,6 +400,8 @@ final class App
'mood' => 10, 'mood' => 10,
'energy' => 10, 'energy' => 10,
'stress' => 1, 'stress' => 1,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7, 'sleep_hours' => 7,
'sleep_feeling' => 5, 'sleep_feeling' => 5,
'sport_minutes' => 999, 'sport_minutes' => 999,
@@ -400,6 +412,7 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999, 'walk_minutes' => 999,
'walk_steps' => 10000, 'walk_steps' => 10000,
'alcohol' => false,
'note' => 'x', 'note' => 'x',
], $settings)['max_total'], ], $settings)['max_total'],
]); ]);
@@ -532,6 +545,12 @@ final class App
'value' => $entry['stress'], 'value' => $entry['stress'],
]; ];
}, $recent), }, $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 { 'sport' => array_map(static function (array $entry): array {
return [ return [
'date' => $entry['date'], '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']['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']['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']['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']['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['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) { 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))); $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'], Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $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( $settings['notifications'] = array_replace(
Defaults::settings()['notifications'], Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $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); json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
} }
$now = new DateTimeImmutable('now'); $stats = $this->runDueReminders(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),
]);
}
}
json_response([ json_response([
'ok' => true, 'ok' => true,
'processed' => $processed, 'processed' => $stats['processed'],
'sent_users' => $sentUsers, 'sent_users' => $stats['sent_users'],
'already_tracked' => $alreadyTracked, 'already_tracked' => $stats['already_tracked'],
'skipped' => $skipped, 'skipped' => $stats['skipped'],
'removed_subscriptions' => $removed, 'removed_subscriptions' => $stats['removed_subscriptions'],
]); ]);
} }
@@ -968,4 +950,88 @@ final class App
return (string) ($state['last_sent_date'] ?? '') !== $today; 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')); $walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time'; $walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0); $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 = [ $entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate, 'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5), 'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5), 'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\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_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3), 'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
@@ -94,6 +98,7 @@ final class EntryRepository
'walk_mode' => $walkMode, 'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0, 'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0, 'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content), 'note' => $this->extractNote($content),
]; ];
@@ -140,12 +145,14 @@ final class EntryRepository
'- Stimmung: ' . $entry['mood'], '- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'], '- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'], '- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'], '- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'], '- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'], '- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues), '- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'), '- 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)), '- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'', '',
'## Bewertung', '## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']), '- 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']), '- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']), '- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']), '- 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']), '- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']), '- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)), '- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']), '- Notiz: ' . format_points((float) $evaluation['components']['note']),
'', '',
'## Notiz', '## Notiz',
+27
View File
@@ -13,6 +13,8 @@ final class ScoringService
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))), 'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))), 'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 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_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))), 'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), '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_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_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))), 'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
'note' => trim((string) ($input['note'] ?? '')), 'note' => trim((string) ($input['note'] ?? '')),
]; ];
} }
@@ -33,6 +36,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []); $ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']); $sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes); $sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [ $components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'], '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_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus, 'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings), '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'], '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); $total = round(array_sum($components), 1);
$maxTotal = round( $maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) + (10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) + (10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) + (10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) + max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) + (5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) + $this->maxBandPoints($scoring['sport_bands']) +
@@ -353,4 +363,21 @@ final class ScoringService
{ {
return $mode === 'steps' ? 'steps' : 'time'; 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' => [ 'walk' => [
'mode' => 'time', 'mode' => 'time',
], ],
'tracking' => [
'pain_enabled' => false,
],
'sport_types' => [ 'sport_types' => [
[ [
'id' => 'running', 'id' => 'running',
@@ -115,6 +118,7 @@ final class Defaults
'mood_multiplier' => 3, 'mood_multiplier' => 3,
'energy_multiplier' => 2, 'energy_multiplier' => 2,
'stress_multiplier' => 2, 'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2, 'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [ 'sleep_duration_points' => [
'lt4' => 0, 'lt4' => 0,
@@ -149,6 +153,7 @@ final class Defaults
['steps' => 20000, 'points' => 0], ['steps' => 20000, 'points' => 0],
], ],
'journal_points' => 2, 'journal_points' => 2,
'alcohol_penalty' => 5,
], ],
'ratings' => [ 'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39], ['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
+5
View File
@@ -112,6 +112,11 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?> <?php endforeach; ?>
<?= $content ?> <?= $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> </main>
<?php if ($authUser !== null): ?> <?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>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/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> <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>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>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> <div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
@@ -76,6 +79,7 @@
</div> </div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></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>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl> </dl>
<div class="note-box"> <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> <div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article> </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"> <article class="glass-panel chart-card chart-card--wide">
<div class="section-head"> <div class="section-head">
<div> <div>
+24
View File
@@ -23,6 +23,30 @@
</div> </div>
</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"> <div class="settings-section">
<h4>Schlafdauerpunkte</h4> <h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four"> <div class="field-grid field-grid--four">
+30
View File
@@ -37,6 +37,34 @@
</label> </label>
</div> </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"> <div class="field-grid field-grid--two">
<label> <label>
<span>Schlafdauer in Stunden</span> <span>Schlafdauer in Stunden</span>
@@ -127,11 +155,13 @@
'mood' => 'Stimmung', 'mood' => 'Stimmung',
'energy' => 'Energie', 'energy' => 'Energie',
'stress' => 'Stress', 'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer', 'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl', 'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport', 'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus', 'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang', 'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz', 'note' => 'Notiz',
]; ];
?> ?>