Add AI weekly and monthly summaries with archive UI and backup support

This commit is contained in:
2026-04-14 09:57:53 +02:00
parent 0a8ccef5a7
commit 9e79e93724
10 changed files with 1538 additions and 49 deletions
+498 -15
View File
@@ -12,6 +12,9 @@ final class App
private Auth $auth;
private NotificationRepository $notifications;
private WebPushService $webPush;
private SummaryRepository $summaries;
private AiConfigRepository $aiConfig;
private OpenAiSummaryService $openAi;
public function __construct()
{
@@ -23,6 +26,9 @@ final class App
$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
@@ -89,7 +95,7 @@ final class App
return;
case '/archive':
$this->showArchive();
$method === 'POST' ? $this->handleArchive() : $this->showArchive();
return;
case '/options':
@@ -340,8 +346,12 @@ final class App
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$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;
$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']);
$selectedEntry = null;
if ($selectedDate !== null) {
@@ -353,16 +363,78 @@ final class App
}
}
$selectedSummary = null;
if ($selectedSummaryKind !== null && $selectedSummaryKey !== null) {
$selectedSummary = $this->summaries->find($user['username'], $selectedSummaryKind, $selectedSummaryKey);
}
View::render('archive', [
'pageTitle' => 'Archiv',
'page' => 'archive',
'authUser' => $user,
'entries' => $archive,
'selectedEntry' => $selectedEntry,
'selectedSummary' => $selectedSummary,
'settings' => $settings,
'weeklyArchive' => $this->buildWeeklyArchiveCards($archive, $weeklySummaries),
'monthlyArchive' => $this->buildMonthlyArchiveCards($archive, $weeklySummaries, $monthlySummaries),
'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'] ?? '');
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('/archive?summary_kind=weekly&summary_key=' . rawurlencode($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('/archive?summary_kind=monthly&summary_key=' . rawurlencode($monthKey));
}
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
redirect('/archive');
}
redirect('/archive');
}
private function showOptions(): void
{
$user = $this->requireUser();
@@ -402,6 +474,8 @@ final class App
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'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() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
@@ -440,6 +514,12 @@ final class App
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);
@@ -450,7 +530,12 @@ final class App
try {
$imported = $this->importUserBackup($user, $settings);
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.');
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());
}
@@ -609,6 +694,17 @@ final class App
$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';
@@ -632,6 +728,7 @@ final class App
}
$importedEntries = [];
$importedSummaryCount = 0;
foreach ($files as $file) {
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
@@ -653,9 +750,11 @@ final class App
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($extension === 'zip') {
foreach ($this->entriesFromZip($tmpName) as $date => $entry) {
$zipContent = $this->entriesFromZip($user['username'], $tmpName);
foreach ($zipContent['entries'] as $date => $entry) {
$importedEntries[$date] = $entry;
}
$importedSummaryCount += (int) ($zipContent['summaries'] ?? 0);
continue;
}
@@ -663,8 +762,13 @@ final class App
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
}
$date = $this->dateFromBackupFileName($name);
$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) {
@@ -674,8 +778,8 @@ final class App
$importedEntries[$date] = $entry;
}
if ($importedEntries === []) {
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
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'] ?? ''));
@@ -693,12 +797,14 @@ final class App
$entryMap[$date] = $entry;
}
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
if ($importedEntries !== []) {
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
}
return count($importedEntries);
return count($importedEntries) + $importedSummaryCount;
}
private function entriesFromZip(string $path): array
private function entriesFromZip(string $username, string $path): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
@@ -712,6 +818,7 @@ final class App
}
$entries = [];
$summaryCount = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
@@ -720,17 +827,23 @@ final class App
}
$baseName = basename($name);
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
continue;
}
$date = $this->dateFromBackupFileName($baseName);
$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;
@@ -739,7 +852,10 @@ final class App
$zip->close();
return $entries;
return [
'entries' => $entries,
'summaries' => $summaryCount,
];
}
private function dateFromBackupFileName(string $fileName): string
@@ -961,6 +1077,373 @@ final class App
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'] ?? '')) !== ''
));
$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' => count($noteEntries) >= 3,
'summary' => $summary,
'has_summary' => $summary !== null,
];
}
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
{
$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)
));
usort($monthWeeklySummaries, static fn (array $left, array $right): int => strcmp((string) ($left['date_from'] ?? ''), (string) ($right['date_from'] ?? '')));
$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' => count($monthWeeklySummaries),
'can_generate' => count($monthWeeklySummaries) >= 2,
'summary' => $monthlySummaryMap[$monthKey] ?? null,
'has_summary' => isset($monthlySummaryMap[$monthKey]),
];
}
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 sendSecurityHeaders(): void
{
header('Referrer-Policy: strict-origin-when-cross-origin');