commit 58bcc8f0f3fb344aa0366a92a2a13e947ae247e7 Author: Florian Heinz Date: Sat Apr 11 18:57:00 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da15d50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# OS / editor +.DS_Store +Thumbs.db +*.swp +*.swo + +# Env / local runtime +.env +.env.* +!.env.example +*.log + +# Runtime storage +storage/system/*.json +storage/users/** +!storage/users/.gitignore + diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..365773d --- /dev/null +++ b/.htaccess @@ -0,0 +1,14 @@ +Options -Indexes +DirectoryIndex index.php + + + RewriteEngine On + + RewriteRule ^storage/ - [F,L] + RewriteRule ^(?:src|templates|bin)/ - [F,L] + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [QSA,L] + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c67eb9 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Mood + +Dateibasierter Stimmungstracker fuer LAMP/Cloudron ohne Datenbank. + +## Features + +- Geschuetzter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern +- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv +- Speicherung aller Tage als Markdown in `storage/users//days/YYYY-MM-DD.txt` +- Pro Nutzer eigene Einstellungen fuer die Bewertungslogik +- Admin kann weitere Accounts direkt in der Weboberflaeche anlegen +- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs + +## Struktur + +- `index.php`: Front-Controller und Routing-Einstieg +- `src/`: PHP-Logik fuer Auth, Storage, Scoring und Rendering +- `templates/`: Seiten-Templates +- `assets/`: CSS und JavaScript +- `storage/`: geschuetzter Dateispeicher, per `.htaccess` nicht direkt abrufbar + +## Deployment auf Cloudron / LAMP + +1. Den Projektordner in die App deployen und Apache auf dieses Verzeichnis zeigen lassen. +2. `AllowOverride All` aktiv lassen, damit `.htaccess` greift. +3. Sicherstellen, dass der Webserver Schreibrechte auf `storage/` hat. +4. Die Seite das erste Mal aufrufen und unter `/setup` den ersten Admin-Account erstellen. +5. Danach ist die Anwendung nur noch nach Login nutzbar. + +## Hinweise + +- 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. +- Wenn du spaeter Reverse Proxy oder HTTPS ueber Cloudron nutzt, bleiben die Daten weiterhin nur ueber die App erreichbar. diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..b421f75 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,805 @@ +:root { + --bg: #09131f; + --bg-soft: #10253a; + --surface: rgba(242, 249, 255, 0.16); + --surface-strong: rgba(255, 255, 255, 0.24); + --surface-border: rgba(255, 255, 255, 0.26); + --text: #eff7ff; + --muted: rgba(239, 247, 255, 0.72); + --shadow: 0 24px 70px rgba(4, 18, 31, 0.35); + --primary: #8be4ff; + --primary-strong: #3cc7ff; + --accent: #8bffcf; + --warm: #ffbf8d; + --danger: #ff8f8f; + --good: #7ff3bb; + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 18px; + --radius-sm: 14px; + --panel-blur: 28px; + --font-ui: "SF Pro Display", "Avenir Next", "Segoe UI Variable", "Helvetica Neue", system-ui, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-height: 100%; + color-scheme: dark; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-ui); + background: + radial-gradient(circle at 18% 18%, rgba(76, 171, 255, 0.24), transparent 34%), + radial-gradient(circle at 82% 12%, rgba(113, 255, 210, 0.18), transparent 28%), + linear-gradient(145deg, #07111b 0%, #0b1e2e 35%, #13273b 65%, #08111b 100%); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.aurora { + position: fixed; + inset: auto; + z-index: 0; + border-radius: 999px; + pointer-events: none; + filter: blur(24px); + opacity: 0.65; +} + +.aurora-one { + top: 5rem; + right: 8vw; + width: 18rem; + height: 18rem; + background: radial-gradient(circle, rgba(90, 196, 255, 0.44), transparent 70%); +} + +.aurora-two { + bottom: 8vh; + left: 8vw; + width: 22rem; + height: 22rem; + background: radial-gradient(circle, rgba(149, 255, 214, 0.34), transparent 70%); +} + +.shell { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + min-height: 100vh; + gap: 1.25rem; + padding: 1.25rem; +} + +.sidebar, +.topbar, +.glass-panel { + border: 1px solid var(--surface-border); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.08)), + radial-gradient(circle at top left, rgba(255, 255, 255, 0.2), transparent 48%); + backdrop-filter: blur(var(--panel-blur)) saturate(150%); + -webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%); + box-shadow: var(--shadow); +} + +.sidebar { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 1.35rem; + border-radius: var(--radius-xl); + min-height: calc(100vh - 2.5rem); + position: sticky; + top: 1.25rem; +} + +.content { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.2rem; + border-radius: var(--radius-lg); +} + +.topbar__meta { + display: flex; + gap: 0.65rem; + flex-wrap: wrap; +} + +.meta-pill, +.chart-chip { + display: inline-flex; + align-items: center; + padding: 0.48rem 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); + color: var(--muted); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 0.88rem; + letter-spacing: 0.01em; +} + +.chart-chip--warm { + background: rgba(255, 173, 124, 0.12); +} + +.chart-chip--cool { + background: rgba(118, 228, 255, 0.12); +} + +.brand-block { + display: flex; + align-items: center; + gap: 0.95rem; +} + +.brand-mark { + width: 3rem; + height: 3rem; + display: grid; + place-items: center; + border-radius: 18px; + background: linear-gradient(180deg, rgba(146, 232, 255, 0.9), rgba(95, 181, 255, 0.55)); + color: #032338; + font-size: 1.25rem; + font-weight: 800; + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.35); +} + +.brand-block h1, +.topbar h2, +.hero-card h3, +.section-head h3, +.detail-card h3, +.auth-card h1 { + margin: 0; + line-height: 1.1; +} + +.brand-block h1 { + font-size: 1.3rem; +} + +.eyebrow { + margin: 0 0 0.28rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: rgba(239, 247, 255, 0.62); + font-size: 0.74rem; +} + +.main-nav { + display: grid; + gap: 0.55rem; + margin-top: 2rem; +} + +.main-nav a { + padding: 0.9rem 1rem; + border-radius: 18px; + color: var(--muted); + transition: transform 180ms ease, background 180ms ease, color 180ms ease; +} + +.main-nav a:hover, +.main-nav a.active { + background: rgba(255, 255, 255, 0.13); + color: var(--text); + transform: translateX(2px); +} + +.sidebar-footer { + display: grid; + gap: 0.85rem; +} + +.user-chip { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.9rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.1); +} + +.user-chip__name { + font-weight: 700; +} + +.user-chip__role { + font-size: 0.88rem; + color: var(--muted); +} + +.flash { + padding: 0.92rem 1.1rem; + border-radius: var(--radius-md); +} + +.flash-success { + border-color: rgba(127, 243, 187, 0.35); +} + +.flash-error { + border-color: rgba(255, 143, 143, 0.38); +} + +.hero-grid, +.stats-grid, +.dashboard-grid, +.page-grid, +.field-grid, +.band-grid { + display: grid; + gap: 1rem; +} + +.hero-grid { + grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.8fr); +} + +.hero-card, +.metric-card, +.chart-card, +.form-panel, +.detail-card, +.info-card, +.preview-card, +.archive-list, +.auth-card { + padding: 1.25rem; + border-radius: var(--radius-xl); +} + +.hero-card { + position: relative; + overflow: hidden; +} + +.hero-card::after { + content: ""; + position: absolute; + inset: auto -10% -45% auto; + width: 15rem; + height: 15rem; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 64%); +} + +.hero-card--wide { + padding: 1.6rem; +} + +.hero-copy, +.auth-copy, +.helper-text, +.detail-card p, +.info-card p { + color: var(--muted); + line-height: 1.6; +} + +.hero-score { + font-size: clamp(2.5rem, 4vw, 4rem); + font-weight: 800; + line-height: 1; + margin-top: 0.55rem; +} + +.hero-label { + margin: 0.45rem 0 0; + color: var(--text); + font-size: 1.03rem; +} + +.stats-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.metric-card { + display: grid; + gap: 0.35rem; +} + +.metric-card span { + color: var(--muted); +} + +.metric-card strong { + font-size: 1.8rem; +} + +.dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.chart-card--calendar, +.chart-card--wide, +.form-panel--wide { + grid-column: 1 / -1; +} + +.section-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1rem; +} + +.calendar-heatmap { + min-height: 16rem; + overflow-x: auto; +} + +.calendar-legend { + display: flex; + justify-content: flex-end; + gap: 0.45rem; + margin-top: 1rem; + color: var(--muted); + font-size: 0.82rem; +} + +.calendar-scale { + display: flex; + gap: 0.32rem; +} + +.calendar-dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); +} + +.calendar-dot--1 { + background: rgba(126, 239, 205, 0.32); +} + +.calendar-dot--2 { + background: rgba(126, 239, 205, 0.56); +} + +.calendar-dot--3 { + background: rgba(126, 239, 205, 0.82); +} + +.calendar-svg, +.line-chart svg { + width: 100%; + height: auto; + display: block; +} + +.calendar-tooltip { + fill: var(--muted); + font-size: 11px; +} + +.line-chart, +.bar-chart { + min-height: 17rem; +} + +.line-chart svg { + overflow: visible; +} + +.line-fill { + opacity: 0.12; +} + +.line-stroke { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +} + +.line-point { + stroke: rgba(7, 17, 27, 0.9); + stroke-width: 1.5; +} + +.chart-axis { + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 1; +} + +.chart-label { + fill: rgba(239, 247, 255, 0.65); + font-size: 11px; +} + +.bar-chart svg { + width: 100%; + height: auto; + display: block; +} + +.bar-grid { + fill: rgba(255, 255, 255, 0.08); +} + +.bar-segment--sport { + fill: rgba(87, 214, 255, 0.88); +} + +.bar-segment--walk { + fill: rgba(138, 255, 198, 0.82); +} + +.bar-label { + fill: rgba(239, 247, 255, 0.62); + font-size: 11px; +} + +.bar-value { + fill: rgba(239, 247, 255, 0.82); + font-size: 10px; + text-anchor: middle; +} + +.page-grid { + grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr); + align-items: start; +} + +.stack-column, +.stack-form { + display: grid; + gap: 1rem; +} + +.stack-form--spacious { + gap: 1.4rem; +} + +.tracker-form, +.settings-section { + display: grid; + gap: 1rem; +} + +.field-grid--single { + grid-template-columns: 1fr; +} + +.field-grid--two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field-grid--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-grid--four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +label { + display: grid; + gap: 0.55rem; + color: var(--muted); +} + +label span { + font-size: 0.93rem; +} + +input[type="text"], +input[type="password"], +input[type="number"], +input[type="date"], +select, +textarea { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 16px; + background: rgba(255, 255, 255, 0.09); + color: var(--text); + padding: 0.9rem 1rem; + outline: none; + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease; +} + +input:focus, +select:focus, +textarea:focus { + border-color: rgba(139, 228, 255, 0.5); + background: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +textarea { + resize: vertical; + min-height: 11rem; +} + +input[type="range"] { + width: 100%; + accent-color: var(--primary-strong); +} + +.range-card { + padding: 1rem; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.range-card output { + font-size: 1.7rem; + color: var(--text); + font-weight: 700; +} + +.component-list, +.detail-grid { + display: grid; + gap: 0.75rem; +} + +.component-list div, +.detail-grid div, +.user-row { + display: flex; + justify-content: space-between; + gap: 0.85rem; + padding: 0.85rem 0.95rem; + border-radius: 15px; + background: rgba(255, 255, 255, 0.08); +} + +.component-list dt, +.detail-grid dt { + color: var(--muted); +} + +.component-list dd, +.detail-grid dd { + margin: 0; + font-weight: 700; +} + +.form-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.primary-button, +.ghost-button, +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 3rem; + padding: 0.75rem 1.2rem; + border-radius: 999px; + border: 1px solid transparent; + transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease; +} + +.primary-button, +.button-link { + color: #092033; + font-weight: 700; + background: linear-gradient(180deg, rgba(164, 239, 255, 0.98), rgba(95, 198, 255, 0.78)); + box-shadow: 0 16px 30px rgba(59, 167, 230, 0.28); +} + +.primary-button:hover, +.ghost-button:hover, +.button-link:hover { + transform: translateY(-1px); +} + +.ghost-button, +.ghost-link { + color: var(--text); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); +} + +.ghost-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.75rem; + padding: 0.55rem 0.95rem; + border-radius: 999px; +} + +.archive-items, +.user-list { + display: grid; + gap: 0.75rem; +} + +.archive-item { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 1rem 1.05rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid transparent; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.archive-item span { + display: block; + color: var(--muted); + margin-top: 0.2rem; +} + +.archive-item:hover, +.archive-item.active { + transform: translateY(-1px); + border-color: rgba(139, 228, 255, 0.34); + background: rgba(255, 255, 255, 0.12); +} + +.archive-item__meta { + text-align: right; +} + +.note-box { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.08); +} + +.note-box h4 { + margin: 0 0 0.55rem; +} + +.empty-state { + color: var(--muted); + line-height: 1.6; +} + +.band-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.band-card { + display: grid; + gap: 0.75rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.08); +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 0.7rem; +} + +.checkbox-row input { + width: auto; +} + +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.auth-card { + width: min(100%, 30rem); +} + +.auth-card form { + margin-top: 1.4rem; +} + +.button-link { + width: fit-content; +} + +@media (max-width: 1100px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + min-height: auto; + } + + .stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .topbar, + .section-head, + .form-actions { + flex-direction: column; + align-items: stretch; + } + + .hero-grid, + .dashboard-grid, + .page-grid, + .stats-grid, + .field-grid--two, + .field-grid--three, + .field-grid--four, + .band-grid { + grid-template-columns: 1fr; + } + + .bar-chart { + overflow-x: auto; + padding-bottom: 0.4rem; + } +} + +@media (max-width: 640px) { + .shell { + padding: 0.8rem; + gap: 0.8rem; + } + + .sidebar, + .hero-card, + .metric-card, + .chart-card, + .form-panel, + .detail-card, + .info-card, + .preview-card, + .archive-list, + .topbar { + border-radius: 22px; + } + + .hero-score { + font-size: 2.8rem; + } +} diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..a26204c --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,424 @@ +(function () { + const textDecoder = new TextDecoder(); + + function decodePayload(raw) { + if (!raw) { + return null; + } + + const bytes = Uint8Array.from(atob(raw), char => char.charCodeAt(0)); + return JSON.parse(textDecoder.decode(bytes)); + } + + function formatNumber(value) { + const rounded = Math.round(value * 10) / 10; + return Number.isInteger(rounded) + ? String(rounded) + : rounded.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + } + + function formatDateLabel(value) { + const [year, month, day] = value.split("-"); + return `${day}.${month}.`; + } + + function toLocalIso(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function getCssVar(name, fallback) { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; + } + + function updateRangeOutputs() { + document.querySelectorAll("[data-output-for]").forEach(output => { + const input = document.querySelector(`[name="${output.dataset.outputFor}"]`); + if (!input) { + return; + } + + const sync = () => { + output.textContent = input.value; + }; + + sync(); + input.addEventListener("input", sync); + }); + } + + function sleepDurationPoints(hours, points) { + if (hours < 4) { + return Number(points.lt4 || 0); + } + + if (hours >= 10) { + return Number(points.h10plus || 0); + } + + const anchors = { + 4: Number(points.h4 || 0), + 5: Number(points.h5 || 0), + 6: Number(points.h6 || 0), + 7: Number(points.h7 || 0), + 8: Number(points.h8 || 0), + 9: Number(points.h9 || 0), + 10: Number(points.h10plus || 0), + }; + + const lower = Math.floor(hours); + const upper = Math.ceil(hours); + + if (lower === upper) { + return anchors[lower] || 0; + } + + const fraction = hours - lower; + const lowerPoints = anchors[lower] || 0; + const upperPoints = anchors[upper] || 0; + return Math.round((lowerPoints + ((upperPoints - lowerPoints) * fraction)) * 10) / 10; + } + + function bandPoints(value, bands) { + for (const band of bands || []) { + if (value >= Number(band.min || 0) && value <= Number(band.max || 0)) { + return Number(band.points || 0); + } + } + + const last = (bands || []).slice(-1)[0]; + return last ? Number(last.points || 0) : 0; + } + + function sortedRatings(ratings) { + return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0)); + } + + function labelForScore(score, ratings) { + for (const rating of ratings) { + if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) { + return rating.label; + } + } + + if (!ratings.length) { + return "unbewertet"; + } + + return score < Number(ratings[0].min || 0) ? ratings[0].label : ratings[ratings.length - 1].label; + } + + function capLabel(current, cap, ratings) { + const order = ratings.map(item => item.label); + const currentIndex = order.indexOf(current); + const capIndex = order.indexOf(cap); + + if (currentIndex === -1 || capIndex === -1) { + return current; + } + + return currentIndex > capIndex ? cap : current; + } + + function evaluateEntry(entry, settings) { + const ratings = sortedRatings(settings.ratings || []); + const scoring = settings.scoring || {}; + const components = { + mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0), + energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0), + stress: (11 - Number(entry.stress)) * Number(scoring.stress_multiplier || 0), + sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}), + sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0), + sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []), + walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), + note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), + }; + + const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10; + let label = labelForScore(total, ratings); + + for (const guardrail of settings.guardrails || []) { + const moodMatch = Number(entry.mood) <= Number(guardrail.mood_max || 10); + const energyMatch = guardrail.energy_max === null || guardrail.energy_max === "" + ? true + : Number(entry.energy) <= Number(guardrail.energy_max); + + if (moodMatch && energyMatch) { + label = capLabel(label, guardrail.cap_label, ratings); + } + } + + return { total, label, components }; + } + + function initTrackPreview() { + const card = document.querySelector("#live-score-card"); + const form = document.querySelector("#tracker-form"); + + if (!card || !form) { + return; + } + + const payload = decodePayload(card.dataset.payload); + if (!payload) { + return; + } + + const totalNode = card.querySelector("[data-preview-total]"); + const labelNode = card.querySelector("[data-preview-label]"); + const componentsNode = card.querySelector("[data-preview-components]"); + const componentLabels = { + mood: "Stimmung", + energy: "Energie", + stress: "Stress", + sleep_hours: "Schlafdauer", + sleep_feeling: "Schlafgefühl", + sport_minutes: "Sport", + walk_minutes: "Spaziergang", + note: "Notiz", + }; + + const collect = () => ({ + mood: Number(form.elements.mood.value), + energy: Number(form.elements.energy.value), + stress: Number(form.elements.stress.value), + 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), + walk_minutes: Number(form.elements.walk_minutes.value || 0), + note: form.elements.note.value || "", + }); + + const render = () => { + const result = evaluateEntry(collect(), payload.settings); + totalNode.textContent = formatNumber(result.total); + labelNode.textContent = result.label; + componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => { + return `
${componentLabels[key] || key}
${formatNumber(Number(value))}
`; + }).join(""); + }; + + render(); + form.addEventListener("input", render); + form.addEventListener("change", render); + } + + function emptyState(message) { + const wrapper = document.createElement("div"); + wrapper.className = "empty-state"; + wrapper.textContent = message; + return wrapper; + } + + function linePath(points) { + if (!points.length) { + return ""; + } + + let path = `M ${points[0].x} ${points[0].y}`; + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const midX = (previous.x + current.x) / 2; + path += ` Q ${midX} ${previous.y}, ${current.x} ${current.y}`; + } + return path; + } + + function renderLineChart(container, items, color) { + if (!items || !items.length) { + container.append(emptyState("Noch nicht genug Daten für diesen Verlauf.")); + return; + } + + const width = 760; + const height = 220; + const padding = { top: 18, right: 18, bottom: 38, left: 14 }; + const maxValue = Math.max(...items.map(item => Number(item.value)), 10); + const minValue = 0; + const step = items.length > 1 ? (width - padding.left - padding.right) / (items.length - 1) : 0; + + const points = items.map((item, index) => { + const ratio = (Number(item.value) - minValue) / Math.max(maxValue - minValue, 1); + return { + x: padding.left + (index * step), + y: padding.top + ((1 - ratio) * (height - padding.top - padding.bottom)), + label: formatDateLabel(item.date), + value: Number(item.value), + }; + }); + + const path = linePath(points); + const fillPath = `${path} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`; + + const labels = points.filter((_, index) => index === 0 || index === points.length - 1 || index % Math.ceil(points.length / 5) === 0); + + container.innerHTML = ` + + + + + ${points.map(point => `${point.label}: ${formatNumber(point.value)}`).join("")} + ${labels.map(point => `${point.label}`).join("")} + + `; + } + + function renderBarChart(container, items) { + if (!items || !items.length) { + container.append(emptyState("Sobald Sport- oder Gehwerte vorhanden sind, erscheint hier die Entwicklung.")); + return; + } + + const recent = items.slice(-18); + const maxValue = Math.max(...recent.map(item => Number(item.value)), 1); + const width = Math.max(recent.length * 34, 520); + const height = 220; + const chartHeight = 150; + const baseY = 176; + + const bars = recent.map((item, index) => { + const sport = Number(item.sport || 0); + const walk = Number(item.walk || 0); + const total = sport + walk; + const x = 18 + (index * 34); + const columnHeight = total > 0 ? Math.max((total / maxValue) * chartHeight, 8) : 0; + const walkHeight = total > 0 ? (walk / total) * columnHeight : 0; + const sportHeight = total > 0 ? (sport / total) * columnHeight : 0; + const backgroundY = baseY - chartHeight; + const walkY = baseY - walkHeight; + const sportY = walkY - sportHeight; + + return ` + + + ${formatDateLabel(item.date)} · Spaziergang ${walk} min + + + ${formatDateLabel(item.date)} · Sport ${sport} min + + ${Math.round(total)} + ${formatDateLabel(item.date)} + `; + }).join(""); + + container.innerHTML = ` + + ${bars} + + `; + } + + function renderCalendar(container, items) { + if (!items || !items.length) { + container.append(emptyState("Der Kalender füllt sich automatisch mit den gespeicherten Tagen.")); + return; + } + + const map = new Map(items.map(item => [item.date, item])); + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - 364); + + const days = []; + const cursor = new Date(start); + while (cursor <= end) { + const iso = toLocalIso(cursor); + const entry = map.get(iso) || null; + days.push({ + date: iso, + entry, + weekday: cursor.getDay(), + month: cursor.getMonth(), + day: cursor.getDate(), + }); + cursor.setDate(cursor.getDate() + 1); + } + + const width = Math.ceil(days.length / 7) * 17 + 56; + const height = 148; + const cellSize = 12; + const cellGap = 5; + const xOffset = 34; + const yOffset = 24; + const monthLabels = []; + let lastMonth = -1; + + const cells = days.map((item, index) => { + const week = Math.floor(index / 7); + const row = item.weekday === 0 ? 6 : item.weekday - 1; + const x = xOffset + (week * (cellSize + cellGap)); + const y = yOffset + (row * (cellSize + cellGap)); + const level = item.entry ? Math.max(0, Math.min(1, Number(item.entry.score) / Math.max(Number(item.entry.max || 1), 1))) : 0; + const fill = item.entry + ? `rgba(126, 239, 205, ${0.18 + (level * 0.72)})` + : "rgba(255, 255, 255, 0.06)"; + + if (item.day <= 7 && item.month !== lastMonth) { + monthLabels.push({ + x, + label: new Intl.DateTimeFormat("de-DE", { month: "short" }).format(new Date(`${item.date}T12:00:00`)), + }); + lastMonth = item.month; + } + + const title = item.entry + ? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}` + : `${item.date}: kein Eintrag`; + + return `${title}`; + }).join(""); + + container.innerHTML = ` + + ${monthLabels.map(item => `${item.label}`).join("")} + Mo + Mi + Fr + ${cells} + +
+ weniger +
+ + + + +
+ mehr +
+ `; + } + + function initDashboardCharts() { + const calendar = document.querySelector("#calendar-heatmap"); + if (calendar) { + const payload = decodePayload(calendar.dataset.payload); + if (payload) { + renderCalendar(calendar, payload.calendar || []); + } + } + + document.querySelectorAll(".line-chart[data-chart-type='line']").forEach(chart => { + const payload = decodePayload(chart.dataset.payload); + const seriesName = chart.dataset.series; + const color = seriesName === "stress" + ? getCssVar("--warm", "#ffbf8d") + : getCssVar("--primary-strong", "#3cc7ff"); + + renderLineChart(chart, payload ? payload[seriesName] || [] : [], color); + }); + + document.querySelectorAll(".bar-chart[data-chart-type='bars']").forEach(chart => { + const payload = decodePayload(chart.dataset.payload); + renderBarChart(chart, payload ? payload.sport || [] : []); + }); + } + + updateRangeOutputs(); + initTrackPreview(); + initDashboardCharts(); +})(); diff --git a/index.php b/index.php new file mode 100644 index 0000000..cdcbe55 --- /dev/null +++ b/index.php @@ -0,0 +1,9 @@ +run(); + diff --git a/src/App.php b/src/App.php new file mode 100644 index 0000000..b5e16c7 --- /dev/null +++ b/src/App.php @@ -0,0 +1,579 @@ +users = new UserRepository(); + $this->settings = new SettingsRepository(); + $this->entries = new EntryRepository(); + $this->throttle = new LoginThrottle(); + $this->scoring = new ScoringService(); + $this->auth = new Auth($this->users); + } + + public function run(): void + { + $this->sendSecurityHeaders(); + + $path = request_path(); + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + if (!$this->users->hasAnyUsers()) { + if ($path !== '/setup') { + redirect('/setup'); + } + } elseif (!$this->auth->check() && $path !== '/login') { + redirect('/login'); + } + + if ($this->auth->check() && in_array($path, ['/login', '/setup'], true)) { + redirect('/'); + } + + switch ($path) { + case '/setup': + $method === 'POST' ? $this->handleSetup() : $this->showSetup(); + return; + + case '/login': + $method === 'POST' ? $this->handleLogin() : $this->showLogin(); + return; + + case '/logout': + if ($method !== 'POST') { + http_response_code(405); + exit('Method Not Allowed'); + } + + $this->enforceCsrf(); + $this->auth->logout(); + flash('success', 'Du wurdest abgemeldet.'); + redirect('/login'); + + case '/': + $this->showDashboard(); + return; + + case '/track': + $method === 'POST' ? $this->handleTrack() : $this->showTrack(); + return; + + case '/archive': + $this->showArchive(); + return; + + case '/options': + $method === 'POST' ? $this->handleOptions() : $this->showOptions(); + return; + + default: + http_response_code(404); + View::render('not-found', [ + 'pageTitle' => 'Nicht gefunden', + 'page' => 'not-found', + 'authUser' => $this->auth->user(), + ]); + } + } + + private function showSetup(): void + { + View::render('setup', [ + 'pageTitle' => 'Setup', + 'page' => 'setup', + 'authUser' => null, + ]); + } + + private function handleSetup(): void + { + $this->enforceCsrf(); + + if ($this->users->hasAnyUsers()) { + redirect('/login'); + } + + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); + + if (!$this->isValidUsername($username)) { + flash('error', 'Bitte nutze einen Benutzernamen mit 3 bis 32 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich.'); + redirect('/setup'); + } + + if (!$this->isStrongPassword($password)) { + flash('error', 'Das Passwort sollte mindestens 10 Zeichen lang sein.'); + redirect('/setup'); + } + + if ($password !== $passwordConfirm) { + flash('error', 'Die Passwoerter stimmen nicht ueberein.'); + redirect('/setup'); + } + + $user = $this->users->create($username, $password, true); + $this->auth->login($user); + flash('success', 'Der erste Account wurde erstellt. Du kannst direkt loslegen.'); + redirect('/'); + } + + private function showLogin(): void + { + View::render('login', [ + 'pageTitle' => 'Login', + 'page' => 'login', + 'authUser' => null, + ]); + } + + private function handleLogin(): void + { + $this->enforceCsrf(); + + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $throttleKey = $this->throttleKey($username); + + if ($this->throttle->tooManyAttempts($throttleKey)) { + $seconds = $this->throttle->availableInSeconds($throttleKey); + flash('error', 'Zu viele fehlgeschlagene Login-Versuche. Bitte warte ' . max(1, $seconds) . ' Sekunden.'); + redirect('/login'); + } + + if (!$this->auth->attempt($username, $password)) { + $this->throttle->hit($throttleKey); + flash('error', 'Login fehlgeschlagen. Bitte pruefe Benutzername und Passwort.'); + redirect('/login'); + } + + $this->throttle->clear($throttleKey); + flash('success', 'Willkommen zurueck.'); + redirect('/'); + } + + private function showDashboard(): void + { + $user = $this->requireUser(); + $settings = $this->settings->forUser($user['username']); + $entries = $this->entries->all($user['username']); + + $evaluatedEntries = []; + foreach ($entries as $entry) { + $evaluation = $this->scoring->evaluate($entry, $settings); + $evaluatedEntries[] = array_merge($entry, ['evaluation' => $evaluation]); + } + + usort($evaluatedEntries, static fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); + + $summary = $this->buildDashboardSummary($evaluatedEntries); + $chartData = $this->buildDashboardCharts($evaluatedEntries); + + View::render('dashboard', [ + 'pageTitle' => 'Dashboard', + 'page' => 'dashboard', + 'authUser' => $user, + 'summary' => $summary, + 'entries' => array_reverse($evaluatedEntries), + 'chartPayload' => encode_payload($chartData), + ]); + } + + private function showTrack(): void + { + $user = $this->requireUser(); + $settings = $this->settings->forUser($user['username']); + $date = (string) ($_GET['date'] ?? today()); + if (!$this->isValidDate($date)) { + $date = today(); + } + $entry = $this->entries->find($user['username'], $date) ?? [ + 'date' => $date, + 'mood' => 6, + 'energy' => 6, + 'stress' => 4, + 'sleep_hours' => 7, + 'sleep_feeling' => 3, + 'sport_minutes' => 0, + 'walk_minutes' => 0, + 'note' => '', + ]; + + $entry = $this->scoring->normalize($entry); + $evaluation = $this->scoring->evaluate($entry, $settings); + + View::render('track', [ + 'pageTitle' => 'Tag tracken', + 'page' => 'track', + 'authUser' => $user, + 'entry' => $entry, + 'evaluation' => $evaluation, + 'settings' => $settings, + 'trackPayload' => encode_payload([ + 'settings' => $settings, + 'entry' => $entry, + ]), + ]); + } + + private function handleTrack(): void + { + $this->enforceCsrf(); + + $user = $this->requireUser(); + $settings = $this->settings->forUser($user['username']); + + $entry = $this->scoring->normalize([ + 'date' => $_POST['date'] ?? today(), + 'mood' => $_POST['mood'] ?? 5, + 'energy' => $_POST['energy'] ?? 5, + 'stress' => $_POST['stress'] ?? 5, + 'sleep_hours' => $_POST['sleep_hours'] ?? 0, + 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, + 'sport_minutes' => $_POST['sport_minutes'] ?? 0, + 'walk_minutes' => $_POST['walk_minutes'] ?? 0, + 'note' => $_POST['note'] ?? '', + ]); + + if (!$this->isValidDate($entry['date'])) { + flash('error', 'Bitte waehle ein gueltiges Datum.'); + redirect('/track'); + } + + $evaluation = $this->scoring->evaluate($entry, $settings); + $this->entries->save($user['username'], $entry['date'], $entry, $evaluation); + + flash('success', 'Der Tag wurde gespeichert.'); + redirect('/track?date=' . rawurlencode($entry['date'])); + } + + private function showArchive(): void + { + $user = $this->requireUser(); + $settings = $this->settings->forUser($user['username']); + $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; + $entries = $this->entries->all($user['username']); + + $archive = []; + foreach ($entries as $entry) { + $archive[] = array_merge($entry, [ + 'evaluation' => $this->scoring->evaluate($entry, $settings), + ]); + } + + $selectedEntry = null; + if ($selectedDate !== null) { + foreach ($archive as $entry) { + if ($entry['date'] === $selectedDate) { + $selectedEntry = $entry; + break; + } + } + } + + View::render('archive', [ + 'pageTitle' => 'Archiv', + 'page' => 'archive', + 'authUser' => $user, + 'entries' => $archive, + 'selectedEntry' => $selectedEntry, + ]); + } + + private function showOptions(): void + { + $user = $this->requireUser(); + $settings = $this->settings->forUser($user['username']); + + View::render('options', [ + 'pageTitle' => 'Optionen', + 'page' => 'options', + 'authUser' => $user, + 'settings' => $settings, + 'users' => $user['is_admin'] ? $this->users->all() : [], + 'maxScore' => $this->scoring->evaluate([ + 'mood' => 10, + 'energy' => 10, + 'stress' => 1, + 'sleep_hours' => 7, + 'sleep_feeling' => 5, + 'sport_minutes' => 999, + 'walk_minutes' => 999, + 'note' => 'x', + ], $settings)['max_total'], + ]); + } + + private function handleOptions(): void + { + $this->enforceCsrf(); + + $user = $this->requireUser(); + $form = (string) ($_POST['form_name'] ?? ''); + + if ($form === 'settings') { + $settings = $this->sanitizeSettings($_POST['settings'] ?? []); + $this->settings->saveForUser($user['username'], $settings); + flash('success', 'Die Bewertungslogik wurde aktualisiert.'); + redirect('/options'); + } + + if ($form === 'password') { + $current = (string) ($_POST['current_password'] ?? ''); + $new = (string) ($_POST['new_password'] ?? ''); + $confirm = (string) ($_POST['new_password_confirm'] ?? ''); + + if ($this->users->verify($user['username'], $current) === null) { + flash('error', 'Das aktuelle Passwort stimmt nicht.'); + redirect('/options'); + } + + if (!$this->isStrongPassword($new)) { + flash('error', 'Das neue Passwort sollte mindestens 10 Zeichen lang sein.'); + redirect('/options'); + } + + if ($new !== $confirm) { + flash('error', 'Die neuen Passwoerter stimmen nicht ueberein.'); + redirect('/options'); + } + + $this->users->changePassword($user['username'], $new); + flash('success', 'Dein Passwort wurde aktualisiert.'); + redirect('/options'); + } + + if ($form === 'create_user' && ($user['is_admin'] ?? false)) { + $username = trim((string) ($_POST['username'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + $isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1'; + + if (!$this->isValidUsername($username)) { + flash('error', 'Bitte nutze fuer neue Accounts einen sauberen Benutzernamen.'); + redirect('/options'); + } + + if (!$this->isStrongPassword($password)) { + flash('error', 'Das Startpasswort sollte mindestens 10 Zeichen lang sein.'); + redirect('/options'); + } + + try { + $this->users->create($username, $password, $isAdmin); + flash('success', 'Der neue Account wurde angelegt.'); + } catch (RuntimeException $exception) { + flash('error', $exception->getMessage()); + } + + redirect('/options'); + } + + redirect('/options'); + } + + private function buildDashboardSummary(array $entries): array + { + $count = count($entries); + $todayEntry = null; + + foreach ($entries as $entry) { + if ($entry['date'] === today()) { + $todayEntry = $entry; + } + } + + $avgScore = $count > 0 + ? round(array_sum(array_map(static fn (array $entry): float => (float) $entry['evaluation']['total'], $entries)) / $count, 1) + : 0.0; + + $avgMood = $count > 0 + ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['mood'], $entries)) / $count, 1) + : 0.0; + + $avgStress = $count > 0 + ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['stress'], $entries)) / $count, 1) + : 0.0; + + return [ + 'tracked_days' => $count, + 'average_score' => $avgScore, + 'average_mood' => $avgMood, + 'average_stress' => $avgStress, + 'streak' => $this->calculateStreak($entries), + 'today' => $todayEntry, + ]; + } + + private function buildDashboardCharts(array $entries): array + { + $recent = array_slice($entries, -30); + $calendar = array_slice($entries, -365); + + return [ + 'calendar' => array_map(static function (array $entry): array { + return [ + 'date' => $entry['date'], + 'score' => $entry['evaluation']['total'], + 'max' => $entry['evaluation']['max_total'], + 'label' => $entry['evaluation']['label'], + ]; + }, $calendar), + 'mood' => array_map(static function (array $entry): array { + return [ + 'date' => $entry['date'], + 'value' => $entry['mood'], + ]; + }, $recent), + 'stress' => array_map(static function (array $entry): array { + return [ + 'date' => $entry['date'], + 'value' => $entry['stress'], + ]; + }, $recent), + 'sport' => array_map(static function (array $entry): array { + return [ + 'date' => $entry['date'], + 'value' => $entry['sport_minutes'] + $entry['walk_minutes'], + 'sport' => $entry['sport_minutes'], + 'walk' => $entry['walk_minutes'], + ]; + }, $recent), + ]; + } + + private function calculateStreak(array $entries): int + { + if ($entries === []) { + return 0; + } + + $dates = array_map(static fn (array $entry): string => $entry['date'], $entries); + rsort($dates, SORT_STRING); + + $streak = 1; + $previous = new DateTimeImmutable($dates[0]); + + for ($index = 1, $count = count($dates); $index < $count; $index++) { + $current = new DateTimeImmutable($dates[$index]); + $diff = (int) $previous->diff($current)->format('%a'); + + if ($diff === 1) { + $streak++; + $previous = $current; + continue; + } + + break; + } + + return $streak; + } + + private function sanitizeSettings(array $input): array + { + $defaults = Defaults::settings(); + $settings = $defaults; + + $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']['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))); + + 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))); + } + + foreach (['sport_bands', 'walk_bands'] as $bandKey) { + foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) { + $settings['scoring'][$bandKey][$index] = [ + 'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))), + 'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $defaultBand['max']))), + 'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $defaultBand['points']))), + ]; + } + } + + foreach ($defaults['ratings'] as $index => $defaultRating) { + $settings['ratings'][$index] = [ + 'label' => trim((string) ($input['ratings'][$index]['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'], + 'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $defaultRating['min']))), + 'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $defaultRating['max']))), + ]; + } + + foreach ($defaults['guardrails'] as $index => $defaultGuardrail) { + $energyRaw = $input['guardrails'][$index]['energy_max'] ?? $defaultGuardrail['energy_max']; + $settings['guardrails'][$index] = [ + 'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $defaultGuardrail['mood_max']))), + 'energy_max' => $energyRaw === '' || $energyRaw === null ? null : max(1, min(10, (int) $energyRaw)), + 'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'], + ]; + } + + return $settings; + } + + private function sendSecurityHeaders(): void + { + header('Referrer-Policy: strict-origin-when-cross-origin'); + header('X-Frame-Options: DENY'); + header('X-Content-Type-Options: nosniff'); + header('Cross-Origin-Opener-Policy: same-origin'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); + header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"); + } + + private function enforceCsrf(): void + { + if (!verify_csrf($_POST['_token'] ?? null)) { + http_response_code(419); + exit('Ungueltiges Formular-Token.'); + } + } + + private function requireUser(): array + { + $user = $this->auth->user(); + + if ($user === null) { + redirect('/login'); + } + + return $user; + } + + private function isValidDate(string $date): bool + { + $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date); + + return $parsed !== false && $parsed->format('Y-m-d') === $date; + } + + private function isValidUsername(string $username): bool + { + return preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username) === 1; + } + + private function isStrongPassword(string $password): bool + { + return strlen($password) >= 10; + } + + private function throttleKey(string $username): string + { + $remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); + + return sha1($remoteAddress . '|' . normalize_username($username)); + } +} diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php new file mode 100644 index 0000000..3f56293 --- /dev/null +++ b/src/Domain/EntryRepository.php @@ -0,0 +1,140 @@ +pathFor($username, $date); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation)); + } + + public function find(string $username, string $date): ?array + { + $path = $this->pathFor($username, $date); + + if (!is_file($path)) { + return null; + } + + return $this->parse((string) file_get_contents($path), $date); + } + + public function all(string $username): array + { + $directory = $this->directoryFor($username); + + if (!is_dir($directory)) { + return []; + } + + $files = glob($directory . '/*.txt') ?: []; + rsort($files, SORT_STRING); + + $entries = []; + foreach ($files as $file) { + $date = basename($file, '.txt'); + $parsed = $this->parse((string) file_get_contents($file), $date); + if ($parsed !== null) { + $entries[] = $parsed; + } + } + + return $entries; + } + + private function directoryFor(string $username): string + { + return storage_path('users/' . normalize_username($username) . '/days'); + } + + private function pathFor(string $username, string $date): string + { + return $this->directoryFor($username) . '/' . $date . '.txt'; + } + + private function parse(string $content, string $fallbackDate): ?array + { + $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), + 'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0), + 'sleep_feeling' => (int) ($this->extract('/^- Schlafgefuehl:\s*(.+)$/m', $content) ?? 3), + 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), + 'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0), + 'note' => $this->extractNote($content), + ]; + + return $entry; + } + + private function extract(?string $pattern, string $content): ?string + { + if ($pattern === null) { + return null; + } + + if (!preg_match($pattern, $content, $matches)) { + return null; + } + + return trim((string) ($matches[1] ?? '')); + } + + private function extractNote(string $content): string + { + if (!preg_match('/^## Notiz\s*$\R?([\s\S]*)\z/m', $content, $matches)) { + return ''; + } + + return trim((string) ($matches[1] ?? '')); + } + + private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string + { + $lines = [ + '', + '# Stimmungstracker', + 'Datum: ' . $date, + 'Benutzer: ' . normalize_username($username), + '', + '## Werte', + '- Stimmung: ' . $entry['mood'], + '- Energie: ' . $entry['energy'], + '- Stress: ' . $entry['stress'], + '- Schlafdauer: ' . $entry['sleep_hours'], + '- Schlafgefuehl: ' . $entry['sleep_feeling'], + '- Sport: ' . $entry['sport_minutes'], + '- Spaziergang: ' . $entry['walk_minutes'], + '', + '## Bewertung', + '- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']), + '- Urteil: ' . $evaluation['label'], + '', + '## Punktedetails', + '- Stimmung: ' . format_points((float) $evaluation['components']['mood']), + '- Energie: ' . format_points((float) $evaluation['components']['energy']), + '- Stress: ' . format_points((float) $evaluation['components']['stress']), + '- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']), + '- Schlafgefuehl: ' . format_points((float) $evaluation['components']['sleep_feeling']), + '- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']), + '- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']), + '- Notiz: ' . format_points((float) $evaluation['components']['note']), + '', + '## Notiz', + trim((string) $entry['note']), + '', + ]; + + return implode("\n", $lines); + } +} diff --git a/src/Domain/LoginThrottle.php b/src/Domain/LoginThrottle.php new file mode 100644 index 0000000..19a0d5d --- /dev/null +++ b/src/Domain/LoginThrottle.php @@ -0,0 +1,93 @@ +path = storage_path('system/login-throttle.json'); + } + + public function tooManyAttempts(string $key): bool + { + $attempts = $this->attempts(); + $bucket = $attempts[$key] ?? []; + + return count($bucket) >= $this->maxAttempts; + } + + public function availableInSeconds(string $key): int + { + $attempts = $this->attempts(); + $bucket = $attempts[$key] ?? []; + + if ($bucket === []) { + return 0; + } + + $oldest = min($bucket); + $wait = ($oldest + $this->windowSeconds) - time(); + + return max(0, $wait); + } + + public function hit(string $key): void + { + $attempts = $this->attempts(); + $attempts[$key] ??= []; + $attempts[$key][] = time(); + $attempts[$key] = $this->pruneBucket($attempts[$key]); + $this->write($attempts); + } + + public function clear(string $key): void + { + $attempts = $this->attempts(); + unset($attempts[$key]); + $this->write($attempts); + } + + private function attempts(): array + { + $data = decode_json_file($this->path, []); + $clean = []; + + foreach ($data as $key => $bucket) { + if (!is_array($bucket)) { + continue; + } + + $pruned = $this->pruneBucket($bucket); + if ($pruned !== []) { + $clean[$key] = $pruned; + } + } + + return $clean; + } + + private function pruneBucket(array $bucket): array + { + $threshold = time() - $this->windowSeconds; + + return array_values(array_filter($bucket, static fn (mixed $timestamp): bool => (int) $timestamp >= $threshold)); + } + + private function write(array $attempts): void + { + if (!is_dir(dirname($this->path))) { + mkdir(dirname($this->path), 0775, true); + } + + file_put_contents( + $this->path, + json_encode($attempts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } +} + diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php new file mode 100644 index 0000000..5b126b4 --- /dev/null +++ b/src/Domain/ScoringService.php @@ -0,0 +1,172 @@ + $input['date'] ?? today(), + '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))), + '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))), + 'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))), + 'note' => trim((string) ($input['note'] ?? '')), + ]; + } + + public function evaluate(array $entry, array $settings): array + { + $entry = $this->normalize($entry); + $scoring = $settings['scoring']; + $ratings = $this->sortedRatings($settings['ratings'] ?? []); + + $components = [ + 'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'], + 'energy' => $entry['energy'] * (float) $scoring['energy_multiplier'], + 'stress' => (11 - $entry['stress']) * (float) $scoring['stress_multiplier'], + 'sleep_hours' => $this->sleepDurationPoints((float) $entry['sleep_hours'], $scoring['sleep_duration_points']), + 'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'], + 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), + 'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']), + 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], + ]; + + $total = round(array_sum($components), 1); + $maxTotal = round( + (10 * (float) $scoring['mood_multiplier']) + + (10 * (float) $scoring['energy_multiplier']) + + (10 * (float) $scoring['stress_multiplier']) + + max(array_map('floatval', $scoring['sleep_duration_points'])) + + (5 * (float) $scoring['sleep_feeling_multiplier']) + + $this->maxBandPoints($scoring['sport_bands']) + + $this->maxBandPoints($scoring['walk_bands']) + + (float) $scoring['journal_points'], + 1 + ); + + $label = $this->labelForScore($total, $ratings); + $guardrail = null; + + foreach ($settings['guardrails'] ?? [] as $rule) { + $moodMatch = $entry['mood'] <= (int) ($rule['mood_max'] ?? 10); + $energyLimit = $rule['energy_max'] ?? null; + $energyMatch = $energyLimit === null || $entry['energy'] <= (int) $energyLimit; + + if ($moodMatch && $energyMatch) { + $capped = $this->capLabel($label, (string) ($rule['cap_label'] ?? $label), $ratings); + if ($capped !== $label) { + $guardrail = (string) ($rule['cap_label'] ?? ''); + $label = $capped; + } + } + } + + return [ + 'components' => $components, + 'total' => $total, + 'max_total' => $maxTotal, + 'label' => $label, + 'guardrail' => $guardrail, + 'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0, + ]; + } + + private function sleepDurationPoints(float $hours, array $points): float + { + if ($hours < 4) { + return (float) ($points['lt4'] ?? 0); + } + + if ($hours >= 10) { + return (float) ($points['h10plus'] ?? 0); + } + + $anchors = [ + 4.0 => (float) ($points['h4'] ?? 0), + 5.0 => (float) ($points['h5'] ?? 0), + 6.0 => (float) ($points['h6'] ?? 0), + 7.0 => (float) ($points['h7'] ?? 0), + 8.0 => (float) ($points['h8'] ?? 0), + 9.0 => (float) ($points['h9'] ?? 0), + 10.0 => (float) ($points['h10plus'] ?? 0), + ]; + + $lowerHour = floor($hours); + $upperHour = ceil($hours); + + if ($lowerHour === $upperHour) { + return (float) ($anchors[(float) $lowerHour] ?? 0); + } + + $lowerPoints = $anchors[(float) $lowerHour] ?? 0.0; + $upperPoints = $anchors[(float) $upperHour] ?? 0.0; + $fraction = $hours - $lowerHour; + + return round($lowerPoints + (($upperPoints - $lowerPoints) * $fraction), 1); + } + + private function bandPoints(int $value, array $bands): float + { + foreach ($bands as $band) { + if ($value >= (int) ($band['min'] ?? 0) && $value <= (int) ($band['max'] ?? 0)) { + return (float) ($band['points'] ?? 0); + } + } + + $last = end($bands); + + return (float) ($last['points'] ?? 0); + } + + private function maxBandPoints(array $bands): float + { + $max = 0.0; + + foreach ($bands as $band) { + $max = max($max, (float) ($band['points'] ?? 0)); + } + + return $max; + } + + private function sortedRatings(array $ratings): array + { + usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min'])); + + return $ratings; + } + + private function labelForScore(float $score, array $ratings): string + { + foreach ($ratings as $rating) { + if ($score >= (float) $rating['min'] && $score <= (float) $rating['max']) { + return (string) $rating['label']; + } + } + + if ($score < (float) ($ratings[0]['min'] ?? 0)) { + return (string) ($ratings[0]['label'] ?? 'unbewertet'); + } + + return (string) ($ratings[count($ratings) - 1]['label'] ?? 'unbewertet'); + } + + private function capLabel(string $current, string $cap, array $ratings): string + { + $order = array_map(static fn (array $rating): string => (string) $rating['label'], $ratings); + $currentIndex = array_search($current, $order, true); + $capIndex = array_search($cap, $order, true); + + if ($currentIndex === false || $capIndex === false) { + return $current; + } + + return $currentIndex > $capIndex ? $cap : $current; + } +} + diff --git a/src/Domain/SettingsRepository.php b/src/Domain/SettingsRepository.php new file mode 100644 index 0000000..4983143 --- /dev/null +++ b/src/Domain/SettingsRepository.php @@ -0,0 +1,35 @@ +pathFor($username); + $saved = decode_json_file($path, []); + + return array_replace_recursive(Defaults::settings(), $saved); + } + + public function saveForUser(string $username, array $settings): void + { + $path = $this->pathFor($username); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + file_put_contents( + $path, + json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + private function pathFor(string $username): string + { + return storage_path('users/' . normalize_username($username) . '/settings.json'); + } +} + diff --git a/src/Domain/UserRepository.php b/src/Domain/UserRepository.php new file mode 100644 index 0000000..8ec2461 --- /dev/null +++ b/src/Domain/UserRepository.php @@ -0,0 +1,103 @@ +path = storage_path('system/users.json'); + } + + public function hasAnyUsers(): bool + { + return count($this->all()) > 0; + } + + public function all(): array + { + $data = decode_json_file($this->path, ['users' => []]); + + return array_values(array_filter($data['users'] ?? [], 'is_array')); + } + + public function find(string $username): ?array + { + $needle = normalize_username($username); + + foreach ($this->all() as $user) { + if (($user['username'] ?? '') === $needle) { + return $user; + } + } + + return null; + } + + public function verify(string $username, string $password): ?array + { + $user = $this->find($username); + + if ($user === null) { + return null; + } + + if (!password_verify($password, (string) ($user['password_hash'] ?? ''))) { + return null; + } + + return $user; + } + + public function create(string $username, string $password, bool $isAdmin = false): array + { + $normalized = normalize_username($username); + + if ($normalized === '' || $this->find($normalized) !== null) { + throw new RuntimeException('Benutzername existiert bereits oder ist ungueltig.'); + } + + $users = $this->all(); + $users[] = [ + 'username' => $normalized, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'is_admin' => $isAdmin, + 'created_at' => date(DATE_ATOM), + ]; + + $this->write(['users' => $users]); + + return $this->find($normalized) ?? []; + } + + public function changePassword(string $username, string $password): void + { + $normalized = normalize_username($username); + $users = $this->all(); + + foreach ($users as &$user) { + if (($user['username'] ?? '') === $normalized) { + $user['password_hash'] = password_hash($password, PASSWORD_DEFAULT); + $user['updated_at'] = date(DATE_ATOM); + } + } + unset($user); + + $this->write(['users' => $users]); + } + + private function write(array $payload): void + { + if (!is_dir(dirname($this->path))) { + mkdir(dirname($this->path), 0775, true); + } + + file_put_contents( + $this->path, + json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } +} + diff --git a/src/Support/Auth.php b/src/Support/Auth.php new file mode 100644 index 0000000..0971d09 --- /dev/null +++ b/src/Support/Auth.php @@ -0,0 +1,54 @@ +check()) { + return null; + } + + return $_SESSION['user']; + } + + public function attempt(string $username, string $password): bool + { + $user = $this->users->verify($username, $password); + + if ($user === null) { + return false; + } + + $this->login($user); + + return true; + } + + public function login(array $user): void + { + session_regenerate_id(true); + + $_SESSION['user'] = [ + 'username' => $user['username'], + 'is_admin' => (bool) ($user['is_admin'] ?? false), + ]; + } + + public function logout(): void + { + unset($_SESSION['user']); + session_regenerate_id(true); + } +} + diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php new file mode 100644 index 0000000..b4843d6 --- /dev/null +++ b/src/Support/Defaults.php @@ -0,0 +1,69 @@ + [ + 'sleep_feeling' => [ + 1 => 'hundemüde', + 2 => 'müde', + 3 => 'geht so', + 4 => 'ausgeschlafen', + 5 => 'sehr ausgeschlafen', + ], + ], + 'scoring' => [ + 'mood_multiplier' => 3, + 'energy_multiplier' => 2, + 'stress_multiplier' => 2, + 'sleep_feeling_multiplier' => 2, + 'sleep_duration_points' => [ + 'lt4' => 0, + 'h4' => 2, + 'h5' => 5, + 'h6' => 8, + 'h7' => 10, + 'h8' => 9, + 'h9' => 7, + 'h10plus' => 5, + ], + 'sport_bands' => [ + ['min' => 0, 'max' => 0, 'points' => 0], + ['min' => 1, 'max' => 20, 'points' => 2], + ['min' => 21, 'max' => 45, 'points' => 5], + ['min' => 46, 'max' => 10000, 'points' => 7], + ], + 'walk_bands' => [ + ['min' => 0, 'max' => 0, 'points' => 0], + ['min' => 1, 'max' => 15, 'points' => 2], + ['min' => 16, 'max' => 40, 'points' => 5], + ['min' => 41, 'max' => 10000, 'points' => 7], + ], + 'journal_points' => 2, + ], + 'ratings' => [ + ['label' => 'Scheißtag', 'min' => 0, 'max' => 39], + ['label' => 'schwerer Tag', 'min' => 40, 'max' => 54], + ['label' => 'okayer Tag', 'min' => 55, 'max' => 69], + ['label' => 'guter Tag', 'min' => 70, 'max' => 84], + ['label' => 'super Tag', 'min' => 85, 'max' => 106], + ], + 'guardrails' => [ + [ + 'mood_max' => 3, + 'energy_max' => null, + 'cap_label' => 'okayer Tag', + ], + [ + 'mood_max' => 2, + 'energy_max' => 3, + 'cap_label' => 'schwerer Tag', + ], + ], + ]; + } +} diff --git a/src/Support/View.php b/src/Support/View.php new file mode 100644 index 0000000..879a67c --- /dev/null +++ b/src/Support/View.php @@ -0,0 +1,23 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax', +]); + +if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); +} + +foreach ([ + storage_path(), + storage_path('system'), + storage_path('users'), +] as $directory) { + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..791b179 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,124 @@ + $type, + 'message' => $message, + ]; +} + +function pull_flashes(): array +{ + $flashes = $_SESSION['_flash'] ?? []; + unset($_SESSION['_flash']); + + return is_array($flashes) ? $flashes : []; +} + +function csrf_token(): string +{ + if (empty($_SESSION['_csrf'])) { + $_SESSION['_csrf'] = bin2hex(random_bytes(32)); + } + + return (string) $_SESSION['_csrf']; +} + +function csrf_field(): string +{ + return ''; +} + +function verify_csrf(?string $token): bool +{ + if (!is_string($token) || $token === '') { + return false; + } + + return hash_equals(csrf_token(), $token); +} + +function is_active_path(string $path): bool +{ + return request_path() === $path; +} + +function format_points(float $value): string +{ + $rounded = round($value, 1); + + if (abs($rounded - round($rounded)) < 0.05) { + return (string) (int) round($rounded); + } + + return number_format($rounded, 1, ',', '.'); +} + +function normalize_username(string $username): string +{ + return strtolower(trim($username)); +} + +function today(): string +{ + return date('Y-m-d'); +} + +function decode_json_file(string $path, array $fallback = []): array +{ + if (!is_file($path)) { + return $fallback; + } + + $decoded = json_decode((string) file_get_contents($path), true); + + return is_array($decoded) ? $decoded : $fallback; +} + +function encode_payload(array $payload): string +{ + return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); +} + diff --git a/storage/.gitignore b/storage/.gitignore new file mode 100644 index 0000000..16926c8 --- /dev/null +++ b/storage/.gitignore @@ -0,0 +1,5 @@ +* +!/.gitignore +!/.htaccess +!/system/ +!/users/ diff --git a/storage/.htaccess b/storage/.htaccess new file mode 100644 index 0000000..182023b --- /dev/null +++ b/storage/.htaccess @@ -0,0 +1,6 @@ +Require all denied + + + Deny from all + + diff --git a/storage/system/.gitignore b/storage/system/.gitignore new file mode 100644 index 0000000..0cc23fe --- /dev/null +++ b/storage/system/.gitignore @@ -0,0 +1,3 @@ +* +!/.gitignore + diff --git a/storage/users/.gitignore b/storage/users/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/storage/users/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..393fd4a --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,81 @@ + 'Statistiken und Verlauf', + 'track' => 'Tag erfassen und bewerten', + 'archive' => 'Rueckblick auf vergangene Tage', + 'options' => 'Logik, Sicherheit und Accounts', + 'login' => 'Geschuetzter Zugang', + 'setup' => 'Erstkonfiguration', + default => 'Stimmungstracker', +}; +?> + + + + + + <?= e($pageTitle) ?> · Mood + + + + +
+
+
+ + + + +
+ +
+
+

+

+
+
+ + +
+
+ + + +
+ + + +
+
+ + + diff --git a/templates/pages/archive.php b/templates/pages/archive.php new file mode 100644 index 0000000..dff2d90 --- /dev/null +++ b/templates/pages/archive.php @@ -0,0 +1,62 @@ +
+
+
+
+

Archiv

+

Alle gespeicherten Tage

+
+ Eintraege +
+ + +

Noch keine Eintraege vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.

+ + + +
+ + +
+ diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php new file mode 100644 index 0000000..cb3db2e --- /dev/null +++ b/templates/pages/dashboard.php @@ -0,0 +1,87 @@ +
+
+

Stimmung im Blick

+

Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Uebersicht.

+

Die Bewertung basiert auf deiner konfigurierbaren Logik. Sobald du die Regeln in den Optionen aenderst, spiegeln Archiv und Statistiken die neue Gewichtung wider.

+
+ +
+

Heute

+ +
+

+ +
-
+

Noch kein Eintrag fuer heute

+ +
+
+ +
+
+ Getrackte Tage + +
+
+ Ø Score + +
+
+ Ø Stimmung + /10 +
+
+ Ø Stress + /10 +
+
+ Serie + Tage +
+
+ +
+
+
+
+

Kalender

+

Gesamtstimmung pro Tag

+
+
+
+
+ +
+
+
+

Trend

+

Tagesstimmung

+
+ letzte 30 Eintraege +
+
+
+ +
+
+
+

Belastung

+

Stressverlauf

+
+ letzte 30 Eintraege +
+
+
+ +
+
+
+

Aktivitaet

+

Sport und Spaziergang

+
+ Minuten pro Tag +
+
+
+
+ diff --git a/templates/pages/login.php b/templates/pages/login.php new file mode 100644 index 0000000..a5ecb28 --- /dev/null +++ b/templates/pages/login.php @@ -0,0 +1,23 @@ +
+
+

Geschuetzt und dateibasiert

+

Einloggen

+

Die Eintraege liegen als Markdown-TXT-Dateien im geschuetzten Speicher und sind nur nach Login sichtbar.

+ +
+ + + + + + +
+
+
+ diff --git a/templates/pages/not-found.php b/templates/pages/not-found.php new file mode 100644 index 0000000..114c1f8 --- /dev/null +++ b/templates/pages/not-found.php @@ -0,0 +1,9 @@ +
+
+

404

+

Diese Seite gibt es nicht

+

Der angeforderte Bereich wurde nicht gefunden.

+ Zur Startseite +
+
+ diff --git a/templates/pages/options.php b/templates/pages/options.php new file mode 100644 index 0000000..62493a0 --- /dev/null +++ b/templates/pages/options.php @@ -0,0 +1,139 @@ +
+
+
+
+

Bewertungslogik

+

Score und Schutzregeln anpassen

+
+ Maximal Punkte +
+ +
+ + + +
+

Multiplikatoren

+
+ + + + +
+
+ +
+

Schlafdauerpunkte

+
+ $value): ?> + + +
+
+ +
+

Sport-Baender

+
+ $band): ?> +
+ + + +
+ +
+
+ +
+

Spaziergang-Baender

+
+ $band): ?> +
+ + + +
+ +
+
+ +
+

Bewertungsskala

+
+ $rating): ?> +
+ + + +
+ +
+
+ +
+

Schutzregeln

+
+ $guardrail): ?> +
+ + + +
+ +
+
+ + + + +
+
+ + +
+ diff --git a/templates/pages/setup.php b/templates/pages/setup.php new file mode 100644 index 0000000..a3f6cfe --- /dev/null +++ b/templates/pages/setup.php @@ -0,0 +1,28 @@ +
+
+

Erste Einrichtung

+

Mood initialisieren

+

Lege den ersten Admin-Account an. Danach ist die Anwendung sofort geschuetzt und weitere Accounts koennen spaeter in den Optionen erstellt werden.

+ +
+ + + + + + + + +
+
+
+ diff --git a/templates/pages/track.php b/templates/pages/track.php new file mode 100644 index 0000000..b8af43e --- /dev/null +++ b/templates/pages/track.php @@ -0,0 +1,105 @@ +
+
+
+
+

Tag erfassen

+

Eintrag fuer

+
+ Im Archiv ansehen +
+ +
+ + +
+ +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ + + +
+ + + +
+ Heute laden + +
+
+
+ + +