first commit
This commit is contained in:
+17
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/src/bootstrap.php';
|
||||||
|
|
||||||
|
$app = new App();
|
||||||
|
$app->run();
|
||||||
|
|
||||||
+579
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
!/.gitignore
|
||||||
|
!/.htaccess
|
||||||
|
!/system/
|
||||||
|
!/users/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Require all denied
|
||||||
|
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!/.gitignore
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!/.gitignore
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user