Add PWA reminders and flexible walk scoring

This commit is contained in:
2026-04-12 19:40:40 +02:00
parent 2cd00b1bf6
commit cd7526bd80
19 changed files with 1561 additions and 33 deletions
+312 -10
View File
@@ -10,6 +10,8 @@ final class App
private LoginThrottle $throttle;
private ScoringService $scoring;
private Auth $auth;
private NotificationRepository $notifications;
private WebPushService $webPush;
public function __construct()
{
@@ -19,6 +21,8 @@ final class App
$this->throttle = new LoginThrottle();
$this->scoring = new ScoringService();
$this->auth = new Auth($this->users);
$this->notifications = new NotificationRepository();
$this->webPush = new WebPushService($this->notifications);
}
public function run(): void
@@ -29,6 +33,7 @@ final class App
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
// A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) {
@@ -39,13 +44,13 @@ final class App
if (!$hasUsers) {
if ($path === '/login') {
$path = '/setup';
} elseif ($path !== '/setup') {
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
redirect('/setup');
}
} elseif (!$isAuthenticated) {
if ($path === '/setup') {
$path = '/login';
} elseif ($path !== '/login') {
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
redirect('/login');
}
}
@@ -90,6 +95,37 @@ final class App
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
return;
case '/push/subscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushSubscribe();
return;
case '/push/unsubscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushUnsubscribe();
return;
case '/push/test':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushTest();
return;
case '/reminders/run':
$this->handleReminderRun();
return;
default:
http_response_code(404);
View::render('not-found', [
@@ -221,7 +257,9 @@ final class App
'sport_minutes' => 0,
'sport_type' => '',
'sport_types' => [],
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'note' => '',
];
@@ -263,7 +301,9 @@ final class App
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
'sport_types' => $_POST['sport_types'] ?? [],
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'note' => $_POST['note'] ?? '',
]);
@@ -323,6 +363,16 @@ final class App
return true;
}
));
$pushAvailable = $this->webPush->isAvailable();
$pushPublicKey = null;
if ($pushAvailable) {
try {
$pushPublicKey = $this->webPush->publicKey();
} catch (RuntimeException) {
$pushAvailable = false;
}
}
View::render('options', [
'pageTitle' => 'Optionen',
@@ -331,6 +381,10 @@ final class App
'settings' => $settings,
'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(),
'walkModeOptions' => walk_mode_options(),
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
@@ -343,7 +397,9 @@ final class App
static fn (array $type): string => (string) ($type['id'] ?? ''),
normalized_sport_types($settings)
),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999,
'walk_steps' => 10000,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -357,7 +413,8 @@ final class App
$form = (string) ($_POST['form_name'] ?? '');
if ($form === 'settings') {
$settings = $this->sanitizeSettings($_POST['settings'] ?? []);
$currentSettings = $this->hydrateSettings($this->settings->forUser($user['username']));
$settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings);
$this->settings->saveForUser($user['username'], $settings);
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
redirect('/options');
@@ -478,9 +535,10 @@ final class App
'sport' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
'sport' => $entry['sport_minutes'],
'walk' => $entry['walk_minutes'],
'walk' => walk_chart_value($entry),
'walk_label' => format_walk_value($entry),
'sport_labels' => array_values(array_filter(array_map(
static function (array $type): string {
$label = (string) ($type['label'] ?? '');
@@ -528,10 +586,14 @@ final class App
return $streak;
}
private function sanitizeSettings(array $input): array
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
{
$defaults = Defaults::settings();
$settings = $defaults;
$settings = array_replace_recursive($defaults, $existingSettings ?? []);
$settings['walk'] = [
'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time',
];
$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)));
@@ -545,10 +607,12 @@ final class App
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
$currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand;
$settings['scoring'][$bandKey][$index] = [
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $defaultBand['points']))),
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
];
}
}
@@ -579,12 +643,30 @@ final class App
: ($sportTypesProvided ? [] : $defaults['sport_types']),
]);
$time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time'])));
if (!$this->isValidTime($time)) {
$time = $defaults['notifications']['time'];
}
$settings['notifications'] = [
'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1',
'time' => $time,
];
return $settings;
}
private function hydrateSettings(array $settings): array
{
$settings['sport_types'] = normalized_sport_types($settings);
$settings['walk'] = array_replace(
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
);
return $settings;
}
@@ -632,6 +714,16 @@ final class App
}
}
private function enforceRequestCsrf(): void
{
if (!verify_request_csrf()) {
json_response([
'ok' => false,
'message' => 'Ungültiges Formular-Token.',
], 419);
}
}
private function requireUser(): array
{
$user = $this->auth->user();
@@ -660,10 +752,220 @@ final class App
return strlen($password) >= 10;
}
private function isValidTime(string $time): bool
{
return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
}
private function throttleKey(string $username): string
{
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
return sha1($remoteAddress . '|' . normalize_username($username));
}
private function handlePushSubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$subscription = $payload['subscription'] ?? null;
if (!is_array($subscription)) {
json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422);
}
try {
$this->notifications->saveSubscription($user['username'], [
'endpoint' => trim((string) ($subscription['endpoint'] ?? '')),
'keys' => [
'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')),
'auth' => trim((string) ($subscription['keys']['auth'] ?? '')),
],
'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
]);
} catch (RuntimeException $exception) {
json_response(['ok' => false, 'message' => $exception->getMessage()], 422);
}
json_response([
'ok' => true,
'message' => 'Push ist auf diesem Gerät aktiviert.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushUnsubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$endpoint = trim((string) ($payload['endpoint'] ?? ''));
if ($endpoint === '') {
json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422);
}
$this->notifications->removeSubscription($user['username'], $endpoint);
json_response([
'ok' => true,
'message' => 'Push wurde für dieses Gerät entfernt.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushTest(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$result = $this->sendNotificationsForUser($user['username'], [
'title' => 'Mood-Board',
'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.',
'url' => '/options',
'tag' => 'mood-push-test',
]);
if ($result['sent'] <= 0) {
json_response([
'ok' => false,
'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.',
'removed' => $result['removed'],
], 422);
}
json_response([
'ok' => true,
'message' => 'Die Test-Benachrichtigung wurde verschickt.',
'sent' => $result['sent'],
'removed' => $result['removed'],
]);
}
private function handleReminderRun(): void
{
$providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? '')));
$expectedToken = $this->webPush->cronToken();
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
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),
]);
}
}
json_response([
'ok' => true,
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
]);
}
private function sendNotificationsForUser(string $username, array $message): array
{
$subscriptions = $this->notifications->subscriptionsForUser($username);
$sent = 0;
$removedEndpoints = [];
foreach ($subscriptions as $subscription) {
try {
$result = $this->webPush->send($subscription, $message);
} catch (RuntimeException) {
$result = [
'ok' => false,
'remove' => false,
];
}
if (!empty($result['ok'])) {
$sent++;
continue;
}
if (!empty($result['remove'])) {
$endpoint = (string) ($subscription['endpoint'] ?? '');
if ($endpoint !== '') {
$removedEndpoints[] = $endpoint;
}
}
}
if ($removedEndpoints !== []) {
$this->notifications->removeInvalidSubscriptions($username, $removedEndpoints);
}
return [
'sent' => $sent,
'removed' => count($removedEndpoints),
];
}
private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool
{
if (empty($settings['notifications']['enabled'])) {
return false;
}
$reminderTime = (string) ($settings['notifications']['time'] ?? '');
if (!$this->isValidTime($reminderTime)) {
return false;
}
if ($currentTime < $reminderTime) {
return false;
}
return (string) ($state['last_sent_date'] ?? '') !== $today;
}
}