first commit

This commit is contained in:
2026-04-11 18:57:00 +02:00
commit 58bcc8f0f3
29 changed files with 3290 additions and 0 deletions
+17
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
Options -Indexes
DirectoryIndex index.php
<IfModule mod_rewrite.c>
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]
</IfModule>
+34
View File
@@ -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/<user>/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.
+805
View File
@@ -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;
}
}
+424
View File
@@ -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 `<div><dt>${componentLabels[key] || key}</dt><dd>${formatNumber(Number(value))}</dd></div>`;
}).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 = `
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf">
<line class="chart-axis" x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}"></line>
<path class="line-fill" d="${fillPath}" fill="${color}"></path>
<path class="line-stroke" d="${path}" stroke="${color}"></path>
${points.map(point => `<circle class="line-point" cx="${point.x}" cy="${point.y}" r="4" fill="${color}"><title>${point.label}: ${formatNumber(point.value)}</title></circle>`).join("")}
${labels.map(point => `<text class="chart-label" x="${point.x}" y="${height - 12}" text-anchor="middle">${point.label}</text>`).join("")}
</svg>
`;
}
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 `
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
<rect class="bar-segment--walk" x="${x}" y="${walkY}" width="18" height="${walkHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title>
</rect>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Sport ${sport} min</title>
</rect>
<text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text>
<text class="bar-label" x="${x + 9}" y="202" text-anchor="middle">${formatDateLabel(item.date)}</text>
`;
}).join("");
container.innerHTML = `
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Sport und Spaziergang">
${bars}
</svg>
`;
}
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 `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"><title>${title}</title></rect>`;
}).join("");
container.innerHTML = `
<svg class="calendar-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="Kalender">
${monthLabels.map(item => `<text class="calendar-tooltip" x="${item.x}" y="14">${item.label}</text>`).join("")}
<text class="calendar-tooltip" x="0" y="34">Mo</text>
<text class="calendar-tooltip" x="0" y="68">Mi</text>
<text class="calendar-tooltip" x="0" y="102">Fr</text>
${cells}
</svg>
<div class="calendar-legend">
<span>weniger</span>
<div class="calendar-scale">
<span class="calendar-dot"></span>
<span class="calendar-dot calendar-dot--1"></span>
<span class="calendar-dot calendar-dot--2"></span>
<span class="calendar-dot calendar-dot--3"></span>
</div>
<span>mehr</span>
</div>
`;
}
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();
})();
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
require __DIR__ . '/src/bootstrap.php';
$app = new App();
$app->run();
+579
View File
@@ -0,0 +1,579 @@
<?php
declare(strict_types=1);
final class App
{
private UserRepository $users;
private SettingsRepository $settings;
private EntryRepository $entries;
private LoginThrottle $throttle;
private ScoringService $scoring;
private Auth $auth;
public function __construct()
{
$this->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));
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryRepository
{
public function save(string $username, string $date, array $entry, array $evaluation): void
{
$path = $this->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 = [
'<!-- mood-tracker:v1 -->',
'# 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);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
final class LoginThrottle
{
private string $path;
private int $windowSeconds = 600;
private int $maxAttempts = 5;
public function __construct()
{
$this->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)
);
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
final class ScoringService
{
public function normalize(array $input): array
{
return [
'date' => $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;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
final class SettingsRepository
{
public function forUser(string $username): array
{
$path = $this->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');
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
final class UserRepository
{
private string $path;
public function __construct()
{
$this->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)
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
final class Auth
{
public function __construct(private UserRepository $users)
{
}
public function check(): bool
{
return isset($_SESSION['user']) && is_array($_SESSION['user']);
}
public function user(): ?array
{
if (!$this->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);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
final class Defaults
{
public static function settings(): array
{
return [
'labels' => [
'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',
],
],
];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
final class View
{
public static function render(string $template, array $data = []): void
{
$pageTitle = $data['pageTitle'] ?? 'Mood';
$page = $data['page'] ?? 'dashboard';
$authUser = $data['authUser'] ?? null;
$flashes = pull_flashes();
extract($data, EXTR_SKIP);
ob_start();
require base_path('templates/pages/' . $template . '.php');
$content = (string) ob_get_clean();
require base_path('templates/layout.php');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/View.php';
require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/ScoringService.php';
require __DIR__ . '/App.php';
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin');
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_name('mood_session');
session_set_cookie_params([
'lifetime' => 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);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
function base_path(string $path = ''): string
{
$base = dirname(__DIR__);
if ($path === '') {
return $base;
}
return $base . '/' . ltrim($path, '/');
}
function storage_path(string $path = ''): string
{
return base_path('storage/' . ltrim($path, '/'));
}
function e(mixed $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function redirect(string $path): never
{
header('Location: ' . $path);
exit;
}
function request_path(): string
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$path = is_string($path) ? $path : '/';
if ($path !== '/') {
$path = rtrim($path, '/');
}
return $path === '' ? '/' : $path;
}
function flash(string $type, string $message): void
{
$_SESSION['_flash'][] = [
'type' => $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 '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
}
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));
}
+5
View File
@@ -0,0 +1,5 @@
*
!/.gitignore
!/.htaccess
!/system/
!/users/
+6
View File
@@ -0,0 +1,6 @@
Require all denied
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
+3
View File
@@ -0,0 +1,3 @@
*
!/.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!/.gitignore
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
$brandSubtitle = match ($page) {
'dashboard' => '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',
};
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle) ?> · Mood</title>
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
</head>
<body class="app-body page-<?= e($page) ?>">
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="shell">
<?php if ($authUser !== null): ?>
<aside class="sidebar glass-panel">
<div class="brand-block">
<div class="brand-mark">M</div>
<div>
<p class="eyebrow">mood.hnz.io</p>
<h1>Mood</h1>
</div>
</div>
<nav class="main-nav" aria-label="Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">Dashboard</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">Tracken</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">Archiv</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">Optionen</a>
</nav>
<div class="sidebar-footer">
<div class="user-chip">
<span class="user-chip__name"><?= e($authUser['username']) ?></span>
<span class="user-chip__role"><?= !empty($authUser['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
</div>
<form method="post" action="/logout">
<?= csrf_field() ?>
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</aside>
<?php endif; ?>
<main class="content">
<?php if ($authUser !== null): ?>
<header class="topbar glass-panel">
<div>
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
<h2><?= e($pageTitle) ?></h2>
</div>
<div class="topbar__meta">
<span class="meta-pill"><?= e(date('d.m.Y')) ?></span>
<span class="meta-pill"><?= e($authUser['username']) ?></span>
</div>
</header>
<?php endif; ?>
<?php foreach ($flashes as $flash): ?>
<div class="flash flash-<?= e($flash['type']) ?> glass-panel"><?= e($flash['message']) ?></div>
<?php endforeach; ?>
<?= $content ?>
</main>
</div>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
<section class="page-grid">
<article class="glass-panel archive-list">
<div class="section-head">
<div>
<p class="eyebrow">Archiv</p>
<h3>Alle gespeicherten Tage</h3>
</div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Eintraege</span>
</div>
<?php if ($entries === []): ?>
<p class="empty-state">Noch keine Eintraege vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
<?php else: ?>
<div class="archive-items">
<?php foreach ($entries as $entry): ?>
<a class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">
<div>
<strong><?= e($entry['date']) ?></strong>
<span><?= e($entry['evaluation']['label']) ?></span>
</div>
<div class="archive-item__meta">
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<aside class="stack-column">
<?php if ($selectedEntry !== null): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">Ausgewaehlt</p>
<h3><?= e($selectedEntry['date']) ?></h3>
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
<dl class="detail-grid">
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<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>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
</dl>
<div class="note-box">
<h4>Notiz</h4>
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
</div>
</article>
<?php else: ?>
<article class="glass-panel detail-card">
<p class="eyebrow">Details</p>
<h3>Archivansicht</h3>
<p>Waehle links einen Tag aus, um alle Werte und die Tagebuchnotiz anzuzeigen.</p>
</article>
<?php endif; ?>
</aside>
</section>
+87
View File
@@ -0,0 +1,87 @@
<section class="hero-grid">
<article class="hero-card hero-card--wide glass-panel">
<p class="eyebrow">Stimmung im Blick</p>
<h3>Dein Dashboard verbindet Verlauf, Score und Bewegungsdaten in einer schnellen Uebersicht.</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>
</article>
<article class="hero-card glass-panel">
<p class="eyebrow">Heute</p>
<?php if ($summary['today'] !== null): ?>
<div class="hero-score"><?= e(format_points((float) $summary['today']['evaluation']['total'])) ?></div>
<p class="hero-label"><?= e($summary['today']['evaluation']['label']) ?></p>
<?php else: ?>
<div class="hero-score">-</div>
<p class="hero-label">Noch kein Eintrag fuer heute</p>
<?php endif; ?>
</article>
</section>
<section class="stats-grid">
<article class="metric-card glass-panel">
<span>Getrackte Tage</span>
<strong><?= e((string) $summary['tracked_days']) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Score</span>
<strong><?= e(format_points((float) $summary['average_score'])) ?></strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stimmung</span>
<strong><?= e(format_points((float) $summary['average_mood'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Ø Stress</span>
<strong><?= e(format_points((float) $summary['average_stress'])) ?>/10</strong>
</article>
<article class="metric-card glass-panel">
<span>Serie</span>
<strong><?= e((string) $summary['streak']) ?> Tage</strong>
</article>
</section>
<section class="dashboard-grid">
<article class="glass-panel chart-card chart-card--calendar">
<div class="section-head">
<div>
<p class="eyebrow">Kalender</p>
<h3>Gesamtstimmung pro Tag</h3>
</div>
</div>
<div id="calendar-heatmap" class="calendar-heatmap" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Trend</p>
<h3>Tagesstimmung</h3>
</div>
<span class="chart-chip">letzte 30 Eintraege</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="mood" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Belastung</p>
<h3>Stressverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">letzte 30 Eintraege</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
<p class="eyebrow">Aktivitaet</p>
<h3>Sport und Spaziergang</h3>
</div>
<span class="chart-chip chart-chip--cool">Minuten pro Tag</span>
</div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article>
</section>
+23
View File
@@ -0,0 +1,23 @@
<section class="auth-shell">
<div class="auth-card glass-panel">
<p class="eyebrow">Geschuetzt und dateibasiert</p>
<h1>Einloggen</h1>
<p class="auth-copy">Die Eintraege liegen als Markdown-TXT-Dateien im geschuetzten Speicher und sind nur nach Login sichtbar.</p>
<form method="post" action="/login" class="stack-form">
<?= csrf_field() ?>
<label>
<span>Benutzername</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Passwort</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="primary-button" type="submit">Anmelden</button>
</form>
</div>
</section>
+9
View File
@@ -0,0 +1,9 @@
<section class="auth-shell">
<div class="auth-card glass-panel">
<p class="eyebrow">404</p>
<h1>Diese Seite gibt es nicht</h1>
<p class="auth-copy">Der angeforderte Bereich wurde nicht gefunden.</p>
<a class="primary-button button-link" href="/">Zur Startseite</a>
</div>
</section>
+139
View File
@@ -0,0 +1,139 @@
<section class="page-grid">
<article class="glass-panel form-panel form-panel--wide">
<div class="section-head">
<div>
<p class="eyebrow">Bewertungslogik</p>
<h3>Score und Schutzregeln anpassen</h3>
</div>
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
</div>
<form method="post" action="/options" class="stack-form stack-form--spacious">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="settings">
<div class="settings-section">
<h4>Multiplikatoren</h4>
<div class="field-grid field-grid--four">
<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>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>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
<?php foreach ($settings['scoring']['sleep_duration_points'] as $key => $value): ?>
<label>
<span><?= e($key) ?></span>
<input type="number" name="settings[scoring][sleep_duration_points][<?= e($key) ?>]" value="<?= e((string) $value) ?>" min="0" max="20">
</label>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Sport-Baender</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['sport_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][sport_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Spaziergang-Baender</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Bewertungsskala</h4>
<div class="band-grid">
<?php foreach ($settings['ratings'] as $index => $rating): ?>
<div class="band-card">
<label><span>Label</span><input type="text" name="settings[ratings][<?= e((string) $index) ?>][label]" value="<?= e($rating['label']) ?>"></label>
<label><span>Min</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][min]" value="<?= e((string) $rating['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[ratings][<?= e((string) $index) ?>][max]" value="<?= e((string) $rating['max']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="settings-section">
<h4>Schutzregeln</h4>
<div class="band-grid">
<?php foreach ($settings['guardrails'] as $index => $guardrail): ?>
<div class="band-card">
<label><span>Stimmung max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][mood_max]" value="<?= e((string) $guardrail['mood_max']) ?>" min="1" max="10"></label>
<label><span>Energie max</span><input type="number" name="settings[guardrails][<?= e((string) $index) ?>][energy_max]" value="<?= e((string) ($guardrail['energy_max'] ?? '')) ?>" min="1" max="10"></label>
<label><span>Maximales Label</span><input type="text" name="settings[guardrails][<?= e((string) $index) ?>][cap_label]" value="<?= e($guardrail['cap_label']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
</div>
<label>
<span>Tagebuchpunkte bei nicht-leerer Notiz</span>
<input type="number" name="settings[scoring][journal_points]" value="<?= e((string) $settings['scoring']['journal_points']) ?>" min="0" max="20">
</label>
<button class="primary-button" type="submit">Bewertung speichern</button>
</form>
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort aendern</h3>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="password">
<label><span>Aktuelles Passwort</span><input type="password" name="current_password" required></label>
<label><span>Neues Passwort</span><input type="password" name="new_password" minlength="10" required></label>
<label><span>Neues Passwort wiederholen</span><input type="password" name="new_password_confirm" minlength="10" required></label>
<button class="primary-button" type="submit">Passwort aktualisieren</button>
</form>
</article>
<?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="create_user">
<label><span>Benutzername</span><input type="text" name="username" required></label>
<label><span>Startpasswort</span><input type="password" name="password" minlength="10" required></label>
<label class="checkbox-row"><input type="checkbox" name="is_admin" value="1"><span>Als Admin anlegen</span></label>
<button class="primary-button" type="submit">Account erstellen</button>
</form>
<?php if ($users !== []): ?>
<div class="user-list">
<?php foreach ($users as $account): ?>
<div class="user-row">
<strong><?= e($account['username']) ?></strong>
<span><?= !empty($account['is_admin']) ? 'Admin' : 'Nutzer' ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</article>
<?php endif; ?>
</aside>
</section>
+28
View File
@@ -0,0 +1,28 @@
<section class="auth-shell">
<div class="auth-card glass-panel">
<p class="eyebrow">Erste Einrichtung</p>
<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>
<form method="post" action="/setup" class="stack-form">
<?= csrf_field() ?>
<label>
<span>Admin-Benutzername</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Passwort</span>
<input type="password" name="password" autocomplete="new-password" minlength="10" required>
</label>
<label>
<span>Passwort wiederholen</span>
<input type="password" name="password_confirm" autocomplete="new-password" minlength="10" required>
</label>
<button class="primary-button" type="submit">Setup abschliessen</button>
</form>
</div>
</section>
+105
View File
@@ -0,0 +1,105 @@
<section class="page-grid">
<article class="glass-panel form-panel form-panel--wide">
<div class="section-head">
<div>
<p class="eyebrow">Tag erfassen</p>
<h3>Eintrag fuer <?= e($entry['date']) ?></h3>
</div>
<a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
</div>
<form method="post" action="/track" class="tracker-form" id="tracker-form">
<?= csrf_field() ?>
<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">
<label class="range-card">
<span>Stimmung</span>
<output data-output-for="mood"><?= e((string) $entry['mood']) ?></output>
<input type="range" min="1" max="10" step="1" name="mood" value="<?= e((string) $entry['mood']) ?>">
</label>
<label class="range-card">
<span>Energie</span>
<output data-output-for="energy"><?= e((string) $entry['energy']) ?></output>
<input type="range" min="1" max="10" step="1" name="energy" value="<?= e((string) $entry['energy']) ?>">
</label>
<label class="range-card">
<span>Stress</span>
<output data-output-for="stress"><?= e((string) $entry['stress']) ?></output>
<input type="range" min="1" max="10" step="1" name="stress" value="<?= e((string) $entry['stress']) ?>">
</label>
</div>
<div class="field-grid field-grid--two">
<label>
<span>Schlafdauer in Stunden</span>
<input type="number" min="0" max="24" step="0.25" name="sleep_hours" value="<?= e((string) $entry['sleep_hours']) ?>" required>
</label>
<label>
<span>Schlafgefuehl</span>
<select name="sleep_feeling">
<?php foreach ($settings['labels']['sleep_feeling'] as $value => $label): ?>
<option value="<?= e((string) $value) ?>" <?= (int) $entry['sleep_feeling'] === (int) $value ? 'selected' : '' ?>>
<?= e((string) $value) ?> · <?= e($label) ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
<div class="field-grid field-grid--two">
<label>
<span>Sport in Minuten</span>
<input type="number" min="0" max="1440" step="1" name="sport_minutes" value="<?= e((string) $entry['sport_minutes']) ?>" required>
</label>
<label>
<span>Spaziergang in Minuten</span>
<input type="number" min="0" max="1440" step="1" name="walk_minutes" value="<?= e((string) $entry['walk_minutes']) ?>" required>
</label>
</div>
<label>
<span>Tagebuchnotiz</span>
<textarea name="note" rows="8" placeholder="Was war heute wichtig, schwer oder schoen?"><?= e($entry['note']) ?></textarea>
</label>
<div class="form-actions">
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute laden</a>
<button class="primary-button" type="submit">Tag speichern</button>
</div>
</form>
</article>
<aside class="stack-column">
<article class="glass-panel preview-card" id="live-score-card" data-payload="<?= e($trackPayload) ?>">
<p class="eyebrow">Live-Bewertung</p>
<div class="hero-score" data-preview-total><?= e(format_points((float) $evaluation['total'])) ?></div>
<p class="hero-label" data-preview-label><?= e($evaluation['label']) ?></p>
<p class="helper-text">Die Einschaetzung passt sich beim Aendern der Werte sofort an.</p>
<dl class="component-list" data-preview-components>
<?php foreach ($evaluation['components'] as $name => $value): ?>
<div>
<dt><?= e($name) ?></dt>
<dd><?= e(format_points((float) $value)) ?></dd>
</div>
<?php endforeach; ?>
</dl>
</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>
</section>