diff --git a/assets/css/app.css b/assets/css/app.css index d2a1e82..f3354ab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -48,6 +48,30 @@ --control-soft-bg: rgba(255, 255, 255, 0.08); --control-soft-border: rgba(255, 255, 255, 0.16); --brand-shadow: 0 10px 24px rgba(9, 25, 40, 0.22); + --archive-shell-bg: + linear-gradient(180deg, rgba(22, 38, 58, 0.94), rgba(10, 25, 41, 0.92)), + radial-gradient(circle at top right, rgba(59, 173, 212, 0.12), transparent 42%); + --archive-shell-border: rgba(148, 198, 228, 0.18); + --archive-toolbar-bg: + linear-gradient(180deg, rgba(34, 57, 79, 0.82), rgba(22, 40, 58, 0.76)), + radial-gradient(circle at top right, rgba(75, 203, 223, 0.1), transparent 48%); + --archive-toolbar-border: rgba(148, 198, 228, 0.14); + --archive-switcher-bg: rgba(12, 24, 38, 0.34); + --archive-switcher-border: rgba(148, 198, 228, 0.12); + --archive-switcher-active-bg: rgba(173, 213, 245, 0.14); + --archive-row-bg: rgba(255, 255, 255, 0.06); + --archive-row-border: rgba(255, 255, 255, 0.05); + --archive-row-active-bg: rgba(255, 255, 255, 0.09); + --archive-detail-bg: + linear-gradient(180deg, rgba(40, 62, 86, 0.88), rgba(24, 41, 60, 0.82)), + radial-gradient(circle at top left, rgba(135, 217, 255, 0.12), transparent 42%); + --archive-select-bg: rgba(30, 51, 72, 0.84); + --archive-select-border: rgba(148, 198, 228, 0.18); + --archive-select-focus-bg: rgba(35, 59, 83, 0.94); + --archive-mobile-overlay-bg: rgba(6, 14, 24, 0.36); + --archive-mobile-top-bg: + linear-gradient(180deg, rgba(17, 33, 50, 0.96), rgba(17, 33, 50, 0.78)), + radial-gradient(circle at top left, rgba(135, 217, 255, 0.1), transparent 42%); } @media (prefers-color-scheme: light) { @@ -95,6 +119,30 @@ --control-soft-bg: rgba(255, 255, 255, 0.58); --control-soft-border: rgba(123, 153, 182, 0.22); --brand-shadow: 0 10px 24px rgba(82, 111, 138, 0.14); + --archive-shell-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 252, 255, 0.72)), + radial-gradient(circle at top right, rgba(123, 190, 255, 0.16), transparent 46%); + --archive-shell-border: rgba(120, 146, 172, 0.2); + --archive-toolbar-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(245, 251, 255, 0.64)), + radial-gradient(circle at top right, rgba(106, 203, 219, 0.12), transparent 48%); + --archive-toolbar-border: rgba(120, 146, 172, 0.16); + --archive-switcher-bg: rgba(255, 255, 255, 0.34); + --archive-switcher-border: rgba(120, 146, 172, 0.18); + --archive-switcher-active-bg: rgba(255, 255, 255, 0.72); + --archive-row-bg: rgba(255, 255, 255, 0.38); + --archive-row-border: rgba(120, 146, 172, 0.14); + --archive-row-active-bg: rgba(255, 255, 255, 0.56); + --archive-detail-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(244, 250, 255, 0.72)), + radial-gradient(circle at top left, rgba(141, 205, 255, 0.16), transparent 42%); + --archive-select-bg: rgba(255, 255, 255, 0.62); + --archive-select-border: rgba(123, 153, 182, 0.2); + --archive-select-focus-bg: rgba(255, 255, 255, 0.84); + --archive-mobile-overlay-bg: rgba(236, 243, 249, 0.42); + --archive-mobile-top-bg: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 251, 255, 0.84)), + radial-gradient(circle at top left, rgba(141, 205, 255, 0.16), transparent 42%); } html { @@ -104,6 +152,31 @@ select { color-scheme: light; } + + .mobile-nav .nav-icon { + opacity: 1; + filter: + saturate(2.1) + contrast(1.16) + brightness(0.76) + drop-shadow(0 1px 0 rgba(255, 255, 255, 0.5)) + drop-shadow(0 0 8px rgba(110, 214, 255, 0.18)); + } + + .mobile-nav a.active .nav-icon { + filter: + saturate(2.35) + contrast(1.2) + brightness(0.68) + drop-shadow(0 1px 0 rgba(255, 255, 255, 0.56)) + drop-shadow(0 0 10px rgba(110, 214, 255, 0.24)); + } + + .detail-grid--archive-day div { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(244, 249, 255, 0.64)); + border: 1px solid rgba(126, 156, 184, 0.18); + box-shadow: 0 8px 22px rgba(138, 167, 194, 0.1); + } } *, @@ -851,6 +924,10 @@ button:disabled { gap: 0.7rem; } +.sport-choice-list--single { + grid-template-columns: minmax(0, 1fr); +} + .sport-choice { display: block; } @@ -890,6 +967,11 @@ button:disabled { line-height: 1.3; } +.sport-choice__card--toggle { + min-height: 100%; + align-content: center; +} + .sport-choice input:checked + .sport-choice__card { border-color: rgba(139, 228, 255, 0.44); background: linear-gradient(180deg, rgba(96, 184, 255, 0.18), rgba(255, 255, 255, 0.08)); @@ -1156,6 +1238,399 @@ input[type="range"] { margin-bottom: 1.2rem; } +.archive-page { + display: grid; +} + +.archive-shell { + display: grid; + gap: 1rem; + padding: 1.2rem; + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--archive-shell-bg); + border-color: var(--archive-shell-border); +} + +.archive-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.archive-header__meta { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.archive-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.85rem 0.95rem; + border-radius: 18px; + background: var(--archive-toolbar-bg); + border: 1px solid var(--archive-toolbar-border); +} + +.archive-toolbar--compact { + margin-bottom: 0.15rem; +} + +.archive-switcher { + display: inline-grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.35rem; + padding: 0.25rem; + border-radius: 999px; + background: var(--archive-switcher-bg); + border: 1px solid var(--archive-switcher-border); +} + +.archive-switcher__item { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.45rem; + padding: 0.45rem 0.9rem; + border-radius: 999px; + color: var(--muted); + transition: background 180ms ease, color 180ms ease, transform 180ms ease; +} + +.archive-switcher__item.active { + color: var(--text); + background: var(--archive-switcher-active-bg); + transform: translateY(-1px); +} + +.archive-filter { + display: flex; + align-items: end; + gap: 0.8rem; +} + +.archive-filter label { + min-width: 13rem; +} + +.archive-filter select { + background: var(--archive-select-bg); + border-color: var(--archive-select-border); +} + +.archive-filter select:focus { + background: var(--archive-select-focus-bg); +} + +.archive-workspace { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.78fr); + gap: 1rem; + align-items: start; +} + +.archive-main { + display: grid; + gap: 0.85rem; + min-width: 0; +} + +.archive-list-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.archive-rows { + display: grid; + gap: 0.7rem; +} + +.archive-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) auto auto; + align-items: center; + gap: 0.85rem; + padding: 0.95rem 1rem; + border-radius: 18px; + background: var(--archive-row-bg); + border: 1px solid var(--archive-row-border); + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.archive-row:hover, +.archive-row.active { + transform: translateY(-1px); + border-color: rgba(139, 228, 255, 0.28); + background: var(--archive-row-active-bg); +} + +.archive-row__main, +.archive-row__meta { + display: grid; + gap: 0.18rem; + min-width: 0; +} + +.archive-row__main strong, +.archive-row__title-group strong { + font-size: 1rem; + line-height: 1.25; +} + +.archive-row__main span, +.archive-row__meta span, +.archive-row__title-group span, +.archive-row__hint { + color: var(--muted); + font-size: 0.92rem; + overflow-wrap: normal; + word-break: normal; + hyphens: auto; +} + +.archive-row__meta { + justify-items: end; + text-align: right; +} + +.archive-row__meta--stack { + justify-items: start; + text-align: left; +} + +.archive-row__hint { + white-space: nowrap; +} + +.archive-row__title-group span, +.archive-row__meta span { + line-height: 1.45; +} + +.archive-row--summary { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.95fr) auto; + align-items: flex-start; +} + +.archive-row--summary .archive-row__main { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; +} + +.archive-row--summary .status-badge { + justify-self: end; + align-self: start; +} + +.archive-row--week .archive-row__main--week { + grid-template-columns: 1fr; + gap: 0.6rem; +} + +.archive-row--week .archive-row__main--week .status-badge, +.archive-row--month .archive-row__main--month .status-badge { + justify-self: start; +} + +.archive-row--month .archive-row__main--month { + grid-template-columns: 1fr; + gap: 0.6rem; +} + +.archive-row--summary .archive-row__hint { + align-self: start; +} + +.archive-row__title-group { + display: grid; + gap: 0.18rem; + min-width: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.7rem; + padding: 0.2rem 0.65rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.03em; + border: 1px solid transparent; + max-width: 100%; + white-space: normal; + line-height: 1.25; + text-align: center; +} + +.status-badge--ready { + color: #082336; + background: rgba(127, 243, 187, 0.92); +} + +.status-badge--pending { + color: var(--text); + background: rgba(139, 228, 255, 0.14); + border-color: rgba(139, 228, 255, 0.22); +} + +.status-badge--blocked { + color: var(--text); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); +} + +.archive-detail { + min-width: 0; +} + +.archive-detail__panel { + display: grid; + gap: 1rem; + padding: 1.15rem; + border-radius: var(--radius-xl); + position: sticky; + top: 1.25rem; + min-width: 0; + overflow: hidden; + background: var(--archive-detail-bg); + border-color: var(--archive-shell-border); +} + +.archive-detail__top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + min-width: 0; +} + +.archive-detail__close { + display: none; +} + +.archive-detail__status-row, +.archive-detail__actions { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + min-width: 0; +} + +.archive-detail__single-action form, +.archive-detail__actions form { + margin: 0; + min-width: 0; +} + +.archive-detail__single-action { + display: flex; + min-width: 0; +} + +.archive-detail__week-status { + display: grid; + gap: 0.7rem; +} + +.archive-detail__status-note { + display: grid; + gap: 0.4rem; +} + +.archive-detail__status-note p { + margin: 0; +} + +.archive-mini-list { + display: grid; + gap: 0.55rem; +} + +.archive-mini-list__row { + display: flex; + justify-content: space-between; + gap: 0.7rem; + align-items: center; + padding: 0.72rem 0.85rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.07); + min-width: 0; +} + +.archive-detail__panel > *, +.archive-detail__top > *, +.archive-detail__status-row > *, +.archive-detail__actions > * { + min-width: 0; +} + +.archive-detail__actions .ghost-link, +.archive-detail__actions .ghost-button, +.archive-detail__single-action .primary-button { + max-width: 100%; + white-space: normal; +} + +.archive-detail__status-row .chart-chip { + max-width: 100%; + white-space: normal; + text-align: center; +} + +.archive-detail__single-action .primary-button { + width: 100%; +} + +.detail-grid--archive div { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + align-items: flex-start; + min-width: 0; +} + +.detail-grid--archive-day { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; +} + +.detail-grid--archive-day div { + display: grid; + gap: 0.28rem; + align-content: start; +} + +.detail-grid--archive-day dt, +.detail-grid--archive-day dd { + min-width: 0; +} + +.detail-grid--archive-day dd { + text-align: left; + white-space: normal; +} + +.detail-grid--archive dd, +.archive-mini-list__row span, +.note-box p { + min-width: 0; + overflow-wrap: anywhere; +} + +.detail-grid--archive dd { + text-align: right; +} + .archive-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); @@ -1606,6 +2081,75 @@ input[type="range"] { grid-template-columns: 1fr; } + .archive-header, + .archive-toolbar, + .archive-workspace, + .archive-list-header, + .archive-row, + .archive-row--summary { + grid-template-columns: 1fr; + } + + .archive-header, + .archive-toolbar, + .archive-list-header { + flex-direction: column; + align-items: stretch; + } + + .archive-workspace { + gap: 0.85rem; + } + + .archive-row__meta { + justify-items: start; + text-align: left; + } + + .archive-row--summary .archive-row__main { + grid-template-columns: 1fr; + } + + .archive-row--summary .status-badge { + justify-self: start; + } + + .archive-row--day { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; + padding: 0.9rem 0.95rem; + } + + .archive-row--day .archive-row__main { + gap: 0.12rem; + } + + .archive-row--day .archive-row__meta { + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; + gap: 0.12rem; + } + + .archive-row--day .archive-row__hint { + display: none; + } + + .archive-row--day .archive-row__main strong { + font-size: 1.02rem; + } + + .archive-row--day .archive-row__main span, + .archive-row--day .archive-row__meta span { + font-size: 0.9rem; + } + + .archive-detail { + scroll-margin-top: 1rem; + } + .calendar-detail { flex-direction: column; align-items: flex-start; @@ -1680,4 +2224,19 @@ input[type="range"] { align-items: flex-start; gap: 0.4rem; } + + .archive-shell { + padding: 1rem; + } + + .archive-switcher, + .archive-filter, + .archive-header__meta { + width: 100%; + } + + .archive-filter label { + min-width: 0; + } + } diff --git a/assets/js/app.js b/assets/js/app.js index 9927e19..6744b31 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -70,6 +70,25 @@ }); } + function initArchiveMobileDetail() { + if (!document.body.classList.contains("page-archive")) { + return; + } + + const isMobileViewport = () => window.matchMedia("(max-width: 820px)").matches; + const detail = document.querySelector("#archive-detail-panel[data-detail-open='1']"); + + if (!detail || !isMobileViewport()) { + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + detail.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + }); + } + function sleepDurationPoints(hours, points) { if (hours < 4) { return Number(points.lt4 || 0); @@ -542,8 +561,12 @@ } function calendarColor(entry) { + const isLightMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; + if (!entry) { - return "rgba(255, 255, 255, 0.06)"; + return isLightMode + ? "rgba(86, 124, 156, 0.11)" + : "rgba(255, 255, 255, 0.06)"; } const ratio = Math.max(0, Math.min(1, Number(entry.score) / Math.max(Number(entry.max || 1), 1))); @@ -644,6 +667,7 @@ const yOffset = config.yOffset; const gridHeight = (7 * cellSize) + (6 * verticalGap); const height = yOffset + gridHeight + 8; + const sundayLabelY = yOffset + (6 * (cellSize + verticalGap)) + (cellSize * 0.78); const rightPadding = 4; const naturalWidth = xOffset + (totalWeeks * cellSize) + ((totalWeeks - 1) * baseCellGap) + rightPadding; const availableWidth = Math.floor(container.clientWidth || 0); @@ -718,6 +742,7 @@ Mo Mi Fr + So ${cells}
@@ -1273,6 +1298,7 @@ updateRangeOutputs(); initHeaderDatePicker(); initTrackPreview(); + initArchiveMobileDetail(); initDashboardCharts(); initSportTypeManager(); initPwaShell(); diff --git a/src/App.php b/src/App.php index 3e2cce3..ac4086e 100644 --- a/src/App.php +++ b/src/App.php @@ -345,17 +345,47 @@ final class App { $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; - $selectedSummaryKind = isset($_GET['summary_kind']) ? (string) $_GET['summary_kind'] : null; - $selectedSummaryKey = isset($_GET['summary_key']) ? (string) $_GET['summary_key'] : 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 ($selectedDate !== null) { - foreach ($archive as $entry) { + if ($view === 'days' && $selectedDate !== null) { + foreach ($filteredDays as $entry) { if ($entry['date'] === $selectedDate) { $selectedEntry = $entry; break; @@ -363,21 +393,40 @@ final class App } } - $selectedSummary = null; - if ($selectedSummaryKind !== null && $selectedSummaryKey !== null) { - $selectedSummary = $this->summaries->find($user['username'], $selectedSummaryKind, $selectedSummaryKey); + $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' => $archive, + 'entries' => $filteredDays, 'selectedEntry' => $selectedEntry, - 'selectedSummary' => $selectedSummary, + 'selectedWeek' => $selectedWeek, + 'selectedMonth' => $selectedMonth, 'settings' => $settings, - 'weeklyArchive' => $this->buildWeeklyArchiveCards($archive, $weeklySummaries), - 'monthlyArchive' => $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries), + 'archiveView' => $view, + 'archiveFilterMonth' => $filterMonth, + 'archiveMonthOptions' => $monthOptions, + 'weeklyArchive' => $filteredWeeks, + 'monthlyArchive' => $filteredMonths, 'aiAvailable' => $this->openAi->isAvailable(), ]); } @@ -390,6 +439,11 @@ final class App $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') { @@ -406,7 +460,11 @@ final class App ]); flash('success', 'Die KI-Wochenzusammenfassung wurde erstellt.'); - redirect('/archive?summary_kind=weekly&summary_key=' . rawurlencode($weekKey)); + redirect($this->archivePath([ + 'view' => 'weeks', + 'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : month_key($context['date_to']), + 'week' => $weekKey, + ])); } if ($form === 'generate_monthly_summary') { @@ -425,14 +483,24 @@ final class App ]); flash('success', 'Die KI-Monatszusammenfassung wurde erstellt.'); - redirect('/archive?summary_kind=monthly&summary_key=' . rawurlencode($monthKey)); + redirect($this->archivePath([ + 'view' => 'months', + 'filter_month' => $returnFilterMonth !== '' ? $returnFilterMonth : $monthKey, + 'month_key' => $monthKey, + ])); } } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); - redirect('/archive'); + redirect($this->archivePath([ + 'view' => $returnView, + 'filter_month' => $returnFilterMonth, + ])); } - redirect('/archive'); + redirect($this->archivePath([ + 'view' => $returnView, + 'filter_month' => $returnFilterMonth, + ])); } private function showOptions(): void @@ -1107,6 +1175,21 @@ final class App $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, @@ -1115,9 +1198,13 @@ final class App 'date_to' => $summary['date_to'] ?? $range['date_to'], 'tracked_days' => count($weekEntries), 'note_entries_count' => count($noteEntries), - 'can_generate' => count($noteEntries) >= 3, + 'can_generate' => $canGenerate, 'summary' => $summary, 'has_summary' => $summary !== null, + 'status_label' => $statusLabel, + 'status_tone' => $statusTone, + 'status_hint' => $statusHint, + 'trend_label' => $this->weeklyTrendLabel($weekEntries), ]; } @@ -1126,7 +1213,7 @@ final class App return $cards; } - private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries): array + private function buildMonthlyArchiveCards(array $entries, array $weeklySummaries, array $monthlySummaries, array $weeklyArchive): array { $monthKeys = []; @@ -1163,8 +1250,32 @@ final class App $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, @@ -1172,10 +1283,22 @@ final class App 'date_from' => $range['date_from'], 'date_to' => $range['date_to'], 'tracked_days' => count($monthEntries), - 'weekly_summary_count' => count($monthWeeklySummaries), - 'can_generate' => count($monthWeeklySummaries) >= 2, - 'summary' => $monthlySummaryMap[$monthKey] ?? null, - 'has_summary' => isset($monthlySummaryMap[$monthKey]), + '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), ]; } @@ -1444,6 +1567,84 @@ final class App && $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'); diff --git a/src/helpers.php b/src/helpers.php index 6c4e5da..596512f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -197,6 +197,17 @@ function format_display_date(string $date, bool $withWeekday = true): string 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 { @@ -223,6 +234,17 @@ function format_display_datetime(string $value): string 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); diff --git a/templates/layout.php b/templates/layout.php index baf1ae4..419061e 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -5,7 +5,7 @@ declare(strict_types=1); $brandSubtitle = match ($page) { 'dashboard' => 'Statistiken und Verlauf', 'track' => 'Tag erfassen und bewerten', - 'archive' => 'Rückblick auf vergangene Tage', + 'archive' => '', 'options' => 'Logik, Erinnerungen, Sicherheit und Accounts', 'login' => 'Geschützter Zugang', 'setup' => 'Erstkonfiguration', @@ -36,7 +36,7 @@ $brandSubtitle = match ($page) { -> +>
@@ -89,7 +89,9 @@ $brandSubtitle = match ($page) {
-

+ +

+

diff --git a/templates/pages/archive.php b/templates/pages/archive.php index c1bd142..cf4c60a 100644 --- a/templates/pages/archive.php +++ b/templates/pages/archive.php @@ -1,232 +1,298 @@ -
-
-
-
-

Archiv

-

KI-Rückblicke und gespeicherte Tage

-
- Einträge + $archiveView]; +if ($archiveFilterMonth !== '') { + $baseParams['filter_month'] = $archiveFilterMonth; +} + +$archiveUrl = static function (array $params = []) use ($baseParams): string { + $query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== ''); + + return $query === [] ? '/archive' : '/archive?' . http_build_query($query); +}; + +$detailType = $selectedEntry !== null + ? 'day' + : ($selectedWeek !== null + ? 'week' + : ($selectedMonth !== null ? 'month' : null)); + +$detailOpen = $detailType !== null; +?> + +
+
+
+ + +
+ + +
-
-
-
-

KI

-

Monatszusammenfassungen

-
- - API nicht bereit +
+
+ +
+
+

Tage

+

Gespeicherte Tage

+
+ Tage +
+ + +

Für diesen Zeitraum gibt es noch keine getrackten Tage.

+ + + + +
+
+

Wochen

+

Wöchentliche KI-Rückblicke

+
+ Wochen +
+ + +

Für diesen Zeitraum sind noch keine Wochen im Archiv vorhanden.

+ + + + +
+
+

Monate

+

Monatliche KI-Rückblicke

+
+ Monate +
+ + +

Für diesen Zeitraum sind noch keine Monatsobjekte im Archiv vorhanden.

+ + + -
+
- -

Sobald genügend Wochenzusammenfassungen vorliegen, erscheinen hier die Monatsrückblicke.

- -
- -
-
-
- KI - -
- - vorhanden - - offen - -
- -

bis

-

KI-Wochenzusammenfassungen im Monat verfügbar

- - -

Erstellt am

+
- -
- -
+ +

· Punkte

+ Diesen Tag bearbeiten -
-
-
-

KI

-

Wochenzusammenfassungen

-
-
- - -

Noch keine Wochen verfügbar. Sobald Einträge vorliegen, kannst du hier Wochenrückblicke erzeugen.

- -
- -
-
-
- KI - -
- - vorhanden - - offen - -
- -

bis

-

Texteinträge · getrackte Tage

- - -

Erstellt am

- -

Mindestens 3 Texteinträge nötig.

+
+
Stimmung
/10
+
Energie
/10
+
Stress
/10
+ +
Schmerzen
/10
+
Schlaf
h
+
Schlafgefühl
/5
+
Sport
min
+
Spaziergang
+
Alkohol
+
-
- - Öffnen - +
+

Notiz

+

+
+ +

bis

+
+ + +
+ +
+
Texteinträge
+
Getrackte Tage
+ +
Erstellt am
+ +
+ +
+

KI-Status

+

+
+ + +
+ Öffnen
- - + + + +
-
- -
- -
-
-
-
-

Tage

-

Alle gespeicherten Tage

-
-
- - -

Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.

- -
- -
-
- - - 0 && !empty($entry['sport_type_meta'])): ?> - - - - - - - - - +
+

KI-Wochenzusammenfassung

+

-
- - Stimmung /10 -
- -
- -
- -
-
+ +
+ + + + + + +
+ + +

bis

- + +
+