4048 lines
159 KiB
PHP
4048 lines
159 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class App
|
|
{
|
|
private UserRepository $users;
|
|
private SettingsRepository $settings;
|
|
private EntryRepository $entries;
|
|
private LoginThrottle $throttle;
|
|
private ScoringService $scoring;
|
|
private Auth $auth;
|
|
private NotificationRepository $notifications;
|
|
private WebPushService $webPush;
|
|
private SummaryRepository $summaries;
|
|
private AiConfigRepository $aiConfig;
|
|
private OpenAiSummaryService $openAi;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->users = new UserRepository();
|
|
$this->settings = new SettingsRepository();
|
|
$this->entries = new EntryRepository();
|
|
$this->throttle = new LoginThrottle();
|
|
$this->scoring = new ScoringService();
|
|
$this->auth = new Auth($this->users);
|
|
$this->notifications = new NotificationRepository();
|
|
$this->webPush = new WebPushService($this->notifications);
|
|
$this->summaries = new SummaryRepository();
|
|
$this->aiConfig = new AiConfigRepository();
|
|
$this->openAi = new OpenAiSummaryService($this->aiConfig);
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
$this->sendSecurityHeaders();
|
|
|
|
$path = request_path();
|
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
|
$this->triggerReminderCheckFromTraffic($method, $path);
|
|
$hasUsers = $this->users->hasAnyUsers();
|
|
$isAuthenticated = $this->auth->check();
|
|
$systemPaths = ['/reminders/run', '/api/health', '/api/putzliga'];
|
|
|
|
// A failed setup must never leave the app in a half-authenticated redirect loop.
|
|
if (!$hasUsers && $isAuthenticated) {
|
|
$this->auth->logout();
|
|
$isAuthenticated = false;
|
|
}
|
|
|
|
if (!$hasUsers) {
|
|
if ($path === '/login') {
|
|
$path = '/setup';
|
|
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
|
|
redirect('/setup');
|
|
}
|
|
} elseif (!$isAuthenticated) {
|
|
if ($path === '/setup') {
|
|
$path = '/login';
|
|
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
|
|
redirect('/login');
|
|
}
|
|
}
|
|
|
|
if ($isAuthenticated && in_array($path, ['/login', '/setup'], true)) {
|
|
redirect('/');
|
|
}
|
|
|
|
switch ($path) {
|
|
case '/setup':
|
|
$method === 'POST' ? $this->handleSetup() : $this->showSetup();
|
|
return;
|
|
|
|
case '/login':
|
|
$method === 'POST' ? $this->handleLogin() : $this->showLogin();
|
|
return;
|
|
|
|
case '/logout':
|
|
if ($method !== 'POST') {
|
|
http_response_code(405);
|
|
exit('Method Not Allowed');
|
|
}
|
|
|
|
$this->enforceCsrf();
|
|
$this->auth->logout();
|
|
flash('success', 'Du wurdest abgemeldet.');
|
|
redirect('/login');
|
|
|
|
case '/':
|
|
$method === 'POST' ? $this->handleDashboard() : $this->showDashboard();
|
|
return;
|
|
|
|
case '/day-image':
|
|
$this->serveDayImage();
|
|
return;
|
|
|
|
case '/event-image':
|
|
$this->serveEventImage();
|
|
return;
|
|
|
|
case '/track':
|
|
$method === 'POST' ? $this->handleTrack() : $this->showTrack();
|
|
return;
|
|
|
|
case '/archive':
|
|
$method === 'POST' ? $this->handleArchive() : $this->showArchive();
|
|
return;
|
|
|
|
case '/options':
|
|
$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;
|
|
|
|
case '/api/health':
|
|
if ($method !== 'POST') {
|
|
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
|
|
}
|
|
|
|
$this->handleHealthImport();
|
|
return;
|
|
|
|
case '/api/health/status':
|
|
if ($method !== 'GET') {
|
|
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
|
|
}
|
|
|
|
$this->handleHealthImportStatus();
|
|
return;
|
|
|
|
case '/api/putzliga':
|
|
if ($method !== 'POST') {
|
|
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
|
|
}
|
|
|
|
$this->handlePutzligaImport();
|
|
return;
|
|
|
|
default:
|
|
http_response_code(404);
|
|
View::render('not-found', [
|
|
'pageTitle' => 'Nicht gefunden',
|
|
'page' => 'not-found',
|
|
'authUser' => $this->auth->user(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function showSetup(): void
|
|
{
|
|
View::render('setup', [
|
|
'pageTitle' => 'Setup',
|
|
'page' => 'setup',
|
|
'authUser' => null,
|
|
]);
|
|
}
|
|
|
|
private function handleSetup(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
if ($this->users->hasAnyUsers()) {
|
|
redirect('/login');
|
|
}
|
|
|
|
$username = trim((string) ($_POST['username'] ?? ''));
|
|
$password = (string) ($_POST['password'] ?? '');
|
|
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
|
|
|
|
if (!$this->isValidUsername($username)) {
|
|
flash('error', 'Bitte nutze einen Benutzernamen mit 3 bis 32 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich.');
|
|
redirect('/setup');
|
|
}
|
|
|
|
if (!$this->isStrongPassword($password)) {
|
|
flash('error', 'Das Passwort sollte mindestens 10 Zeichen lang sein.');
|
|
redirect('/setup');
|
|
}
|
|
|
|
if ($password !== $passwordConfirm) {
|
|
flash('error', 'Die Passwörter stimmen nicht überein.');
|
|
redirect('/setup');
|
|
}
|
|
|
|
try {
|
|
$user = $this->users->create($username, $password, true);
|
|
$this->auth->login($user);
|
|
flash('success', 'Der erste Account wurde erstellt. Du kannst direkt loslegen.');
|
|
redirect('/');
|
|
} catch (RuntimeException $exception) {
|
|
flash('error', $exception->getMessage());
|
|
redirect('/setup');
|
|
}
|
|
}
|
|
|
|
private function showLogin(): void
|
|
{
|
|
View::render('login', [
|
|
'pageTitle' => 'Login',
|
|
'page' => 'login',
|
|
'authUser' => null,
|
|
]);
|
|
}
|
|
|
|
private function handleLogin(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
$username = trim((string) ($_POST['username'] ?? ''));
|
|
$password = (string) ($_POST['password'] ?? '');
|
|
$throttleKey = $this->throttleKey($username);
|
|
|
|
if ($this->throttle->tooManyAttempts($throttleKey)) {
|
|
$seconds = $this->throttle->availableInSeconds($throttleKey);
|
|
flash('error', 'Zu viele fehlgeschlagene Login-Versuche. Bitte warte ' . max(1, $seconds) . ' Sekunden.');
|
|
redirect('/login');
|
|
}
|
|
|
|
$remember = isset($_POST['remember_me']) && $_POST['remember_me'] === '1';
|
|
|
|
if (!$this->auth->attempt($username, $password, $remember)) {
|
|
$this->throttle->hit($throttleKey);
|
|
flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.');
|
|
redirect('/login');
|
|
}
|
|
|
|
$this->throttle->clear($throttleKey);
|
|
flash('success', 'Willkommen zurück.');
|
|
redirect('/');
|
|
}
|
|
|
|
private function handleHealthImport(): void
|
|
{
|
|
ignore_user_abort(true);
|
|
@set_time_limit(0);
|
|
|
|
$token = $this->healthImportBearerToken();
|
|
if ($token === '') {
|
|
json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401);
|
|
}
|
|
|
|
$user = $this->users->findByHealthImportToken($token);
|
|
if ($user === null) {
|
|
json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401);
|
|
}
|
|
|
|
$username = (string) ($user['username'] ?? '');
|
|
$rawBody = (string) file_get_contents('php://input');
|
|
$payload = $this->decodeHealthImportPayload($rawBody);
|
|
if ($payload === []) {
|
|
$traceID = $this->healthImportTraceID();
|
|
$message = 'Diagnose-ID: ' . $traceID . '. Leerer oder ungültiger JSON-Import.';
|
|
$this->logHealthImportFailure($traceID, $username, 'Leerer oder ungültiger JSON-Import.', [], strlen($rawBody));
|
|
$this->users->recordHealthImport($username, 'error', $message);
|
|
json_response(['ok' => false, 'message' => $message], 400);
|
|
}
|
|
|
|
try {
|
|
$settings = $this->hydrateSettings($this->settings->forUser($username));
|
|
$result = $this->importHealthPayload($username, $settings, $payload);
|
|
$message = sprintf(
|
|
'Importiert: %d Tage, %d Schlaf, %d Sport, %d Spaziergänge.',
|
|
(int) $result['days'],
|
|
(int) $result['sleep'],
|
|
(int) $result['sport'],
|
|
(int) $result['walk']
|
|
);
|
|
$this->users->recordHealthImport($username, 'ok', $message);
|
|
json_response(['ok' => true, 'message' => $message, 'result' => $result]);
|
|
} catch (RuntimeException $exception) {
|
|
$traceID = $this->healthImportTraceID();
|
|
$message = 'Diagnose-ID: ' . $traceID . '. ' . $exception->getMessage();
|
|
$this->logHealthImportFailure($traceID, $username, $exception->getMessage(), $payload, strlen($rawBody));
|
|
$this->users->recordHealthImport($username, 'error', $message);
|
|
json_response(['ok' => false, 'message' => $message], 400);
|
|
}
|
|
}
|
|
|
|
private function decodeHealthImportPayload(string $rawBody): array
|
|
{
|
|
if (trim($rawBody) === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($rawBody, true);
|
|
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
private function healthImportTraceID(): string
|
|
{
|
|
try {
|
|
return substr(bin2hex(random_bytes(4)), 0, 8);
|
|
} catch (Exception) {
|
|
return substr(sha1((string) microtime(true)), 0, 8);
|
|
}
|
|
}
|
|
|
|
private function logHealthImportFailure(string $traceID, string $username, string $message, array $payload, int $rawLength): void
|
|
{
|
|
$metrics = $payload === [] ? [] : $this->healthMetricsFromPayload($payload);
|
|
$workouts = $payload === [] ? [] : $this->healthWorkoutsFromPayload($payload);
|
|
$context = [
|
|
'trace_id' => $traceID,
|
|
'user' => $username,
|
|
'message' => $message,
|
|
'method' => (string) ($_SERVER['REQUEST_METHOD'] ?? ''),
|
|
'path' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
|
|
'content_type' => (string) ($_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? '')),
|
|
'content_length' => (string) ($_SERVER['CONTENT_LENGTH'] ?? ''),
|
|
'raw_length' => $rawLength,
|
|
'json_error' => json_last_error_msg(),
|
|
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
|
'payload' => $this->healthPayloadDiagnostics($payload, $metrics, $workouts),
|
|
];
|
|
|
|
error_log('[Mood Health Import] ' . json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
private function handleHealthImportStatus(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
|
|
json_response([
|
|
'ok' => true,
|
|
'status' => $this->users->healthImportConfig((string) ($user['username'] ?? '')),
|
|
]);
|
|
}
|
|
|
|
private function handlePutzligaImport(): void
|
|
{
|
|
$token = $this->healthImportBearerToken();
|
|
if ($token === '') {
|
|
json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401);
|
|
}
|
|
|
|
$user = $this->users->findByPutzligaImportToken($token);
|
|
if ($user === null) {
|
|
json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401);
|
|
}
|
|
|
|
$payload = $this->decodeHealthImportPayload((string) file_get_contents('php://input'));
|
|
$date = (string) ($payload['date'] ?? '');
|
|
$tasksPayload = is_array($payload['tasks'] ?? null) ? $payload['tasks'] : [];
|
|
$tasks = array_values(array_filter(array_map(
|
|
static fn (mixed $task): string => trim((string) $task),
|
|
$tasksPayload
|
|
), static fn (string $task): bool => $task !== ''));
|
|
|
|
if (!$this->isValidDate($date)) {
|
|
json_response(['ok' => false, 'message' => 'Datum fehlt oder ist ungültig.'], 400);
|
|
}
|
|
|
|
if (count($tasks) < 3) {
|
|
json_response(['ok' => false, 'message' => 'Mindestens 3 erledigte Aufgaben sind nötig.'], 400);
|
|
}
|
|
|
|
$username = (string) ($user['username'] ?? '');
|
|
$settings = $this->hydrateSettings($this->settings->forUser($username));
|
|
$entries = $this->entries->all($username);
|
|
$entryMap = [];
|
|
foreach ($entries as $entry) {
|
|
if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) {
|
|
$entryMap[(string) $entry['date']] = $entry;
|
|
}
|
|
}
|
|
|
|
$entry = $entryMap[$date] ?? $this->scoring->normalize([
|
|
'date' => $date,
|
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
|
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
|
'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false],
|
|
'events' => [],
|
|
'background_image' => '',
|
|
]);
|
|
|
|
$importID = 'putzliga-' . $date;
|
|
$events = array_values(array_filter(
|
|
is_array($entry['events'] ?? null) ? $entry['events'] : [],
|
|
static fn (array $event): bool => (string) ($event['import_id'] ?? '') !== $importID
|
|
));
|
|
$events[] = [
|
|
'id' => $importID,
|
|
'type' => 'event',
|
|
'time' => (string) ($payload['time'] ?? ''),
|
|
'comment' => 'Du warst fleißig',
|
|
'value' => 0,
|
|
'unit' => '',
|
|
'mood' => 0,
|
|
'energy' => 1,
|
|
'stress' => 0,
|
|
'source' => 'putzliga',
|
|
'import_id' => $importID,
|
|
'task_titles' => array_slice(array_values(array_unique($tasks)), 0, 20),
|
|
];
|
|
|
|
$entry['events'] = $events;
|
|
$entryMap[$date] = $entry;
|
|
$this->persistUserEntries($username, $settings, array_values($entryMap));
|
|
$this->users->recordPutzligaImport($username, 'ok', count($tasks) . ' Aufgaben synchronisiert.');
|
|
|
|
json_response(['ok' => true, 'message' => 'Putzliga-Moment aktualisiert.', 'tasks' => count($tasks)]);
|
|
}
|
|
|
|
private function healthImportBearerToken(): string
|
|
{
|
|
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
|
|
if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) === 1) {
|
|
return trim((string) ($matches[1] ?? ''));
|
|
}
|
|
|
|
return trim((string) ($_SERVER['HTTP_X_MOOD_HEALTH_TOKEN'] ?? ''));
|
|
}
|
|
|
|
private function importHealthPayload(string $username, array $settings, array $payload): array
|
|
{
|
|
if ($this->isHealthAutoExportConfigurationPayload($payload)) {
|
|
throw new RuntimeException('Diese JSON-Datei ist eine Health-Auto-Export-Konfiguration, aber kein Health-Datenexport. Bitte in Health Auto Export die Automation ausführen und die dabei erzeugte REST-API-Nutzlast senden.');
|
|
}
|
|
|
|
$metrics = $this->healthMetricsFromPayload($payload);
|
|
$workouts = $this->healthWorkoutsFromPayload($payload);
|
|
$metricImport = $this->healthEventsFromMetrics($metrics, (float) ($settings['sleep']['optimal_hours'] ?? 7.0));
|
|
$workoutImport = $this->healthEventsFromWorkouts($workouts, $settings);
|
|
|
|
$entries = $this->entries->all($username);
|
|
$entryMap = [];
|
|
foreach ($entries as $entry) {
|
|
if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) {
|
|
$entryMap[(string) $entry['date']] = $entry;
|
|
}
|
|
}
|
|
|
|
$dates = array_unique(array_merge(
|
|
array_keys($metricImport['steps']),
|
|
array_keys($metricImport['sleep']),
|
|
array_keys($workoutImport['sport']),
|
|
array_keys($workoutImport['walk'])
|
|
));
|
|
sort($dates, SORT_STRING);
|
|
|
|
if ($dates === []) {
|
|
throw new RuntimeException('Der Import enthielt keine unterstützten Health-Daten. ' . $this->healthPayloadSummary($payload, $metrics, $workouts));
|
|
}
|
|
|
|
$totalItems = max(1, $this->countHealthImportItems($metricImport, $workoutImport));
|
|
$processedItems = 0;
|
|
$startedAt = date(DATE_ATOM);
|
|
$this->users->recordHealthImportProgress($username, 'Import vorbereitet.', 0, $totalItems, $startedAt);
|
|
|
|
$sleepCount = 0;
|
|
$sportCount = 0;
|
|
$walkCount = 0;
|
|
$now = date(DATE_ATOM);
|
|
|
|
foreach ($dates as $date) {
|
|
if (!$this->isValidDate((string) $date)) {
|
|
continue;
|
|
}
|
|
|
|
$current = $entryMap[$date] ?? $this->scoring->normalize([
|
|
'date' => $date,
|
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
|
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
|
'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false],
|
|
'events' => [],
|
|
'background_image' => '',
|
|
]);
|
|
|
|
$events = array_values(array_filter(
|
|
is_array($current['events'] ?? null) ? $current['events'] : [],
|
|
'is_array'
|
|
));
|
|
|
|
if (isset($metricImport['steps'][$date])) {
|
|
$health = is_array($current['health'] ?? null) ? $current['health'] : [];
|
|
$health['steps'] = max(0, (int) $metricImport['steps'][$date]);
|
|
$health['steps_imported_at'] = $now;
|
|
$current['health'] = $health;
|
|
$processedItems++;
|
|
}
|
|
|
|
if (isset($metricImport['sleep'][$date])) {
|
|
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sleep'));
|
|
foreach ($metricImport['sleep'][$date] as $event) {
|
|
$events[] = $event;
|
|
$sleepCount++;
|
|
$processedItems++;
|
|
}
|
|
}
|
|
|
|
if (isset($workoutImport['sport'][$date])) {
|
|
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sport'));
|
|
foreach ($workoutImport['sport'][$date] as $event) {
|
|
$events[] = $event;
|
|
$sportCount++;
|
|
$processedItems++;
|
|
}
|
|
}
|
|
|
|
if (isset($workoutImport['walk'][$date])) {
|
|
$importIDs = [];
|
|
foreach ($workoutImport['walk'][$date] as $event) {
|
|
$importID = (string) ($event['import_id'] ?? '');
|
|
if ($importID !== '') {
|
|
$importIDs[$importID] = true;
|
|
}
|
|
}
|
|
|
|
$events = array_values(array_filter($events, static function (array $event) use ($importIDs): bool {
|
|
$importID = (string) ($event['import_id'] ?? '');
|
|
|
|
return $importID === '' || !isset($importIDs[$importID]);
|
|
}));
|
|
|
|
foreach ($workoutImport['walk'][$date] as $event) {
|
|
$events[] = $event;
|
|
$walkCount++;
|
|
$processedItems++;
|
|
}
|
|
}
|
|
|
|
$current['events'] = $events;
|
|
$entryMap[$date] = $current;
|
|
$this->users->recordHealthImportProgress(
|
|
$username,
|
|
'Verarbeite ' . format_display_date((string) $date, false) . '.',
|
|
$processedItems,
|
|
$totalItems,
|
|
$startedAt
|
|
);
|
|
}
|
|
|
|
if (!empty($workoutImport['settings_changed'])) {
|
|
$this->settings->saveForUser($username, $settings);
|
|
}
|
|
|
|
$this->persistUserEntries($username, $settings, array_values($entryMap));
|
|
|
|
return [
|
|
'days' => count($dates),
|
|
'steps' => count($metricImport['steps']),
|
|
'sleep' => $sleepCount,
|
|
'sport' => $sportCount,
|
|
'walk' => $walkCount,
|
|
'sport_types_added' => (int) ($workoutImport['sport_types_added'] ?? 0),
|
|
];
|
|
}
|
|
|
|
private function isHealthAutoExportConfigurationPayload(array $payload): bool
|
|
{
|
|
if (!isset($payload['exportDestination'], $payload['exportDataType'], $payload['urlString'])) {
|
|
return false;
|
|
}
|
|
|
|
$metrics = is_array($payload['metrics'] ?? null) ? $payload['metrics'] : [];
|
|
$hasOnlyMetricNames = $metrics !== [] && array_filter($metrics, 'is_array') === [];
|
|
$hasDataCollections = is_array($payload['workouts'] ?? null) || is_array($payload['data'] ?? null);
|
|
|
|
return $hasOnlyMetricNames && !$hasDataCollections;
|
|
}
|
|
|
|
private function countHealthImportItems(array $metricImport, array $workoutImport): int
|
|
{
|
|
return count($metricImport['steps'] ?? [])
|
|
+ array_sum(array_map('count', $metricImport['sleep'] ?? []))
|
|
+ array_sum(array_map('count', $workoutImport['sport'] ?? []))
|
|
+ array_sum(array_map('count', $workoutImport['walk'] ?? []));
|
|
}
|
|
|
|
private function healthMetricsFromPayload(array $payload): array
|
|
{
|
|
$metrics = [];
|
|
$this->collectHealthMetrics($payload, $metrics);
|
|
|
|
return $metrics;
|
|
}
|
|
|
|
private function healthWorkoutsFromPayload(array $payload): array
|
|
{
|
|
if (is_array($payload['data'] ?? null)) {
|
|
$nested = $this->healthWorkoutsFromPayload($payload['data']);
|
|
if ($nested !== []) {
|
|
return $nested;
|
|
}
|
|
}
|
|
|
|
if (is_array($payload['workouts'] ?? null)) {
|
|
return array_values(array_filter($payload['workouts'], 'is_array'));
|
|
}
|
|
|
|
if ($this->looksLikeHealthWorkout($payload)) {
|
|
return [$payload];
|
|
}
|
|
|
|
if (array_is_list($payload) && isset($payload[0]) && is_array($payload[0])) {
|
|
return array_values(array_filter($payload, fn (array $item): bool => $this->looksLikeHealthWorkout($item)));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function collectHealthMetrics(array $payload, array &$metrics): void
|
|
{
|
|
if (is_array($payload['metrics'] ?? null)) {
|
|
foreach ($payload['metrics'] as $metric) {
|
|
if (is_array($metric)) {
|
|
$this->collectHealthMetrics($metric, $metrics);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (is_array($payload['data'] ?? null)) {
|
|
$this->collectHealthMetrics($payload['data'], $metrics);
|
|
}
|
|
|
|
if (array_is_list($payload)) {
|
|
foreach ($payload as $item) {
|
|
if (is_array($item)) {
|
|
$this->collectHealthMetrics($item, $metrics);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
$name = trim((string) ($payload['name'] ?? ($payload['metric'] ?? ($payload['metricName'] ?? ($payload['type'] ?? '')))));
|
|
if ($name === '') {
|
|
return;
|
|
}
|
|
|
|
$data = is_array($payload['data'] ?? null)
|
|
? $payload['data']
|
|
: (is_array($payload['records'] ?? null) ? $payload['records'] : []);
|
|
|
|
if ($data === [] && (isset($payload['qty']) || isset($payload['value']) || isset($payload['date']) || isset($payload['startDate']))) {
|
|
$data = [$payload];
|
|
}
|
|
|
|
if ($data !== []) {
|
|
$metrics[] = ['name' => $name, 'data' => array_values(array_filter($data, 'is_array'))];
|
|
}
|
|
}
|
|
|
|
private function looksLikeHealthWorkout(array $payload): bool
|
|
{
|
|
return isset($payload['start'], $payload['duration'])
|
|
|| isset($payload['startDate'], $payload['duration'])
|
|
|| isset($payload['workoutActivityType'])
|
|
|| isset($payload['workoutType']);
|
|
}
|
|
|
|
private function healthPayloadSummary(array $payload, array $metrics, array $workouts): string
|
|
{
|
|
$keys = array_slice(array_keys($payload), 0, 8);
|
|
$metricNames = [];
|
|
foreach (array_slice($metrics, 0, 5) as $metric) {
|
|
if (is_array($metric) && trim((string) ($metric['name'] ?? '')) !== '') {
|
|
$metricNames[] = trim((string) $metric['name']);
|
|
}
|
|
}
|
|
|
|
$parts = ['Erkannt: ' . count($metrics) . ' Metriken, ' . count($workouts) . ' Workouts'];
|
|
if ($keys !== []) {
|
|
$parts[] = 'Top-Level: ' . implode(', ', $keys);
|
|
}
|
|
if ($metricNames !== []) {
|
|
$parts[] = 'Metriken: ' . implode(', ', $metricNames);
|
|
}
|
|
|
|
return implode('. ', $parts) . '.';
|
|
}
|
|
|
|
private function healthPayloadDiagnostics(array $payload, array $metrics, array $workouts): array
|
|
{
|
|
return [
|
|
'top_level' => $this->describeHealthPayloadNode($payload),
|
|
'metrics_count' => count($metrics),
|
|
'workouts_count' => count($workouts),
|
|
'metric_samples' => $this->healthMetricDiagnostics($metrics),
|
|
'workout_samples' => $this->healthArraySamples($workouts),
|
|
'payload_samples' => $this->healthNestedArraySamples($payload),
|
|
];
|
|
}
|
|
|
|
private function healthMetricDiagnostics(array $metrics): array
|
|
{
|
|
$samples = [];
|
|
foreach (array_slice($metrics, 0, 8) as $index => $metric) {
|
|
if (!is_array($metric)) {
|
|
continue;
|
|
}
|
|
|
|
$data = is_array($metric['data'] ?? null) ? $metric['data'] : [];
|
|
$firstPoint = null;
|
|
foreach ($data as $point) {
|
|
if (is_array($point)) {
|
|
$firstPoint = $point;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$samples[] = [
|
|
'index' => $index,
|
|
'name' => (string) ($metric['name'] ?? ''),
|
|
'normalized' => $this->healthMetricName((string) ($metric['name'] ?? '')),
|
|
'data_count' => count($data),
|
|
'keys' => array_slice(array_keys($metric), 0, 12),
|
|
'first_point' => is_array($firstPoint) ? $this->describeHealthPayloadNode($firstPoint) : null,
|
|
];
|
|
}
|
|
|
|
return $samples;
|
|
}
|
|
|
|
private function healthArraySamples(array $items): array
|
|
{
|
|
$samples = [];
|
|
foreach (array_slice($items, 0, 5) as $index => $item) {
|
|
if (is_array($item)) {
|
|
$samples[] = ['index' => $index] + $this->describeHealthPayloadNode($item);
|
|
}
|
|
}
|
|
|
|
return $samples;
|
|
}
|
|
|
|
private function healthNestedArraySamples(array $payload, string $path = '$', int $depth = 0): array
|
|
{
|
|
if ($depth > 2) {
|
|
return [];
|
|
}
|
|
|
|
$samples = [];
|
|
foreach ($payload as $key => $value) {
|
|
$currentPath = $path . (is_int($key) ? '[' . $key . ']' : '.' . (string) $key);
|
|
if (!is_array($value)) {
|
|
continue;
|
|
}
|
|
|
|
$samples[] = ['path' => $currentPath] + $this->describeHealthPayloadNode($value);
|
|
if (count($samples) >= 12) {
|
|
break;
|
|
}
|
|
|
|
foreach ($this->healthNestedArraySamples($value, $currentPath, $depth + 1) as $nested) {
|
|
$samples[] = $nested;
|
|
if (count($samples) >= 12) {
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $samples;
|
|
}
|
|
|
|
private function describeHealthPayloadNode(array $node): array
|
|
{
|
|
$keys = array_slice(array_keys($node), 0, 12);
|
|
$types = [];
|
|
foreach ($keys as $key) {
|
|
$value = $node[$key] ?? null;
|
|
$types[(string) $key] = is_array($value) ? 'array(' . count($value) . ')' : get_debug_type($value);
|
|
}
|
|
|
|
return [
|
|
'is_list' => array_is_list($node),
|
|
'count' => count($node),
|
|
'keys' => $keys,
|
|
'types' => $types,
|
|
];
|
|
}
|
|
|
|
private function healthEventsFromMetrics(array $metrics, float $optimalSleepHours = 7.0): array
|
|
{
|
|
$steps = [];
|
|
$sleepBuckets = [];
|
|
|
|
foreach ($metrics as $metric) {
|
|
$name = $this->healthMetricName((string) ($metric['name'] ?? ($metric['metric'] ?? '')));
|
|
$data = array_values(array_filter(is_array($metric['data'] ?? null) ? $metric['data'] : (is_array($metric['records'] ?? null) ? $metric['records'] : []), 'is_array'));
|
|
|
|
if ($name === 'step_count') {
|
|
foreach ($data as $point) {
|
|
$date = $this->healthPointDate($point['date'] ?? ($point['startDate'] ?? null));
|
|
if ($date === null) {
|
|
continue;
|
|
}
|
|
|
|
$steps[$date] = ($steps[$date] ?? 0) + max(0, (int) round((float) ($point['qty'] ?? ($point['value'] ?? 0))));
|
|
}
|
|
}
|
|
|
|
if ($name === 'sleep_analysis') {
|
|
foreach ($data as $point) {
|
|
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['inBedStart'] ?? ($point['startDate'] ?? null)));
|
|
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['inBedEnd'] ?? ($point['endDate'] ?? null)));
|
|
$date = ($end ?? $this->healthDateTime($point['date'] ?? null))?->format('Y-m-d');
|
|
if ($date === null) {
|
|
continue;
|
|
}
|
|
|
|
$hours = $this->healthSleepHours($point);
|
|
if ($hours <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$bucket = $sleepBuckets[$date] ?? [
|
|
'hours' => 0.0,
|
|
'start' => null,
|
|
'end' => null,
|
|
'core' => 0.0,
|
|
'deep' => 0.0,
|
|
'rem' => 0.0,
|
|
];
|
|
|
|
if (isset($point['totalSleep']) || isset($point['asleep'])) {
|
|
$bucket['hours'] = max((float) $bucket['hours'], $hours);
|
|
} else {
|
|
$bucket['hours'] = (float) $bucket['hours'] + $hours;
|
|
}
|
|
|
|
foreach (['core', 'deep', 'rem'] as $phase) {
|
|
if (is_numeric($point[$phase] ?? null)) {
|
|
$bucket[$phase] = max((float) $bucket[$phase], (float) $point[$phase]);
|
|
}
|
|
}
|
|
|
|
if ($start !== null && ($bucket['start'] === null || $start < $bucket['start'])) {
|
|
$bucket['start'] = $start;
|
|
}
|
|
if ($end !== null && ($bucket['end'] === null || $end > $bucket['end'])) {
|
|
$bucket['end'] = $end;
|
|
}
|
|
|
|
$sleepBuckets[$date] = $bucket;
|
|
}
|
|
}
|
|
}
|
|
|
|
$sleep = [];
|
|
foreach ($sleepBuckets as $date => $bucket) {
|
|
$signals = $this->healthSleepSignals($bucket, $optimalSleepHours);
|
|
|
|
$sleep[$date][] = [
|
|
'id' => 'health-sleep-' . $date,
|
|
'type' => 'sleep',
|
|
'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '',
|
|
'comment' => '',
|
|
'value' => round((float) $bucket['hours'], 2),
|
|
'unit' => 'h',
|
|
'sport_type_id' => '',
|
|
'consumed' => true,
|
|
'mood' => $signals['mood'],
|
|
'energy' => $signals['energy'],
|
|
'stress' => $signals['stress'],
|
|
'source' => 'health_auto_export',
|
|
'import_id' => 'health-sleep-' . $date,
|
|
'sleep_deep' => round((float) ($bucket['deep'] ?? 0), 2),
|
|
'sleep_rem' => round((float) ($bucket['rem'] ?? 0), 2),
|
|
'sleep_core' => round((float) ($bucket['core'] ?? 0), 2),
|
|
'route' => [],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'steps' => $steps,
|
|
'sleep' => $sleep,
|
|
];
|
|
}
|
|
|
|
private function healthSleepSignals(array $bucket, float $optimalSleepHours = 7.0): array
|
|
{
|
|
$hours = (float) ($bucket['hours'] ?? 0);
|
|
$deep = (float) ($bucket['deep'] ?? 0);
|
|
$rem = (float) ($bucket['rem'] ?? 0);
|
|
$core = (float) ($bucket['core'] ?? 0);
|
|
$optimalSleepHours = max(1.0, min(16.0, $optimalSleepHours));
|
|
$quality = 0;
|
|
$deviation = abs($hours - $optimalSleepHours);
|
|
|
|
if ($deviation <= 0.75) {
|
|
$quality++;
|
|
} elseif ($deviation >= 2.0) {
|
|
$quality--;
|
|
}
|
|
|
|
if ($deep >= 0.8) {
|
|
$quality++;
|
|
} elseif ($deep > 0 && $deep < 0.4) {
|
|
$quality--;
|
|
}
|
|
|
|
if ($rem >= 1.2) {
|
|
$quality++;
|
|
} elseif ($rem > 0 && $rem < 0.6) {
|
|
$quality--;
|
|
}
|
|
|
|
return [
|
|
'mood' => max(-2, min(2, $quality - 1)),
|
|
'energy' => max(-2, min(2, (int) round(($deep + $rem) - 2 - max(0, $deviation - 1)))),
|
|
'stress' => max(-2, min(2, $core >= 3.5 && $deviation <= 1.0 ? -1 : ($deviation >= 2.0 ? 1 : 0))),
|
|
];
|
|
}
|
|
|
|
private function healthMetricName(string $name): string
|
|
{
|
|
$normalized = normalize_sport_type_id($name);
|
|
|
|
return match ($normalized) {
|
|
'step-count', 'steps', 'hkquantitytypeidentifierstepcount' => 'step_count',
|
|
'sleep-analysis', 'sleep', 'hkcategorytypeidentifiersleepanalysis' => 'sleep_analysis',
|
|
default => str_replace('-', '_', $normalized),
|
|
};
|
|
}
|
|
|
|
private function healthEventsFromWorkouts(array $workouts, array &$settings): array
|
|
{
|
|
$sport = [];
|
|
$walk = [];
|
|
$settingsChanged = false;
|
|
$sportTypesAdded = 0;
|
|
|
|
foreach ($workouts as $workout) {
|
|
$start = $this->healthDateTime($workout['start'] ?? ($workout['startDate'] ?? null));
|
|
$end = $this->healthDateTime($workout['end'] ?? ($workout['endDate'] ?? null));
|
|
if ($start === null) {
|
|
continue;
|
|
}
|
|
|
|
$date = $start->format('Y-m-d');
|
|
$duration = is_numeric($workout['duration'] ?? null)
|
|
? ((float) $workout['duration'] / 60)
|
|
: ($end !== null ? max(0, ($end->getTimestamp() - $start->getTimestamp()) / 60) : 0);
|
|
if ($duration <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$name = trim((string) ($workout['name'] ?? 'Workout')) ?: 'Workout';
|
|
$importID = 'health-workout-' . trim((string) ($workout['id'] ?? sha1($name . $start->format(DATE_ATOM) . (string) ($workout['duration'] ?? ''))));
|
|
$route = $this->healthRouteFromWorkout($workout);
|
|
$comment = '';
|
|
$durationLabel = format_points($duration) . ' min';
|
|
$distanceLabel = $this->healthQuantityLabel($workout['distance'] ?? null);
|
|
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
|
|
$heartRateLabel = is_numeric($heartRate) ? 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm' : '';
|
|
|
|
if ($this->healthWorkoutIsWalk($name)) {
|
|
$walk[$date][] = [
|
|
'id' => 'health-walk-' . substr(sha1($importID), 0, 12),
|
|
'type' => 'walk',
|
|
'time' => $start->format('H:i'),
|
|
'comment' => $comment,
|
|
'value' => round($duration),
|
|
'unit' => 'min',
|
|
'sport_type_id' => '',
|
|
'consumed' => true,
|
|
'mood' => 0,
|
|
'energy' => 0,
|
|
'stress' => 0,
|
|
'source' => 'health_auto_export',
|
|
'import_id' => $importID,
|
|
'duration_label' => $durationLabel,
|
|
'distance_label' => $distanceLabel,
|
|
'energy_label' => '',
|
|
'heart_rate_label' => $heartRateLabel,
|
|
'route' => $route,
|
|
];
|
|
continue;
|
|
}
|
|
|
|
[$sportTypeID, $wasAdded] = $this->healthSportTypeForWorkout($name, $settings);
|
|
if ($wasAdded) {
|
|
$settingsChanged = true;
|
|
$sportTypesAdded++;
|
|
}
|
|
|
|
$sport[$date][] = [
|
|
'id' => 'health-sport-' . substr(sha1($importID), 0, 12),
|
|
'type' => 'sport',
|
|
'time' => $start->format('H:i'),
|
|
'comment' => $comment,
|
|
'value' => round($duration),
|
|
'unit' => 'min',
|
|
'sport_type_id' => $sportTypeID,
|
|
'consumed' => true,
|
|
'mood' => 0,
|
|
'energy' => 0,
|
|
'stress' => 0,
|
|
'source' => 'health_auto_export',
|
|
'import_id' => $importID,
|
|
'duration_label' => $durationLabel,
|
|
'distance_label' => $distanceLabel,
|
|
'energy_label' => '',
|
|
'heart_rate_label' => $heartRateLabel,
|
|
'route' => $route,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'sport' => $sport,
|
|
'walk' => $walk,
|
|
'settings_changed' => $settingsChanged,
|
|
'sport_types_added' => $sportTypesAdded,
|
|
];
|
|
}
|
|
|
|
private function healthPointDate(mixed $value): ?string
|
|
{
|
|
$dateTime = $this->healthDateTime($value);
|
|
|
|
return $dateTime?->format('Y-m-d');
|
|
}
|
|
|
|
private function healthDateTime(mixed $value): ?DateTimeImmutable
|
|
{
|
|
$raw = trim((string) $value);
|
|
if ($raw === '') {
|
|
return null;
|
|
}
|
|
|
|
$timezone = new DateTimeZone(date_default_timezone_get());
|
|
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
|
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, $timezone);
|
|
|
|
return $date instanceof DateTimeImmutable ? $date : null;
|
|
}
|
|
|
|
try {
|
|
return (new DateTimeImmutable($raw))->setTimezone($timezone);
|
|
} catch (Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function healthSleepHours(array $point): float
|
|
{
|
|
foreach (['totalSleep', 'asleep', 'qty', 'value', 'inBed'] as $key) {
|
|
if (is_numeric($point[$key] ?? null)) {
|
|
return max(0.0, min(24.0, (float) $point[$key]));
|
|
}
|
|
}
|
|
|
|
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['startDate'] ?? null));
|
|
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['endDate'] ?? null));
|
|
|
|
if ($start !== null && $end !== null && $end > $start) {
|
|
return min(24.0, ($end->getTimestamp() - $start->getTimestamp()) / 3600);
|
|
}
|
|
|
|
return 0.0;
|
|
}
|
|
|
|
private function healthWorkoutIsWalk(string $name): bool
|
|
{
|
|
$normalized = normalize_sport_type_id($name);
|
|
|
|
return in_array($normalized, ['walking', 'walk', 'outdoor-walk', 'indoor-walk', 'spaziergang'], true)
|
|
|| str_contains($normalized, 'walking')
|
|
|| str_contains($normalized, 'spaziergang');
|
|
}
|
|
|
|
private function healthSportTypeForWorkout(string $name, array &$settings): array
|
|
{
|
|
$mapping = $this->healthWorkoutSportMapping($name);
|
|
$candidates = array_values(array_unique(array_filter(array_merge(
|
|
[(string) $mapping['id'], normalize_sport_type_id($name)],
|
|
$mapping['aliases']
|
|
))));
|
|
|
|
foreach (normalized_sport_types($settings) as $type) {
|
|
$typeValues = array_filter([
|
|
normalize_sport_type_id((string) ($type['id'] ?? '')),
|
|
normalize_sport_type_id((string) ($type['label'] ?? '')),
|
|
normalize_sport_type_id((string) ($type['recovery_group'] ?? '')),
|
|
]);
|
|
|
|
foreach ($candidates as $candidate) {
|
|
foreach ($typeValues as $typeValue) {
|
|
if ($candidate === $typeValue || str_contains($typeValue, $candidate) || str_contains($candidate, $typeValue)) {
|
|
return [(string) ($type['id'] ?? $candidate), false];
|
|
}
|
|
}
|
|
|
|
if (in_array($candidate, $typeValues, true)) {
|
|
return [(string) ($type['id'] ?? $candidate), false];
|
|
}
|
|
}
|
|
}
|
|
|
|
$settings['sport_types'][] = [
|
|
'id' => (string) $mapping['id'],
|
|
'label' => (string) $mapping['label'],
|
|
'icon' => (string) $mapping['icon'],
|
|
'location' => '',
|
|
'recovery_group' => (string) $mapping['id'],
|
|
'bonus_points' => 2,
|
|
'allow_consecutive' => false,
|
|
];
|
|
$settings['sport_types'] = normalized_sport_types($settings);
|
|
|
|
return [(string) $mapping['id'], true];
|
|
}
|
|
|
|
private function healthWorkoutSportMapping(string $name): array
|
|
{
|
|
$normalized = normalize_sport_type_id($name);
|
|
$mappings = [
|
|
'running' => ['label' => 'Joggen', 'icon' => 'run', 'aliases' => ['running', 'run', 'jogging', 'joggen']],
|
|
'cycling' => ['label' => 'Radfahren', 'icon' => 'bike', 'aliases' => ['cycling', 'biking', 'bike', 'radfahren', 'fahrrad']],
|
|
'swimming' => ['label' => 'Schwimmen', 'icon' => 'swim', 'aliases' => ['swimming', 'swim', 'schwimmen']],
|
|
'hiking' => ['label' => 'Wandern', 'icon' => 'hike', 'aliases' => ['hiking', 'wandern', 'hike']],
|
|
'rowing' => ['label' => 'Rudergerät', 'icon' => 'row', 'aliases' => ['rowing', 'rower', 'rudergeraet', 'rudern']],
|
|
'yoga' => ['label' => 'Yoga', 'icon' => 'yoga', 'aliases' => ['yoga']],
|
|
'strength' => ['label' => 'Krafttraining', 'icon' => 'strength', 'aliases' => ['strength', 'strength-training', 'traditional-strength-training', 'functional-strength-training', 'krafttraining']],
|
|
'hiit-workout' => ['label' => 'HIIT / Workout', 'icon' => 'hiit', 'aliases' => ['hiit', 'high-intensity-interval-training', 'workout', 'cross-training', 'functional-training']],
|
|
'dance' => ['label' => 'Tanzen', 'icon' => 'dance', 'aliases' => ['dance', 'dancing', 'tanzen']],
|
|
'core' => ['label' => 'Core', 'icon' => 'core', 'aliases' => ['core', 'core-training']],
|
|
'pilates' => ['label' => 'Pilates', 'icon' => 'yoga', 'aliases' => ['pilates']],
|
|
'elliptical' => ['label' => 'Crosstrainer', 'icon' => 'hiit', 'aliases' => ['elliptical', 'cross-trainer', 'crosstrainer']],
|
|
'stair-climbing' => ['label' => 'Treppensteigen', 'icon' => 'hike', 'aliases' => ['stair-climbing', 'stairs', 'treppensteigen']],
|
|
];
|
|
|
|
foreach ($mappings as $id => $mapping) {
|
|
foreach ($mapping['aliases'] as $alias) {
|
|
if ($normalized === $alias || str_contains($normalized, $alias)) {
|
|
return ['id' => $id] + $mapping;
|
|
}
|
|
}
|
|
}
|
|
|
|
$id = normalize_sport_type_id($name) ?: 'workout';
|
|
|
|
return [
|
|
'id' => $id,
|
|
'label' => trim($name) !== '' ? trim($name) : 'Workout',
|
|
'icon' => 'hiit',
|
|
'aliases' => [$id],
|
|
];
|
|
}
|
|
|
|
private function healthWorkoutComment(string $name, array $workout, DateTimeImmutable $start, ?DateTimeImmutable $end): string
|
|
{
|
|
$parts = ['Apple Health · ' . $name];
|
|
if ($end !== null) {
|
|
$parts[] = $start->format('H:i') . '-' . $end->format('H:i');
|
|
}
|
|
|
|
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
|
|
if (is_numeric($heartRate)) {
|
|
$parts[] = 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm';
|
|
}
|
|
|
|
return implode(' · ', $parts);
|
|
}
|
|
|
|
private function healthQuantityLabel(mixed $quantity): string
|
|
{
|
|
if (!is_array($quantity) || !is_numeric($quantity['qty'] ?? null)) {
|
|
return '';
|
|
}
|
|
|
|
$qty = (float) $quantity['qty'];
|
|
$units = trim((string) ($quantity['units'] ?? ''));
|
|
|
|
if ($units === '') {
|
|
return format_points($qty);
|
|
}
|
|
|
|
return format_points($qty) . ' ' . $units;
|
|
}
|
|
|
|
private function healthRouteFromWorkout(array $workout): array
|
|
{
|
|
$route = is_array($workout['route'] ?? null) ? $workout['route'] : [];
|
|
$points = [];
|
|
|
|
foreach ($route as $point) {
|
|
if (!is_array($point)) {
|
|
continue;
|
|
}
|
|
|
|
$lat = $point['latitude'] ?? ($point['lat'] ?? null);
|
|
$lon = $point['longitude'] ?? ($point['lon'] ?? null);
|
|
if (!is_numeric($lat) || !is_numeric($lon)) {
|
|
continue;
|
|
}
|
|
|
|
$lat = (float) $lat;
|
|
$lon = (float) $lon;
|
|
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
|
continue;
|
|
}
|
|
|
|
$points[] = ['lat' => round($lat, 6), 'lon' => round($lon, 6)];
|
|
}
|
|
|
|
if (count($points) <= 180) {
|
|
return $points;
|
|
}
|
|
|
|
$step = max(1, (int) ceil(count($points) / 180));
|
|
$reduced = [];
|
|
foreach ($points as $index => $point) {
|
|
if ($index % $step === 0) {
|
|
$reduced[] = $point;
|
|
}
|
|
}
|
|
|
|
$last = $points[count($points) - 1];
|
|
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
|
|
$reduced[] = $last;
|
|
}
|
|
|
|
return $reduced;
|
|
}
|
|
|
|
private function showDashboard(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$entries = $this->entries->all($user['username']);
|
|
$evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings);
|
|
$evaluatedEntries = array_map(
|
|
fn (array $entry): array => $this->withDashboardImageState($user['username'], $entry),
|
|
$evaluatedEntries
|
|
);
|
|
$dashboardView = $this->normalizeDashboardView((string) ($_GET['view'] ?? 'day'));
|
|
$dashboardDate = (string) ($_GET['date'] ?? today());
|
|
|
|
if (!$this->isValidDate($dashboardDate)) {
|
|
$dashboardDate = today();
|
|
}
|
|
|
|
$entryMap = [];
|
|
foreach ($evaluatedEntries as $entry) {
|
|
$entryMap[(string) ($entry['date'] ?? '')] = $entry;
|
|
}
|
|
|
|
$selectedEntry = $entryMap[$dashboardDate] ?? $this->withDashboardImageState($user['username'], $this->buildEmptyDashboardEntry($dashboardDate, $settings, $entryMap[shift_date($dashboardDate, -1)] ?? null));
|
|
|
|
$summary = $this->buildDashboardSummary($evaluatedEntries);
|
|
$chartData = $this->buildDashboardCharts($evaluatedEntries);
|
|
|
|
View::render('dashboard', [
|
|
'pageTitle' => 'Mood',
|
|
'page' => 'dashboard',
|
|
'pageBodyClass' => 'page-dashboard-immersive',
|
|
'authUser' => $user,
|
|
'settings' => $settings,
|
|
'summary' => $summary,
|
|
'entries' => array_reverse($evaluatedEntries),
|
|
'chartPayload' => encode_payload($chartData),
|
|
'dashboardView' => $dashboardView,
|
|
'dashboardDate' => $dashboardDate,
|
|
'dayEntry' => $selectedEntry,
|
|
'dashboardEventTypes' => day_event_type_options(),
|
|
'dashboardSignals' => signal_scale_options(),
|
|
'dashboardTimeline' => $this->buildDashboardTimeline($selectedEntry, $settings),
|
|
'dashboardCompareDays' => $this->buildDashboardCompareDays($dashboardDate, $entryMap, $settings),
|
|
'dashboardWeek' => $this->buildDashboardWeekView($dashboardDate, $entryMap),
|
|
'dashboardMonth' => $this->buildDashboardMonthView($dashboardDate, $entryMap),
|
|
'dashboardPrevDate' => shift_date($dashboardDate, -1),
|
|
'dashboardNextDate' => shift_date($dashboardDate, 1),
|
|
'dashboardSportTypes' => normalized_sport_types($settings),
|
|
'dashboardWalkMode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
|
'dashboardHasContent' => $this->entryHasContent($selectedEntry, isset($entryMap[$dashboardDate])),
|
|
'dashboardVisualScore' => $this->dashboardVisualScore($selectedEntry, isset($entryMap[$dashboardDate])),
|
|
'dashboardLineLevel' => $this->dashboardLineLevel($selectedEntry, isset($entryMap[$dashboardDate])),
|
|
'dashboardLineTone' => $this->dashboardLineTone($selectedEntry, isset($entryMap[$dashboardDate])),
|
|
]);
|
|
}
|
|
|
|
private function handleDashboard(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$form = (string) ($_POST['form_name'] ?? '');
|
|
$date = (string) ($_POST['date'] ?? today());
|
|
|
|
if (!$this->isValidDate($date)) {
|
|
flash('error', 'Bitte wähle einen gültigen Tag.');
|
|
redirect('/');
|
|
}
|
|
|
|
$entries = $this->entries->all($user['username']);
|
|
$entryMap = [];
|
|
foreach ($entries as $existingEntry) {
|
|
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
|
|
}
|
|
|
|
$current = $entryMap[$date] ?? $this->buildEmptyDashboardEntry($date, $settings, $entryMap[shift_date($date, -1)] ?? null);
|
|
|
|
try {
|
|
if ($form === 'save_day_summary') {
|
|
$current['summary'] = [
|
|
'comment' => trim((string) ($_POST['summary_comment'] ?? '')),
|
|
'mood' => normalize_signal_value($_POST['summary_mood'] ?? 0),
|
|
'energy' => normalize_signal_value($_POST['summary_energy'] ?? 0),
|
|
'stress' => normalize_signal_value($_POST['summary_stress'] ?? 0),
|
|
'alcohol' => isset($_POST['summary_alcohol']) && (string) $_POST['summary_alcohol'] === '1',
|
|
];
|
|
$current['summary_comment'] = $current['summary']['comment'];
|
|
$current['summary_mood'] = $current['summary']['mood'];
|
|
$current['summary_energy'] = $current['summary']['energy'];
|
|
$current['summary_stress'] = $current['summary']['stress'];
|
|
$current['summary_alcohol'] = !empty($current['summary']['alcohol']);
|
|
$current['note'] = $current['summary']['comment'];
|
|
$current['alcohol'] = !empty($current['summary']['alcohol']);
|
|
|
|
$upload = uploaded_files('background_image')[0] ?? null;
|
|
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
|
$previousImage = (string) ($current['background_image'] ?? '');
|
|
$current['background_image'] = $this->storeDashboardImage($user['username'], $date, $upload);
|
|
if ($previousImage !== (string) $current['background_image']) {
|
|
$this->deleteDashboardImage($user['username'], $previousImage);
|
|
}
|
|
}
|
|
|
|
$entryMap[$date] = $current;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
flash('success', 'Die Tagesbilanz wurde gespeichert.');
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
if ($form === 'add_event') {
|
|
$event = $this->dashboardEventFromPost($_POST);
|
|
$upload = uploaded_files('event_image')[0] ?? null;
|
|
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
|
$event['image'] = $this->storeDashboardImage($user['username'], $date, $upload);
|
|
}
|
|
$events = is_array($current['events'] ?? null) ? $current['events'] : [];
|
|
$events[] = $event;
|
|
$current['events'] = $events;
|
|
$entryMap[$date] = $current;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
flash('success', 'Der Moment wurde hinzugefügt.');
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
if ($form === 'update_event') {
|
|
$eventID = trim((string) ($_POST['event_id'] ?? ''));
|
|
$updatedEvent = $this->dashboardEventFromPost($_POST);
|
|
$updatedEvent['id'] = $eventID !== '' ? $eventID : $updatedEvent['id'];
|
|
$upload = uploaded_files('event_image')[0] ?? null;
|
|
$events = [];
|
|
|
|
foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) {
|
|
if (!is_array($event)) {
|
|
continue;
|
|
}
|
|
|
|
if ((string) ($event['id'] ?? '') === $eventID) {
|
|
$updatedEvent['image'] = (string) ($event['image'] ?? '');
|
|
$updatedEvent['source'] = (string) ($event['source'] ?? '');
|
|
$updatedEvent['import_id'] = (string) ($event['import_id'] ?? '');
|
|
$updatedEvent['duration_label'] = (string) ($event['duration_label'] ?? '');
|
|
$updatedEvent['distance_label'] = (string) ($event['distance_label'] ?? '');
|
|
$updatedEvent['energy_label'] = (string) ($event['energy_label'] ?? '');
|
|
$updatedEvent['heart_rate_label'] = (string) ($event['heart_rate_label'] ?? '');
|
|
$updatedEvent['sleep_deep'] = (float) ($event['sleep_deep'] ?? 0);
|
|
$updatedEvent['sleep_rem'] = (float) ($event['sleep_rem'] ?? 0);
|
|
$updatedEvent['sleep_core'] = (float) ($event['sleep_core'] ?? 0);
|
|
$updatedEvent['route'] = is_array($event['route'] ?? null) ? $event['route'] : [];
|
|
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
|
$previousImage = (string) ($event['image'] ?? '');
|
|
$updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload);
|
|
if ($previousImage !== (string) $updatedEvent['image']) {
|
|
$this->deleteDashboardImage($user['username'], $previousImage);
|
|
}
|
|
}
|
|
$events[] = $updatedEvent;
|
|
continue;
|
|
}
|
|
|
|
$events[] = $event;
|
|
}
|
|
|
|
$current['events'] = $events;
|
|
$entryMap[$date] = $current;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
flash('success', 'Der Moment wurde aktualisiert.');
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
if ($form === 'delete_event') {
|
|
$eventID = trim((string) ($_POST['event_id'] ?? ''));
|
|
foreach (is_array($current['events'] ?? null) ? $current['events'] : [] as $event) {
|
|
if (is_array($event) && (string) ($event['id'] ?? '') === $eventID) {
|
|
$this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? ''));
|
|
}
|
|
}
|
|
$current['events'] = array_values(array_filter(
|
|
is_array($current['events'] ?? null) ? $current['events'] : [],
|
|
static fn (array $event): bool => (string) ($event['id'] ?? '') !== $eventID
|
|
));
|
|
$entryMap[$date] = $current;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
flash('success', 'Der Moment wurde entfernt.');
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
if ($form === 'remove_background') {
|
|
$this->deleteDashboardImage($user['username'], (string) ($current['background_image'] ?? ''));
|
|
$current['background_image'] = '';
|
|
$entryMap[$date] = $current;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
flash('success', 'Das Tagesbild wurde entfernt.');
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
} catch (RuntimeException $exception) {
|
|
flash('error', $exception->getMessage());
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
redirect('/?view=day&date=' . rawurlencode($date));
|
|
}
|
|
|
|
private function normalizeDashboardView(string $view): string
|
|
{
|
|
return in_array($view, ['day', 'week', 'month'], true) ? $view : 'day';
|
|
}
|
|
|
|
private function buildEmptyDashboardEntry(string $date, array $settings, ?array $previousEntry = null): array
|
|
{
|
|
$entry = $this->scoring->normalize([
|
|
'date' => $date,
|
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
|
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
|
'summary' => [
|
|
'comment' => '',
|
|
'mood' => 0,
|
|
'energy' => 0,
|
|
'stress' => 0,
|
|
'alcohol' => false,
|
|
],
|
|
'events' => [],
|
|
'background_image' => '',
|
|
]);
|
|
|
|
return array_merge($entry, [
|
|
'evaluation' => $this->scoring->evaluate($entry, $settings, $previousEntry),
|
|
'sport_type_meta' => [],
|
|
]);
|
|
}
|
|
|
|
private function buildDashboardTimeline(array $entry, array $settings): array
|
|
{
|
|
$timeline = [];
|
|
|
|
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
|
|
if (!is_array($event)) {
|
|
continue;
|
|
}
|
|
|
|
$type = (string) ($event['type'] ?? 'event');
|
|
$mood = normalize_signal_value($event['mood'] ?? 0);
|
|
$energy = normalize_signal_value($event['energy'] ?? 0);
|
|
$stress = normalize_signal_value($event['stress'] ?? 0);
|
|
if ($type === 'sleep' && (string) ($event['source'] ?? '') === 'health_auto_export') {
|
|
$signals = $this->healthSleepSignals([
|
|
'hours' => (float) ($event['value'] ?? 0),
|
|
'deep' => (float) ($event['sleep_deep'] ?? 0),
|
|
'rem' => (float) ($event['sleep_rem'] ?? 0),
|
|
'core' => (float) ($event['sleep_core'] ?? 0),
|
|
], (float) ($settings['sleep']['optimal_hours'] ?? 7.0));
|
|
$mood = $signals['mood'];
|
|
$energy = $signals['energy'];
|
|
$stress = $signals['stress'];
|
|
}
|
|
|
|
$timeline[] = [
|
|
'kind' => 'event',
|
|
'id' => (string) ($event['id'] ?? ''),
|
|
'type' => $type,
|
|
'time' => (string) ($event['time'] ?? ''),
|
|
'comment' => (string) ($event['comment'] ?? ''),
|
|
'value' => (float) ($event['value'] ?? 0),
|
|
'unit' => (string) ($event['unit'] ?? ''),
|
|
'sport_type_id' => (string) ($event['sport_type_id'] ?? ''),
|
|
'consumed' => !empty($event['consumed']),
|
|
'image' => (string) ($event['image'] ?? ''),
|
|
'image_url' => is_string($event['image_url'] ?? null) ? (string) $event['image_url'] : null,
|
|
'mood' => $mood,
|
|
'energy' => $energy,
|
|
'stress' => $stress,
|
|
'source' => (string) ($event['source'] ?? ''),
|
|
'import_id' => (string) ($event['import_id'] ?? ''),
|
|
'duration_label' => (string) ($event['duration_label'] ?? ''),
|
|
'distance_label' => (string) ($event['distance_label'] ?? ''),
|
|
'energy_label' => (string) ($event['energy_label'] ?? ''),
|
|
'heart_rate_label' => (string) ($event['heart_rate_label'] ?? ''),
|
|
'sleep_deep' => (float) ($event['sleep_deep'] ?? 0),
|
|
'sleep_rem' => (float) ($event['sleep_rem'] ?? 0),
|
|
'sleep_core' => (float) ($event['sleep_core'] ?? 0),
|
|
'task_titles' => is_array($event['task_titles'] ?? null) ? $event['task_titles'] : [],
|
|
'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []),
|
|
];
|
|
}
|
|
|
|
return $timeline;
|
|
}
|
|
|
|
private function buildOsmRouteMap(array $route): ?array
|
|
{
|
|
$points = array_values(array_filter($route, static function (mixed $point): bool {
|
|
return is_array($point)
|
|
&& is_numeric($point['lat'] ?? null)
|
|
&& is_numeric($point['lon'] ?? null)
|
|
&& (float) $point['lat'] >= -90
|
|
&& (float) $point['lat'] <= 90
|
|
&& (float) $point['lon'] >= -180
|
|
&& (float) $point['lon'] <= 180;
|
|
}));
|
|
|
|
if (count($points) < 2) {
|
|
return null;
|
|
}
|
|
|
|
$width = 320;
|
|
$height = 168;
|
|
$tileSize = 256;
|
|
$padding = 24;
|
|
$zoom = 15;
|
|
|
|
for ($candidateZoom = 16; $candidateZoom >= 3; $candidateZoom--) {
|
|
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $candidateZoom, $tileSize), $points);
|
|
$xs = array_column($projected, 'x');
|
|
$ys = array_column($projected, 'y');
|
|
$spanX = max($xs) - min($xs);
|
|
$spanY = max($ys) - min($ys);
|
|
|
|
if ($spanX <= ($width - ($padding * 2)) && $spanY <= ($height - ($padding * 2))) {
|
|
$zoom = $candidateZoom;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $zoom, $tileSize), $points);
|
|
$xs = array_column($projected, 'x');
|
|
$ys = array_column($projected, 'y');
|
|
$left = ((min($xs) + max($xs)) / 2) - ($width / 2);
|
|
$top = ((min($ys) + max($ys)) / 2) - ($height / 2);
|
|
|
|
$tileMinX = (int) floor($left / $tileSize);
|
|
$tileMaxX = (int) floor(($left + $width) / $tileSize);
|
|
$tileMinY = (int) floor($top / $tileSize);
|
|
$tileMaxY = (int) floor(($top + $height) / $tileSize);
|
|
$tileLimit = 2 ** $zoom;
|
|
$tiles = [];
|
|
|
|
for ($x = $tileMinX; $x <= $tileMaxX; $x++) {
|
|
for ($y = $tileMinY; $y <= $tileMaxY; $y++) {
|
|
if ($y < 0 || $y >= $tileLimit) {
|
|
continue;
|
|
}
|
|
|
|
$wrappedX = (($x % $tileLimit) + $tileLimit) % $tileLimit;
|
|
$tiles[] = [
|
|
'url' => 'https://tile.openstreetmap.org/' . $zoom . '/' . $wrappedX . '/' . $y . '.png',
|
|
'left' => round(($x * $tileSize) - $left, 2),
|
|
'top' => round(($y * $tileSize) - $top, 2),
|
|
];
|
|
}
|
|
}
|
|
|
|
$linePoints = implode(' ', array_map(
|
|
static fn (array $point): string => round($point['x'] - $left, 1) . ',' . round($point['y'] - $top, 1),
|
|
$projected
|
|
));
|
|
|
|
return [
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'tiles' => $tiles,
|
|
'line' => $linePoints,
|
|
];
|
|
}
|
|
|
|
private function projectOsmPoint(float $lat, float $lon, int $zoom, int $tileSize): array
|
|
{
|
|
$lat = max(-85.05112878, min(85.05112878, $lat));
|
|
$scale = (2 ** $zoom) * $tileSize;
|
|
$x = (($lon + 180.0) / 360.0) * $scale;
|
|
$latRad = deg2rad($lat);
|
|
$y = (0.5 - (log(tan($latRad) + (1 / cos($latRad))) / (2 * M_PI))) * $scale;
|
|
|
|
return ['x' => $x, 'y' => $y];
|
|
}
|
|
|
|
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
|
|
{
|
|
$days = [];
|
|
|
|
for ($offset = -3; $offset <= 1; $offset++) {
|
|
$dayDate = shift_date($date, $offset);
|
|
$entry = $entryMap[$dayDate] ?? $this->buildEmptyDashboardEntry($dayDate, $settings, $entryMap[shift_date($dayDate, -1)] ?? null);
|
|
$isPersisted = isset($entryMap[$dayDate]);
|
|
$hasContent = $isPersisted || $this->entryHasContent($entry);
|
|
$visualScore = $this->dashboardVisualScore($entry, $isPersisted);
|
|
$lineLevel = $this->dashboardLineLevel($entry, $isPersisted);
|
|
|
|
$days[] = [
|
|
'date' => $dayDate,
|
|
'short' => (new DateTimeImmutable($dayDate))->format('D'),
|
|
'day' => format_compact_date($dayDate),
|
|
'offset' => $offset,
|
|
'is_current' => $dayDate === $date,
|
|
'has_content' => $hasContent,
|
|
'visual_score' => $visualScore,
|
|
'score_level' => $lineLevel,
|
|
'line_level' => $lineLevel,
|
|
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
|
|
];
|
|
}
|
|
|
|
return $days;
|
|
}
|
|
|
|
private function buildDashboardWeekView(string $date, array $entryMap): array
|
|
{
|
|
$current = new DateTimeImmutable($date);
|
|
$selectedStart = $current->modify('monday this week');
|
|
$selectedKey = $selectedStart->format('Y-m-d');
|
|
$currentStart = (new DateTimeImmutable(today()))->modify('monday this week');
|
|
$currentKey = $currentStart->format('Y-m-d');
|
|
$weekKeys = [$currentKey => true, $selectedKey => true];
|
|
|
|
foreach (array_keys($entryMap) as $entryDate) {
|
|
if (!$this->isValidDate((string) $entryDate)) {
|
|
continue;
|
|
}
|
|
|
|
$weekKeys[(new DateTimeImmutable((string) $entryDate))->modify('monday this week')->format('Y-m-d')] = true;
|
|
}
|
|
|
|
unset($weekKeys[$currentKey]);
|
|
$otherWeekKeys = array_keys($weekKeys);
|
|
rsort($otherWeekKeys, SORT_STRING);
|
|
$orderedWeekKeys = array_merge([$currentKey], $otherWeekKeys);
|
|
|
|
$periods = [];
|
|
foreach ($orderedWeekKeys as $weekKey) {
|
|
$periods[] = $this->buildDashboardWeekPeriod(new DateTimeImmutable((string) $weekKey), $date, $entryMap, $weekKey === $selectedKey);
|
|
}
|
|
|
|
$selectedPeriod = $this->buildDashboardWeekPeriod($selectedStart, $date, $entryMap, true);
|
|
|
|
return array_merge($selectedPeriod, [
|
|
'periods' => $periods,
|
|
]);
|
|
}
|
|
|
|
private function buildDashboardWeekPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
|
|
{
|
|
$days = [];
|
|
|
|
for ($index = 0; $index < 7; $index++) {
|
|
$day = $start->modify('+' . $index . ' day');
|
|
$iso = $day->format('Y-m-d');
|
|
$entry = $entryMap[$iso] ?? null;
|
|
$hasContent = $entry !== null && $this->entryHasContent($entry);
|
|
$lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null;
|
|
|
|
$days[] = [
|
|
'date' => $iso,
|
|
'weekday' => format_display_date($iso, true),
|
|
'short' => $day->format('D'),
|
|
'day' => $day->format('j'),
|
|
'entry' => $entry,
|
|
'has_content' => $hasContent,
|
|
'score_level' => $lineLevel,
|
|
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
|
|
'is_current' => $iso === $selectedDate,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'key' => $start->format('Y-m-d'),
|
|
'title' => 'Woche ' . $start->format('W'),
|
|
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
|
|
'is_selected' => $isSelected,
|
|
'days' => $days,
|
|
'insights' => $this->buildWeekHealthInsights($start, $days, $entryMap),
|
|
];
|
|
}
|
|
|
|
private function buildWeekHealthInsights(DateTimeImmutable $start, array $days, array $entryMap): array
|
|
{
|
|
$weekSteps = [];
|
|
$sportMinutes = 0;
|
|
|
|
foreach ($days as $day) {
|
|
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
|
if ($entry === null) {
|
|
continue;
|
|
}
|
|
|
|
$steps = (int) ($entry['health']['steps'] ?? 0);
|
|
if ($steps > 0) {
|
|
$weekSteps[] = $steps;
|
|
}
|
|
|
|
$sportMinutes += (int) ($entry['sport_minutes'] ?? 0);
|
|
}
|
|
|
|
$weekAverageSteps = $weekSteps !== [] ? (int) round(array_sum($weekSteps) / count($weekSteps)) : 0;
|
|
$previousMonthStart = $start->modify('first day of previous month');
|
|
$previousMonthEnd = $previousMonthStart->modify('last day of this month');
|
|
$previousMonthSteps = [];
|
|
|
|
for ($day = $previousMonthStart; $day <= $previousMonthEnd; $day = $day->modify('+1 day')) {
|
|
$entry = $entryMap[$day->format('Y-m-d')] ?? null;
|
|
$steps = is_array($entry) ? (int) ($entry['health']['steps'] ?? 0) : 0;
|
|
if ($steps > 0) {
|
|
$previousMonthSteps[] = $steps;
|
|
}
|
|
}
|
|
|
|
$previousAverageSteps = $previousMonthSteps !== [] ? (int) round(array_sum($previousMonthSteps) / count($previousMonthSteps)) : 0;
|
|
$stepDifference = $previousAverageSteps > 0 ? $weekAverageSteps - $previousAverageSteps : 0;
|
|
|
|
return [
|
|
'average_steps' => $weekAverageSteps,
|
|
'previous_month_average_steps' => $previousAverageSteps,
|
|
'step_difference' => $stepDifference,
|
|
'step_direction' => $stepDifference >= 0 ? 'mehr' : 'weniger',
|
|
'daily_sport_minutes' => (int) round($sportMinutes / 7),
|
|
'has_step_comparison' => $weekAverageSteps > 0 && $previousAverageSteps > 0,
|
|
];
|
|
}
|
|
|
|
private function buildDashboardMonthView(string $date, array $entryMap): array
|
|
{
|
|
$current = new DateTimeImmutable($date);
|
|
$selectedStart = $current->modify('first day of this month');
|
|
$selectedKey = $selectedStart->format('Y-m-d');
|
|
$currentStart = (new DateTimeImmutable(today()))->modify('first day of this month');
|
|
$currentKey = $currentStart->format('Y-m-d');
|
|
$monthKeys = [$currentKey => true, $selectedKey => true];
|
|
|
|
foreach (array_keys($entryMap) as $entryDate) {
|
|
if (!$this->isValidDate((string) $entryDate)) {
|
|
continue;
|
|
}
|
|
|
|
$monthKeys[(new DateTimeImmutable((string) $entryDate))->modify('first day of this month')->format('Y-m-d')] = true;
|
|
}
|
|
|
|
unset($monthKeys[$currentKey]);
|
|
$otherMonthKeys = array_keys($monthKeys);
|
|
rsort($otherMonthKeys, SORT_STRING);
|
|
$orderedMonthKeys = array_merge([$currentKey], $otherMonthKeys);
|
|
|
|
$periods = [];
|
|
foreach ($orderedMonthKeys as $monthKey) {
|
|
$periods[] = $this->buildDashboardMonthPeriod(new DateTimeImmutable((string) $monthKey), $date, $entryMap, $monthKey === $selectedKey);
|
|
}
|
|
|
|
$selectedPeriod = $this->buildDashboardMonthPeriod($selectedStart, $date, $entryMap, true);
|
|
|
|
return array_merge($selectedPeriod, [
|
|
'periods' => $periods,
|
|
]);
|
|
}
|
|
|
|
private function buildDashboardMonthPeriod(DateTimeImmutable $start, string $selectedDate, array $entryMap, bool $isSelected): array
|
|
{
|
|
$end = $start->modify('last day of this month');
|
|
$days = [];
|
|
|
|
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
|
|
$iso = $day->format('Y-m-d');
|
|
$entry = $entryMap[$iso] ?? null;
|
|
$hasContent = $entry !== null && $this->entryHasContent($entry);
|
|
$lineLevel = $entry !== null ? $this->dashboardLineLevel($entry, true) : null;
|
|
$days[] = [
|
|
'date' => $iso,
|
|
'day' => $day->format('j'),
|
|
'weekday' => format_display_date($iso, true),
|
|
'entry' => $entry,
|
|
'has_content' => $hasContent,
|
|
'score_level' => $lineLevel,
|
|
'line_tone' => $lineLevel === null ? 'empty' : signal_value_class($lineLevel),
|
|
'is_future' => $iso > $selectedDate,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'key' => $start->format('Y-m-d'),
|
|
'title' => month_label($start->format('Y-m')),
|
|
'is_selected' => $isSelected,
|
|
'days' => $days,
|
|
];
|
|
}
|
|
|
|
private function entryHasContent(array $entry, bool $isPersisted = false): bool
|
|
{
|
|
if ($isPersisted) {
|
|
return true;
|
|
}
|
|
|
|
if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') {
|
|
return true;
|
|
}
|
|
|
|
if (!empty($entry['summary']['alcohol'] ?? $entry['summary_alcohol'] ?? false)) {
|
|
return true;
|
|
}
|
|
|
|
if (trim((string) ($entry['background_image'] ?? '')) !== '') {
|
|
return true;
|
|
}
|
|
|
|
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
|
|
return true;
|
|
}
|
|
|
|
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
|
}
|
|
|
|
private function dashboardVisualScore(array $entry, bool $isPersisted = false): ?int
|
|
{
|
|
if (!$this->entryHasContent($entry, $isPersisted)) {
|
|
return null;
|
|
}
|
|
|
|
$summary = is_array($entry['summary'] ?? null) ? $entry['summary'] : [];
|
|
$mood = normalize_signal_value($summary['mood'] ?? $entry['summary_mood'] ?? legacy_to_signal_scale($entry['mood'] ?? 5));
|
|
$energy = normalize_signal_value($summary['energy'] ?? $entry['summary_energy'] ?? legacy_to_signal_scale($entry['energy'] ?? 5));
|
|
$stress = normalize_signal_value($summary['stress'] ?? $entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5));
|
|
|
|
return signal_combo_score($mood, $energy, $stress);
|
|
}
|
|
|
|
private function dashboardLineLevel(array $entry, bool $isPersisted = false): ?int
|
|
{
|
|
if (!$this->entryHasContent($entry, $isPersisted)) {
|
|
return null;
|
|
}
|
|
|
|
if (is_array($entry['evaluation']['balance'] ?? null)) {
|
|
return max(-2, min(2, (int) ($entry['evaluation']['balance']['level'] ?? 0)));
|
|
}
|
|
|
|
$percentage = max(0.0, min(100.0, (float) ($entry['evaluation']['percentage'] ?? 0)));
|
|
|
|
return max(-2, min(2, (int) round(($percentage - 50.0) / 25.0)));
|
|
}
|
|
|
|
private function dashboardLineTone(array $entry, bool $isPersisted = false): string
|
|
{
|
|
return signal_value_class($this->dashboardLineLevel($entry, $isPersisted) ?? 0);
|
|
}
|
|
|
|
private function dashboardEventFromPost(array $input): array
|
|
{
|
|
$type = trim((string) ($input['event_type'] ?? 'event'));
|
|
if (!array_key_exists($type, day_event_type_options())) {
|
|
$type = 'event';
|
|
}
|
|
|
|
$time = trim((string) ($input['event_time'] ?? ''));
|
|
if (!$this->isValidTime($time)) {
|
|
$time = date('H:i');
|
|
}
|
|
|
|
$comment = trim((string) ($input['event_comment'] ?? ''));
|
|
|
|
$value = max(0, min(50000, $this->localizedFloat($input['event_value'] ?? 0)));
|
|
if (in_array($type, ['walk', 'sport', 'sleep'], true) && $value <= 0) {
|
|
throw new RuntimeException('Für diesen Moment braucht es einen Wert oder eine Dauer.');
|
|
}
|
|
|
|
$sportTypeID = trim((string) ($input['event_sport_type_id'] ?? ''));
|
|
if ($type === 'sport' && $sportTypeID === '') {
|
|
throw new RuntimeException('Bitte wähle eine Sportart.');
|
|
}
|
|
|
|
$unit = trim((string) ($input['event_unit'] ?? day_event_type_unit($type)));
|
|
if ($type === 'walk') {
|
|
$walkMode = trim((string) ($input['event_walk_mode'] ?? 'time'));
|
|
$unit = $walkMode === 'steps' ? 'steps' : 'min';
|
|
}
|
|
|
|
return [
|
|
'id' => 'evt-' . substr(sha1((string) microtime(true) . $type . $time . (string) ($input['event_comment'] ?? '')), 0, 12),
|
|
'type' => $type,
|
|
'time' => $time,
|
|
'comment' => $comment,
|
|
'value' => $value,
|
|
'unit' => $unit,
|
|
'sport_type_id' => $sportTypeID,
|
|
'consumed' => isset($input['event_consumed']) ? ((string) $input['event_consumed'] === '1') : true,
|
|
'mood' => normalize_signal_value($input['event_mood'] ?? 0),
|
|
'energy' => normalize_signal_value($input['event_energy'] ?? 0),
|
|
'stress' => normalize_signal_value($input['event_stress'] ?? 0),
|
|
];
|
|
}
|
|
|
|
private function localizedFloat(mixed $value): float
|
|
{
|
|
$normalized = str_replace(',', '.', trim((string) $value));
|
|
|
|
return is_numeric($normalized) ? (float) $normalized : 0.0;
|
|
}
|
|
|
|
private function dashboardMediaDirectory(string $username): string
|
|
{
|
|
return storage_path('users/' . normalize_username($username) . '/media');
|
|
}
|
|
|
|
private function withDashboardImageState(string $username, array $entry): array
|
|
{
|
|
$fileName = trim((string) ($entry['background_image'] ?? ''));
|
|
$date = (string) ($entry['date'] ?? '');
|
|
|
|
$entry['background_image_url'] = null;
|
|
$events = [];
|
|
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
|
|
if (!is_array($event)) {
|
|
continue;
|
|
}
|
|
|
|
$event['image_url'] = null;
|
|
$eventImage = trim((string) ($event['image'] ?? ''));
|
|
if ($eventImage !== '' && is_file($this->dashboardMediaDirectory($username) . '/' . basename($eventImage))) {
|
|
$event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? ''));
|
|
}
|
|
$events[] = $event;
|
|
}
|
|
$entry['events'] = $events;
|
|
|
|
if ($fileName === '' || !$this->isValidDate($date)) {
|
|
return $entry;
|
|
}
|
|
|
|
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
|
|
if (is_file($path)) {
|
|
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date);
|
|
}
|
|
|
|
return $entry;
|
|
}
|
|
|
|
private function storeDashboardImage(string $username, string $date, array $upload): string
|
|
{
|
|
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
|
|
if ($error !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('Das Bild konnte nicht hochgeladen werden.');
|
|
}
|
|
|
|
$tmpName = (string) ($upload['tmp_name'] ?? '');
|
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
|
throw new RuntimeException('Die hochgeladene Bilddatei ist ungültig.');
|
|
}
|
|
|
|
$mime = mime_content_type($tmpName) ?: '';
|
|
$extension = match ($mime) {
|
|
'image/jpeg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/webp' => 'webp',
|
|
default => '',
|
|
};
|
|
|
|
if ($extension === '') {
|
|
throw new RuntimeException('Bitte nutze JPG, PNG oder WebP als Bild.');
|
|
}
|
|
|
|
$directory = $this->dashboardMediaDirectory($username);
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0775, true);
|
|
}
|
|
|
|
$hash = hash_file('sha256', $tmpName);
|
|
if (!is_string($hash) || $hash === '') {
|
|
throw new RuntimeException('Das Bild konnte nicht gelesen werden.');
|
|
}
|
|
|
|
$targetExtension = function_exists('imagecreatetruecolor') ? 'webp' : $extension;
|
|
$fileName = $date . '-' . substr($hash, 0, 16) . '.' . $targetExtension;
|
|
$target = $directory . '/' . $fileName;
|
|
|
|
if (is_file($target) && $targetExtension !== 'webp') {
|
|
return $fileName;
|
|
}
|
|
|
|
if ($targetExtension === 'webp' && $this->writeOptimizedDashboardImage($tmpName, $mime, $target)) {
|
|
return $fileName;
|
|
}
|
|
|
|
$fileName = $date . '-' . substr($hash, 0, 16) . '.' . $extension;
|
|
$target = $directory . '/' . $fileName;
|
|
if (is_file($target)) {
|
|
return $fileName;
|
|
}
|
|
|
|
if (!move_uploaded_file($tmpName, $target)) {
|
|
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
|
|
}
|
|
|
|
return $fileName;
|
|
}
|
|
|
|
private function writeOptimizedDashboardImage(string $sourcePath, string $mime, string $target): bool
|
|
{
|
|
if (!function_exists('imagecreatetruecolor') || !function_exists('imagewebp')) {
|
|
return false;
|
|
}
|
|
|
|
$source = match ($mime) {
|
|
'image/jpeg' => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($sourcePath) : false,
|
|
'image/png' => function_exists('imagecreatefrompng') ? @imagecreatefrompng($sourcePath) : false,
|
|
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false,
|
|
default => false,
|
|
};
|
|
|
|
if (!$source instanceof GdImage) {
|
|
return false;
|
|
}
|
|
|
|
$source = $this->applyImageOrientation($source, $sourcePath, $mime);
|
|
|
|
$width = imagesx($source);
|
|
$height = imagesy($source);
|
|
if ($width <= 0 || $height <= 0) {
|
|
imagedestroy($source);
|
|
return false;
|
|
}
|
|
|
|
$maxWidth = 1800;
|
|
$maxHeight = 1800;
|
|
$scale = min(1.0, $maxWidth / $width, $maxHeight / $height);
|
|
$targetWidth = max(1, (int) round($width * $scale));
|
|
$targetHeight = max(1, (int) round($height * $scale));
|
|
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
if (!$canvas instanceof GdImage) {
|
|
imagedestroy($source);
|
|
return false;
|
|
}
|
|
|
|
imagealphablending($canvas, true);
|
|
imagesavealpha($canvas, true);
|
|
imagecopyresampled($canvas, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
|
$written = imagewebp($canvas, $target, 84);
|
|
imagedestroy($source);
|
|
imagedestroy($canvas);
|
|
|
|
return $written && is_file($target);
|
|
}
|
|
|
|
private function applyImageOrientation(GdImage $source, string $sourcePath, string $mime): GdImage
|
|
{
|
|
if ($mime !== 'image/jpeg' || !function_exists('exif_read_data')) {
|
|
return $source;
|
|
}
|
|
|
|
$exif = @exif_read_data($sourcePath);
|
|
$orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1;
|
|
$oriented = match ($orientation) {
|
|
3 => imagerotate($source, 180, 0),
|
|
6 => imagerotate($source, -90, 0),
|
|
8 => imagerotate($source, 90, 0),
|
|
default => $source,
|
|
};
|
|
|
|
if ($oriented instanceof GdImage && $oriented !== $source) {
|
|
imagedestroy($source);
|
|
|
|
return $oriented;
|
|
}
|
|
|
|
return $source;
|
|
}
|
|
|
|
private function deleteDashboardImage(string $username, string $fileName): void
|
|
{
|
|
$fileName = basename(trim($fileName));
|
|
if ($fileName === '' || $this->dashboardImageReferenceCount($username, $fileName) > 1) {
|
|
return;
|
|
}
|
|
|
|
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
private function dashboardImageReferenceCount(string $username, string $fileName): int
|
|
{
|
|
$fileName = basename(trim($fileName));
|
|
if ($fileName === '') {
|
|
return 0;
|
|
}
|
|
|
|
$count = 0;
|
|
foreach ($this->entries->all($username) as $entry) {
|
|
if (!is_array($entry)) {
|
|
continue;
|
|
}
|
|
|
|
if (basename((string) ($entry['background_image'] ?? '')) === $fileName) {
|
|
$count++;
|
|
}
|
|
|
|
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
|
|
if (is_array($event) && basename((string) ($event['image'] ?? '')) === $fileName) {
|
|
$count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
private function serveDayImage(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$date = (string) ($_GET['date'] ?? '');
|
|
|
|
if (!$this->isValidDate($date)) {
|
|
http_response_code(404);
|
|
exit('Nicht gefunden');
|
|
}
|
|
|
|
$entry = $this->entries->find($user['username'], $date);
|
|
$fileName = trim((string) ($entry['background_image'] ?? ''));
|
|
if ($fileName === '') {
|
|
http_response_code(404);
|
|
exit('Nicht gefunden');
|
|
}
|
|
|
|
$path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName);
|
|
if (!is_file($path)) {
|
|
http_response_code(404);
|
|
exit('Nicht gefunden');
|
|
}
|
|
|
|
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
|
header('Content-Type: ' . $mime);
|
|
header('Content-Length: ' . (string) filesize($path));
|
|
header('Cache-Control: private, max-age=604800');
|
|
readfile($path);
|
|
exit;
|
|
}
|
|
|
|
private function serveEventImage(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$date = (string) ($_GET['date'] ?? '');
|
|
$eventID = trim((string) ($_GET['id'] ?? ''));
|
|
|
|
if (!$this->isValidDate($date) || $eventID === '') {
|
|
http_response_code(404);
|
|
exit('Nicht gefunden');
|
|
}
|
|
|
|
$entry = $this->entries->find($user['username'], $date);
|
|
foreach (is_array($entry['events'] ?? null) ? $entry['events'] : [] as $event) {
|
|
if (!is_array($event) || (string) ($event['id'] ?? '') !== $eventID) {
|
|
continue;
|
|
}
|
|
|
|
$fileName = trim((string) ($event['image'] ?? ''));
|
|
$path = $this->dashboardMediaDirectory($user['username']) . '/' . basename($fileName);
|
|
if ($fileName === '' || !is_file($path)) {
|
|
break;
|
|
}
|
|
|
|
$mime = mime_content_type($path) ?: 'application/octet-stream';
|
|
header('Content-Type: ' . $mime);
|
|
header('Content-Length: ' . (string) filesize($path));
|
|
header('Cache-Control: private, max-age=604800');
|
|
readfile($path);
|
|
exit;
|
|
}
|
|
|
|
http_response_code(404);
|
|
exit('Nicht gefunden');
|
|
}
|
|
|
|
private function showTrack(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$date = (string) ($_GET['date'] ?? today());
|
|
if (!$this->isValidDate($date)) {
|
|
$date = today();
|
|
}
|
|
$entry = $this->entries->find($user['username'], $date) ?? [
|
|
'date' => $date,
|
|
'mood' => 6,
|
|
'energy' => 6,
|
|
'stress' => 4,
|
|
'pain' => 1,
|
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
|
'sleep_hours' => 7,
|
|
'sleep_feeling' => 3,
|
|
'sport_minutes' => 0,
|
|
'sport_type' => '',
|
|
'sport_types' => [],
|
|
'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);
|
|
|
|
View::render('track', [
|
|
'pageTitle' => 'Tag tracken',
|
|
'page' => 'track',
|
|
'authUser' => $user,
|
|
'entry' => $entry,
|
|
'evaluation' => $evaluation,
|
|
'settings' => $settings,
|
|
'sportTypes' => normalized_sport_types($settings),
|
|
'trackMood' => $evaluation['sentiment'],
|
|
'topbarDate' => $entry['date'],
|
|
'trackPayload' => encode_payload([
|
|
'settings' => $settings,
|
|
'entry' => $entry,
|
|
'previousEntry' => $previousEntry !== null ? $this->scoring->normalize($previousEntry) : null,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
private function handleTrack(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
|
|
$entry = $this->scoring->normalize([
|
|
'date' => $_POST['date'] ?? today(),
|
|
'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,
|
|
'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,
|
|
'alcohol' => $_POST['alcohol'] ?? false,
|
|
'note' => $_POST['note'] ?? '',
|
|
]);
|
|
|
|
if (!$this->isValidDate($entry['date'])) {
|
|
flash('error', 'Bitte wähle ein gültiges Datum.');
|
|
redirect('/track');
|
|
}
|
|
|
|
$entries = $this->entries->all($user['username']);
|
|
$entryMap = [];
|
|
|
|
foreach ($entries as $existingEntry) {
|
|
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
|
|
}
|
|
|
|
$entryMap[$entry['date']] = $entry;
|
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
|
|
|
flash('success', 'Der Tag wurde gespeichert.');
|
|
redirect('/track?date=' . rawurlencode($entry['date']));
|
|
}
|
|
|
|
private function showArchive(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$view = $this->normalizeArchiveView((string) ($_GET['view'] ?? 'days'));
|
|
$filterMonth = trim((string) ($_GET['filter_month'] ?? ''));
|
|
if ($filterMonth !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $filterMonth) !== 1) {
|
|
$filterMonth = '';
|
|
}
|
|
|
|
$selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null;
|
|
$selectedWeekKey = isset($_GET['week'] ) ? trim((string) $_GET['week']) : null;
|
|
$selectedMonthKey = isset($_GET['month_key']) ? trim((string) $_GET['month_key']) : null;
|
|
$entries = $this->entries->all($user['username']);
|
|
$archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings));
|
|
$weeklySummaries = $this->summaries->weekly($user['username']);
|
|
$monthlySummaries = $this->summaries->monthly($user['username']);
|
|
$weeklyArchive = $this->buildWeeklyArchiveCards($archive, $weeklySummaries);
|
|
$monthlyArchive = $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries, $weeklyArchive);
|
|
$monthOptions = $this->buildArchiveMonthOptions($archive, $weeklyArchive, $monthlyArchive);
|
|
|
|
$filteredDays = $filterMonth === ''
|
|
? $archive
|
|
: array_values(array_filter(
|
|
$archive,
|
|
static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $filterMonth
|
|
));
|
|
|
|
$filteredWeeks = $filterMonth === ''
|
|
? $weeklyArchive
|
|
: array_values(array_filter(
|
|
$weeklyArchive,
|
|
fn (array $week): bool => $this->archiveItemOverlapsMonth($week, $filterMonth)
|
|
));
|
|
|
|
$filteredMonths = $filterMonth === ''
|
|
? $monthlyArchive
|
|
: array_values(array_filter(
|
|
$monthlyArchive,
|
|
static fn (array $month): bool => (string) ($month['summary_key'] ?? '') === $filterMonth
|
|
));
|
|
|
|
$selectedEntry = null;
|
|
if ($view === 'days' && $selectedDate !== null) {
|
|
foreach ($filteredDays as $entry) {
|
|
if ($entry['date'] === $selectedDate) {
|
|
$selectedEntry = $entry;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$selectedWeek = null;
|
|
if ($view === 'weeks' && $selectedWeekKey !== null) {
|
|
foreach ($filteredWeeks as $week) {
|
|
if (($week['summary_key'] ?? '') === $selectedWeekKey) {
|
|
$selectedWeek = $week;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
$selectedMonth = null;
|
|
if ($view === 'months' && $selectedMonthKey !== null) {
|
|
foreach ($filteredMonths as $month) {
|
|
if (($month['summary_key'] ?? '') === $selectedMonthKey) {
|
|
$selectedMonth = $month;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
View::render('archive', [
|
|
'pageTitle' => 'Archiv',
|
|
'page' => 'archive',
|
|
'authUser' => $user,
|
|
'entries' => $filteredDays,
|
|
'selectedEntry' => $selectedEntry,
|
|
'selectedWeek' => $selectedWeek,
|
|
'selectedMonth' => $selectedMonth,
|
|
'settings' => $settings,
|
|
'archiveView' => $view,
|
|
'archiveFilterMonth' => $filterMonth,
|
|
'archiveMonthOptions' => $monthOptions,
|
|
'weeklyArchive' => $filteredWeeks,
|
|
'monthlyArchive' => $filteredMonths,
|
|
'aiAvailable' => $this->openAi->isAvailable(),
|
|
]);
|
|
}
|
|
|
|
private function handleArchive(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
|
|
$form = (string) ($_POST['form_name'] ?? '');
|
|
$returnView = $this->normalizeArchiveView((string) ($_POST['view'] ?? 'days'));
|
|
$returnFilterMonth = trim((string) ($_POST['filter_month'] ?? ''));
|
|
if ($returnFilterMonth !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $returnFilterMonth) !== 1) {
|
|
$returnFilterMonth = '';
|
|
}
|
|
|
|
try {
|
|
if ($form === 'generate_weekly_summary') {
|
|
$weekKey = trim((string) ($_POST['week_key'] ?? ''));
|
|
$context = $this->buildWeeklySummaryContext($weekKey, $entries);
|
|
$text = $this->openAi->generateWeekly($context['prompt']);
|
|
|
|
$this->summaries->save($user['username'], 'weekly', $weekKey, [
|
|
'title' => 'Wochenzusammenfassung ' . iso_week_label($weekKey),
|
|
'created_at' => date(DATE_ATOM),
|
|
'date_from' => $context['date_from'],
|
|
'date_to' => $context['date_to'],
|
|
'text' => $text,
|
|
]);
|
|
|
|
flash('success', 'Die KI-Wochenzusammenfassung wurde erstellt.');
|
|
redirect($this->archivePath([
|
|
'view' => 'weeks',
|
|
'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : month_key($context['date_to']),
|
|
'week' => $weekKey,
|
|
]));
|
|
}
|
|
|
|
if ($form === 'generate_monthly_summary') {
|
|
$monthKey = trim((string) ($_POST['month_key'] ?? ''));
|
|
$weeklySummaries = $this->summaries->weekly($user['username']);
|
|
$context = $this->buildMonthlySummaryContext($monthKey, $entries, $weeklySummaries);
|
|
$text = $this->openAi->generateMonthly($context['prompt']);
|
|
preg_match('/^(\d{4})-(\d{2})$/', $monthKey, $monthMatches);
|
|
|
|
$this->summaries->save($user['username'], 'monthly', $monthKey, [
|
|
'title' => 'Monatszusammenfassung ' . (string) ($monthMatches[2] ?? '') . ' / ' . (string) ($monthMatches[1] ?? ''),
|
|
'created_at' => date(DATE_ATOM),
|
|
'date_from' => $context['date_from'],
|
|
'date_to' => $context['date_to'],
|
|
'text' => $text,
|
|
]);
|
|
|
|
flash('success', 'Die KI-Monatszusammenfassung wurde erstellt.');
|
|
redirect($this->archivePath([
|
|
'view' => 'months',
|
|
'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : $monthKey,
|
|
'month_key' => $monthKey,
|
|
]));
|
|
}
|
|
} catch (RuntimeException $exception) {
|
|
flash('error', $exception->getMessage());
|
|
redirect($this->archivePath([
|
|
'view' => $returnView,
|
|
'filter_month' => $returnFilterMonth,
|
|
]));
|
|
}
|
|
|
|
redirect($this->archivePath([
|
|
'view' => $returnView,
|
|
'filter_month' => $returnFilterMonth,
|
|
]));
|
|
}
|
|
|
|
private function showOptions(): void
|
|
{
|
|
$user = $this->requireUser();
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$evaluatedEntries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
|
|
$sportTypePresets = array_values(array_filter(
|
|
Defaults::settings()['sport_types'],
|
|
static function (array $preset) use ($settings): bool {
|
|
foreach (normalized_sport_types($settings) as $type) {
|
|
if (($type['id'] ?? '') === ($preset['id'] ?? '')) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
));
|
|
$pushAvailable = $this->webPush->isAvailable();
|
|
$pushPublicKey = null;
|
|
|
|
if ($pushAvailable) {
|
|
try {
|
|
$pushPublicKey = $this->webPush->publicKey();
|
|
} catch (RuntimeException) {
|
|
$pushAvailable = false;
|
|
}
|
|
}
|
|
|
|
$pendingHealthTokens = is_array($_SESSION['_health_import_token'] ?? null) ? $_SESSION['_health_import_token'] : [];
|
|
$healthImportToken = $pendingHealthTokens[$user['username']] ?? null;
|
|
if (is_string($healthImportToken)) {
|
|
unset($_SESSION['_health_import_token'][$user['username']]);
|
|
} else {
|
|
$healthImportToken = null;
|
|
}
|
|
|
|
$pendingPutzligaTokens = is_array($_SESSION['_putzliga_import_token'] ?? null) ? $_SESSION['_putzliga_import_token'] : [];
|
|
$putzligaImportToken = $pendingPutzligaTokens[$user['username']] ?? null;
|
|
if (is_string($putzligaImportToken)) {
|
|
unset($_SESSION['_putzliga_import_token'][$user['username']]);
|
|
} else {
|
|
$putzligaImportToken = null;
|
|
}
|
|
|
|
$optionsOpenPanel = trim((string) ($_GET['panel'] ?? ''));
|
|
if ($optionsOpenPanel === 'score') {
|
|
$optionsOpenPanel = '';
|
|
}
|
|
|
|
View::render('options', [
|
|
'pageTitle' => 'Optionen',
|
|
'page' => 'options',
|
|
'authUser' => $user,
|
|
'optionsOpenPanel' => $optionsOpenPanel,
|
|
'settings' => $settings,
|
|
'sportTypePresets' => $sportTypePresets,
|
|
'sportLocationOptions' => sport_location_options(),
|
|
'walkModeOptions' => walk_mode_options(),
|
|
'pushAvailable' => $pushAvailable,
|
|
'pushPublicKey' => $pushPublicKey,
|
|
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
|
|
'healthImportConfig' => $this->users->healthImportConfig($user['username']),
|
|
'healthImportToken' => $healthImportToken,
|
|
'healthImportUrl' => app_origin() . '/api/health',
|
|
'putzligaImportConfig' => $this->users->putzligaImportConfig($user['username']),
|
|
'putzligaImportToken' => $putzligaImportToken,
|
|
'putzligaImportUrl' => app_origin() . '/api/putzliga',
|
|
'backupAvailable' => class_exists('ZipArchive'),
|
|
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
|
|
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
|
|
'users' => $user['is_admin'] ? $this->users->all() : [],
|
|
'statsSummary' => $this->buildDashboardSummary($evaluatedEntries),
|
|
'statsChartPayload' => encode_payload($this->buildDashboardCharts($evaluatedEntries)),
|
|
'maxScore' => $this->scoring->evaluate([
|
|
'mood' => 10,
|
|
'energy' => 10,
|
|
'stress' => 1,
|
|
'pain' => 1,
|
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
|
'sleep_hours' => 7,
|
|
'sleep_feeling' => 5,
|
|
'sport_minutes' => 999,
|
|
'sport_types' => array_map(
|
|
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,
|
|
'alcohol' => false,
|
|
'note' => 'x',
|
|
], $settings)['max_total'],
|
|
]);
|
|
}
|
|
|
|
private function handleOptions(): void
|
|
{
|
|
$this->enforceCsrf();
|
|
|
|
$user = $this->requireUser();
|
|
$form = (string) ($_POST['form_name'] ?? '');
|
|
|
|
if ($form === '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');
|
|
}
|
|
|
|
if ($form === 'ai_config' && ($user['is_admin'] ?? false)) {
|
|
$this->aiConfig->save($_POST['ai'] ?? []);
|
|
flash('success', 'Die zentrale KI-Konfiguration wurde aktualisiert.');
|
|
redirect('/options');
|
|
}
|
|
|
|
if ($form === 'export_backup') {
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
$this->downloadUserBackup($user, $settings);
|
|
}
|
|
|
|
if ($form === 'import_backup') {
|
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
|
|
|
try {
|
|
$imported = $this->importUserBackup($user, $settings);
|
|
flash(
|
|
'success',
|
|
$imported === 1
|
|
? '1 Archivobjekt wurde aus dem Backup verarbeitet.'
|
|
: $imported . ' Archivobjekte wurden aus dem Backup verarbeitet.'
|
|
);
|
|
} catch (RuntimeException $exception) {
|
|
flash('error', $exception->getMessage());
|
|
}
|
|
|
|
redirect('/options');
|
|
}
|
|
|
|
if ($form === 'health_import_token') {
|
|
$token = $this->users->issueHealthImportToken($user['username']);
|
|
$_SESSION['_health_import_token'][$user['username']] = $token;
|
|
flash('success', 'Der Health-Import-Token wurde erstellt. Kopiere ihn jetzt in Health Auto Export.');
|
|
redirect('/options?panel=health');
|
|
}
|
|
|
|
if ($form === 'health_import_revoke') {
|
|
$this->users->revokeHealthImportToken($user['username']);
|
|
flash('success', 'Der Health-Import-Token wurde deaktiviert.');
|
|
redirect('/options?panel=health');
|
|
}
|
|
|
|
if ($form === 'putzliga_import_token') {
|
|
$token = $this->users->issuePutzligaImportToken($user['username']);
|
|
$_SESSION['_putzliga_import_token'][$user['username']] = $token;
|
|
flash('success', 'Der Putzliga-Token wurde erstellt. Kopiere ihn in Putzliga.');
|
|
redirect('/options?panel=health');
|
|
}
|
|
|
|
if ($form === 'putzliga_import_revoke') {
|
|
$this->users->revokePutzligaImportToken($user['username']);
|
|
flash('success', 'Der Putzliga-Token wurde deaktiviert.');
|
|
redirect('/options?panel=health');
|
|
}
|
|
|
|
if ($form === 'password') {
|
|
$current = (string) ($_POST['current_password'] ?? '');
|
|
$new = (string) ($_POST['new_password'] ?? '');
|
|
$confirm = (string) ($_POST['new_password_confirm'] ?? '');
|
|
|
|
if ($this->users->verify($user['username'], $current) === null) {
|
|
flash('error', 'Das aktuelle Passwort stimmt nicht.');
|
|
redirect('/options');
|
|
}
|
|
|
|
if (!$this->isStrongPassword($new)) {
|
|
flash('error', 'Das neue Passwort sollte mindestens 10 Zeichen lang sein.');
|
|
redirect('/options');
|
|
}
|
|
|
|
if ($new !== $confirm) {
|
|
flash('error', 'Die neuen Passwörter stimmen nicht überein.');
|
|
redirect('/options');
|
|
}
|
|
|
|
$this->users->changePassword($user['username'], $new);
|
|
flash('success', 'Dein Passwort wurde aktualisiert.');
|
|
redirect('/options');
|
|
}
|
|
|
|
if ($form === 'create_user' && ($user['is_admin'] ?? false)) {
|
|
$username = trim((string) ($_POST['username'] ?? ''));
|
|
$password = (string) ($_POST['password'] ?? '');
|
|
$isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1';
|
|
|
|
if (!$this->isValidUsername($username)) {
|
|
flash('error', 'Bitte nutze für neue Accounts einen sauberen Benutzernamen.');
|
|
redirect('/options');
|
|
}
|
|
|
|
if (!$this->isStrongPassword($password)) {
|
|
flash('error', 'Das Startpasswort sollte mindestens 10 Zeichen lang sein.');
|
|
redirect('/options');
|
|
}
|
|
|
|
try {
|
|
$this->users->create($username, $password, $isAdmin);
|
|
flash('success', 'Der neue Account wurde angelegt.');
|
|
} catch (RuntimeException $exception) {
|
|
flash('error', $exception->getMessage());
|
|
}
|
|
|
|
redirect('/options');
|
|
}
|
|
|
|
redirect('/options');
|
|
}
|
|
|
|
private function buildDashboardSummary(array $entries): array
|
|
{
|
|
$count = count($entries);
|
|
$todayEntry = null;
|
|
|
|
foreach ($entries as $entry) {
|
|
if ($entry['date'] === today()) {
|
|
$todayEntry = $entry;
|
|
}
|
|
}
|
|
|
|
$avgScore = $count > 0
|
|
? round(array_sum(array_map(static fn (array $entry): float => (float) $entry['evaluation']['total'], $entries)) / $count, 1)
|
|
: 0.0;
|
|
|
|
$avgBalance = $count > 0
|
|
? round(array_sum(array_map(static fn (array $entry): float => (float) ($entry['evaluation']['balance']['raw'] ?? 0), $entries)) / $count, 1)
|
|
: 0.0;
|
|
|
|
$avgMood = $count > 0
|
|
? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['mood'], $entries)) / $count, 1)
|
|
: 0.0;
|
|
|
|
$avgStress = $count > 0
|
|
? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['stress'], $entries)) / $count, 1)
|
|
: 0.0;
|
|
|
|
return [
|
|
'tracked_days' => $count,
|
|
'average_score' => $avgScore,
|
|
'average_balance' => $avgBalance,
|
|
'average_mood' => $avgMood,
|
|
'average_stress' => $avgStress,
|
|
'streak' => $this->calculateStreak($entries),
|
|
'today' => $todayEntry,
|
|
];
|
|
}
|
|
|
|
private function persistUserEntries(string $username, array $settings, array $entries): void
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
if (!is_array($entry)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedEntry = $this->scoring->normalize($entry);
|
|
if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[$normalizedEntry['date']] = $normalizedEntry;
|
|
}
|
|
|
|
ksort($normalized, SORT_STRING);
|
|
|
|
$previousEntry = null;
|
|
foreach ($normalized as $date => $entry) {
|
|
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
|
$this->entries->save($username, $date, $entry, $evaluation);
|
|
$previousEntry = $entry;
|
|
}
|
|
}
|
|
|
|
private function downloadUserBackup(array $user, array $settings): never
|
|
{
|
|
if (!class_exists('ZipArchive')) {
|
|
flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.');
|
|
redirect('/options');
|
|
}
|
|
|
|
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
|
|
$tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-');
|
|
|
|
if ($tempPath === false) {
|
|
throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.');
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
$opened = $zip->open($tempPath, ZipArchive::OVERWRITE);
|
|
|
|
if ($opened !== true) {
|
|
@unlink($tempPath);
|
|
throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.');
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
$date = (string) ($entry['date'] ?? '');
|
|
if (!$this->isValidDate($date)) {
|
|
continue;
|
|
}
|
|
|
|
$markdown = $this->entries->exportMarkdown(
|
|
(string) ($user['username'] ?? ''),
|
|
$date,
|
|
$entry,
|
|
$entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings)
|
|
);
|
|
|
|
$zip->addFromString($date . '.txt', $markdown);
|
|
}
|
|
|
|
foreach ($this->summaries->exportBackupFiles((string) ($user['username'] ?? '')) as $summaryFile) {
|
|
$path = trim((string) ($summaryFile['path'] ?? ''));
|
|
$content = (string) ($summaryFile['content'] ?? '');
|
|
|
|
if ($path === '' || $content === '') {
|
|
continue;
|
|
}
|
|
|
|
$zip->addFromString($path, $content);
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
|
|
|
|
header('Content-Type: application/zip');
|
|
header('Content-Disposition: attachment; filename="' . $fileName . '"');
|
|
header('Content-Length: ' . (string) filesize($tempPath));
|
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
|
header('Pragma: no-cache');
|
|
|
|
readfile($tempPath);
|
|
@unlink($tempPath);
|
|
exit;
|
|
}
|
|
|
|
private function importUserBackup(array $user, array $settings): int
|
|
{
|
|
$files = uploaded_files('backup_files');
|
|
if ($files === []) {
|
|
throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.');
|
|
}
|
|
|
|
$importedEntries = [];
|
|
$importedSummaryCount = 0;
|
|
|
|
foreach ($files as $file) {
|
|
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
|
if ($error === UPLOAD_ERR_NO_FILE) {
|
|
continue;
|
|
}
|
|
|
|
if ($error !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.');
|
|
}
|
|
|
|
$tmpName = (string) ($file['tmp_name'] ?? '');
|
|
$name = trim((string) ($file['name'] ?? ''));
|
|
|
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
|
throw new RuntimeException('Eine Backup-Datei ist ungültig.');
|
|
}
|
|
|
|
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
|
|
if ($extension === 'zip') {
|
|
$zipContent = $this->entriesFromZip($user['username'], $tmpName);
|
|
foreach ($zipContent['entries'] as $date => $entry) {
|
|
$importedEntries[$date] = $entry;
|
|
}
|
|
$importedSummaryCount += (int) ($zipContent['summaries'] ?? 0);
|
|
continue;
|
|
}
|
|
|
|
if ($extension !== 'txt') {
|
|
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
|
|
}
|
|
|
|
$content = (string) file_get_contents($tmpName);
|
|
if ($this->summaries->importBackupFile((string) ($user['username'] ?? ''), $name, $content)) {
|
|
$importedSummaryCount++;
|
|
continue;
|
|
}
|
|
|
|
$date = $this->dateFromBackupFileName($name);
|
|
$entry = $this->entries->parseMarkdown($content, $date);
|
|
|
|
if ($entry === null) {
|
|
throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.');
|
|
}
|
|
|
|
$importedEntries[$date] = $entry;
|
|
}
|
|
|
|
if ($importedEntries === [] && $importedSummaryCount === 0) {
|
|
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien oder KI-Zusammenfassungen gefunden.');
|
|
}
|
|
|
|
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
|
|
$entryMap = [];
|
|
|
|
foreach ($existingEntries as $entry) {
|
|
if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) {
|
|
continue;
|
|
}
|
|
|
|
$entryMap[$entry['date']] = $entry;
|
|
}
|
|
|
|
foreach ($importedEntries as $date => $entry) {
|
|
$entryMap[$date] = $entry;
|
|
}
|
|
|
|
if ($importedEntries !== []) {
|
|
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
|
|
}
|
|
|
|
return count($importedEntries) + $importedSummaryCount;
|
|
}
|
|
|
|
private function entriesFromZip(string $username, string $path): array
|
|
{
|
|
if (!class_exists('ZipArchive')) {
|
|
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
|
|
}
|
|
|
|
$zip = new ZipArchive();
|
|
$opened = $zip->open($path);
|
|
|
|
if ($opened !== true) {
|
|
throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.');
|
|
}
|
|
|
|
$entries = [];
|
|
$summaryCount = 0;
|
|
|
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
|
$name = (string) $zip->getNameIndex($index);
|
|
if ($name === '' || str_ends_with($name, '/')) {
|
|
continue;
|
|
}
|
|
|
|
$baseName = basename($name);
|
|
$content = $zip->getFromIndex($index);
|
|
|
|
if (!is_string($content)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->summaries->importBackupFile($username, $name, $content)) {
|
|
$summaryCount++;
|
|
continue;
|
|
}
|
|
|
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
|
|
continue;
|
|
}
|
|
|
|
$date = $this->dateFromBackupFileName($baseName);
|
|
|
|
$entry = $this->entries->parseMarkdown($content, $date);
|
|
if ($entry !== null) {
|
|
$entries[$date] = $entry;
|
|
}
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
return [
|
|
'entries' => $entries,
|
|
'summaries' => $summaryCount,
|
|
];
|
|
}
|
|
|
|
private function dateFromBackupFileName(string $fileName): string
|
|
{
|
|
$baseName = basename($fileName);
|
|
|
|
if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) {
|
|
throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.');
|
|
}
|
|
|
|
$date = (string) ($matches[1] ?? '');
|
|
if (!$this->isValidDate($date)) {
|
|
throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.');
|
|
}
|
|
|
|
return $date;
|
|
}
|
|
|
|
private function buildDashboardCharts(array $entries): array
|
|
{
|
|
$recent = array_slice($entries, -30);
|
|
$calendar = array_slice($entries, -365);
|
|
|
|
return [
|
|
'calendar' => array_map(static function (array $entry): array {
|
|
$balance = is_array($entry['evaluation']['balance'] ?? null) ? $entry['evaluation']['balance'] : [];
|
|
return [
|
|
'date' => $entry['date'],
|
|
'score' => (float) ($balance['percentage'] ?? $entry['evaluation']['percentage'] ?? 0),
|
|
'max' => 100,
|
|
'label' => $entry['evaluation']['label'],
|
|
];
|
|
}, $calendar),
|
|
'balance' => array_map(static function (array $entry): array {
|
|
return [
|
|
'date' => $entry['date'],
|
|
'value' => (float) ($entry['evaluation']['balance']['raw'] ?? 0),
|
|
];
|
|
}, $recent),
|
|
'mood' => array_map(static function (array $entry): array {
|
|
return [
|
|
'date' => $entry['date'],
|
|
'value' => $entry['mood'],
|
|
];
|
|
}, $recent),
|
|
'stress' => array_map(static function (array $entry): array {
|
|
return [
|
|
'date' => $entry['date'],
|
|
'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'],
|
|
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
|
|
'sport' => $entry['sport_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'] ?? '');
|
|
$location = sport_location_label((string) ($type['location'] ?? ''));
|
|
|
|
return $location !== '' ? $label . ' · ' . $location : $label;
|
|
},
|
|
$entry['sport_type_meta'] ?? []
|
|
))),
|
|
'sport_icons' => array_values(array_filter(array_map(
|
|
static fn (array $type): ?string => isset($type['icon']) ? sport_icon_path((string) $type['icon']) : null,
|
|
$entry['sport_type_meta'] ?? []
|
|
))),
|
|
'sport_bonus' => (float) ($entry['evaluation']['components']['sport_bonus'] ?? 0),
|
|
];
|
|
}, $recent),
|
|
];
|
|
}
|
|
|
|
private function calculateStreak(array $entries): int
|
|
{
|
|
if ($entries === []) {
|
|
return 0;
|
|
}
|
|
|
|
$dates = array_map(static fn (array $entry): string => $entry['date'], $entries);
|
|
rsort($dates, SORT_STRING);
|
|
|
|
$streak = 1;
|
|
$previous = new DateTimeImmutable($dates[0]);
|
|
|
|
for ($index = 1, $count = count($dates); $index < $count; $index++) {
|
|
$current = new DateTimeImmutable($dates[$index]);
|
|
$diff = (int) $previous->diff($current)->format('%a');
|
|
|
|
if ($diff === 1) {
|
|
$streak++;
|
|
$previous = $current;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return $streak;
|
|
}
|
|
|
|
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
|
|
{
|
|
$defaults = Defaults::settings();
|
|
$settings = array_replace_recursive($defaults, $existingSettings ?? []);
|
|
|
|
$settings['walk'] = [
|
|
'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time',
|
|
];
|
|
$settings['sleep'] = [
|
|
'optimal_hours' => max(1.0, min(16.0, round((float) ($input['sleep']['optimal_hours'] ?? ($settings['sleep']['optimal_hours'] ?? 7.0)), 1))),
|
|
];
|
|
$scoreMode = (string) ($input['display']['score_mode'] ?? ($settings['display']['score_mode'] ?? 'scale'));
|
|
$settings['display'] = [
|
|
'score_mode' => in_array($scoreMode, ['scale', 'percent', 'points'], true) ? $scoreMode : 'scale',
|
|
];
|
|
$settings['day_balance'] = [
|
|
'mood_weight' => max(0, min(10, (int) ($input['day_balance']['mood_weight'] ?? ($settings['day_balance']['mood_weight'] ?? 3)))),
|
|
'energy_weight' => max(0, min(10, (int) ($input['day_balance']['energy_weight'] ?? ($settings['day_balance']['energy_weight'] ?? 2)))),
|
|
'stress_weight' => max(0, min(10, (int) ($input['day_balance']['stress_weight'] ?? ($settings['day_balance']['stress_weight'] ?? 2)))),
|
|
'adjustment_cap' => max(0.0, min(2.0, round((float) ($input['day_balance']['adjustment_cap'] ?? ($settings['day_balance']['adjustment_cap'] ?? 1.0)), 1))),
|
|
'points_per_step' => max(1, min(50, (int) ($input['day_balance']['points_per_step'] ?? ($settings['day_balance']['points_per_step'] ?? 12)))),
|
|
];
|
|
|
|
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? ($settings['scoring']['mood_multiplier'] ?? 3))));
|
|
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? ($settings['scoring']['energy_multiplier'] ?? 2))));
|
|
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? ($settings['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'] ?? ($settings['scoring']['sleep_feeling_multiplier'] ?? 2))));
|
|
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? ($settings['scoring']['journal_points'] ?? 2))));
|
|
$stepBonus = is_array($settings['scoring']['step_bonus'] ?? null) ? $settings['scoring']['step_bonus'] : $defaults['scoring']['step_bonus'];
|
|
$settings['scoring']['step_bonus'] = [
|
|
'min' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['min'] ?? $stepBonus['min'] ?? 10000))),
|
|
'max' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['max'] ?? $stepBonus['max'] ?? 15000))),
|
|
'points' => max(0, min(20, (int) ($input['scoring']['step_bonus']['points'] ?? $stepBonus['points'] ?? 1))),
|
|
];
|
|
if ($settings['scoring']['step_bonus']['max'] < $settings['scoring']['step_bonus']['min']) {
|
|
$settings['scoring']['step_bonus']['max'] = $settings['scoring']['step_bonus']['min'];
|
|
}
|
|
$settings['tracking'] = [
|
|
'pain_enabled' => array_key_exists('tracking', $input)
|
|
? (isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1')
|
|
: !empty($settings['tracking']['pain_enabled']),
|
|
];
|
|
|
|
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] ?? ($settings['scoring']['sleep_duration_points'][$key] ?? $default))));
|
|
}
|
|
|
|
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'] ?? $currentBand['min'] ?? $defaultBand['min']))),
|
|
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
|
|
'points' => max(-20, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
|
|
];
|
|
}
|
|
}
|
|
|
|
foreach ($defaults['scoring']['walk_step_targets'] as $index => $defaultTarget) {
|
|
$currentTarget = $settings['scoring']['walk_step_targets'][$index] ?? $defaultTarget;
|
|
$settings['scoring']['walk_step_targets'][$index] = [
|
|
'steps' => max(0, min(100000, (int) ($input['scoring']['walk_step_targets'][$index]['steps'] ?? $currentTarget['steps'] ?? $defaultTarget['steps']))),
|
|
'points' => max(-20, min(20, (int) ($input['scoring']['walk_step_targets'][$index]['points'] ?? $currentTarget['points'] ?? $defaultTarget['points']))),
|
|
];
|
|
}
|
|
|
|
foreach ($defaults['ratings'] as $index => $defaultRating) {
|
|
$currentRating = $settings['ratings'][$index] ?? $defaultRating;
|
|
$settings['ratings'][$index] = [
|
|
'label' => trim((string) ($input['ratings'][$index]['label'] ?? $currentRating['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'],
|
|
'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $currentRating['min'] ?? $defaultRating['min']))),
|
|
'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $currentRating['max'] ?? $defaultRating['max']))),
|
|
];
|
|
}
|
|
|
|
foreach ($defaults['guardrails'] as $index => $defaultGuardrail) {
|
|
$currentGuardrail = $settings['guardrails'][$index] ?? $defaultGuardrail;
|
|
$energyRaw = $input['guardrails'][$index]['energy_max'] ?? $currentGuardrail['energy_max'] ?? $defaultGuardrail['energy_max'];
|
|
$settings['guardrails'][$index] = [
|
|
'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $currentGuardrail['mood_max'] ?? $defaultGuardrail['mood_max']))),
|
|
'energy_max' => $energyRaw === '' || $energyRaw === null ? null : max(1, min(10, (int) $energyRaw)),
|
|
'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $currentGuardrail['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'],
|
|
];
|
|
}
|
|
|
|
$sportTypesProvided = array_key_exists('sport_types_present', $input)
|
|
|| array_key_exists('sport_types', $input);
|
|
|
|
$settings['sport_types'] = normalized_sport_types([
|
|
'sport_types' => $sportTypesProvided && is_array($input['sport_types'] ?? null)
|
|
? $input['sport_types']
|
|
: ($sportTypesProvided ? [] : ($settings['sport_types'] ?? $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' => array_key_exists('notifications', $input)
|
|
? (isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1')
|
|
: !empty($settings['notifications']['enabled']),
|
|
'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['sleep'] = array_replace(
|
|
Defaults::settings()['sleep'],
|
|
is_array($settings['sleep'] ?? null) ? $settings['sleep'] : []
|
|
);
|
|
$settings['sleep']['optimal_hours'] = max(1.0, min(16.0, round((float) ($settings['sleep']['optimal_hours'] ?? 7.0), 1)));
|
|
$settings['display'] = array_replace(
|
|
Defaults::settings()['display'],
|
|
is_array($settings['display'] ?? null) ? $settings['display'] : []
|
|
);
|
|
if (!in_array((string) ($settings['display']['score_mode'] ?? 'scale'), ['scale', 'percent', 'points'], true)) {
|
|
$settings['display']['score_mode'] = 'scale';
|
|
}
|
|
$settings['day_balance'] = array_replace(
|
|
Defaults::settings()['day_balance'],
|
|
is_array($settings['day_balance'] ?? null) ? $settings['day_balance'] : []
|
|
);
|
|
$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'] : []
|
|
);
|
|
|
|
return $settings;
|
|
}
|
|
|
|
private function evaluateEntriesWithContext(array $entries, array $settings): array
|
|
{
|
|
$normalized = array_map(fn (array $entry): array => $this->scoring->normalize($entry), $entries);
|
|
usort($normalized, static fn (array $a, array $b): int => strcmp($a['date'], $b['date']));
|
|
|
|
$entryMap = [];
|
|
foreach ($normalized as $entry) {
|
|
$entryMap[$entry['date']] = $entry;
|
|
}
|
|
|
|
$evaluated = [];
|
|
foreach ($normalized as $entry) {
|
|
$previousEntry = $entryMap[shift_date($entry['date'], -1)] ?? null;
|
|
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
|
|
|
$evaluated[] = array_merge($entry, [
|
|
'evaluation' => $evaluation,
|
|
'sport_type_meta' => find_sport_types($settings, $entry['sport_types']),
|
|
]);
|
|
}
|
|
|
|
return $evaluated;
|
|
}
|
|
|
|
private function buildWeeklyArchiveCards(array $entries, array $weeklySummaries): array
|
|
{
|
|
$groups = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
$key = iso_week_key((string) ($entry['date'] ?? ''));
|
|
if (!isset($groups[$key])) {
|
|
$groups[$key] = [];
|
|
}
|
|
|
|
$groups[$key][] = $entry;
|
|
}
|
|
|
|
$summaryMap = [];
|
|
foreach ($weeklySummaries as $summary) {
|
|
$key = (string) ($summary['summary_key'] ?? '');
|
|
if ($key !== '') {
|
|
$summaryMap[$key] = $summary;
|
|
$groups[$key] = $groups[$key] ?? [];
|
|
}
|
|
}
|
|
|
|
$cards = [];
|
|
foreach ($groups as $key => $weekEntries) {
|
|
$range = $this->weekRangeFromKey($key);
|
|
$summary = $summaryMap[$key] ?? null;
|
|
$noteEntries = array_values(array_filter(
|
|
$weekEntries,
|
|
static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== ''
|
|
));
|
|
$canGenerate = count($noteEntries) >= 3;
|
|
|
|
if ($summary !== null) {
|
|
$statusLabel = 'KI vorhanden';
|
|
$statusTone = 'ready';
|
|
$statusHint = 'Erstellt am ' . format_compact_datetime((string) ($summary['created_at'] ?? ''));
|
|
} elseif ($canGenerate) {
|
|
$statusLabel = 'KI möglich';
|
|
$statusTone = 'pending';
|
|
$statusHint = 'KI-Wochenzusammenfassung kann erzeugt werden';
|
|
} else {
|
|
$statusLabel = 'KI nicht möglich';
|
|
$statusTone = 'blocked';
|
|
$statusHint = 'Mindestens 3 Texteinträge nötig';
|
|
}
|
|
|
|
$cards[] = [
|
|
'summary_key' => $key,
|
|
'label' => iso_week_label($key),
|
|
'date_from' => $summary['date_from'] ?? $range['date_from'],
|
|
'date_to' => $summary['date_to'] ?? $range['date_to'],
|
|
'tracked_days' => count($weekEntries),
|
|
'note_entries_count' => count($noteEntries),
|
|
'can_generate' => $canGenerate,
|
|
'summary' => $summary,
|
|
'has_summary' => $summary !== null,
|
|
'status_label' => $statusLabel,
|
|
'status_tone' => $statusTone,
|
|
'status_hint' => $statusHint,
|
|
'trend_label' => $this->weeklyTrendLabel($weekEntries),
|
|
];
|
|
}
|
|
|
|
usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['date_to'], (string) $left['date_to']));
|
|
|
|
return $cards;
|
|
}
|
|
|
|
private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries, array $weeklyArchive): array
|
|
{
|
|
$monthKeys = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
$monthKeys[month_key((string) ($entry['date'] ?? ''))] = true;
|
|
}
|
|
|
|
foreach ($monthlySummaries as $summary) {
|
|
$monthKeys[(string) ($summary['summary_key'] ?? '')] = true;
|
|
}
|
|
|
|
foreach ($weeklySummaries as $summary) {
|
|
foreach ($this->monthKeysForRange((string) ($summary['date_from'] ?? ''), (string) ($summary['date_to'] ?? '')) as $monthKey) {
|
|
$monthKeys[$monthKey] = true;
|
|
}
|
|
}
|
|
|
|
$monthlySummaryMap = [];
|
|
foreach ($monthlySummaries as $summary) {
|
|
$key = (string) ($summary['summary_key'] ?? '');
|
|
if ($key !== '') {
|
|
$monthlySummaryMap[$key] = $summary;
|
|
}
|
|
}
|
|
|
|
$cards = [];
|
|
foreach (array_keys($monthKeys) as $monthKey) {
|
|
$range = $this->monthRangeFromKey($monthKey);
|
|
$monthEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey
|
|
));
|
|
$monthWeeklySummaries = array_values(array_filter(
|
|
$weeklySummaries,
|
|
fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey)
|
|
));
|
|
$monthWeeks = array_values(array_filter(
|
|
$weeklyArchive,
|
|
fn (array $week): bool => $this->archiveItemOverlapsMonth($week, $monthKey)
|
|
));
|
|
|
|
usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
|
|
usort($monthWeeks, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
|
|
|
|
$availableWeeklyCount = count($monthWeeklySummaries);
|
|
$totalWeekCount = count($monthWeeks);
|
|
$canGenerate = $availableWeeklyCount >= 2;
|
|
$summary = $monthlySummaryMap[$monthKey] ?? null;
|
|
|
|
if ($summary !== null) {
|
|
$statusLabel = 'KI vorhanden';
|
|
$statusTone = 'ready';
|
|
$statusHint = 'Erstellt am ' . format_compact_datetime((string) ($summary['created_at'] ?? ''));
|
|
} elseif ($canGenerate) {
|
|
$statusLabel = 'KI möglich';
|
|
$statusTone = 'pending';
|
|
$statusHint = 'KI-Monatszusammenfassung kann erzeugt werden';
|
|
} else {
|
|
$statusLabel = 'KI nicht möglich';
|
|
$statusTone = 'blocked';
|
|
$statusHint = 'Mindestens 2 KI-Wochenzusammenfassungen nötig';
|
|
}
|
|
|
|
$cards[] = [
|
|
'summary_key' => $monthKey,
|
|
'label' => month_label($monthKey),
|
|
'date_from' => $range['date_from'],
|
|
'date_to' => $range['date_to'],
|
|
'tracked_days' => count($monthEntries),
|
|
'weekly_summary_count' => $availableWeeklyCount,
|
|
'weekly_total_count' => $totalWeekCount,
|
|
'weekly_progress_label' => $availableWeeklyCount . ' von ' . ($totalWeekCount > 0 ? $totalWeekCount : 0) . ' Wochen mit KI',
|
|
'can_generate' => $canGenerate,
|
|
'summary' => $summary,
|
|
'has_summary' => $summary !== null,
|
|
'status_label' => $statusLabel,
|
|
'status_tone' => $statusTone,
|
|
'status_hint' => $statusHint,
|
|
'weeks' => array_map(static function (array $week): array {
|
|
return [
|
|
'label' => (string) ($week['label'] ?? ''),
|
|
'summary_key' => (string) ($week['summary_key'] ?? ''),
|
|
'has_summary' => !empty($week['has_summary']),
|
|
];
|
|
}, $monthWeeks),
|
|
];
|
|
}
|
|
|
|
usort($cards, static fn (array $left, array $right): int => strcmp((string) $right['summary_key'], (string) $left['summary_key']));
|
|
|
|
return $cards;
|
|
}
|
|
|
|
private function buildWeeklySummaryContext(string $weekKey, array $entries): array
|
|
{
|
|
$range = $this->weekRangeFromKey($weekKey);
|
|
$weekEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey
|
|
));
|
|
|
|
if ($weekEntries === []) {
|
|
throw new RuntimeException('Für diese Kalenderwoche gibt es noch keine getrackten Tage.');
|
|
}
|
|
|
|
$noteEntries = array_values(array_filter(
|
|
$weekEntries,
|
|
static fn (array $entry): bool => trim((string) ($entry['note'] ?? '')) !== ''
|
|
));
|
|
|
|
if (count($noteEntries) < 3) {
|
|
throw new RuntimeException('Für eine KI-Wochenzusammenfassung sind mindestens 3 Texteinträge nötig.');
|
|
}
|
|
|
|
usort($weekEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
|
usort($noteEntries, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
|
|
|
|
$bestDay = $this->bestEntry($weekEntries);
|
|
$worstDay = $this->worstEntry($weekEntries);
|
|
$alcoholDays = count(array_filter($weekEntries, static fn (array $entry): bool => !empty($entry['alcohol'])));
|
|
$sportDays = count(array_filter($weekEntries, static fn (array $entry): bool => (int) ($entry['sport_minutes'] ?? 0) > 0));
|
|
|
|
return [
|
|
'date_from' => $range['date_from'],
|
|
'date_to' => $range['date_to'],
|
|
'prompt' => [
|
|
'week_label' => iso_week_label($weekKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')',
|
|
'entry_count' => (string) count($noteEntries),
|
|
'tracked_days' => (string) count($weekEntries),
|
|
'avg_mood' => format_points($this->average($weekEntries, 'mood')),
|
|
'avg_stress' => format_points($this->average($weekEntries, 'stress')),
|
|
'avg_energy' => format_points($this->average($weekEntries, 'energy')),
|
|
'avg_sleep' => format_points($this->average($weekEntries, 'sleep_hours')) . ' h',
|
|
'walk_days' => (string) count(array_filter($weekEntries, static fn (array $entry): bool => walk_chart_value($entry) > 0)),
|
|
'sport_days' => (string) $sportDays,
|
|
'alcohol_days' => (string) $alcoholDays,
|
|
'best_day' => $bestDay !== null ? $this->summaryDayLabel($bestDay) : 'nicht bestimmbar',
|
|
'worst_day' => $worstDay !== null ? $this->summaryDayLabel($worstDay) : 'nicht bestimmbar',
|
|
'daily_entries' => $this->renderWeeklyDailyEntries($noteEntries),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function buildMonthlySummaryContext(string $monthKey, array $entries, array $weeklySummaries): array
|
|
{
|
|
$range = $this->monthRangeFromKey($monthKey);
|
|
$monthEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => month_key((string) ($entry['date'] ?? '')) === $monthKey
|
|
));
|
|
$monthWeeklySummaries = array_values(array_filter(
|
|
$weeklySummaries,
|
|
fn (array $summary): bool => $this->summaryOverlapsMonth($summary, $monthKey)
|
|
));
|
|
|
|
usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
|
|
|
|
if (count($monthWeeklySummaries) < 2) {
|
|
throw new RuntimeException('Für eine KI-Monatszusammenfassung sind mindestens 2 KI-Wochenzusammenfassungen nötig.');
|
|
}
|
|
|
|
return [
|
|
'date_from' => $range['date_from'],
|
|
'date_to' => $range['date_to'],
|
|
'prompt' => [
|
|
'month_label' => month_label($monthKey) . ' (' . $range['date_from'] . ' bis ' . $range['date_to'] . ')',
|
|
'weekly_summary_count' => (string) count($monthWeeklySummaries),
|
|
'avg_mood_month' => format_points($this->average($monthEntries, 'mood')),
|
|
'avg_stress_month' => format_points($this->average($monthEntries, 'stress')),
|
|
'avg_energy_month' => format_points($this->average($monthEntries, 'energy')),
|
|
'avg_sleep_month' => format_points($this->average($monthEntries, 'sleep_hours')) . ' h',
|
|
'weekly_summaries' => $this->renderMonthlyWeeklySummaries($monthWeeklySummaries, $entries),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function renderWeeklyDailyEntries(array $entries): string
|
|
{
|
|
$chunks = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
$details = [
|
|
'Stimmung ' . (string) ($entry['mood'] ?? 0) . '/10',
|
|
'Energie ' . (string) ($entry['energy'] ?? 0) . '/10',
|
|
'Stress ' . (string) ($entry['stress'] ?? 0) . '/10',
|
|
'Schlaf ' . format_points((float) ($entry['sleep_hours'] ?? 0)) . ' h',
|
|
'Schlafgefühl ' . (string) ($entry['sleep_feeling'] ?? 0) . '/5',
|
|
'Sport ' . (string) ((int) ($entry['sport_minutes'] ?? 0)) . ' min',
|
|
'Spaziergang ' . format_walk_value($entry),
|
|
'Alkohol ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
|
|
'Urteil ' . (string) ($entry['evaluation']['label'] ?? ''),
|
|
];
|
|
|
|
if (array_key_exists('pain', $entry) && !empty($entry['pain_enabled'])) {
|
|
$details[] = 'Schmerzen ' . (string) ($entry['pain'] ?? 0) . '/10';
|
|
}
|
|
|
|
$chunks[] = implode("\n", [
|
|
format_display_date((string) ($entry['date'] ?? ''), false) . ' (' . (string) ($entry['date'] ?? '') . ')',
|
|
implode(' · ', $details),
|
|
'Notiz: ' . trim((string) ($entry['note'] ?? '')),
|
|
]);
|
|
}
|
|
|
|
return implode("\n\n", $chunks);
|
|
}
|
|
|
|
private function renderMonthlyWeeklySummaries(array $weeklySummaries, array $entries): string
|
|
{
|
|
$blocks = [];
|
|
|
|
foreach ($weeklySummaries as $summary) {
|
|
$weekKey = (string) ($summary['summary_key'] ?? '');
|
|
$weekEntries = array_values(array_filter(
|
|
$entries,
|
|
static fn (array $entry): bool => iso_week_key((string) ($entry['date'] ?? '')) === $weekKey
|
|
));
|
|
|
|
$blocks[] = implode("\n", [
|
|
iso_week_label($weekKey) . ' (' . (string) ($summary['date_from'] ?? '') . ' bis ' . (string) ($summary['date_to'] ?? '') . ')',
|
|
'Wochenkennzahlen: Stimmung ' . format_points($this->average($weekEntries, 'mood'))
|
|
. ' · Stress ' . format_points($this->average($weekEntries, 'stress'))
|
|
. ' · Energie ' . format_points($this->average($weekEntries, 'energy'))
|
|
. ' · Schlaf ' . format_points($this->average($weekEntries, 'sleep_hours')) . ' h',
|
|
'KI-Wochenrückblick:',
|
|
trim((string) ($summary['text'] ?? '')),
|
|
]);
|
|
}
|
|
|
|
return implode("\n\n", $blocks);
|
|
}
|
|
|
|
private function average(array $entries, string $field): float
|
|
{
|
|
if ($entries === []) {
|
|
return 0.0;
|
|
}
|
|
|
|
$sum = 0.0;
|
|
foreach ($entries as $entry) {
|
|
$sum += (float) ($entry[$field] ?? 0);
|
|
}
|
|
|
|
return round($sum / count($entries), 1);
|
|
}
|
|
|
|
private function bestEntry(array $entries): ?array
|
|
{
|
|
if ($entries === []) {
|
|
return null;
|
|
}
|
|
|
|
usort($entries, static fn (array $left, array $right): int => ((float) ($right['evaluation']['total'] ?? 0)) <=> ((float) ($left['evaluation']['total'] ?? 0)));
|
|
|
|
return $entries[0] ?? null;
|
|
}
|
|
|
|
private function worstEntry(array $entries): ?array
|
|
{
|
|
if ($entries === []) {
|
|
return null;
|
|
}
|
|
|
|
usort($entries, static fn (array $left, array $right): int => ((float) ($left['evaluation']['total'] ?? 0)) <=> ((float) ($right['evaluation']['total'] ?? 0)));
|
|
|
|
return $entries[0] ?? null;
|
|
}
|
|
|
|
private function summaryDayLabel(array $entry): string
|
|
{
|
|
return format_display_date((string) ($entry['date'] ?? ''), false)
|
|
. ' · '
|
|
. format_points((float) ($entry['evaluation']['total'] ?? 0))
|
|
. ' Punkte'
|
|
. ' · '
|
|
. (string) ($entry['evaluation']['label'] ?? '');
|
|
}
|
|
|
|
private function weekRangeFromKey(string $weekKey): array
|
|
{
|
|
if (preg_match('/^(\d{4})-KW-(\d{2})$/', $weekKey, $matches) !== 1) {
|
|
throw new RuntimeException('Die Kalenderwoche ist ungültig.');
|
|
}
|
|
|
|
$year = (int) ($matches[1] ?? 0);
|
|
$week = (int) ($matches[2] ?? 0);
|
|
if ($week < 1 || $week > 53) {
|
|
throw new RuntimeException('Die Kalenderwoche ist ungültig.');
|
|
}
|
|
|
|
$start = (new DateTimeImmutable('now'))->setISODate($year, $week, 1);
|
|
$end = $start->modify('+6 days');
|
|
|
|
return [
|
|
'date_from' => $start->format('Y-m-d'),
|
|
'date_to' => $end->format('Y-m-d'),
|
|
];
|
|
}
|
|
|
|
private function monthRangeFromKey(string $monthKey): array
|
|
{
|
|
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) {
|
|
throw new RuntimeException('Der Monat ist ungültig.');
|
|
}
|
|
|
|
$current = DateTimeImmutable::createFromFormat('Y-m-d', $monthKey . '-01');
|
|
|
|
if ($current === false) {
|
|
throw new RuntimeException('Der Monat ist ungültig.');
|
|
}
|
|
|
|
return [
|
|
'date_from' => $current->format('Y-m-01'),
|
|
'date_to' => $current->modify('last day of this month')->format('Y-m-d'),
|
|
];
|
|
}
|
|
|
|
private function monthKeysForRange(string $dateFrom, string $dateTo): array
|
|
{
|
|
if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) {
|
|
return [];
|
|
}
|
|
|
|
$keys = [];
|
|
$current = DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom);
|
|
$end = DateTimeImmutable::createFromFormat('Y-m-d', $dateTo);
|
|
|
|
if ($current === false || $end === false) {
|
|
return [];
|
|
}
|
|
|
|
$current = $current->modify('first day of this month');
|
|
$end = $end->modify('first day of this month');
|
|
|
|
while ($current <= $end) {
|
|
$keys[] = $current->format('Y-m');
|
|
$current = $current->modify('+1 month');
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
|
|
private function summaryOverlapsMonth(array $summary, string $monthKey): bool
|
|
{
|
|
$range = $this->monthRangeFromKey($monthKey);
|
|
$summaryFrom = (string) ($summary['date_from'] ?? '');
|
|
$summaryTo = (string) ($summary['date_to'] ?? '');
|
|
|
|
return $summaryFrom !== '' && $summaryTo !== ''
|
|
&& $summaryFrom <= $range['date_to']
|
|
&& $summaryTo >= $range['date_from'];
|
|
}
|
|
|
|
private function buildArchiveMonthOptions(array $entries, array $weeklyArchive, array $monthlyArchive): array
|
|
{
|
|
$keys = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
$keys[month_key((string) ($entry['date'] ?? ''))] = true;
|
|
}
|
|
|
|
foreach ($weeklyArchive as $week) {
|
|
foreach ($this->monthKeysForRange((string) ($week['date_from'] ?? ''), (string) ($week['date_to'] ?? '')) as $monthKey) {
|
|
$keys[$monthKey] = true;
|
|
}
|
|
}
|
|
|
|
foreach ($monthlyArchive as $month) {
|
|
$keys[(string) ($month['summary_key'] ?? '')] = true;
|
|
}
|
|
|
|
$options = array_values(array_filter(array_keys($keys), static fn (string $key): bool => $key !== ''));
|
|
rsort($options, SORT_STRING);
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function normalizeArchiveView(string $view): string
|
|
{
|
|
$view = trim($view);
|
|
|
|
return in_array($view, ['days', 'weeks', 'months'], true) ? $view : 'days';
|
|
}
|
|
|
|
private function weeklyTrendLabel(array $entries): string
|
|
{
|
|
if ($entries === []) {
|
|
return 'Keine Tendenz';
|
|
}
|
|
|
|
$mood = $this->average($entries, 'mood');
|
|
$stress = $this->average($entries, 'stress');
|
|
|
|
if ($mood >= 7 && $stress <= 4) {
|
|
return 'eher stabil';
|
|
}
|
|
|
|
if ($mood <= 4 || $stress >= 7) {
|
|
return 'eher belastet';
|
|
}
|
|
|
|
return 'gemischte Tendenz';
|
|
}
|
|
|
|
private function archiveItemOverlapsMonth(array $item, string $monthKey): bool
|
|
{
|
|
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $monthKey) !== 1) {
|
|
return true;
|
|
}
|
|
|
|
$dateFrom = (string) ($item['date_from'] ?? '');
|
|
$dateTo = (string) ($item['date_to'] ?? '');
|
|
|
|
if ($dateFrom === '' || $dateTo === '') {
|
|
return false;
|
|
}
|
|
|
|
$range = $this->monthRangeFromKey($monthKey);
|
|
|
|
return $dateFrom <= $range['date_to'] && $dateTo >= $range['date_from'];
|
|
}
|
|
|
|
private function archivePath(array $params = []): string
|
|
{
|
|
$filtered = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== '');
|
|
|
|
return $filtered === []
|
|
? '/archive'
|
|
: '/archive?' . http_build_query($filtered);
|
|
}
|
|
|
|
private function sendSecurityHeaders(): void
|
|
{
|
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
header('X-Frame-Options: DENY');
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');
|
|
header('Cross-Origin-Opener-Policy: same-origin');
|
|
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
|
header("Content-Security-Policy: default-src 'self'; img-src 'self' data: https://tile.openstreetmap.org; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
|
|
}
|
|
|
|
private function enforceCsrf(): void
|
|
{
|
|
if (!verify_csrf($_POST['_token'] ?? null)) {
|
|
http_response_code(419);
|
|
exit('Ungültiges Formular-Token.');
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
if ($user === null) {
|
|
redirect('/login');
|
|
}
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function isValidDate(string $date): bool
|
|
{
|
|
$parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
|
|
|
return $parsed !== false && $parsed->format('Y-m-d') === $date;
|
|
}
|
|
|
|
private function isValidUsername(string $username): bool
|
|
{
|
|
return preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username) === 1;
|
|
}
|
|
|
|
private function isStrongPassword(string $password): bool
|
|
{
|
|
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);
|
|
}
|
|
|
|
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
|
|
|
|
json_response([
|
|
'ok' => true,
|
|
'processed' => $stats['processed'],
|
|
'sent_users' => $stats['sent_users'],
|
|
'already_tracked' => $stats['already_tracked'],
|
|
'skipped' => $stats['skipped'],
|
|
'removed_subscriptions' => $stats['removed_subscriptions'],
|
|
]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|