Add AI weekly and monthly summaries with archive UI and backup support
This commit is contained in:
+498
-15
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user