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) {
->
+>
Zum Aktualisieren ziehen
@@ -89,7 +89,9 @@ $brandSubtitle = match ($page) {