5 Commits

23 changed files with 953 additions and 69 deletions
+15
View File
@@ -0,0 +1,15 @@
Mood-Board
Copyright (c) 2026 HNZIO
Licensed under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software only for permitted
noncommercial purposes under the terms of that license.
Commercial use is not allowed without a separate written agreement from the
copyright holder.
Required Notice: Copyright (c) 2026 HNZIO
Full license text:
https://polyformproject.org/licenses/noncommercial/1.0.0/
+7
View File
@@ -32,3 +32,10 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
- Nicht-kommerzielle Nutzung ist erlaubt.
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+75
View File
@@ -128,6 +128,34 @@ body {
color: var(--text);
}
.pull-refresh-indicator {
position: fixed;
top: max(0.85rem, env(safe-area-inset-top));
left: 50%;
z-index: 30;
padding: 0.72rem 1rem;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.9rem) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease;
color: var(--muted);
font-size: 0.92rem;
letter-spacing: 0.01em;
}
body.is-pull-refreshing .pull-refresh-indicator,
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
color: var(--text);
}
a {
color: inherit;
text-decoration: none;
@@ -216,6 +244,26 @@ button:disabled {
gap: 1rem;
}
.site-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.9rem;
margin-top: auto;
padding: 0.9rem 1.1rem;
border-radius: var(--radius-lg);
}
.site-footer__link {
color: var(--muted);
font-size: 0.92rem;
transition: color 180ms ease, opacity 180ms ease;
}
.site-footer__link:hover {
color: var(--text);
}
.topbar {
display: flex;
justify-content: space-between;
@@ -1273,6 +1321,17 @@ input[type="range"] {
gap: 0.7rem;
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
line-height: 1.45;
}
.checkbox-row--panel {
padding: 0.95rem 1rem;
border-radius: 18px;
@@ -1281,6 +1340,12 @@ input[type="range"] {
min-height: 100%;
}
.checkbox-row--tall {
align-items: flex-start;
padding-top: 1.05rem;
padding-bottom: 1.05rem;
}
.checkbox-row input {
width: auto;
}
@@ -1455,6 +1520,10 @@ input[type="range"] {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
}
.site-footer {
margin-bottom: 0.5rem;
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1535,4 +1604,10 @@ input[type="range"] {
.mobile-nav a span {
font-size: 0.72rem;
}
.site-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
}
+133 -3
View File
@@ -264,6 +264,7 @@
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const painEnabled = Boolean(settings.tracking?.pain_enabled);
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -275,9 +276,14 @@
walk_minutes: walkMode === "steps"
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0,
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
if (painEnabled) {
components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 0);
}
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings);
@@ -316,11 +322,13 @@
mood: "Stimmung",
energy: "Energie",
stress: "Stress",
pain: "Schmerzen",
sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport",
sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang",
alcohol: "Alkohol",
note: "Notiz",
};
@@ -328,6 +336,7 @@
mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value),
pain: Number(form.elements.pain?.value || 1),
sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0),
@@ -335,6 +344,7 @@
walk_mode: form.elements.walk_mode?.value || "time",
walk_minutes: Number(form.elements.walk_minutes?.value || 0),
walk_steps: Number(form.elements.walk_steps?.value || 0),
alcohol: Boolean(form.elements.alcohol?.checked),
note: form.elements.note.value || "",
});
@@ -384,7 +394,7 @@
}
const seriesName = container.dataset.series || "";
const invertScale = seriesName === "stress";
const invertScale = seriesName === "stress" || seriesName === "pain";
const values = items.map(item => Number(item.value));
const width = 760;
const height = 196;
@@ -392,7 +402,7 @@
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5);
} else {
@@ -402,7 +412,7 @@
if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5);
} else {
@@ -992,6 +1002,125 @@
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isAppleTouchDevice() {
return /iPhone|iPad|iPod/i.test(window.navigator.userAgent)
|| (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1);
}
function initPullToRefresh() {
if (!isStandaloneMode() || !isAppleTouchDevice()) {
return;
}
const indicator = document.querySelector("[data-pull-refresh-indicator]");
const body = document.body;
const threshold = 96;
let isTracking = false;
let isReady = false;
let startY = 0;
const setIndicator = message => {
if (indicator) {
indicator.textContent = message;
}
};
const resetState = () => {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isTracking = false;
isReady = false;
startY = 0;
setIndicator("Zum Aktualisieren ziehen");
};
const scrollTop = () => Math.max(
window.scrollY || 0,
document.documentElement.scrollTop || 0,
document.body.scrollTop || 0
);
const canStart = target => {
if (scrollTop() > 0) {
return false;
}
if (!(target instanceof Element)) {
return true;
}
return !target.closest("input, textarea, select, button");
};
window.addEventListener("touchstart", event => {
if (event.touches.length !== 1 || !canStart(event.target)) {
resetState();
return;
}
isTracking = true;
startY = event.touches[0].clientY;
}, { passive: true });
window.addEventListener("touchmove", event => {
if (!isTracking || event.touches.length !== 1) {
return;
}
if (scrollTop() > 0) {
resetState();
return;
}
const delta = event.touches[0].clientY - startY;
if (delta <= 0) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isReady = false;
setIndicator("Zum Aktualisieren ziehen");
return;
}
if (delta > 18) {
body.classList.add("is-pull-refreshing");
event.preventDefault();
}
if (delta >= threshold) {
if (!isReady) {
body.classList.add("is-pull-refresh-ready");
setIndicator("Loslassen zum Aktualisieren");
isReady = true;
}
return;
}
if (isReady) {
body.classList.remove("is-pull-refresh-ready");
isReady = false;
}
setIndicator("Zum Aktualisieren ziehen");
}, { passive: false });
window.addEventListener("touchend", () => {
if (!isTracking) {
return;
}
if (isReady) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
body.classList.add("is-pull-refresh-reloading");
setIndicator("Wird aktualisiert ...");
window.location.reload();
return;
}
resetState();
}, { passive: true });
window.addEventListener("touchcancel", resetState, { passive: true });
}
function initPushControls() {
const panel = document.querySelector("[data-push-panel]");
if (!panel) {
@@ -1147,5 +1276,6 @@
initDashboardCharts();
initSportTypeManager();
initPwaShell();
initPullToRefresh();
initPushControls();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6 -6
View File
@@ -12,15 +12,15 @@
"theme_color": "#0b1e2e",
"icons": [
{
"src": "/assets/branding/logo-mark.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/assets/branding/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/branding/apple-touch-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "/assets/branding/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
+359 -54
View File
@@ -31,6 +31,7 @@ final class App
$path = request_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
@@ -233,6 +234,7 @@ final class App
'pageTitle' => 'Dashboard',
'page' => 'dashboard',
'authUser' => $user,
'settings' => $settings,
'summary' => $summary,
'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData),
@@ -252,6 +254,8 @@ final class App
'mood' => 6,
'energy' => 6,
'stress' => 4,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 3,
'sport_minutes' => 0,
@@ -260,9 +264,11 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'alcohol' => false,
'note' => '',
];
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
$entry = $this->scoring->normalize($entry);
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
@@ -297,6 +303,8 @@ final class App
'mood' => $_POST['mood'] ?? 5,
'energy' => $_POST['energy'] ?? 5,
'stress' => $_POST['stress'] ?? 5,
'pain' => $_POST['pain'] ?? 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
@@ -304,6 +312,7 @@ final class App
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'alcohol' => $_POST['alcohol'] ?? false,
'note' => $_POST['note'] ?? '',
]);
@@ -312,9 +321,15 @@ final class App
redirect('/track');
}
$previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($user['username'], $entry['date'], $entry, $evaluation);
$entries = $this->entries->all($user['username']);
$entryMap = [];
foreach ($entries as $existingEntry) {
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
}
$entryMap[$entry['date']] = $entry;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Der Tag wurde gespeichert.');
redirect('/track?date=' . rawurlencode($entry['date']));
@@ -344,6 +359,7 @@ final class App
'authUser' => $user,
'entries' => $archive,
'selectedEntry' => $selectedEntry,
'settings' => $settings,
]);
}
@@ -385,11 +401,14 @@ final class App
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'backupAvailable' => class_exists('ZipArchive'),
'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
'energy' => 10,
'stress' => 1,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 5,
'sport_minutes' => 999,
@@ -400,6 +419,7 @@ final class App
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999,
'walk_steps' => 10000,
'alcohol' => false,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -420,6 +440,24 @@ final class App
redirect('/options');
}
if ($form === 'export_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$this->downloadUserBackup($user, $settings);
}
if ($form === 'import_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
try {
$imported = $this->importUserBackup($user, $settings);
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.');
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
}
redirect('/options');
}
if ($form === 'password') {
$current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? '');
@@ -506,6 +544,220 @@ final class App
];
}
private function persistUserEntries(string $username, array $settings, array $entries): void
{
$normalized = [];
foreach ($entries as $entry) {
if (!is_array($entry)) {
continue;
}
$normalizedEntry = $this->scoring->normalize($entry);
if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) {
continue;
}
$normalized[$normalizedEntry['date']] = $normalizedEntry;
}
ksort($normalized, SORT_STRING);
$previousEntry = null;
foreach ($normalized as $date => $entry) {
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($username, $date, $entry, $evaluation);
$previousEntry = $entry;
}
}
private function downloadUserBackup(array $user, array $settings): never
{
if (!class_exists('ZipArchive')) {
flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.');
redirect('/options');
}
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-');
if ($tempPath === false) {
throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.');
}
$zip = new ZipArchive();
$opened = $zip->open($tempPath, ZipArchive::OVERWRITE);
if ($opened !== true) {
@unlink($tempPath);
throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.');
}
foreach ($entries as $entry) {
$date = (string) ($entry['date'] ?? '');
if (!$this->isValidDate($date)) {
continue;
}
$markdown = $this->entries->exportMarkdown(
(string) ($user['username'] ?? ''),
$date,
$entry,
$entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings)
);
$zip->addFromString($date . '.txt', $markdown);
}
$zip->close();
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Content-Length: ' . (string) filesize($tempPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($tempPath);
@unlink($tempPath);
exit;
}
private function importUserBackup(array $user, array $settings): int
{
$files = uploaded_files('backup_files');
if ($files === []) {
throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.');
}
$importedEntries = [];
foreach ($files as $file) {
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
continue;
}
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.');
}
$tmpName = (string) ($file['tmp_name'] ?? '');
$name = trim((string) ($file['name'] ?? ''));
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('Eine Backup-Datei ist ungültig.');
}
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($extension === 'zip') {
foreach ($this->entriesFromZip($tmpName) as $date => $entry) {
$importedEntries[$date] = $entry;
}
continue;
}
if ($extension !== 'txt') {
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
}
$date = $this->dateFromBackupFileName($name);
$content = (string) file_get_contents($tmpName);
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry === null) {
throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.');
}
$importedEntries[$date] = $entry;
}
if ($importedEntries === []) {
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
}
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
$entryMap = [];
foreach ($existingEntries as $entry) {
if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) {
continue;
}
$entryMap[$entry['date']] = $entry;
}
foreach ($importedEntries as $date => $entry) {
$entryMap[$date] = $entry;
}
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
return count($importedEntries);
}
private function entriesFromZip(string $path): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
}
$zip = new ZipArchive();
$opened = $zip->open($path);
if ($opened !== true) {
throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.');
}
$entries = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_ends_with($name, '/')) {
continue;
}
$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;
}
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry !== null) {
$entries[$date] = $entry;
}
}
$zip->close();
return $entries;
}
private function dateFromBackupFileName(string $fileName): string
{
$baseName = basename($fileName);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) {
throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.');
}
$date = (string) ($matches[1] ?? '');
if (!$this->isValidDate($date)) {
throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.');
}
return $date;
}
private function buildDashboardCharts(array $entries): array
{
$recent = array_slice($entries, -30);
@@ -532,6 +784,12 @@ final class App
'value' => $entry['stress'],
];
}, $recent),
'pain' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['pain'],
];
}, $recent),
'sport' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
@@ -598,8 +856,12 @@ final class App
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
];
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
@@ -663,6 +925,10 @@ final class App
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['tracking'] = array_replace(
Defaults::settings()['tracking'],
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
@@ -855,60 +1121,15 @@ final class App
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
}
$now = new DateTimeImmutable('now');
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => date(DATE_ATOM),
]);
}
}
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
json_response([
'ok' => true,
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
'processed' => $stats['processed'],
'sent_users' => $stats['sent_users'],
'already_tracked' => $stats['already_tracked'],
'skipped' => $stats['skipped'],
'removed_subscriptions' => $stats['removed_subscriptions'],
]);
}
@@ -968,4 +1189,88 @@ final class App
return (string) ($state['last_sent_date'] ?? '') !== $today;
}
private function triggerReminderCheckFromTraffic(string $method, string $path): void
{
if ($method !== 'GET' || $path === '/reminders/run') {
return;
}
$lockPath = storage_path('system/reminder-traffic.lock');
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return;
}
try {
$this->runDueReminders(new DateTimeImmutable('now'));
} catch (Throwable) {
// Reminder checks should never break normal page delivery.
}
flock($handle, LOCK_UN);
fclose($handle);
}
private function runDueReminders(DateTimeImmutable $now): array
{
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => $now->format(DATE_ATOM),
]);
}
}
return [
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
];
}
}
+46 -3
View File
@@ -4,6 +4,13 @@ declare(strict_types=1);
final class EntryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function save(string $username, string $date, array $entry, array $evaluation): void
{
$path = $this->pathFor($username, $date);
@@ -13,7 +20,8 @@ final class EntryRepository
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
$markdown = $this->toMarkdown($username, $date, $entry, $evaluation);
file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX);
}
public function find(string $username, string $date): ?array
@@ -24,7 +32,14 @@ final class EntryRepository
return null;
}
return $this->parse((string) file_get_contents($path), $date);
$content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $date);
}
public function all(string $username): array
@@ -41,7 +56,14 @@ final class EntryRepository
$entries = [];
foreach ($files as $file) {
$date = basename($file, '.txt');
$parsed = $this->parse((string) file_get_contents($file), $date);
$content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$parsed = $this->parse($plaintext, $date);
if ($parsed !== null) {
$entries[] = $parsed;
}
@@ -50,6 +72,18 @@ final class EntryRepository
return $entries;
}
public function parseMarkdown(string $content, string $fallbackDate): ?array
{
$plaintext = $this->crypto->decrypt($content);
return $this->parse($plaintext, $fallbackDate);
}
public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
return $this->toMarkdown($username, $date, $entry, $evaluation);
}
private function directoryFor(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/days');
@@ -80,12 +114,16 @@ final class EntryRepository
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'pain' => $painRaw !== null ? (int) $painRaw : 1,
'pain_enabled' => $painRaw !== null,
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
@@ -94,6 +132,7 @@ final class EntryRepository
'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content),
];
@@ -140,12 +179,14 @@ final class EntryRepository
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
@@ -155,11 +196,13 @@ final class EntryRepository
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
+27
View File
@@ -13,6 +13,8 @@ final class ScoringService
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
@@ -21,6 +23,7 @@ final class ScoringService
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
'note' => trim((string) ($input['note'] ?? '')),
];
}
@@ -33,6 +36,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
@@ -43,14 +47,20 @@ final class ScoringService
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->walkPoints($entry, $settings),
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
if ($painEnabled) {
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
}
$total = round(array_sum($components), 1);
$maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
@@ -353,4 +363,21 @@ final class ScoringService
{
return $mode === 'steps' ? 'steps' : 'time';
}
private function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int) $value === 1;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
}
}
+5
View File
@@ -19,6 +19,9 @@ final class Defaults
'walk' => [
'mode' => 'time',
],
'tracking' => [
'pain_enabled' => false,
],
'sport_types' => [
[
'id' => 'running',
@@ -115,6 +118,7 @@ final class Defaults
'mood_multiplier' => 3,
'energy_multiplier' => 2,
'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [
'lt4' => 0,
@@ -149,6 +153,7 @@ final class Defaults
['steps' => 20000, 'points' => 0],
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryCrypto
{
private const HEADER = "MOODENC1\n";
private string $fallbackKeyPath;
public function __construct()
{
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
}
public function isAvailable(): bool
{
return function_exists('openssl_encrypt')
&& function_exists('openssl_decrypt')
&& function_exists('random_bytes');
}
public function shouldEncrypt(): bool
{
return $this->isAvailable();
}
public function isEncrypted(string $content): bool
{
return str_starts_with($content, self::HEADER);
}
public function encrypt(string $plaintext): string
{
if (!$this->shouldEncrypt()) {
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
if (!is_string($ciphertext) || $tag === '') {
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
}
$payload = json_encode([
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
}
return self::HEADER . $payload;
}
public function decrypt(string $content): string
{
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
return $content;
}
$payload = substr($content, strlen(self::HEADER));
$decoded = json_decode($payload, true);
if (
!is_array($decoded)
|| !is_string($decoded['iv'] ?? null)
|| !is_string($decoded['tag'] ?? null)
|| !is_string($decoded['data'] ?? null)
) {
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
}
$plaintext = openssl_decrypt(
(string) base64_decode($decoded['data'], true),
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
(string) base64_decode($decoded['iv'], true),
(string) base64_decode($decoded['tag'], true)
);
if (!is_string($plaintext)) {
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function key(): string
{
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
if ($configured !== '') {
return hash('sha256', $configured, true);
}
$stored = $this->readFallbackKey();
if ($stored !== null) {
return $stored;
}
$raw = random_bytes(32);
$directory = dirname($this->fallbackKeyPath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
@chmod($this->fallbackKeyPath, 0600);
return $raw;
}
private function readFallbackKey(): ?string
{
if (!is_file($this->fallbackKeyPath)) {
return null;
}
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
if ($raw === '') {
return null;
}
$decoded = base64_decode($raw, true);
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
}
}
+1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/EntryCrypto.php';
require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/UserRepository.php';
+31
View File
@@ -352,6 +352,37 @@ function base64url_decode(string $data): string
return $decoded;
}
function uploaded_files(string $field): array
{
$raw = $_FILES[$field] ?? null;
if (!is_array($raw) || !isset($raw['name'])) {
return [];
}
if (!is_array($raw['name'])) {
return [[
'name' => (string) ($raw['name'] ?? ''),
'type' => (string) ($raw['type'] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'] ?? ''),
'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'] ?? 0),
]];
}
$files = [];
foreach ($raw['name'] as $index => $name) {
$files[] = [
'name' => (string) ($name ?? ''),
'type' => (string) ($raw['type'][$index] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''),
'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'][$index] ?? 0),
];
}
return $files;
}
function normalize_sport_type_id(string $value): string
{
$value = trim(strtr($value, [
+11 -3
View File
@@ -27,9 +27,11 @@ $brandSubtitle = match ($page) {
<meta name="mood-push-public-key" content="<?= e((string) $pushPublicKey) ?>">
<?php endif; ?>
<title><?= e($pageTitle) ?> · Mood</title>
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
<link rel="apple-touch-icon" href="/assets/branding/apple-touch-icon.svg">
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/branding/favicon-32.png?v=20260412">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/branding/favicon-16.png?v=20260412">
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
@@ -37,6 +39,7 @@ $brandSubtitle = match ($page) {
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell">
<?php if ($authUser !== null): ?>
<aside class="sidebar glass-panel">
@@ -109,6 +112,11 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?>
<?= $content ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
</main>
<?php if ($authUser !== null): ?>
+4
View File
@@ -54,6 +54,9 @@
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
@@ -76,6 +79,7 @@
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
<div class="note-box">
+13
View File
@@ -73,6 +73,19 @@
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Körper</p>
<h3>Schmerzverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php endif; ?>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
+50
View File
@@ -23,6 +23,30 @@
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
</div>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span>
<strong>Schmerzen aktivieren</strong>
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
</span>
</label>
<label>
<span>Schmerzfaktor</span>
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
</label>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
@@ -326,6 +350,32 @@
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Backup</p>
<h3>Eigene Einträge sichern</h3>
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="export_backup">
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
<?php if (empty($backupAvailable)): ?>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
<?php endif; ?>
</form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="import_backup">
<label>
<span>Backup importieren</span>
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
</label>
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
<button class="ghost-button" type="submit">Backup importieren</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</h3>
+30
View File
@@ -37,6 +37,34 @@
</label>
</div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div class="field-grid field-grid--two">
<label class="range-card">
<span>Schmerzen</span>
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php endif; ?>
<div class="field-grid field-grid--two">
<label>
<span>Schlafdauer in Stunden</span>
@@ -127,11 +155,13 @@
'mood' => 'Stimmung',
'energy' => 'Energie',
'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz',
];
?>