Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8ccef5a7 | |||
| 4a884dd166 | |||
| 5ea1b56649 | |||
| abc0766f16 | |||
| 4e9fe2de6a |
@@ -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/
|
||||
@@ -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 |
@@ -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
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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): ?>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user