$type, 'message' => $message, ]; } function pull_flashes(): array { $flashes = $_SESSION['_flash'] ?? []; unset($_SESSION['_flash']); return is_array($flashes) ? $flashes : []; } function csrf_token(): string { if (empty($_SESSION['_csrf'])) { $_SESSION['_csrf'] = bin2hex(random_bytes(32)); } return (string) $_SESSION['_csrf']; } function csrf_field(): string { return ''; } function verify_csrf(?string $token): bool { if (!is_string($token) || $token === '') { return false; } return hash_equals(csrf_token(), $token); } function verify_request_csrf(): bool { $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; if (is_string($headerToken) && $headerToken !== '') { return verify_csrf($headerToken); } return verify_csrf($_POST['_token'] ?? null); } function is_active_path(string $path): bool { return request_path() === $path; } function format_points(float $value): string { $rounded = round($value, 1); if (abs($rounded - round($rounded)) < 0.05) { return (string) (int) round($rounded); } return number_format($rounded, 1, ',', '.'); } function format_duration_hours(float $hours): string { $minutes = max(0, (int) round($hours * 60)); $wholeHours = intdiv($minutes, 60); $remainingMinutes = $minutes % 60; if ($wholeHours <= 0) { return $remainingMinutes . ' min'; } if ($remainingMinutes === 0) { return $wholeHours . ' h'; } return $wholeHours . ' h ' . $remainingMinutes . ' min'; } function normalize_username(string $username): string { return strtolower(trim($username)); } function today(): string { return date('Y-m-d'); } function decode_json_file(string $path, array $fallback = []): array { if (!is_file($path)) { return $fallback; } $decoded = json_decode((string) file_get_contents($path), true); return is_array($decoded) ? $decoded : $fallback; } function request_json_body(): array { $raw = file_get_contents('php://input'); if (!is_string($raw) || trim($raw) === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } function encode_payload(array $payload): string { return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } function shift_date(string $date, int $days): string { $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($current === false) { return today(); } return $current->modify(($days >= 0 ? '+' : '') . $days . ' day')->format('Y-m-d'); } function format_display_date(string $date, bool $withWeekday = true): string { $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($current === false) { return $date; } $weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; $months = [ 1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', ]; $label = $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y'); if (!$withWeekday) { return $label; } return $weekdays[(int) $current->format('w')] . ', ' . $label; } function format_compact_date(string $date): string { $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($current === false) { return $date; } return $current->format('d.m.Y'); } function format_display_datetime(string $value): string { try { $current = new DateTimeImmutable($value); } catch (Throwable) { return $value; } $months = [ 1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember', ]; return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i'); } function format_compact_datetime(string $value): string { try { $current = new DateTimeImmutable($value); } catch (Throwable) { return $value; } return $current->format('d.m.Y · H:i'); } function iso_week_key(string $date): string { $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($current === false) { return date('o-\K\W-W'); } return $current->format('o-\K\W-W'); } function month_key(string $date): string { $current = DateTimeImmutable::createFromFormat('Y-m-d', $date); if ($current === false) { return date('Y-m'); } return $current->format('Y-m'); } function iso_week_label(string $key): string { if (preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches) === 1) { return 'KW ' . $matches[2] . ' / ' . $matches[1]; } return $key; } function month_label(string $key): string { if (preg_match('/^(\d{4})-(\d{2})$/', $key, $matches) !== 1) { return $key; } $months = [ '01' => 'Januar', '02' => 'Februar', '03' => 'März', '04' => 'April', '05' => 'Mai', '06' => 'Juni', '07' => 'Juli', '08' => 'August', '09' => 'September', '10' => 'Oktober', '11' => 'November', '12' => 'Dezember', ]; return ($months[$matches[2]] ?? $matches[2]) . ' ' . $matches[1]; } function icon_path(string $name): string { return '/assets/icons/' . $name . '.svg'; } function mood_icon_path(string $sentiment): string { return icon_path('mood-' . $sentiment); } function request_is_secure(): bool { $forwardedProto = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); $forwardedSsl = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '')); return ( (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $forwardedProto === 'https' || $forwardedSsl === 'on' ); } function app_origin(): string { $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); $scheme = request_is_secure() ? 'https' : 'http'; return $scheme . '://' . $host; } function remember_me_lifetime(): int { return 60 * 60 * 24 * 30; } function remember_cookie_name(): string { return 'mood_remember'; } function session_cookie_params_for(int $lifetime = 0): array { return [ 'lifetime' => $lifetime, 'path' => '/', 'domain' => '', 'secure' => request_is_secure(), 'httponly' => true, 'samesite' => 'Lax', ]; } function session_cookie_options_for(int $expires = 0): array { return [ 'expires' => $expires, 'path' => '/', 'domain' => '', 'secure' => request_is_secure(), 'httponly' => true, 'samesite' => 'Lax', ]; } function sport_icon_path(string $icon): string { return icon_path('sport-' . $icon); } function sport_icon_options(): array { return [ 'strength' => 'Krafttraining', 'bike' => 'Radfahren', 'run' => 'Joggen', 'hike' => 'Wandern', 'swim' => 'Schwimmen', 'yoga' => 'Yoga', 'hiit' => 'HIIT / Workout', 'row' => 'Rudergerät', 'dance' => 'Tanzen', 'core' => 'Core', 'strength-home' => 'Krafttraining Zuhause', 'strength-gym' => 'Krafttraining Auswärts', ]; } function sport_location_options(): array { return [ '' => 'ohne Angabe', 'home' => 'Zuhause', 'away' => 'Auswärts', ]; } function sport_location_label(?string $value): string { $options = sport_location_options(); $value = is_string($value) ? trim($value) : ''; return $options[$value] ?? ''; } function walk_mode_options(): array { return [ 'time' => 'Spaziergang nach Zeit', 'steps' => 'Spaziergang nach Schritten', ]; } function walk_mode_label(string $mode): string { return walk_mode_options()[$mode] ?? walk_mode_options()['time']; } function format_walk_value(array $entry): string { $mode = (string) ($entry['walk_mode'] ?? 'time'); if ($mode === 'steps') { return number_format((int) ($entry['walk_steps'] ?? 0), 0, ',', '.') . ' Schritte'; } return (string) ((int) ($entry['walk_minutes'] ?? 0)) . ' min'; } function walk_chart_value(array $entry): int { $mode = (string) ($entry['walk_mode'] ?? 'time'); if ($mode === 'steps') { return max(0, (int) round(((int) ($entry['walk_steps'] ?? 0)) / 200)); } return max(0, (int) ($entry['walk_minutes'] ?? 0)); } function base64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } function base64url_decode(string $data): string { $padding = strlen($data) % 4; if ($padding > 0) { $data .= str_repeat('=', 4 - $padding); } $decoded = base64_decode(strtr($data, '-_', '+/'), true); if ($decoded === false) { throw new RuntimeException('Ungültige Base64url-Daten.'); } return $decoded; } function uploaded_files(string $field): array { $raw = $_FILES[$field] ?? null; if (!is_array($raw) || !isset($raw['name'])) { return []; } if (!is_array($raw['name'])) { return [[ 'name' => (string) ($raw['name'] ?? ''), 'type' => (string) ($raw['type'] ?? ''), 'tmp_name' => (string) ($raw['tmp_name'] ?? ''), 'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE), 'size' => (int) ($raw['size'] ?? 0), ]]; } $files = []; foreach ($raw['name'] as $index => $name) { $files[] = [ 'name' => (string) ($name ?? ''), 'type' => (string) ($raw['type'][$index] ?? ''), 'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''), 'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE), 'size' => (int) ($raw['size'][$index] ?? 0), ]; } return $files; } function normalize_sport_type_id(string $value): string { $value = trim(strtr($value, [ 'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue', 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', ])); $value = strtolower($value); $value = strtr($value, [ '--' => '-', ]); $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? ''; return trim($value, '-'); } function normalized_sport_types(array $settings): array { $types = $settings['sport_types'] ?? []; $normalized = []; $usedIds = []; $iconOptions = sport_icon_options(); foreach ($types as $index => $type) { if (!is_array($type)) { continue; } $label = trim((string) ($type['label'] ?? '')); if ($label === '') { continue; } $candidateId = trim((string) ($type['id'] ?? '')); if ($candidateId === '') { $candidateId = normalize_sport_type_id($label); } if ($candidateId === '') { $candidateId = 'sportart'; } $id = $candidateId; $suffix = 2; while (isset($usedIds[$id])) { $id = $candidateId . '-' . $suffix; $suffix++; } $usedIds[$id] = true; $icon = trim((string) ($type['icon'] ?? 'run')); if (!array_key_exists($icon, $iconOptions)) { $icon = 'run'; } $location = trim((string) ($type['location'] ?? '')); if (!array_key_exists($location, sport_location_options())) { $location = ''; } $group = trim((string) ($type['recovery_group'] ?? '')); if ($group === '') { $group = $id; } $normalized[] = [ 'id' => $id, 'label' => $label, 'icon' => $icon, 'location' => $location, 'recovery_group' => normalize_sport_type_id($group) ?: $id, 'bonus_points' => max(0, min(20, (int) ($type['bonus_points'] ?? 0))), 'allow_consecutive' => !empty($type['allow_consecutive']), ]; } return $normalized; } function normalize_sport_type_selection(mixed $value): array { if (is_string($value)) { $value = trim($value); if ($value === '') { return []; } if (str_contains($value, ',')) { $value = array_map('trim', explode(',', $value)); } else { $value = [$value]; } } if (!is_array($value)) { return []; } $normalized = []; foreach ($value as $item) { if (!is_string($item)) { continue; } $id = trim($item); if ($id === '' || isset($normalized[$id])) { continue; } $normalized[$id] = true; } return array_keys($normalized); } function find_sport_type(array $settings, ?string $id): ?array { if (!is_string($id) || trim($id) === '') { return null; } foreach (normalized_sport_types($settings) as $type) { if (($type['id'] ?? '') === $id) { return $type; } } return null; } function find_sport_types(array $settings, array $ids): array { $types = []; foreach (normalize_sport_type_selection($ids) as $id) { $type = find_sport_type($settings, $id); if ($type !== null) { $types[] = $type; } } return $types; } function signal_scale_options(): array { return [ -2 => 'sehr niedrig', -1 => 'niedrig', 0 => 'neutral', 1 => 'hoch', 2 => 'sehr hoch', ]; } function signal_labels_for_metric(string $metric): array { return match ($metric) { 'stress' => [ -2 => 'sehr ruhig', -1 => 'ruhig', 0 => 'neutral', 1 => 'angespannt', 2 => 'sehr angespannt', ], 'energy' => [ -2 => 'leer', -1 => 'matt', 0 => 'okay', 1 => 'wach', 2 => 'kraftvoll', ], default => [ -2 => 'sehr niedrig', -1 => 'niedrig', 0 => 'neutral', 1 => 'hoch', 2 => 'sehr hoch', ], }; } function normalize_signal_value(mixed $value): int { return max(-2, min(2, (int) $value)); } function signal_to_legacy_scale(mixed $value): int { return match (normalize_signal_value($value)) { -2 => 1, -1 => 3, 0 => 5, 1 => 7, 2 => 9, }; } function legacy_to_signal_scale(mixed $value): int { $legacy = max(1, min(10, (int) $value)); return match (true) { $legacy <= 2 => -2, $legacy <= 4 => -1, $legacy <= 6 => 0, $legacy <= 8 => 1, default => 2, }; } function day_event_type_options(): array { return [ 'event' => [ 'label' => 'Moment', 'icon' => '/assets/icons/activity-event.svg', 'unit' => '', ], 'walk' => [ 'label' => 'Spaziergang', 'icon' => sport_icon_path('hike'), 'unit' => 'min', ], 'sport' => [ 'label' => 'Sport', 'icon' => sport_icon_path('run'), 'unit' => 'min', ], 'sleep' => [ 'label' => 'Schlaf', 'icon' => '/assets/icons/activity-sleep.svg', 'unit' => 'h', ], 'alcohol' => [ 'label' => 'Alkohol', 'icon' => icon_path('alcohol'), 'unit' => '', ], ]; } function day_event_type_label(string $type): string { return day_event_type_options()[$type]['label'] ?? day_event_type_options()['event']['label']; } function day_event_type_icon(string $type): string { return day_event_type_options()[$type]['icon'] ?? day_event_type_options()['event']['icon']; } function day_event_type_unit(string $type): string { return day_event_type_options()[$type]['unit'] ?? ''; } function signal_badge_tone(int $value, string $metric): string { $value = normalize_signal_value($value); if ($metric === 'stress') { return match (true) { $value <= -1 => 'good', $value === 0 => 'neutral', default => 'warn', }; } return match (true) { $value <= -1 => 'warn', $value === 0 => 'neutral', default => 'good', }; } function signal_combo_score(mixed $mood, mixed $energy, mixed $stress): int { return max(-2, min(2, (int) round(( normalize_signal_value($mood) + normalize_signal_value($energy) - normalize_signal_value($stress) ) / 3))); } function day_entry_has_content(array $entry): bool { if (trim((string) ($entry['summary']['comment'] ?? '')) !== '') { 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; } function signal_value_class(int $value): string { return match (normalize_signal_value($value)) { -2 => 'neg2', -1 => 'neg1', 0 => 'zero', 1 => 'pos1', 2 => 'pos2', }; }