second commit

This commit is contained in:
2026-04-11 19:13:40 +02:00
parent 58bcc8f0f3
commit 87f7859017
25 changed files with 488 additions and 87 deletions
+8 -8
View File
@@ -1,23 +1,23 @@
# Mood # Mood
Dateibasierter Stimmungstracker fuer LAMP/Cloudron ohne Datenbank. Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
## Features ## Features
- Geschuetzter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern - Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv - Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
- Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt` - Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt`
- Pro Nutzer eigene Einstellungen fuer die Bewertungslogik - Pro Nutzer eigene Einstellungen für die Bewertungslogik
- Admin kann weitere Accounts direkt in der Weboberflaeche anlegen - Admin kann weitere Accounts direkt in der Weboberfläche anlegen
- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs - Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs
## Struktur ## Struktur
- `index.php`: Front-Controller und Routing-Einstieg - `index.php`: Front-Controller und Routing-Einstieg
- `src/`: PHP-Logik fuer Auth, Storage, Scoring und Rendering - `src/`: PHP-Logik für Auth, Storage, Scoring und Rendering
- `templates/`: Seiten-Templates - `templates/`: Seiten-Templates
- `assets/`: CSS und JavaScript - `assets/`: CSS und JavaScript
- `storage/`: geschuetzter Dateispeicher, per `.htaccess` nicht direkt abrufbar - `storage/`: geschützter Dateispeicher, per `.htaccess` nicht direkt abrufbar
## Deployment auf Cloudron / LAMP ## Deployment auf Cloudron / LAMP
@@ -30,5 +30,5 @@ Dateibasierter Stimmungstracker fuer LAMP/Cloudron ohne Datenbank.
## Hinweise ## Hinweise
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien. - Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Mehrere Accounts sind moeglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt. - Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du spaeter Reverse Proxy oder HTTPS ueber Cloudron nutzt, bleiben die Daten weiterhin nur ueber die App erreichbar. - Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
+182 -3
View File
@@ -19,6 +19,9 @@
--radius-sm: 14px; --radius-sm: 14px;
--panel-blur: 28px; --panel-blur: 28px;
--font-ui: "SF Pro Display", "Avenir Next", "Segoe UI Variable", "Helvetica Neue", system-ui, sans-serif; --font-ui: "SF Pro Display", "Avenir Next", "Segoe UI Variable", "Helvetica Neue", system-ui, sans-serif;
--track-accent: rgba(139, 228, 255, 0.34);
--track-surface: rgba(255, 255, 255, 0.08);
--track-glow: rgba(139, 228, 255, 0.18);
} }
*, *,
@@ -110,7 +113,7 @@ button {
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: flex-start;
padding: 1.35rem; padding: 1.35rem;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
min-height: calc(100vh - 2.5rem); min-height: calc(100vh - 2.5rem);
@@ -153,6 +156,24 @@ button {
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.meta-pill--button {
cursor: pointer;
}
.topbar-date-form {
display: inline-flex;
}
.topbar-date-input {
width: auto;
min-height: 2.2rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 0.45rem 0.9rem;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.chart-chip--warm { .chart-chip--warm {
background: rgba(255, 173, 124, 0.12); background: rgba(255, 173, 124, 0.12);
} }
@@ -209,6 +230,9 @@ button {
} }
.main-nav a { .main-nav a {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
border-radius: 18px; border-radius: 18px;
color: var(--muted); color: var(--muted);
@@ -225,6 +249,14 @@ button {
.sidebar-footer { .sidebar-footer {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
margin-top: auto;
padding-top: 1.2rem;
}
.nav-icon {
width: 1.1rem;
height: 1.1rem;
opacity: 0.9;
} }
.user-chip { .user-chip {
@@ -497,6 +529,13 @@ button {
gap: 1rem; gap: 1rem;
} }
.section-head__actions {
display: flex;
gap: 0.7rem;
align-items: center;
flex-wrap: wrap;
}
.field-grid--single { .field-grid--single {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -560,8 +599,9 @@ input[type="range"] {
.range-card { .range-card {
padding: 1rem; padding: 1rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.08); background: var(--track-surface);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--track-accent);
transition: border-color 220ms ease, background 220ms ease, transform 220ms ease, box-shadow 220ms ease;
} }
.range-card output { .range-card output {
@@ -570,6 +610,66 @@ input[type="range"] {
font-weight: 700; font-weight: 700;
} }
.preview-card {
position: relative;
overflow: hidden;
border-color: var(--track-accent);
box-shadow: 0 24px 70px rgba(4, 18, 31, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.preview-card::before {
content: "";
position: absolute;
inset: auto -12% -25% auto;
width: 14rem;
height: 14rem;
border-radius: 50%;
background: radial-gradient(circle, var(--track-glow), transparent 70%);
pointer-events: none;
transition: background 220ms ease, transform 220ms ease;
}
.preview-status {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.8rem;
}
.preview-status__icon {
width: 4.5rem;
height: 4.5rem;
border-radius: 20px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.preview-status__icon img {
width: 2.3rem;
height: 2.3rem;
}
.preview-status__label {
margin: 0;
font-size: 1.55rem;
font-weight: 750;
}
.preview-scoreline {
margin: 0 0 1rem;
color: var(--muted);
font-size: 0.95rem;
}
.preview-scoreline span {
color: var(--text);
font-size: 1.2rem;
font-weight: 700;
}
.component-list, .component-list,
.detail-grid { .detail-grid {
display: grid; display: grid;
@@ -684,6 +784,17 @@ input[type="range"] {
text-align: right; text-align: right;
} }
.archive-item__actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.archive-action {
min-height: 2.4rem;
padding-inline: 0.85rem;
}
.note-box { .note-box {
padding: 1rem; padding: 1rem;
border-radius: 18px; border-radius: 18px;
@@ -740,6 +851,68 @@ input[type="range"] {
width: fit-content; width: fit-content;
} }
.page-track[data-track-mood="storm"] {
--track-accent: rgba(255, 143, 143, 0.38);
--track-surface: rgba(255, 143, 143, 0.08);
--track-glow: rgba(255, 126, 126, 0.22);
}
.page-track[data-track-mood="heavy"] {
--track-accent: rgba(255, 191, 141, 0.34);
--track-surface: rgba(255, 191, 141, 0.08);
--track-glow: rgba(255, 191, 141, 0.18);
}
.page-track[data-track-mood="steady"] {
--track-accent: rgba(139, 228, 255, 0.34);
--track-surface: rgba(255, 255, 255, 0.08);
--track-glow: rgba(139, 228, 255, 0.18);
}
.page-track[data-track-mood="bright"] {
--track-accent: rgba(143, 243, 198, 0.34);
--track-surface: rgba(143, 243, 198, 0.08);
--track-glow: rgba(143, 243, 198, 0.2);
}
.page-track[data-track-mood="radiant"] {
--track-accent: rgba(255, 233, 140, 0.35);
--track-surface: rgba(255, 233, 140, 0.1);
--track-glow: rgba(255, 233, 140, 0.22);
}
.page-track[data-track-mood] .aurora-one,
.page-track[data-track-mood] .aurora-two,
.page-track[data-track-mood] .range-card,
.page-track[data-track-mood] .preview-card,
.page-track[data-track-mood] .preview-card::before {
transition: background 220ms ease, opacity 220ms ease, transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
}
.page-track[data-track-mood="storm"] .aurora-one,
.page-track[data-track-mood="storm"] .aurora-two {
opacity: 0.5;
background: radial-gradient(circle, rgba(255, 128, 128, 0.32), transparent 70%);
}
.page-track[data-track-mood="heavy"] .aurora-one,
.page-track[data-track-mood="heavy"] .aurora-two {
opacity: 0.55;
background: radial-gradient(circle, rgba(255, 192, 128, 0.3), transparent 70%);
}
.page-track[data-track-mood="bright"] .aurora-one,
.page-track[data-track-mood="bright"] .aurora-two {
opacity: 0.72;
background: radial-gradient(circle, rgba(127, 243, 187, 0.3), transparent 70%);
}
.page-track[data-track-mood="radiant"] .aurora-one,
.page-track[data-track-mood="radiant"] .aurora-two {
opacity: 0.78;
background: radial-gradient(circle, rgba(255, 228, 122, 0.32), transparent 70%);
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.shell { .shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -778,6 +951,12 @@ input[type="range"] {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.4rem; padding-bottom: 0.4rem;
} }
.archive-item,
.preview-status {
flex-direction: column;
align-items: flex-start;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 7.5A3.5 3.5 0 0 1 7.5 4H10l1.2 1.6a2 2 0 0 0 1.6.8H16.5A3.5 3.5 0 0 1 20 9.9v6.6A3.5 3.5 0 0 1 16.5 20h-9A3.5 3.5 0 0 1 4 16.5v-9Z" fill="#DFF7FF" opacity=".18"/>
<path d="M8 12h8" stroke="#DFF7FF" stroke-width="2" stroke-linecap="round"/>
<path d="M8 16h5" stroke="#8CFFD1" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="2" fill="#DFF7FF"/>
<rect x="14" y="3" width="7" height="11" rx="2" fill="#90E3FF"/>
<rect x="3" y="14" width="7" height="7" rx="2" fill="#8CFFD1"/>
<rect x="14" y="18" width="7" height="3" rx="1.5" fill="#DFF7FF"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="15" fill="#8CFFD1"/>
<circle cx="19" cy="20.5" r="2" fill="#114F3C"/>
<circle cx="29" cy="20.5" r="2" fill="#114F3C"/>
<path d="M18 27.5c1.7 2.3 3.8 3.5 6 3.5s4.3-1.2 6-3.5" stroke="#114F3C" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path d="M15 30c-4.4 0-8-3.4-8-7.6S10.6 15 15 15c1 0 1.9.2 2.8.5C20 11.6 24 9 28.6 9 35.4 9 41 14.3 41 21c0 .5 0 1-.1 1.5 3 1.4 5.1 4.4 5.1 7.9 0 4.9-4.1 8.6-9.3 8.6H15Z" fill="#FFC98F"/>
<path d="M18 34c1.6 1.3 3.6 2 6 2s4.4-.7 6-2" stroke="#8E4C1F" stroke-width="3" stroke-linecap="round"/>
<circle cx="19" cy="24" r="2" fill="#8E4C1F"/>
<circle cx="29" cy="24" r="2" fill="#8E4C1F"/>
</svg>

After

Width:  |  Height:  |  Size: 476 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="12" fill="#FFE28A"/>
<path d="M24 4v6" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M24 38v6" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M4 24h6" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M38 24h6" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M10 10l4 4" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M34 34l4 4" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M10 38l4-4" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<path d="M34 14l4-4" stroke="#FFE28A" stroke-width="3" stroke-linecap="round"/>
<circle cx="19" cy="22" r="1.8" fill="#7A5A08"/>
<circle cx="29" cy="22" r="1.8" fill="#7A5A08"/>
<path d="M18 27.5c1.7 2.3 3.8 3.5 6 3.5s4.3-1.2 6-3.5" stroke="#7A5A08" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 994 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="15" fill="#A6E8FF"/>
<circle cx="19" cy="21" r="2" fill="#174861"/>
<circle cx="29" cy="21" r="2" fill="#174861"/>
<path d="M18 29h12" stroke="#174861" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path d="M15 29c-4.4 0-8-3.4-8-7.6S10.6 14 15 14c1 0 1.9.2 2.8.5C20 10.6 24 8 28.6 8 35.4 8 41 13.3 41 20c0 .5 0 1-.1 1.5 3 1.4 5.1 4.4 5.1 7.9 0 4.9-4.1 8.6-9.3 8.6H15Z" fill="#FFB2B2"/>
<path d="M20 34l-2.4 5.4" stroke="#FFE6A6" stroke-width="3" stroke-linecap="round"/>
<path d="M28 34l-2.4 5.4" stroke="#FFE6A6" stroke-width="3" stroke-linecap="round"/>
<path d="M24 22c-2.2 0-4 1.8-4 4" stroke="#7A2437" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5 7h14" stroke="#DFF7FF" stroke-width="2" stroke-linecap="round"/>
<path d="M5 17h14" stroke="#DFF7FF" stroke-width="2" stroke-linecap="round"/>
<circle cx="9" cy="7" r="3" fill="#90E3FF"/>
<circle cx="15" cy="17" r="3" fill="#8CFFD1"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="5" width="18" height="16" rx="4" fill="#DFF7FF" opacity=".18"/>
<rect x="6" y="3" width="2" height="4" rx="1" fill="#DFF7FF"/>
<rect x="16" y="3" width="2" height="4" rx="1" fill="#DFF7FF"/>
<path d="M7 11.5h10" stroke="#DFF7FF" stroke-width="2" stroke-linecap="round"/>
<path d="M7 16.5h6" stroke="#8CFFD1" stroke-width="2" stroke-linecap="round"/>
<circle cx="16.5" cy="16.5" r="2.5" fill="#90E3FF"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

+50 -1
View File
@@ -34,6 +34,10 @@
return value || fallback; return value || fallback;
} }
function moodIconPath(sentiment) {
return `/assets/icons/mood-${sentiment}.svg`;
}
function updateRangeOutputs() { function updateRangeOutputs() {
document.querySelectorAll("[data-output-for]").forEach(output => { document.querySelectorAll("[data-output-for]").forEach(output => {
const input = document.querySelector(`[name="${output.dataset.outputFor}"]`); const input = document.querySelector(`[name="${output.dataset.outputFor}"]`);
@@ -50,6 +54,17 @@
}); });
} }
function initHeaderDatePicker() {
document.querySelectorAll(".topbar-date-input").forEach(input => {
input.addEventListener("change", () => {
const form = input.closest("form");
if (form) {
form.submit();
}
});
});
}
function sleepDurationPoints(hours, points) { function sleepDurationPoints(hours, points) {
if (hours < 4) { if (hours < 4) {
return Number(points.lt4 || 0); return Number(points.lt4 || 0);
@@ -123,6 +138,35 @@
return currentIndex > capIndex ? cap : current; return currentIndex > capIndex ? cap : current;
} }
function sentimentForLabel(label, ratings) {
const order = ratings.map(item => item.label);
const index = order.indexOf(label);
if (index === -1 || order.length <= 1) {
return "steady";
}
const ratio = index / Math.max(order.length - 1, 1);
if (ratio <= 0.1) {
return "storm";
}
if (ratio <= 0.35) {
return "heavy";
}
if (ratio <= 0.65) {
return "steady";
}
if (ratio <= 0.9) {
return "bright";
}
return "radiant";
}
function evaluateEntry(entry, settings) { function evaluateEntry(entry, settings) {
const ratings = sortedRatings(settings.ratings || []); const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {}; const scoring = settings.scoring || {};
@@ -151,7 +195,7 @@
} }
} }
return { total, label, components }; return { total, label, components, sentiment: sentimentForLabel(label, ratings) };
} }
function initTrackPreview() { function initTrackPreview() {
@@ -169,6 +213,7 @@
const totalNode = card.querySelector("[data-preview-total]"); const totalNode = card.querySelector("[data-preview-total]");
const labelNode = card.querySelector("[data-preview-label]"); const labelNode = card.querySelector("[data-preview-label]");
const iconNode = card.querySelector("[data-preview-icon]");
const componentsNode = card.querySelector("[data-preview-components]"); const componentsNode = card.querySelector("[data-preview-components]");
const componentLabels = { const componentLabels = {
mood: "Stimmung", mood: "Stimmung",
@@ -196,6 +241,9 @@
const result = evaluateEntry(collect(), payload.settings); const result = evaluateEntry(collect(), payload.settings);
totalNode.textContent = formatNumber(result.total); totalNode.textContent = formatNumber(result.total);
labelNode.textContent = result.label; labelNode.textContent = result.label;
iconNode.src = moodIconPath(result.sentiment);
card.dataset.sentiment = result.sentiment;
document.body.dataset.trackMood = result.sentiment;
componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => { componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => {
return `<div><dt>${componentLabels[key] || key}</dt><dd>${formatNumber(Number(value))}</dd></div>`; return `<div><dt>${componentLabels[key] || key}</dt><dd>${formatNumber(Number(value))}</dd></div>`;
}).join(""); }).join("");
@@ -419,6 +467,7 @@
} }
updateRangeOutputs(); updateRangeOutputs();
initHeaderDatePicker();
initTrackPreview(); initTrackPreview();
initDashboardCharts(); initDashboardCharts();
})(); })();
+9 -7
View File
@@ -118,7 +118,7 @@ final class App
} }
if ($password !== $passwordConfirm) { if ($password !== $passwordConfirm) {
flash('error', 'Die Passwoerter stimmen nicht ueberein.'); flash('error', 'Die Passwörter stimmen nicht überein.');
redirect('/setup'); redirect('/setup');
} }
@@ -153,12 +153,12 @@ final class App
if (!$this->auth->attempt($username, $password)) { if (!$this->auth->attempt($username, $password)) {
$this->throttle->hit($throttleKey); $this->throttle->hit($throttleKey);
flash('error', 'Login fehlgeschlagen. Bitte pruefe Benutzername und Passwort.'); flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.');
redirect('/login'); redirect('/login');
} }
$this->throttle->clear($throttleKey); $this->throttle->clear($throttleKey);
flash('success', 'Willkommen zurueck.'); flash('success', 'Willkommen zurück.');
redirect('/'); redirect('/');
} }
@@ -219,6 +219,8 @@ final class App
'entry' => $entry, 'entry' => $entry,
'evaluation' => $evaluation, 'evaluation' => $evaluation,
'settings' => $settings, 'settings' => $settings,
'trackMood' => $evaluation['sentiment'],
'topbarDate' => $entry['date'],
'trackPayload' => encode_payload([ 'trackPayload' => encode_payload([
'settings' => $settings, 'settings' => $settings,
'entry' => $entry, 'entry' => $entry,
@@ -246,7 +248,7 @@ final class App
]); ]);
if (!$this->isValidDate($entry['date'])) { if (!$this->isValidDate($entry['date'])) {
flash('error', 'Bitte waehle ein gueltiges Datum.'); flash('error', 'Bitte wähle ein gültiges Datum.');
redirect('/track'); redirect('/track');
} }
@@ -344,7 +346,7 @@ final class App
} }
if ($new !== $confirm) { if ($new !== $confirm) {
flash('error', 'Die neuen Passwoerter stimmen nicht ueberein.'); flash('error', 'Die neuen Passwörter stimmen nicht überein.');
redirect('/options'); redirect('/options');
} }
@@ -359,7 +361,7 @@ final class App
$isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1'; $isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1';
if (!$this->isValidUsername($username)) { if (!$this->isValidUsername($username)) {
flash('error', 'Bitte nutze fuer neue Accounts einen sauberen Benutzernamen.'); flash('error', 'Bitte nutze für neue Accounts einen sauberen Benutzernamen.');
redirect('/options'); redirect('/options');
} }
@@ -538,7 +540,7 @@ final class App
{ {
if (!verify_csrf($_POST['_token'] ?? null)) { if (!verify_csrf($_POST['_token'] ?? null)) {
http_response_code(419); http_response_code(419);
exit('Ungueltiges Formular-Token.'); exit('Ungültiges Formular-Token.');
} }
} }
+3 -3
View File
@@ -68,7 +68,7 @@ final class EntryRepository
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5), 'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5), 'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0), 'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlafgefuehl:\s*(.+)$/m', $content) ?? 3), 'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0), 'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'note' => $this->extractNote($content), 'note' => $this->extractNote($content),
@@ -112,7 +112,7 @@ final class EntryRepository
'- Energie: ' . $entry['energy'], '- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'], '- Stress: ' . $entry['stress'],
'- Schlafdauer: ' . $entry['sleep_hours'], '- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefuehl: ' . $entry['sleep_feeling'], '- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'], '- Sport: ' . $entry['sport_minutes'],
'- Spaziergang: ' . $entry['walk_minutes'], '- Spaziergang: ' . $entry['walk_minutes'],
'', '',
@@ -125,7 +125,7 @@ final class EntryRepository
'- Energie: ' . format_points((float) $evaluation['components']['energy']), '- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']), '- Stress: ' . format_points((float) $evaluation['components']['stress']),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']), '- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefuehl: ' . format_points((float) $evaluation['components']['sleep_feeling']), '- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Notiz: ' . format_points((float) $evaluation['components']['note']), '- Notiz: ' . format_points((float) $evaluation['components']['note']),
+20
View File
@@ -72,6 +72,7 @@ final class ScoringService
'max_total' => $maxTotal, 'max_total' => $maxTotal,
'label' => $label, 'label' => $label,
'guardrail' => $guardrail, 'guardrail' => $guardrail,
'sentiment' => $this->sentimentForLabel($label, $ratings),
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0, 'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
]; ];
} }
@@ -168,5 +169,24 @@ final class ScoringService
return $currentIndex > $capIndex ? $cap : $current; return $currentIndex > $capIndex ? $cap : $current;
} }
private function sentimentForLabel(string $label, array $ratings): string
{
$order = array_values(array_map(static fn (array $rating): string => (string) $rating['label'], $ratings));
$index = array_search($label, $order, true);
if ($index === false || count($order) <= 1) {
return 'steady';
} }
$ratio = $index / max(count($order) - 1, 1);
return match (true) {
$ratio <= 0.1 => 'storm',
$ratio <= 0.35 => 'heavy',
$ratio <= 0.65 => 'steady',
$ratio <= 0.9 => 'bright',
default => 'radiant',
};
}
}
+1 -2
View File
@@ -56,7 +56,7 @@ final class UserRepository
$normalized = normalize_username($username); $normalized = normalize_username($username);
if ($normalized === '' || $this->find($normalized) !== null) { if ($normalized === '' || $this->find($normalized) !== null) {
throw new RuntimeException('Benutzername existiert bereits oder ist ungueltig.'); throw new RuntimeException('Benutzername existiert bereits oder ist ungültig.');
} }
$users = $this->all(); $users = $this->all();
@@ -100,4 +100,3 @@ final class UserRepository
); );
} }
} }
+53
View File
@@ -122,3 +122,56 @@ function encode_payload(array $payload): string
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} }
function shift_date(string $date, int $days): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return today();
}
return $current->modify(($days >= 0 ? '+' : '') . $days . ' day')->format('Y-m-d');
}
function format_display_date(string $date, bool $withWeekday = true): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return $date;
}
$weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
$label = $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y');
if (!$withWeekday) {
return $label;
}
return $weekdays[(int) $current->format('w')] . ', ' . $label;
}
function icon_path(string $name): string
{
return '/assets/icons/' . $name . '.svg';
}
function mood_icon_path(string $sentiment): string
{
return icon_path('mood-' . $sentiment);
}
+28 -9
View File
@@ -5,9 +5,9 @@ declare(strict_types=1);
$brandSubtitle = match ($page) { $brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf', 'dashboard' => 'Statistiken und Verlauf',
'track' => 'Tag erfassen und bewerten', 'track' => 'Tag erfassen und bewerten',
'archive' => 'Rueckblick auf vergangene Tage', 'archive' => 'Rückblick auf vergangene Tage',
'options' => 'Logik, Sicherheit und Accounts', 'options' => 'Logik, Sicherheit und Accounts',
'login' => 'Geschuetzter Zugang', 'login' => 'Geschützter Zugang',
'setup' => 'Erstkonfiguration', 'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker', default => 'Stimmungstracker',
}; };
@@ -21,7 +21,7 @@ $brandSubtitle = match ($page) {
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script> <script defer src="/assets/js/app.js"></script>
</head> </head>
<body class="app-body page-<?= e($page) ?>"> <body class="app-body page-<?= e($page) ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<div class="aurora aurora-one"></div> <div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div> <div class="aurora aurora-two"></div>
<div class="shell"> <div class="shell">
@@ -30,16 +30,28 @@ $brandSubtitle = match ($page) {
<div class="brand-block"> <div class="brand-block">
<div class="brand-mark">M</div> <div class="brand-mark">M</div>
<div> <div>
<p class="eyebrow">mood.hnz.io</p> <p class="eyebrow">mood.heinz.media</p>
<h1>Mood</h1> <h1>Mood</h1>
</div> </div>
</div> </div>
<nav class="main-nav" aria-label="Hauptnavigation"> <nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">Dashboard</a> <a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">Tracken</a> <img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">Archiv</a> <span>Dashboard</span>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">Optionen</a> </a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
<span>Archiv</span>
</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
<span>Optionen</span>
</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -63,7 +75,15 @@ $brandSubtitle = match ($page) {
<h2><?= e($pageTitle) ?></h2> <h2><?= e($pageTitle) ?></h2>
</div> </div>
<div class="topbar__meta"> <div class="topbar__meta">
<?php if ($page === 'track' && isset($topbarDate)): ?>
<a class="meta-pill meta-pill--button" href="/track?date=<?= e(rawurlencode(shift_date($topbarDate, -1))) ?>">Vorheriger Tag</a>
<form method="get" action="/track" class="topbar-date-form">
<input class="topbar-date-input" type="date" name="date" value="<?= e($topbarDate) ?>">
</form>
<a class="meta-pill meta-pill--button" href="/track?date=<?= e(rawurlencode(shift_date($topbarDate, 1))) ?>">Nächster Tag</a>
<?php else: ?>
<span class="meta-pill"><?= e(date('d.m.Y')) ?></span> <span class="meta-pill"><?= e(date('d.m.Y')) ?></span>
<?php endif; ?>
<span class="meta-pill"><?= e($authUser['username']) ?></span> <span class="meta-pill"><?= e($authUser['username']) ?></span>
</div> </div>
</header> </header>
@@ -78,4 +98,3 @@ $brandSubtitle = match ($page) {
</div> </div>
</body> </body>
</html> </html>
+14 -10
View File
@@ -5,24 +5,28 @@
<p class="eyebrow">Archiv</p> <p class="eyebrow">Archiv</p>
<h3>Alle gespeicherten Tage</h3> <h3>Alle gespeicherten Tage</h3>
</div> </div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Eintraege</span> <span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
</div> </div>
<?php if ($entries === []): ?> <?php if ($entries === []): ?>
<p class="empty-state">Noch keine Eintraege vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p> <p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
<?php else: ?> <?php else: ?>
<div class="archive-items"> <div class="archive-items">
<?php foreach ($entries as $entry): ?> <?php foreach ($entries as $entry): ?>
<a class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>"> <article class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>">
<div> <div>
<strong><?= e($entry['date']) ?></strong> <strong><?= e(format_display_date($entry['date'], false)) ?></strong>
<span><?= e($entry['evaluation']['label']) ?></span> <span><?= e($entry['evaluation']['label']) ?></span>
</div> </div>
<div class="archive-item__meta"> <div class="archive-item__meta">
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span> <span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span> <span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
</div> </div>
</a> <div class="archive-item__actions">
<a class="ghost-link archive-action" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Ansehen</a>
<a class="ghost-link archive-action" href="/track?date=<?= e(rawurlencode($entry['date'])) ?>">Bearbeiten</a>
</div>
</article>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -31,16 +35,17 @@
<aside class="stack-column"> <aside class="stack-column">
<?php if ($selectedEntry !== null): ?> <?php if ($selectedEntry !== null): ?>
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Ausgewaehlt</p> <p class="eyebrow">Ausgewählt</p>
<h3><?= e($selectedEntry['date']) ?></h3> <h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p> <p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
<dl class="detail-grid"> <dl class="detail-grid">
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div> <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>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div> <div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div> <div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefuehl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</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> <div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div> <div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
</dl> </dl>
@@ -54,9 +59,8 @@
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Details</p> <p class="eyebrow">Details</p>
<h3>Archivansicht</h3> <h3>Archivansicht</h3>
<p>Waehle links einen Tag aus, um alle Werte und die Tagebuchnotiz anzuzeigen.</p> <p>Wähle links einen Tag aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</p>
</article> </article>
<?php endif; ?> <?php endif; ?>
</aside> </aside>
</section> </section>
+6 -7
View File
@@ -1,8 +1,8 @@
<section class="hero-grid"> <section class="hero-grid">
<article class="hero-card hero-card--wide glass-panel"> <article class="hero-card hero-card--wide glass-panel">
<p class="eyebrow">Stimmung im Blick</p> <p class="eyebrow">Stimmung im Blick</p>
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Uebersicht.</h3> <h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Übersicht.</h3>
<p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen aenderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p> <p class="hero-copy">Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen änderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.</p>
</article> </article>
<article class="hero-card glass-panel"> <article class="hero-card glass-panel">
@@ -12,7 +12,7 @@
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p> <p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
<?php else: ?> <?php else: ?>
<div class="hero-score">-</div> <div class="hero-score">-</div>
<p class="hero-label">Noch kein Eintrag fuer heute</p> <p class="hero-label">Noch kein Eintrag für heute</p>
<?php endif; ?> <?php endif; ?>
</article> </article>
</section> </section>
@@ -57,7 +57,7 @@
<p class="eyebrow">Trend</p> <p class="eyebrow">Trend</p>
<h3>Tagesstimmung</h3> <h3>Tagesstimmung</h3>
</div> </div>
<span class="chart-chip">letzte 30 Eintraege</span> <span class="chart-chip">letzte 30 Einträge</span>
</div> </div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div> <div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article> </article>
@@ -68,7 +68,7 @@
<p class="eyebrow">Belastung</p> <p class="eyebrow">Belastung</p>
<h3>Stressverlauf</h3> <h3>Stressverlauf</h3>
</div> </div>
<span class="chart-chip chart-chip--warm">letzte 30 Eintraege</span> <span class="chart-chip chart-chip--warm">letzte 30 Einträge</span>
</div> </div>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div> <div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article> </article>
@@ -76,7 +76,7 @@
<article class="glass-panel chart-card chart-card--wide"> <article class="glass-panel chart-card chart-card--wide">
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow">Aktivitaet</p> <p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3> <h3>Sport und Spaziergang</h3>
</div> </div>
<span class="chart-chip chart-chip--cool">Minuten pro Tag</span> <span class="chart-chip chart-chip--cool">Minuten pro Tag</span>
@@ -84,4 +84,3 @@
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div> <div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article> </article>
</section> </section>
+2 -3
View File
@@ -1,8 +1,8 @@
<section class="auth-shell"> <section class="auth-shell">
<div class="auth-card glass-panel"> <div class="auth-card glass-panel">
<p class="eyebrow">Geschuetzt und dateibasiert</p> <p class="eyebrow">Geschützt und dateibasiert</p>
<h1>Einloggen</h1> <h1>Einloggen</h1>
<p class="auth-copy">Die Eintraege liegen als Markdown-TXT-Dateien im geschuetzten Speicher und sind nur nach Login sichtbar.</p> <p class="auth-copy">Die Einträge liegen als Markdown-TXT-Dateien im geschützten Speicher und sind nur nach Login sichtbar.</p>
<form method="post" action="/login" class="stack-form"> <form method="post" action="/login" class="stack-form">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -20,4 +20,3 @@
</form> </form>
</div> </div>
</section> </section>
-1
View File
@@ -6,4 +6,3 @@
<a class="primary-button button-link" href="/">Zur Startseite</a> <a class="primary-button button-link" href="/">Zur Startseite</a>
</div> </div>
</section> </section>
+4 -5
View File
@@ -18,7 +18,7 @@
<label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label> <label><span>Stimmung</span><input type="number" name="settings[scoring][mood_multiplier]" value="<?= e((string) $settings['scoring']['mood_multiplier']) ?>" min="0" max="10"></label>
<label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label> <label><span>Energie</span><input type="number" name="settings[scoring][energy_multiplier]" value="<?= e((string) $settings['scoring']['energy_multiplier']) ?>" min="0" max="10"></label>
<label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label> <label><span>Stress</span><input type="number" name="settings[scoring][stress_multiplier]" value="<?= e((string) $settings['scoring']['stress_multiplier']) ?>" min="0" max="10"></label>
<label><span>Schlafgefuehl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label> <label><span>Schlafgefühl</span><input type="number" name="settings[scoring][sleep_feeling_multiplier]" value="<?= e((string) $settings['scoring']['sleep_feeling_multiplier']) ?>" min="0" max="10"></label>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h4>Sport-Baender</h4> <h4>Sport-Bänder</h4>
<div class="band-grid"> <div class="band-grid">
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?> <?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
<div class="band-card"> <div class="band-card">
@@ -48,7 +48,7 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h4>Spaziergang-Baender</h4> <h4>Spaziergang-Bänder</h4>
<div class="band-grid"> <div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?> <?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card"> <div class="band-card">
@@ -98,7 +98,7 @@
<aside class="stack-column"> <aside class="stack-column">
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p> <p class="eyebrow">Sicherheit</p>
<h3>Passwort aendern</h3> <h3>Passwort ändern</h3>
<form method="post" action="/options" class="stack-form"> <form method="post" action="/options" class="stack-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="form_name" value="password"> <input type="hidden" name="form_name" value="password">
@@ -136,4 +136,3 @@
<?php endif; ?> <?php endif; ?>
</aside> </aside>
</section> </section>
+2 -3
View File
@@ -2,7 +2,7 @@
<div class="auth-card glass-panel"> <div class="auth-card glass-panel">
<p class="eyebrow">Erste Einrichtung</p> <p class="eyebrow">Erste Einrichtung</p>
<h1>Mood initialisieren</h1> <h1>Mood initialisieren</h1>
<p class="auth-copy">Lege den ersten Admin-Account an. Danach ist die Anwendung sofort geschuetzt und weitere Accounts koennen spaeter in den Optionen erstellt werden.</p> <p class="auth-copy">Lege den ersten Admin-Account an. Danach ist die Anwendung sofort geschützt und weitere Accounts können später in den Optionen erstellt werden.</p>
<form method="post" action="/setup" class="stack-form"> <form method="post" action="/setup" class="stack-form">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -21,8 +21,7 @@
<input type="password" name="password_confirm" autocomplete="new-password" minlength="10" required> <input type="password" name="password_confirm" autocomplete="new-password" minlength="10" required>
</label> </label>
<button class="primary-button" type="submit">Setup abschliessen</button> <button class="primary-button" type="submit">Setup abschließen</button>
</form> </form>
</div> </div>
</section> </section>
+32 -22
View File
@@ -3,20 +3,17 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow">Tag erfassen</p> <p class="eyebrow">Tag erfassen</p>
<h3>Eintrag fuer <?= e($entry['date']) ?></h3> <h3><?= e(format_display_date($entry['date'])) ?></h3>
</div> </div>
<div class="section-head__actions">
<a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a> <a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
</div>
</div> </div>
<form method="post" action="/track" class="tracker-form" id="tracker-form"> <form method="post" action="/track" class="tracker-form" id="tracker-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="date" value="<?= e($entry['date']) ?>">
<div class="field-grid field-grid--single">
<label>
<span>Datum</span>
<input type="date" name="date" value="<?= e($entry['date']) ?>" required>
</label>
</div>
<div class="field-grid field-grid--three"> <div class="field-grid field-grid--three">
<label class="range-card"> <label class="range-card">
@@ -45,7 +42,7 @@
</label> </label>
<label> <label>
<span>Schlafgefuehl</span> <span>Schlafgefühl</span>
<select name="sleep_feeling"> <select name="sleep_feeling">
<?php foreach ($settings['labels']['sleep_feeling'] as $value => $label): ?> <?php foreach ($settings['labels']['sleep_feeling'] as $value => $label): ?>
<option value="<?= e((string) $value) ?>" <?= (int) $entry['sleep_feeling'] === (int) $value ? 'selected' : '' ?>> <option value="<?= e((string) $value) ?>" <?= (int) $entry['sleep_feeling'] === (int) $value ? 'selected' : '' ?>>
@@ -70,36 +67,49 @@
<label> <label>
<span>Tagebuchnotiz</span> <span>Tagebuchnotiz</span>
<textarea name="note" rows="8" placeholder="Was war heute wichtig, schwer oder schoen?"><?= e($entry['note']) ?></textarea> <textarea name="note" rows="8" placeholder="Was war heute wichtig, schwer oder schön?"><?= e($entry['note']) ?></textarea>
</label> </label>
<div class="form-actions"> <div class="form-actions">
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute laden</a> <span class="helper-text">Werte ändern, speichern und bei Bedarf vergangene Tage bequem nachtragen.</span>
<button class="primary-button" type="submit">Tag speichern</button> <button class="primary-button" type="submit">Tag speichern</button>
</div> </div>
</form> </form>
</article> </article>
<aside class="stack-column"> <aside class="stack-column">
<article class="glass-panel preview-card" id="live-score-card" data-payload="<?= e($trackPayload) ?>"> <article class="glass-panel preview-card" id="live-score-card" data-payload="<?= e($trackPayload) ?>" data-sentiment="<?= e($evaluation['sentiment']) ?>">
<p class="eyebrow">Live-Bewertung</p> <p class="eyebrow">Live-Bewertung</p>
<div class="hero-score" data-preview-total><?= e(format_points((float) $evaluation['total'])) ?></div> <div class="preview-status">
<p class="hero-label" data-preview-label><?= e($evaluation['label']) ?></p> <div class="preview-status__icon">
<p class="helper-text">Die Einschaetzung passt sich beim Aendern der Werte sofort an.</p> <img data-preview-icon src="<?= e(mood_icon_path($evaluation['sentiment'])) ?>" alt="">
</div>
<div>
<p class="preview-status__label" data-preview-label><?= e($evaluation['label']) ?></p>
<p class="helper-text">Die Einschätzung passt sich beim Ändern der Werte sofort an.</p>
</div>
</div>
<p class="preview-scoreline"><span data-preview-total><?= e(format_points((float) $evaluation['total'])) ?></span> Punkte</p>
<dl class="component-list" data-preview-components> <dl class="component-list" data-preview-components>
<?php
$componentLabels = [
'mood' => 'Stimmung',
'energy' => 'Energie',
'stress' => 'Stress',
'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport',
'walk_minutes' => 'Spaziergang',
'note' => 'Notiz',
];
?>
<?php foreach ($evaluation['components'] as $name => $value): ?> <?php foreach ($evaluation['components'] as $name => $value): ?>
<div> <div>
<dt><?= e($name) ?></dt> <dt><?= e($componentLabels[$name] ?? $name) ?></dt>
<dd><?= e(format_points((float) $value)) ?></dd> <dd><?= e(format_points((float) $value)) ?></dd>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</dl> </dl>
</article> </article>
<article class="glass-panel info-card">
<p class="eyebrow">Dateiformat</p>
<h3>Markdown in `YYYY-MM-DD.txt`</h3>
<p>Jeder Tag wird als menschenlesbare Datei gespeichert. Das macht Backups, Portabilitaet und manuelle Kontrolle einfach.</p>
</article>
</aside> </aside>
</section> </section>