diff --git a/assets/css/app.css b/assets/css/app.css index bdcba0b..f2a0b8a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1301,6 +1301,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; @@ -1309,6 +1320,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; } diff --git a/assets/js/app.js b/assets/js/app.js index e50a8ec..87266f8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 || "", }); diff --git a/src/App.php b/src/App.php index 063ddc6..f391725 100644 --- a/src/App.php +++ b/src/App.php @@ -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']; @@ -252,6 +253,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 +263,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 +302,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 +311,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 +352,7 @@ final class App 'authUser' => $user, 'entries' => $archive, 'selectedEntry' => $selectedEntry, + 'settings' => $settings, ]); } @@ -390,6 +399,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 +411,7 @@ final class App 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => 999, 'walk_steps' => 10000, + 'alcohol' => false, 'note' => 'x', ], $settings)['max_total'], ]); @@ -598,8 +610,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 +679,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 +875,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 +943,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, + ]; + } } diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index f3061c2..ee854ba 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -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', diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 284dd63..7497ac7 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -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); + } } diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php index 66b5745..7aca432 100644 --- a/src/Support/Defaults.php +++ b/src/Support/Defaults.php @@ -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], diff --git a/templates/pages/archive.php b/templates/pages/archive.php index 304bf46..843ed7e 100644 --- a/templates/pages/archive.php +++ b/templates/pages/archive.php @@ -54,6 +54,9 @@
Stimmung
/10
Energie
/10
Stress
/10
+ +
Schmerzen
/10
+
Schlaf
h
Schlafgefühl
/5
Sport
min
@@ -76,6 +79,7 @@
Sportbonus
Spaziergang
+
Alkohol
diff --git a/templates/pages/options.php b/templates/pages/options.php index 5856921..69799e3 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -23,6 +23,30 @@
+
+
+
+

Tracking-Felder

+

Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.

+
+
+ +
+ + + +
+
+

Schlafdauerpunkte

diff --git a/templates/pages/track.php b/templates/pages/track.php index 980d311..3bbb476 100644 --- a/templates/pages/track.php +++ b/templates/pages/track.php @@ -37,6 +37,34 @@
+ +
+ + + +
+ +
+ +
+ +