12 Commits

38 changed files with 3024 additions and 154 deletions
+1 -1
View File
@@ -1,5 +1,6 @@
Options -Indexes
DirectoryIndex index.php
AddType application/manifest+json .webmanifest
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -11,4 +12,3 @@ DirectoryIndex index.php
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>
+15
View File
@@ -0,0 +1,15 @@
Mood-Board
Copyright (c) 2026 HNZIO
Licensed under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software only for permitted
noncommercial purposes under the terms of that license.
Commercial use is not allowed without a separate written agreement from the
copyright holder.
Required Notice: Copyright (c) 2026 HNZIO
Full license text:
https://polyformproject.org/licenses/noncommercial/1.0.0/
+7
View File
@@ -32,3 +32,10 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Die Inhalte liegen absichtlich nicht in einer Datenbank, sondern in menschenlesbaren TXT-Dateien.
- Mehrere Accounts sind möglich und verursachen hier wenig Overhead, weil jeder Nutzer nur einen eigenen Unterordner mit Tagen und Einstellungen bekommt.
- Wenn du später Reverse Proxy oder HTTPS über Cloudron nutzt, bleiben die Daten weiterhin nur über die App erreichbar.
## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
- Nicht-kommerzielle Nutzung ist erlaubt.
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg width="180" height="180" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="84" height="84" rx="28" fill="url(#bg)"/>
<rect x="6.75" y="6.75" width="82.5" height="82.5" rx="27.25" stroke="rgba(255,255,255,0.28)" stroke-width="1.5"/>
<path d="M48 21C35.2975 21 25 31.2975 25 44C25 61.25 48 76 48 76C48 76 71 61.25 71 44C71 31.2975 60.7025 21 48 21Z" fill="url(#drop)"/>
<circle cx="39.5" cy="35.5" r="7" fill="rgba(255,255,255,0.5)"/>
<defs>
<linearGradient id="bg" x1="14" y1="10" x2="84" y2="84" gradientUnits="userSpaceOnUse">
<stop stop-color="#95E8FF"/>
<stop offset="0.46" stop-color="#5CB5FF"/>
<stop offset="1" stop-color="#173859"/>
</linearGradient>
<linearGradient id="drop" x1="33" y1="24" x2="64" y2="67" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="0.56" stop-color="#8CFFE0"/>
<stop offset="1" stop-color="#49CBFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+348 -35
View File
@@ -22,6 +22,88 @@
--track-accent: rgba(139, 228, 255, 0.34);
--track-surface: rgba(255, 255, 255, 0.08);
--track-glow: rgba(139, 228, 255, 0.18);
--body-radial-one: radial-gradient(circle at 18% 18%, rgba(76, 171, 255, 0.24), transparent 34%);
--body-radial-two: radial-gradient(circle at 82% 12%, rgba(113, 255, 210, 0.18), transparent 28%);
--body-gradient: linear-gradient(145deg, #07111b 0%, #0b1e2e 35%, #13273b 65%, #08111b 100%);
--panel-gradient-top: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.08));
--panel-gradient-accent: radial-gradient(circle at top left, rgba(255, 255, 255, 0.2), transparent 48%);
--pill-bg: rgba(255, 255, 255, 0.12);
--pill-border: rgba(255, 255, 255, 0.12);
--input-bg: rgba(255, 255, 255, 0.09);
--input-bg-focus: rgba(255, 255, 255, 0.12);
--input-border: rgba(255, 255, 255, 0.14);
--input-border-soft: rgba(255, 255, 255, 0.12);
--nav-hover-bg: rgba(255, 255, 255, 0.13);
--user-chip-bg: rgba(255, 255, 255, 0.1);
--eyebrow-color: rgba(239, 247, 255, 0.62);
--chart-axis-color: rgba(255, 255, 255, 0.1);
--chart-label-color: rgba(239, 247, 255, 0.65);
--chart-value-color: rgba(239, 247, 255, 0.9);
--line-point-stroke: rgba(7, 17, 27, 0.9);
--bar-grid-color: rgba(255, 255, 255, 0.08);
--calendar-detail-bg: rgba(255, 255, 255, 0.08);
--calendar-detail-border: rgba(255, 255, 255, 0.1);
--calendar-detail-eyebrow: rgba(239, 247, 255, 0.58);
--calendar-selected-stroke: rgba(255, 255, 255, 0.9);
--control-soft-bg: rgba(255, 255, 255, 0.08);
--control-soft-border: rgba(255, 255, 255, 0.16);
--brand-shadow: 0 10px 24px rgba(9, 25, 40, 0.22);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #eef4fa;
--bg-soft: #dce8f2;
--surface: rgba(255, 255, 255, 0.72);
--surface-strong: rgba(255, 255, 255, 0.86);
--surface-border: rgba(120, 146, 172, 0.24);
--text: #12304b;
--muted: rgba(18, 48, 75, 0.66);
--shadow: 0 24px 60px rgba(78, 105, 130, 0.16);
--primary: #5abcf2;
--primary-strong: #1494de;
--accent: #63d9b4;
--warm: #ee9f63;
--danger: #db6b6b;
--good: #45c98d;
--track-accent: rgba(67, 153, 212, 0.22);
--track-surface: rgba(255, 255, 255, 0.46);
--track-glow: rgba(104, 201, 255, 0.16);
--body-radial-one: radial-gradient(circle at 18% 18%, rgba(115, 196, 255, 0.28), transparent 34%);
--body-radial-two: radial-gradient(circle at 82% 12%, rgba(129, 232, 212, 0.24), transparent 28%);
--body-gradient: linear-gradient(145deg, #eef5fb 0%, #e4eff7 40%, #d8e9f4 72%, #edf5fa 100%);
--panel-gradient-top: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.58));
--panel-gradient-accent: radial-gradient(circle at top left, rgba(123, 190, 255, 0.18), transparent 48%);
--pill-bg: rgba(255, 255, 255, 0.54);
--pill-border: rgba(130, 158, 185, 0.22);
--input-bg: rgba(255, 255, 255, 0.62);
--input-bg-focus: rgba(255, 255, 255, 0.82);
--input-border: rgba(123, 153, 182, 0.26);
--input-border-soft: rgba(123, 153, 182, 0.22);
--nav-hover-bg: rgba(255, 255, 255, 0.56);
--user-chip-bg: rgba(255, 255, 255, 0.54);
--eyebrow-color: rgba(18, 48, 75, 0.5);
--chart-axis-color: rgba(18, 48, 75, 0.12);
--chart-label-color: rgba(18, 48, 75, 0.58);
--chart-value-color: rgba(18, 48, 75, 0.86);
--line-point-stroke: rgba(255, 255, 255, 0.95);
--bar-grid-color: rgba(18, 48, 75, 0.08);
--calendar-detail-bg: rgba(255, 255, 255, 0.6);
--calendar-detail-border: rgba(120, 146, 172, 0.18);
--calendar-detail-eyebrow: rgba(18, 48, 75, 0.48);
--calendar-selected-stroke: rgba(18, 48, 75, 0.52);
--control-soft-bg: rgba(255, 255, 255, 0.58);
--control-soft-border: rgba(123, 153, 182, 0.22);
--brand-shadow: 0 10px 24px rgba(82, 111, 138, 0.14);
}
html {
color-scheme: light;
}
select {
color-scheme: light;
}
}
*,
@@ -40,9 +122,37 @@ body {
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%);
var(--body-radial-one),
var(--body-radial-two),
var(--body-gradient);
color: var(--text);
}
.pull-refresh-indicator {
position: fixed;
top: max(0.85rem, env(safe-area-inset-top));
left: 50%;
z-index: 30;
padding: 0.72rem 1rem;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.9rem) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease;
color: var(--muted);
font-size: 0.92rem;
letter-spacing: 0.01em;
}
body.is-pull-refreshing .pull-refresh-indicator,
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
body.is-pull-refresh-ready .pull-refresh-indicator,
body.is-pull-refresh-reloading .pull-refresh-indicator {
color: var(--text);
}
@@ -62,6 +172,12 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none !important;
}
.aurora {
position: fixed;
inset: auto;
@@ -103,8 +219,8 @@ button {
.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%);
var(--panel-gradient-top),
var(--panel-gradient-accent);
backdrop-filter: blur(var(--panel-blur)) saturate(150%);
-webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
box-shadow: var(--shadow);
@@ -128,6 +244,26 @@ button {
gap: 1rem;
}
.site-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.9rem;
margin-top: auto;
padding: 0.9rem 1.1rem;
border-radius: var(--radius-lg);
}
.site-footer__link {
color: var(--muted);
font-size: 0.92rem;
transition: color 180ms ease, opacity 180ms ease;
}
.site-footer__link:hover {
color: var(--text);
}
.topbar {
display: flex;
justify-content: space-between;
@@ -149,9 +285,9 @@ button {
align-items: center;
padding: 0.48rem 0.8rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
background: var(--pill-bg);
color: var(--muted);
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--pill-border);
font-size: 0.88rem;
letter-spacing: 0.01em;
}
@@ -167,10 +303,10 @@ button {
.topbar-date-input {
width: auto;
min-height: 2.2rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--input-border-soft);
border-radius: 999px;
padding: 0.45rem 0.9rem;
background: rgba(255, 255, 255, 0.08);
background: var(--control-soft-bg);
color: var(--text);
}
@@ -195,7 +331,7 @@ button {
place-items: center;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 10px 24px rgba(9, 25, 40, 0.22);
box-shadow: var(--brand-shadow);
}
.brand-mark img {
@@ -222,7 +358,7 @@ button {
margin: 0 0 0.28rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(239, 247, 255, 0.62);
color: var(--eyebrow-color);
font-size: 0.74rem;
}
@@ -232,6 +368,10 @@ button {
margin-top: 2rem;
}
.mobile-nav {
display: none;
}
.main-nav a {
display: flex;
align-items: center;
@@ -244,7 +384,7 @@ button {
.main-nav a:hover,
.main-nav a.active {
background: rgba(255, 255, 255, 0.13);
background: var(--nav-hover-bg);
color: var(--text);
transform: translateX(2px);
}
@@ -269,7 +409,7 @@ button {
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.1);
background: var(--user-chip-bg);
}
.user-chip__name {
@@ -415,8 +555,8 @@ button {
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--calendar-detail-bg);
border: 1px solid var(--calendar-detail-border);
}
.calendar-detail__meta {
@@ -425,7 +565,7 @@ button {
}
.calendar-detail__eyebrow {
color: rgba(239, 247, 255, 0.58);
color: var(--calendar-detail-eyebrow);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -537,7 +677,7 @@ button {
}
.calendar-cell--selected {
stroke: rgba(255, 255, 255, 0.9);
stroke: var(--calendar-selected-stroke);
stroke-width: 1.2;
filter: brightness(1.08);
}
@@ -566,7 +706,7 @@ button {
}
.line-point {
stroke: rgba(7, 17, 27, 0.9);
stroke: var(--line-point-stroke);
stroke-width: 1.5;
}
@@ -579,7 +719,7 @@ button {
}
.chart-axis {
stroke: rgba(255, 255, 255, 0.1);
stroke: var(--chart-axis-color);
stroke-width: 1;
}
@@ -589,12 +729,12 @@ button {
}
.chart-label {
fill: rgba(239, 247, 255, 0.65);
fill: var(--chart-label-color);
font-size: 11px;
}
.chart-value {
fill: rgba(239, 247, 255, 0.9);
fill: var(--chart-value-color);
font-size: 15px;
font-weight: 700;
}
@@ -607,7 +747,7 @@ button {
}
.bar-grid {
fill: rgba(255, 255, 255, 0.08);
fill: var(--bar-grid-color);
}
.bar-segment--sport {
@@ -619,12 +759,12 @@ button {
}
.bar-label {
fill: rgba(239, 247, 255, 0.62);
fill: var(--chart-label-color);
font-size: 11px;
}
.bar-value {
fill: rgba(239, 247, 255, 0.82);
fill: var(--chart-value-color);
font-size: 10px;
text-anchor: middle;
}
@@ -723,9 +863,9 @@ button {
.sport-choice__card {
display: grid;
gap: 0.35rem;
gap: 0.5rem;
min-height: 100%;
padding: 0.85rem 0.95rem;
padding: 1rem 1.05rem;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
@@ -733,17 +873,23 @@ button {
}
.sport-choice__card img {
width: 1.2rem;
height: 1.2rem;
width: 1.7rem;
height: 1.7rem;
opacity: 0.96;
}
.sport-choice__card strong {
color: var(--text);
font-size: 0.92rem;
font-size: 1rem;
line-height: 1.35;
}
.sport-choice__card small {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.3;
}
.sport-choice input:checked + .sport-choice__card {
border-color: rgba(139, 228, 255, 0.44);
background: linear-gradient(180deg, rgba(96, 184, 255, 0.18), rgba(255, 255, 255, 0.08));
@@ -773,9 +919,9 @@ input[type="date"],
select,
textarea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--input-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.09);
background: var(--input-bg);
color: var(--text);
padding: 0.9rem 1rem;
outline: none;
@@ -788,7 +934,7 @@ select {
option,
optgroup {
background: #10253a;
background: var(--bg-soft);
color: var(--text);
}
@@ -796,7 +942,7 @@ input:focus,
select:focus,
textarea:focus {
border-color: rgba(139, 228, 255, 0.5);
background: rgba(255, 255, 255, 0.12);
background: var(--input-bg-focus);
transform: translateY(-1px);
}
@@ -920,6 +1066,36 @@ input[type="range"] {
flex-wrap: wrap;
}
.section-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.95rem;
}
.push-panel {
display: grid;
gap: 0.9rem;
}
.push-panel h5 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.push-panel [data-push-status][data-tone="success"] {
color: var(--good);
}
.push-panel [data-push-status][data-tone="error"] {
color: var(--danger);
}
.push-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.primary-button,
.ghost-button,
.button-link {
@@ -950,8 +1126,8 @@ input[type="range"] {
.ghost-button,
.ghost-link {
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--control-soft-border);
background: var(--control-soft-bg);
}
.ghost-link {
@@ -1092,6 +1268,41 @@ input[type="range"] {
gap: 0.9rem;
}
.preset-list {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-bottom: 0.95rem;
}
.preset-pill {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.65rem 0.9rem;
border-radius: 999px;
border: 1px solid rgba(138, 210, 235, 0.22);
background: rgba(255, 255, 255, 0.08);
color: var(--text);
backdrop-filter: blur(20px);
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
}
.preset-pill:hover {
transform: translateY(-1px);
border-color: rgba(139, 228, 255, 0.4);
background: rgba(255, 255, 255, 0.12);
}
.preset-pill img {
width: 1rem;
height: 1rem;
}
.preset-pill[hidden] {
display: none;
}
.sport-type-card {
gap: 0.9rem;
}
@@ -1110,6 +1321,31 @@ input[type="range"] {
gap: 0.7rem;
}
.checkbox-row span {
display: grid;
gap: 0.15rem;
}
.checkbox-row small {
color: var(--muted);
font-size: 0.86rem;
line-height: 1.45;
}
.checkbox-row--panel {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
min-height: 100%;
}
.checkbox-row--tall {
align-items: flex-start;
padding-top: 1.05rem;
padding-bottom: 1.05rem;
}
.checkbox-row input {
width: auto;
}
@@ -1211,6 +1447,10 @@ input[type="range"] {
}
@media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
}
.topbar,
.section-head,
.form-actions {
@@ -1233,6 +1473,57 @@ input[type="range"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sidebar {
display: none;
}
.mobile-nav {
position: fixed;
left: 0.8rem;
right: 0.8rem;
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 30;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.45rem;
padding: 0.6rem;
border-radius: 28px;
}
.mobile-nav a {
display: grid;
justify-items: center;
gap: 0.35rem;
padding: 0.7rem 0.5rem;
border-radius: 22px;
color: var(--muted);
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
}
.mobile-nav a.active {
background: var(--nav-hover-bg);
color: var(--text);
transform: translateY(-1px);
}
.mobile-nav a span {
font-size: 0.76rem;
line-height: 1.1;
}
.mobile-nav .nav-icon {
width: 1.25rem;
height: 1.25rem;
}
body.is-authenticated .content {
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
}
.site-footer {
margin-bottom: 0.5rem;
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1297,4 +1588,26 @@ input[type="range"] {
.bar-chart {
min-height: 9.5rem;
}
.mobile-nav {
left: 0.7rem;
right: 0.7rem;
bottom: max(0.7rem, env(safe-area-inset-bottom));
padding: 0.5rem;
gap: 0.3rem;
}
.mobile-nav a {
padding: 0.62rem 0.35rem;
}
.mobile-nav a span {
font-size: 0.72rem;
}
.site-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="44" r="8" stroke="#8BE4FF" stroke-width="3"/>
<circle cx="46" cy="44" r="8" stroke="#8BE4FF" stroke-width="3"/>
<path d="M18 44L28 28H37L46 44" stroke="#EFF7FF" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28 28L24 21H17" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33 20H41" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round"/>
<path d="M31 28L38 36" stroke="#EFF7FF" stroke-width="3.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="34" cy="14" r="5" fill="#EFF7FF"/>
<path d="M28 27L34 19L42 24" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M34 19L31 33L40 39" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31 33L20 39" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M40 39L47 51" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M17 50C22 46.5 27 45 32 45C37 45 42 46.5 47 50" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round" opacity="0.78"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 14H28L24 27H34L21 50L25 36H16L20 14Z" fill="#EFF7FF"/>
<path d="M39 18V46" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round"/>
<path d="M47 22V42" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M55 26V38" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="15" r="5" fill="#EFF7FF"/>
<path d="M28 28L33 21L39 26" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33 21L30 34L24 45" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30 34L39 41L45 51" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 51H50" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round" opacity="0.75"/>
<path d="M44 22V41" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="24" width="8" height="16" rx="3" fill="#8BE4FF" fill-opacity="0.86"/>
<rect x="18" y="27" width="6" height="10" rx="2" fill="#8BE4FF" fill-opacity="0.72"/>
<rect x="40" y="27" width="6" height="10" rx="2" fill="#8BE4FF" fill-opacity="0.72"/>
<rect x="46" y="24" width="8" height="16" rx="3" fill="#8BE4FF" fill-opacity="0.86"/>
<rect x="24" y="29" width="16" height="6" rx="3" fill="#EFF7FF"/>
<path d="M22 47C25 44.5 28.3 43.2 32 43.2C35.7 43.2 39 44.5 42 47" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+7
View File
@@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="39" cy="20" r="5" fill="#EFF7FF"/>
<path d="M25 28C29 23 36 22 43 24" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round"/>
<path d="M27 35C31 31 36 30 42 31" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round"/>
<path d="M11 43C15 40 18 40 22 43C26 46 29 46 33 43C37 40 40 40 44 43C48 46 51 46 55 43" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round"/>
<path d="M9 50C13 47 16 47 20 50C24 53 27 53 31 50C35 47 38 47 42 50C46 53 49 53 53 50" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 657 B

+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="18" r="5" fill="#EFF7FF"/>
<path d="M24 31C27 27.5 29.6 26 32 26C34.4 26 37 27.5 40 31" stroke="#8BE4FF" stroke-width="4" stroke-linecap="round"/>
<path d="M32 26V41" stroke="#EFF7FF" stroke-width="4" stroke-linecap="round"/>
<path d="M32 41L22 48" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M32 41L42 48" stroke="#7FF3BB" stroke-width="4" stroke-linecap="round"/>
<path d="M16 50H48" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

+452 -26
View File
@@ -113,6 +113,38 @@
return last ? Number(last.points || 0) : 0;
}
function stepTargetPoints(value, targets) {
const list = [...(targets || [])].sort((a, b) => Number(a.steps || 0) - Number(b.steps || 0));
if (!list.length) {
return 0;
}
if (value <= Number(list[0].steps || 0)) {
return Number(list[0].points || 0);
}
const last = list[list.length - 1];
if (value >= Number(last.steps || 0)) {
return Number(last.points || 0);
}
for (let index = 1; index < list.length; index += 1) {
const previous = list[index - 1];
const current = list[index];
const previousSteps = Number(previous.steps || 0);
const currentSteps = Number(current.steps || 0);
if (value > currentSteps) {
continue;
}
const ratio = (value - previousSteps) / Math.max(currentSteps - previousSteps, 1);
return Math.round((Number(previous.points || 0) + ((Number(current.points || 0) - Number(previous.points || 0)) * ratio)) * 10) / 10;
}
return 0;
}
function sortedRatings(ratings) {
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
}
@@ -231,6 +263,8 @@
function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const painEnabled = Boolean(settings.tracking?.pain_enabled);
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -239,10 +273,17 @@
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
sport_bonus: sportBonusPoints(entry, settings, previousEntry),
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
walk_minutes: walkMode === "steps"
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0,
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
if (painEnabled) {
components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 0);
}
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
let label = labelForScore(total, ratings);
@@ -281,11 +322,13 @@
mood: "Stimmung",
energy: "Energie",
stress: "Stress",
pain: "Schmerzen",
sleep_hours: "Schlafdauer",
sleep_feeling: "Schlafgefühl",
sport_minutes: "Sport",
sport_bonus: "Sportbonus",
walk_minutes: "Spaziergang",
alcohol: "Alkohol",
note: "Notiz",
};
@@ -293,11 +336,15 @@
mood: Number(form.elements.mood.value),
energy: Number(form.elements.energy.value),
stress: Number(form.elements.stress.value),
pain: Number(form.elements.pain?.value || 1),
sleep_hours: Number(form.elements.sleep_hours.value || 0),
sleep_feeling: Number(form.elements.sleep_feeling.value),
sport_minutes: Number(form.elements.sport_minutes.value || 0),
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value),
walk_minutes: Number(form.elements.walk_minutes.value || 0),
walk_mode: form.elements.walk_mode?.value || "time",
walk_minutes: Number(form.elements.walk_minutes?.value || 0),
walk_steps: Number(form.elements.walk_steps?.value || 0),
alcohol: Boolean(form.elements.alcohol?.checked),
note: form.elements.note.value || "",
});
@@ -347,7 +394,7 @@
}
const seriesName = container.dataset.series || "";
const invertScale = seriesName === "stress";
const invertScale = seriesName === "stress" || seriesName === "pain";
const values = items.map(item => Number(item.value));
const width = 760;
const height = 196;
@@ -355,7 +402,7 @@
let minValue = Math.min(...values);
let maxValue = Math.max(...values);
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, minValue - 1.5);
maxValue = Math.min(10, maxValue + 1.5);
} else {
@@ -365,7 +412,7 @@
if ((maxValue - minValue) < 3) {
const center = (maxValue + minValue) / 2;
if (seriesName === "mood" || seriesName === "stress") {
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
minValue = Math.max(1, center - 1.5);
maxValue = Math.min(10, center + 1.5);
} else {
@@ -452,6 +499,7 @@
const sportY = walkY - sportHeight;
const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20);
const labels = Array.isArray(item.sport_labels) ? item.sport_labels.filter(Boolean) : [];
const walkLabel = item.walk_label || `${walk} Aktivität`;
const label = labels.length ? ` · ${labels.join(", ")}` : "";
const bonus = Number(item.sport_bonus || 0);
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
@@ -469,7 +517,7 @@
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>
<title>${formatDateLabel(item.date)} · Spaziergang ${walkLabel}</title>
</rect>
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
<title>${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
@@ -752,19 +800,27 @@
const list = document.querySelector("[data-sport-type-list]");
const addButton = document.querySelector("[data-add-sport-type]");
const template = document.querySelector("#sport-type-row-template");
const presetButtons = [...document.querySelectorAll("[data-sport-preset]")];
if (!list || !addButton || !template) {
return;
}
const getRows = () => [...list.querySelectorAll("[data-sport-type-row]")];
const syncPreview = row => {
const labelInput = row.querySelector('input[data-name-template$="[label]"]');
const iconSelect = row.querySelector('select[data-name-template$="[icon]"]');
const locationSelect = row.querySelector('select[data-name-template$="[location]"]');
const previewText = row.querySelector(".sport-pill span:last-child");
const previewImage = row.querySelector(".sport-pill img");
if (previewText) {
previewText.textContent = (labelInput && labelInput.value.trim()) || "Neue Sportart";
const label = (labelInput && labelInput.value.trim()) || "Neue Sportart";
const location = locationSelect && locationSelect.value
? locationSelect.options[locationSelect.selectedIndex]?.textContent || ""
: "";
previewText.textContent = location ? `${label} · ${location}` : label;
}
if (previewImage && iconSelect) {
@@ -781,9 +837,77 @@
});
};
addButton.addEventListener("click", () => {
const syncPresets = () => {
const selectedIds = new Set(
getRows()
.map(row => row.querySelector('input[data-name-template$="[id]"]'))
.filter(Boolean)
.map(input => String(input.value || "").trim())
.filter(Boolean)
);
presetButtons.forEach(button => {
button.hidden = selectedIds.has(button.dataset.id || "");
});
};
const createRow = values => {
list.append(template.content.cloneNode(true));
const row = list.querySelector("[data-sport-type-row]:last-child");
if (!row) {
return;
}
const idInput = row.querySelector('input[data-name-template$="[id]"]');
const labelInput = row.querySelector('input[data-name-template$="[label]"]');
const iconSelect = row.querySelector('select[data-name-template$="[icon]"]');
const locationSelect = row.querySelector('select[data-name-template$="[location]"]');
const groupInput = row.querySelector('input[data-name-template$="[recovery_group]"]');
const bonusInput = row.querySelector('input[data-name-template$="[bonus_points]"]');
const consecutiveInput = row.querySelector('input[data-name-template$="[allow_consecutive]"]');
if (idInput) {
idInput.value = values.id || "";
}
if (labelInput) {
labelInput.value = values.label || "";
}
if (iconSelect) {
iconSelect.value = values.icon || "run";
}
if (locationSelect) {
locationSelect.value = values.location || "";
}
if (groupInput) {
groupInput.value = values.recovery_group || "";
}
if (bonusInput) {
bonusInput.value = values.bonus_points || "2";
}
if (consecutiveInput) {
consecutiveInput.checked = Boolean(values.allow_consecutive);
}
renumber();
syncPresets();
};
addButton.addEventListener("click", () => {
createRow({});
});
presetButtons.forEach(button => {
button.addEventListener("click", () => {
createRow({
id: button.dataset.id || "",
label: button.dataset.label || "",
icon: button.dataset.icon || "run",
location: button.dataset.location || "",
recovery_group: button.dataset.recoveryGroup || "",
bonus_points: button.dataset.bonusPoints || "2",
allow_consecutive: button.dataset.allowConsecutive === "1",
});
});
});
list.addEventListener("click", event => {
@@ -797,32 +921,16 @@
return;
}
const rows = list.querySelectorAll("[data-sport-type-row]");
if (rows.length <= 1) {
row.querySelectorAll("input[type='text'], input[type='hidden']").forEach(input => {
input.value = "";
});
row.querySelectorAll("input[type='number']").forEach(input => {
input.value = "2";
});
row.querySelectorAll("input[type='checkbox']").forEach(input => {
input.checked = false;
});
row.querySelectorAll("select").forEach(select => {
select.value = "run";
});
syncPreview(row);
return;
}
row.remove();
renumber();
syncPresets();
});
list.addEventListener("input", event => {
const row = event.target.closest("[data-sport-type-row]");
if (row) {
syncPreview(row);
syncPresets();
}
});
@@ -830,10 +938,325 @@
const row = event.target.closest("[data-sport-type-row]");
if (row) {
syncPreview(row);
syncPresets();
}
});
renumber();
syncPresets();
}
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
}
function pushPublicKey() {
return document.querySelector('meta[name="mood-push-public-key"]')?.getAttribute("content") || "";
}
function base64UrlToUint8Array(value) {
const padded = value + "=".repeat((4 - (value.length % 4)) % 4);
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from(raw, char => char.charCodeAt(0));
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken(),
},
body: JSON.stringify(payload || {}),
});
let data = {};
try {
data = await response.json();
} catch (error) {
data = {};
}
if (!response.ok) {
throw new Error(data.message || "Die Anfrage konnte nicht verarbeitet werden.");
}
return data;
}
async function initPwaShell() {
if (document.body.dataset.authenticated !== "1" || !("serviceWorker" in navigator)) {
return;
}
try {
await navigator.serviceWorker.register("/service-worker.js");
} catch (error) {
console.warn("Service Worker registration failed", error);
}
}
function isStandaloneMode() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isAppleTouchDevice() {
return /iPhone|iPad|iPod/i.test(window.navigator.userAgent)
|| (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1);
}
function initPullToRefresh() {
if (!isStandaloneMode() || !isAppleTouchDevice()) {
return;
}
const indicator = document.querySelector("[data-pull-refresh-indicator]");
const body = document.body;
const threshold = 96;
let isTracking = false;
let isReady = false;
let startY = 0;
const setIndicator = message => {
if (indicator) {
indicator.textContent = message;
}
};
const resetState = () => {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isTracking = false;
isReady = false;
startY = 0;
setIndicator("Zum Aktualisieren ziehen");
};
const scrollTop = () => Math.max(
window.scrollY || 0,
document.documentElement.scrollTop || 0,
document.body.scrollTop || 0
);
const canStart = target => {
if (scrollTop() > 0) {
return false;
}
if (!(target instanceof Element)) {
return true;
}
return !target.closest("input, textarea, select, button");
};
window.addEventListener("touchstart", event => {
if (event.touches.length !== 1 || !canStart(event.target)) {
resetState();
return;
}
isTracking = true;
startY = event.touches[0].clientY;
}, { passive: true });
window.addEventListener("touchmove", event => {
if (!isTracking || event.touches.length !== 1) {
return;
}
if (scrollTop() > 0) {
resetState();
return;
}
const delta = event.touches[0].clientY - startY;
if (delta <= 0) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
isReady = false;
setIndicator("Zum Aktualisieren ziehen");
return;
}
if (delta > 18) {
body.classList.add("is-pull-refreshing");
event.preventDefault();
}
if (delta >= threshold) {
if (!isReady) {
body.classList.add("is-pull-refresh-ready");
setIndicator("Loslassen zum Aktualisieren");
isReady = true;
}
return;
}
if (isReady) {
body.classList.remove("is-pull-refresh-ready");
isReady = false;
}
setIndicator("Zum Aktualisieren ziehen");
}, { passive: false });
window.addEventListener("touchend", () => {
if (!isTracking) {
return;
}
if (isReady) {
body.classList.remove("is-pull-refreshing", "is-pull-refresh-ready");
body.classList.add("is-pull-refresh-reloading");
setIndicator("Wird aktualisiert ...");
window.location.reload();
return;
}
resetState();
}, { passive: true });
window.addEventListener("touchcancel", resetState, { passive: true });
}
function initPushControls() {
const panel = document.querySelector("[data-push-panel]");
if (!panel) {
return;
}
const statusNode = panel.querySelector("[data-push-status]");
const enableButton = panel.querySelector("[data-push-enable]");
const disableButton = panel.querySelector("[data-push-disable]");
const testButton = panel.querySelector("[data-push-test]");
const ready = panel.dataset.pushReady === "1";
const vapidKey = pushPublicKey();
const setStatus = (message, tone = "neutral") => {
if (!statusNode) {
return;
}
statusNode.textContent = message;
statusNode.dataset.tone = tone;
};
if (!ready || !vapidKey) {
setStatus("Push ist auf diesem Server gerade noch nicht bereit.", "error");
return;
}
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
setStatus("Dieses Gerät unterstützt Web Push in diesem Browser leider nicht.", "error");
if (enableButton) enableButton.disabled = true;
if (disableButton) disableButton.disabled = true;
if (testButton) testButton.disabled = true;
return;
}
const updateUi = subscription => {
if (subscription) {
setStatus("Push ist auf diesem Gerät aktiv. Test und tägliche Erinnerungen können gesendet werden.", "success");
if (enableButton) enableButton.disabled = true;
if (disableButton) disableButton.disabled = false;
if (testButton) testButton.disabled = false;
return;
}
if (!isStandaloneMode() && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
setStatus("Für iPhone zuerst in Safari öffnen, zum Home-Bildschirm hinzufügen und danach Push aktivieren.", "neutral");
} else {
setStatus("Push ist auf diesem Gerät noch nicht aktiv. Du kannst ihn hier direkt einschalten.", "neutral");
}
if (enableButton) enableButton.disabled = false;
if (disableButton) disableButton.disabled = true;
if (testButton) testButton.disabled = true;
};
const getRegistration = async () => {
await initPwaShell();
return navigator.serviceWorker.ready;
};
const getSubscription = async () => {
const registration = await getRegistration();
return registration.pushManager.getSubscription();
};
const refreshStatus = async () => {
try {
updateUi(await getSubscription());
} catch (error) {
setStatus("Der Push-Status konnte gerade nicht gelesen werden.", "error");
}
};
if (enableButton) {
enableButton.addEventListener("click", async () => {
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("Die Push-Berechtigung wurde nicht erteilt.", "error");
return;
}
const registration = await getRegistration();
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToUint8Array(vapidKey),
});
}
await postJson("/push/subscribe", {
subscription: subscription.toJSON(),
contentEncoding: subscription.options?.applicationServerKey ? "aes128gcm" : "aes128gcm",
});
updateUi(subscription);
} catch (error) {
setStatus(error.message || "Push konnte nicht aktiviert werden.", "error");
}
});
}
if (disableButton) {
disableButton.addEventListener("click", async () => {
try {
const subscription = await getSubscription();
if (!subscription) {
updateUi(null);
return;
}
await postJson("/push/unsubscribe", {
endpoint: subscription.endpoint,
});
await subscription.unsubscribe();
updateUi(null);
} catch (error) {
setStatus(error.message || "Push konnte nicht entfernt werden.", "error");
}
});
}
if (testButton) {
testButton.addEventListener("click", async () => {
try {
const data = await postJson("/push/test", {});
setStatus(data.message || "Die Test-Benachrichtigung wurde verschickt.", "success");
} catch (error) {
setStatus(error.message || "Die Test-Benachrichtigung konnte nicht gesendet werden.", "error");
}
});
}
refreshStatus();
}
window.addEventListener("resize", () => {
@@ -852,4 +1275,7 @@
initTrackPreview();
initDashboardCharts();
initSportTypeManager();
initPwaShell();
initPullToRefresh();
initPushControls();
})();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"id": "/",
"name": "Mood-Board",
"short_name": "Mood",
"description": "Persönlicher Stimmungstracker mit Archiv, Dashboard und Erinnerungen.",
"lang": "de-DE",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b1e2e",
"theme_color": "#0b1e2e",
"icons": [
{
"src": "/assets/branding/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/branding/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+55
View File
@@ -0,0 +1,55 @@
self.addEventListener("install", event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", event => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (error) {
payload = {};
}
const title = payload.title || "Mood-Board";
const options = {
body: payload.body || "Zeit für deinen heutigen Eintrag.",
icon: payload.icon || "/assets/branding/logo-mark.svg",
badge: payload.badge || "/assets/branding/favicon.svg",
tag: payload.tag || "mood-reminder",
data: {
url: payload.url || "/track",
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
const targetUrl = event.notification.data?.url || "/track";
event.waitUntil((async () => {
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clients) {
const clientUrl = new URL(client.url);
const target = new URL(targetUrl, self.location.origin);
if (clientUrl.pathname === target.pathname) {
await client.focus();
return;
}
}
await self.clients.openWindow(targetUrl);
})());
});
+650 -18
View File
@@ -10,6 +10,8 @@ final class App
private LoginThrottle $throttle;
private ScoringService $scoring;
private Auth $auth;
private NotificationRepository $notifications;
private WebPushService $webPush;
public function __construct()
{
@@ -19,6 +21,8 @@ final class App
$this->throttle = new LoginThrottle();
$this->scoring = new ScoringService();
$this->auth = new Auth($this->users);
$this->notifications = new NotificationRepository();
$this->webPush = new WebPushService($this->notifications);
}
public function run(): void
@@ -27,8 +31,10 @@ final class App
$path = request_path();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run'];
// A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) {
@@ -39,13 +45,13 @@ final class App
if (!$hasUsers) {
if ($path === '/login') {
$path = '/setup';
} elseif ($path !== '/setup') {
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
redirect('/setup');
}
} elseif (!$isAuthenticated) {
if ($path === '/setup') {
$path = '/login';
} elseif ($path !== '/login') {
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
redirect('/login');
}
}
@@ -90,6 +96,37 @@ final class App
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
return;
case '/push/subscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushSubscribe();
return;
case '/push/unsubscribe':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushUnsubscribe();
return;
case '/push/test':
if ($method !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$this->handlePushTest();
return;
case '/reminders/run':
$this->handleReminderRun();
return;
default:
http_response_code(404);
View::render('not-found', [
@@ -170,7 +207,9 @@ final class App
redirect('/login');
}
if (!$this->auth->attempt($username, $password)) {
$remember = isset($_POST['remember_me']) && $_POST['remember_me'] === '1';
if (!$this->auth->attempt($username, $password, $remember)) {
$this->throttle->hit($throttleKey);
flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.');
redirect('/login');
@@ -195,6 +234,7 @@ final class App
'pageTitle' => 'Dashboard',
'page' => 'dashboard',
'authUser' => $user,
'settings' => $settings,
'summary' => $summary,
'entries' => array_reverse($evaluatedEntries),
'chartPayload' => encode_payload($chartData),
@@ -214,15 +254,21 @@ final class App
'mood' => 6,
'energy' => 6,
'stress' => 4,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 3,
'sport_minutes' => 0,
'sport_type' => '',
'sport_types' => [],
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'alcohol' => false,
'note' => '',
];
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
$entry = $this->scoring->normalize($entry);
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
@@ -257,11 +303,16 @@ final class App
'mood' => $_POST['mood'] ?? 5,
'energy' => $_POST['energy'] ?? 5,
'stress' => $_POST['stress'] ?? 5,
'pain' => $_POST['pain'] ?? 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
'sport_types' => $_POST['sport_types'] ?? [],
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
'walk_steps' => $_POST['walk_steps'] ?? 0,
'alcohol' => $_POST['alcohol'] ?? false,
'note' => $_POST['note'] ?? '',
]);
@@ -270,9 +321,15 @@ final class App
redirect('/track');
}
$previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1));
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($user['username'], $entry['date'], $entry, $evaluation);
$entries = $this->entries->all($user['username']);
$entryMap = [];
foreach ($entries as $existingEntry) {
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
}
$entryMap[$entry['date']] = $entry;
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
flash('success', 'Der Tag wurde gespeichert.');
redirect('/track?date=' . rawurlencode($entry['date']));
@@ -302,6 +359,7 @@ final class App
'authUser' => $user,
'entries' => $archive,
'selectedEntry' => $selectedEntry,
'settings' => $settings,
]);
}
@@ -309,17 +367,48 @@ final class App
{
$user = $this->requireUser();
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$sportTypePresets = array_values(array_filter(
Defaults::settings()['sport_types'],
static function (array $preset) use ($settings): bool {
foreach (normalized_sport_types($settings) as $type) {
if (($type['id'] ?? '') === ($preset['id'] ?? '')) {
return false;
}
}
return true;
}
));
$pushAvailable = $this->webPush->isAvailable();
$pushPublicKey = null;
if ($pushAvailable) {
try {
$pushPublicKey = $this->webPush->publicKey();
} catch (RuntimeException) {
$pushAvailable = false;
}
}
View::render('options', [
'pageTitle' => 'Optionen',
'page' => 'options',
'authUser' => $user,
'settings' => $settings,
'sportTypePresets' => $sportTypePresets,
'sportLocationOptions' => sport_location_options(),
'walkModeOptions' => walk_mode_options(),
'pushAvailable' => $pushAvailable,
'pushPublicKey' => $pushPublicKey,
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
'backupAvailable' => class_exists('ZipArchive'),
'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
'energy' => 10,
'stress' => 1,
'pain' => 1,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'sleep_hours' => 7,
'sleep_feeling' => 5,
'sport_minutes' => 999,
@@ -327,7 +416,10 @@ final class App
static fn (array $type): string => (string) ($type['id'] ?? ''),
normalized_sport_types($settings)
),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 999,
'walk_steps' => 10000,
'alcohol' => false,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -341,9 +433,28 @@ final class App
$form = (string) ($_POST['form_name'] ?? '');
if ($form === 'settings') {
$settings = $this->sanitizeSettings($_POST['settings'] ?? []);
$currentSettings = $this->hydrateSettings($this->settings->forUser($user['username']));
$settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings);
$this->settings->saveForUser($user['username'], $settings);
flash('success', 'Die Bewertungslogik wurde aktualisiert.');
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
redirect('/options');
}
if ($form === 'export_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
$this->downloadUserBackup($user, $settings);
}
if ($form === 'import_backup') {
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
try {
$imported = $this->importUserBackup($user, $settings);
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.');
} catch (RuntimeException $exception) {
flash('error', $exception->getMessage());
}
redirect('/options');
}
@@ -433,6 +544,220 @@ final class App
];
}
private function persistUserEntries(string $username, array $settings, array $entries): void
{
$normalized = [];
foreach ($entries as $entry) {
if (!is_array($entry)) {
continue;
}
$normalizedEntry = $this->scoring->normalize($entry);
if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) {
continue;
}
$normalized[$normalizedEntry['date']] = $normalizedEntry;
}
ksort($normalized, SORT_STRING);
$previousEntry = null;
foreach ($normalized as $date => $entry) {
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
$this->entries->save($username, $date, $entry, $evaluation);
$previousEntry = $entry;
}
}
private function downloadUserBackup(array $user, array $settings): never
{
if (!class_exists('ZipArchive')) {
flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.');
redirect('/options');
}
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
$tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-');
if ($tempPath === false) {
throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.');
}
$zip = new ZipArchive();
$opened = $zip->open($tempPath, ZipArchive::OVERWRITE);
if ($opened !== true) {
@unlink($tempPath);
throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.');
}
foreach ($entries as $entry) {
$date = (string) ($entry['date'] ?? '');
if (!$this->isValidDate($date)) {
continue;
}
$markdown = $this->entries->exportMarkdown(
(string) ($user['username'] ?? ''),
$date,
$entry,
$entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings)
);
$zip->addFromString($date . '.txt', $markdown);
}
$zip->close();
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Content-Length: ' . (string) filesize($tempPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($tempPath);
@unlink($tempPath);
exit;
}
private function importUserBackup(array $user, array $settings): int
{
$files = uploaded_files('backup_files');
if ($files === []) {
throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.');
}
$importedEntries = [];
foreach ($files as $file) {
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
continue;
}
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.');
}
$tmpName = (string) ($file['tmp_name'] ?? '');
$name = trim((string) ($file['name'] ?? ''));
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('Eine Backup-Datei ist ungültig.');
}
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($extension === 'zip') {
foreach ($this->entriesFromZip($tmpName) as $date => $entry) {
$importedEntries[$date] = $entry;
}
continue;
}
if ($extension !== 'txt') {
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
}
$date = $this->dateFromBackupFileName($name);
$content = (string) file_get_contents($tmpName);
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry === null) {
throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.');
}
$importedEntries[$date] = $entry;
}
if ($importedEntries === []) {
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
}
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
$entryMap = [];
foreach ($existingEntries as $entry) {
if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) {
continue;
}
$entryMap[$entry['date']] = $entry;
}
foreach ($importedEntries as $date => $entry) {
$entryMap[$date] = $entry;
}
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
return count($importedEntries);
}
private function entriesFromZip(string $path): array
{
if (!class_exists('ZipArchive')) {
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
}
$zip = new ZipArchive();
$opened = $zip->open($path);
if ($opened !== true) {
throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.');
}
$entries = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_ends_with($name, '/')) {
continue;
}
$baseName = basename($name);
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
continue;
}
$date = $this->dateFromBackupFileName($baseName);
$content = $zip->getFromIndex($index);
if (!is_string($content)) {
continue;
}
$entry = $this->entries->parseMarkdown($content, $date);
if ($entry !== null) {
$entries[$date] = $entry;
}
}
$zip->close();
return $entries;
}
private function dateFromBackupFileName(string $fileName): string
{
$baseName = basename($fileName);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) {
throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.');
}
$date = (string) ($matches[1] ?? '');
if (!$this->isValidDate($date)) {
throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.');
}
return $date;
}
private function buildDashboardCharts(array $entries): array
{
$recent = array_slice($entries, -30);
@@ -459,14 +784,26 @@ final class App
'value' => $entry['stress'],
];
}, $recent),
'pain' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['pain'],
];
}, $recent),
'sport' => array_map(static function (array $entry): array {
return [
'date' => $entry['date'],
'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
'sport' => $entry['sport_minutes'],
'walk' => $entry['walk_minutes'],
'walk' => walk_chart_value($entry),
'walk_label' => format_walk_value($entry),
'sport_labels' => array_values(array_filter(array_map(
static fn (array $type): string => (string) ($type['label'] ?? ''),
static function (array $type): string {
$label = (string) ($type['label'] ?? '');
$location = sport_location_label((string) ($type['location'] ?? ''));
return $location !== '' ? $label . ' · ' . $location : $label;
},
$entry['sport_type_meta'] ?? []
))),
'sport_icons' => array_values(array_filter(array_map(
@@ -507,16 +844,24 @@ final class App
return $streak;
}
private function sanitizeSettings(array $input): array
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
{
$defaults = Defaults::settings();
$settings = $defaults;
$settings = array_replace_recursive($defaults, $existingSettings ?? []);
$settings['walk'] = [
'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time',
];
$settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
$settings['tracking'] = [
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
];
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
@@ -524,10 +869,12 @@ final class App
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
$currentBand = $settings['scoring'][$bandKey][$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']))),
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))),
'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))),
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
];
}
}
@@ -549,18 +896,43 @@ final class App
];
}
$sportTypesProvided = array_key_exists('sport_types_present', $input)
|| array_key_exists('sport_types', $input);
$settings['sport_types'] = normalized_sport_types([
'sport_types' => is_array($input['sport_types'] ?? null)
'sport_types' => $sportTypesProvided && is_array($input['sport_types'] ?? null)
? $input['sport_types']
: $defaults['sport_types'],
: ($sportTypesProvided ? [] : $defaults['sport_types']),
]);
$time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time'])));
if (!$this->isValidTime($time)) {
$time = $defaults['notifications']['time'];
}
$settings['notifications'] = [
'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1',
'time' => $time,
];
return $settings;
}
private function hydrateSettings(array $settings): array
{
$settings['sport_types'] = normalized_sport_types($settings);
$settings['walk'] = array_replace(
Defaults::settings()['walk'],
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
);
$settings['tracking'] = array_replace(
Defaults::settings()['tracking'],
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
);
$settings['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
);
return $settings;
}
@@ -594,6 +966,7 @@ final class App
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');
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'");
@@ -607,6 +980,16 @@ final class App
}
}
private function enforceRequestCsrf(): void
{
if (!verify_request_csrf()) {
json_response([
'ok' => false,
'message' => 'Ungültiges Formular-Token.',
], 419);
}
}
private function requireUser(): array
{
$user = $this->auth->user();
@@ -635,10 +1018,259 @@ final class App
return strlen($password) >= 10;
}
private function isValidTime(string $time): bool
{
return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1;
}
private function throttleKey(string $username): string
{
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
return sha1($remoteAddress . '|' . normalize_username($username));
}
private function handlePushSubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$subscription = $payload['subscription'] ?? null;
if (!is_array($subscription)) {
json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422);
}
try {
$this->notifications->saveSubscription($user['username'], [
'endpoint' => trim((string) ($subscription['endpoint'] ?? '')),
'keys' => [
'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')),
'auth' => trim((string) ($subscription['keys']['auth'] ?? '')),
],
'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')),
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
]);
} catch (RuntimeException $exception) {
json_response(['ok' => false, 'message' => $exception->getMessage()], 422);
}
json_response([
'ok' => true,
'message' => 'Push ist auf diesem Gerät aktiviert.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushUnsubscribe(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$payload = request_json_body();
$endpoint = trim((string) ($payload['endpoint'] ?? ''));
if ($endpoint === '') {
json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422);
}
$this->notifications->removeSubscription($user['username'], $endpoint);
json_response([
'ok' => true,
'message' => 'Push wurde für dieses Gerät entfernt.',
'count' => $this->notifications->subscriptionCount($user['username']),
]);
}
private function handlePushTest(): void
{
$this->enforceRequestCsrf();
$user = $this->requireUser();
$result = $this->sendNotificationsForUser($user['username'], [
'title' => 'Mood-Board',
'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.',
'url' => '/options',
'tag' => 'mood-push-test',
]);
if ($result['sent'] <= 0) {
json_response([
'ok' => false,
'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.',
'removed' => $result['removed'],
], 422);
}
json_response([
'ok' => true,
'message' => 'Die Test-Benachrichtigung wurde verschickt.',
'sent' => $result['sent'],
'removed' => $result['removed'],
]);
}
private function handleReminderRun(): void
{
$providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? '')));
$expectedToken = $this->webPush->cronToken();
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
}
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
json_response([
'ok' => true,
'processed' => $stats['processed'],
'sent_users' => $stats['sent_users'],
'already_tracked' => $stats['already_tracked'],
'skipped' => $stats['skipped'],
'removed_subscriptions' => $stats['removed_subscriptions'],
]);
}
private function sendNotificationsForUser(string $username, array $message): array
{
$subscriptions = $this->notifications->subscriptionsForUser($username);
$sent = 0;
$removedEndpoints = [];
foreach ($subscriptions as $subscription) {
try {
$result = $this->webPush->send($subscription, $message);
} catch (RuntimeException) {
$result = [
'ok' => false,
'remove' => false,
];
}
if (!empty($result['ok'])) {
$sent++;
continue;
}
if (!empty($result['remove'])) {
$endpoint = (string) ($subscription['endpoint'] ?? '');
if ($endpoint !== '') {
$removedEndpoints[] = $endpoint;
}
}
}
if ($removedEndpoints !== []) {
$this->notifications->removeInvalidSubscriptions($username, $removedEndpoints);
}
return [
'sent' => $sent,
'removed' => count($removedEndpoints),
];
}
private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool
{
if (empty($settings['notifications']['enabled'])) {
return false;
}
$reminderTime = (string) ($settings['notifications']['time'] ?? '');
if (!$this->isValidTime($reminderTime)) {
return false;
}
if ($currentTime < $reminderTime) {
return false;
}
return (string) ($state['last_sent_date'] ?? '') !== $today;
}
private function triggerReminderCheckFromTraffic(string $method, string $path): void
{
if ($method !== 'GET' || $path === '/reminders/run') {
return;
}
$lockPath = storage_path('system/reminder-traffic.lock');
$handle = fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
return;
}
try {
$this->runDueReminders(new DateTimeImmutable('now'));
} catch (Throwable) {
// Reminder checks should never break normal page delivery.
}
flock($handle, LOCK_UN);
fclose($handle);
}
private function runDueReminders(DateTimeImmutable $now): array
{
$today = $now->format('Y-m-d');
$currentTime = $now->format('H:i');
$processed = 0;
$sentUsers = 0;
$alreadyTracked = 0;
$skipped = 0;
$removed = 0;
foreach ($this->users->all() as $account) {
$username = (string) ($account['username'] ?? '');
if ($username === '') {
continue;
}
$processed++;
$settings = $this->hydrateSettings($this->settings->forUser($username));
$state = $this->notifications->reminderState($username);
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
$skipped++;
continue;
}
if ($this->entries->find($username, $today) !== null) {
$alreadyTracked++;
continue;
}
$result = $this->sendNotificationsForUser($username, [
'title' => 'Mood-Board',
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
'url' => '/track?date=' . rawurlencode($today),
'tag' => 'mood-reminder-' . $today,
]);
$removed += $result['removed'];
if ($result['sent'] > 0) {
$sentUsers++;
$this->notifications->saveReminderState($username, [
'last_sent_date' => $today,
'last_sent_at' => $now->format(DATE_ATOM),
]);
}
}
return [
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
];
}
}
+55 -5
View File
@@ -4,6 +4,13 @@ declare(strict_types=1);
final class EntryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function save(string $username, string $date, array $entry, array $evaluation): void
{
$path = $this->pathFor($username, $date);
@@ -13,7 +20,8 @@ final class EntryRepository
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
$markdown = $this->toMarkdown($username, $date, $entry, $evaluation);
file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX);
}
public function find(string $username, string $date): ?array
@@ -24,7 +32,14 @@ final class EntryRepository
return null;
}
return $this->parse((string) file_get_contents($path), $date);
$content = (string) file_get_contents($path);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
}
return $this->parse($plaintext, $date);
}
public function all(string $username): array
@@ -41,7 +56,14 @@ final class EntryRepository
$entries = [];
foreach ($files as $file) {
$date = basename($file, '.txt');
$parsed = $this->parse((string) file_get_contents($file), $date);
$content = (string) file_get_contents($file);
$plaintext = $this->crypto->decrypt($content);
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
}
$parsed = $this->parse($plaintext, $date);
if ($parsed !== null) {
$entries[] = $parsed;
}
@@ -50,6 +72,18 @@ final class EntryRepository
return $entries;
}
public function parseMarkdown(string $content, string $fallbackDate): ?array
{
$plaintext = $this->crypto->decrypt($content);
return $this->parse($plaintext, $fallbackDate);
}
public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string
{
return $this->toMarkdown($username, $date, $entry, $evaluation);
}
private function directoryFor(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/days');
@@ -77,17 +111,28 @@ final class EntryRepository
$sportTypes = normalize_sport_type_selection($sportType);
}
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
'pain' => $painRaw !== null ? (int) $painRaw : 1,
'pain_enabled' => $painRaw !== null,
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes,
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
'walk_mode' => $walkMode,
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
'note' => $this->extractNote($content),
];
@@ -134,11 +179,14 @@ final class EntryRepository
'- Stimmung: ' . $entry['mood'],
'- Energie: ' . $entry['energy'],
'- Stress: ' . $entry['stress'],
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
'- Schlafdauer: ' . $entry['sleep_hours'],
'- Schlafgefühl: ' . $entry['sleep_feeling'],
'- Sport: ' . $entry['sport_minutes'],
'- Sportarten: ' . implode(', ', $sportTypeValues),
'- Spaziergang: ' . $entry['walk_minutes'],
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
@@ -148,11 +196,13 @@ final class EntryRepository
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
'',
'## Notiz',
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
final class NotificationRepository
{
private string $systemPath;
public function __construct()
{
$this->systemPath = storage_path('system/notifications.json');
}
public function systemConfig(): array
{
$config = decode_json_file($this->systemPath, []);
$changed = false;
if (!isset($config['cron_token']) || !is_string($config['cron_token']) || $config['cron_token'] === '') {
$config['cron_token'] = bin2hex(random_bytes(24));
$changed = true;
}
if (!isset($config['subject']) || !is_string($config['subject']) || $config['subject'] === '') {
$host = parse_url(app_origin(), PHP_URL_HOST);
$host = is_string($host) && $host !== '' ? $host : 'localhost';
$config['subject'] = 'mailto:hello@' . $host;
$changed = true;
}
if ($changed) {
$this->writeJson($this->systemPath, $config);
}
return $config;
}
public function saveVapidKeys(string $publicKey, string $privateKey): void
{
$config = $this->systemConfig();
$config['vapid_public_key'] = $publicKey;
$config['vapid_private_key'] = $privateKey;
$this->writeJson($this->systemPath, $config);
}
public function subscriptionsForUser(string $username): array
{
$payload = decode_json_file($this->subscriptionsPath($username), ['subscriptions' => []]);
return array_values(array_filter($payload['subscriptions'] ?? [], 'is_array'));
}
public function saveSubscription(string $username, array $subscription): void
{
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
if ($endpoint === '') {
throw new RuntimeException('Die Subscription ist unvollständig.');
}
$subscriptions = $this->subscriptionsForUser($username);
$saved = false;
foreach ($subscriptions as &$entry) {
if (($entry['endpoint'] ?? '') === $endpoint) {
$entry = array_merge($entry, $subscription, [
'updated_at' => date(DATE_ATOM),
]);
$saved = true;
break;
}
}
unset($entry);
if (!$saved) {
$subscription['created_at'] = date(DATE_ATOM);
$subscription['updated_at'] = date(DATE_ATOM);
$subscriptions[] = $subscription;
}
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => array_values($subscriptions)]);
}
public function removeSubscription(string $username, string $endpoint): void
{
$endpoint = trim($endpoint);
if ($endpoint === '') {
return;
}
$subscriptions = array_values(array_filter(
$this->subscriptionsForUser($username),
static fn (array $entry): bool => ($entry['endpoint'] ?? '') !== $endpoint
));
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => $subscriptions]);
}
public function removeInvalidSubscriptions(string $username, array $endpoints): void
{
foreach ($endpoints as $endpoint) {
if (is_string($endpoint) && $endpoint !== '') {
$this->removeSubscription($username, $endpoint);
}
}
}
public function reminderState(string $username): array
{
return decode_json_file($this->reminderStatePath($username), []);
}
public function saveReminderState(string $username, array $state): void
{
$this->writeJson($this->reminderStatePath($username), $state);
}
public function subscriptionCount(string $username): int
{
return count($this->subscriptionsForUser($username));
}
private function subscriptionsPath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/push-subscriptions.json');
}
private function reminderStatePath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/notification-state.json');
}
private function writeJson(string $path, array $payload): void
{
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$path,
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die Benachrichtigungsdaten konnten nicht gespeichert werden.');
}
}
}
+102 -2
View File
@@ -13,12 +13,17 @@ final class ScoringService
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
'sport_type' => $sportTypes[0] ?? '',
'sport_types' => $sportTypes,
'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))),
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
'note' => trim((string) ($input['note'] ?? '')),
];
}
@@ -31,6 +36,7 @@ final class ScoringService
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
$sportTypes = find_sport_types($settings, $entry['sport_types']);
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
$painEnabled = !empty($settings['tracking']['pain_enabled']);
$components = [
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
@@ -40,20 +46,26 @@ final class ScoringService
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
'sport_bonus' => $sportBonus,
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
'walk_minutes' => $this->walkPoints($entry, $settings),
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
if ($painEnabled) {
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
}
$total = round(array_sum($components), 1);
$maxTotal = round(
(10 * (float) $scoring['mood_multiplier']) +
(10 * (float) $scoring['energy_multiplier']) +
(10 * (float) $scoring['stress_multiplier']) +
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
max(array_map('floatval', $scoring['sleep_duration_points'])) +
(5 * (float) $scoring['sleep_feeling_multiplier']) +
$this->maxBandPoints($scoring['sport_bands']) +
$this->maxSportBonusPoints($settings) +
$this->maxBandPoints($scoring['walk_bands']) +
$this->maxWalkPoints($entry, $settings) +
(float) $scoring['journal_points'],
1
);
@@ -146,6 +158,72 @@ final class ScoringService
return $max;
}
private function walkPoints(array $entry, array $settings): float
{
$entry = $this->normalize($entry);
$scoring = $settings['scoring'] ?? [];
if (($entry['walk_mode'] ?? 'time') === 'steps') {
return $this->stepTargetPoints((int) $entry['walk_steps'], $scoring['walk_step_targets'] ?? []);
}
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
}
private function maxWalkPoints(array $entry, array $settings): float
{
$scoring = $settings['scoring'] ?? [];
if (($entry['walk_mode'] ?? 'time') === 'steps') {
$max = 0.0;
foreach ($scoring['walk_step_targets'] ?? [] as $target) {
$max = max($max, (float) ($target['points'] ?? 0));
}
return $max;
}
return $this->maxBandPoints($scoring['walk_bands'] ?? []);
}
private function stepTargetPoints(int $steps, array $targets): float
{
if ($targets === []) {
return 0.0;
}
usort($targets, static fn (array $a, array $b): int => ((int) ($a['steps'] ?? 0)) <=> ((int) ($b['steps'] ?? 0)));
if ($steps <= (int) ($targets[0]['steps'] ?? 0)) {
return (float) ($targets[0]['points'] ?? 0);
}
$lastIndex = count($targets) - 1;
if ($steps >= (int) ($targets[$lastIndex]['steps'] ?? 0)) {
return (float) ($targets[$lastIndex]['points'] ?? 0);
}
for ($index = 1; $index < count($targets); $index++) {
$previous = $targets[$index - 1];
$current = $targets[$index];
$previousSteps = (int) ($previous['steps'] ?? 0);
$currentSteps = (int) ($current['steps'] ?? 0);
if ($steps > $currentSteps) {
continue;
}
$range = max(1, $currentSteps - $previousSteps);
$ratio = ($steps - $previousSteps) / $range;
$previousPoints = (float) ($previous['points'] ?? 0);
$currentPoints = (float) ($current['points'] ?? 0);
return round($previousPoints + (($currentPoints - $previousPoints) * $ratio), 1);
}
return 0.0;
}
private function maxSportBonusPoints(array $settings): float
{
$max = 0.0;
@@ -280,4 +358,26 @@ final class ScoringService
default => 'radiant',
};
}
private function normalizeWalkMode(string $mode): string
{
return $mode === 'steps' ? 'steps' : 'time';
}
private function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (int) $value === 1;
}
if (!is_string($value)) {
return false;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
}
}
+19 -4
View File
@@ -16,7 +16,13 @@ final class Auth
$username = $_SESSION['user']['username'] ?? null;
return is_string($username) && $username !== '';
$valid = is_string($username) && $username !== '';
if ($valid && !empty($_SESSION['remember_me'])) {
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
}
return $valid;
}
public function user(): ?array
@@ -28,7 +34,7 @@ final class Auth
return $_SESSION['user'];
}
public function attempt(string $username, string $password): bool
public function attempt(string $username, string $password, bool $remember = false): bool
{
$user = $this->users->verify($username, $password);
@@ -36,12 +42,12 @@ final class Auth
return false;
}
$this->login($user);
$this->login($user, $remember);
return true;
}
public function login(array $user): void
public function login(array $user, bool $remember = false): void
{
if (!isset($user['username']) || !is_string($user['username']) || $user['username'] === '') {
throw new RuntimeException('Der Benutzer konnte nicht angemeldet werden.');
@@ -53,11 +59,20 @@ final class Auth
'username' => $user['username'],
'is_admin' => (bool) ($user['is_admin'] ?? false),
];
$_SESSION['remember_me'] = $remember;
if ($remember) {
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
} else {
setcookie(session_name(), session_id(), session_cookie_options_for());
}
}
public function logout(): void
{
unset($_SESSION['user']);
unset($_SESSION['remember_me']);
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
session_regenerate_id(true);
}
}
+89 -17
View File
@@ -16,43 +16,99 @@ final class Defaults
5 => 'sehr ausgeschlafen',
],
],
'walk' => [
'mode' => 'time',
],
'tracking' => [
'pain_enabled' => false,
],
'sport_types' => [
[
'id' => 'strength-home',
'label' => 'Kraftsport (Keller)',
'icon' => 'strength-home',
'recovery_group' => 'kraftsport',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'strength-gym',
'label' => 'Kraftsport (Gym)',
'icon' => 'strength-gym',
'recovery_group' => 'kraftsport',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'running',
'label' => 'Joggen',
'icon' => 'run',
'location' => '',
'recovery_group' => 'joggen',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'cycling',
'label' => 'Radfahren',
'icon' => 'bike',
'location' => '',
'recovery_group' => 'radfahren',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'strength',
'label' => 'Krafttraining',
'icon' => 'strength',
'location' => '',
'recovery_group' => 'kraftsport',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'hiking',
'label' => 'Wandern',
'icon' => 'hike',
'location' => '',
'recovery_group' => 'wandern',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'swimming',
'label' => 'Schwimmen',
'icon' => 'swim',
'location' => '',
'recovery_group' => 'schwimmen',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'yoga',
'label' => 'Yoga',
'icon' => 'yoga',
'location' => '',
'recovery_group' => 'yoga',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'hiit-workout',
'label' => 'HIIT / Workout',
'icon' => 'hiit',
'location' => '',
'recovery_group' => 'hiit',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'rowing',
'label' => 'Rudergerät',
'label' => 'Rudern',
'icon' => 'row',
'location' => '',
'recovery_group' => 'rudern',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'dance',
'label' => 'Tanzen',
'icon' => 'dance',
'location' => '',
'recovery_group' => 'tanzen',
'bonus_points' => 2,
'allow_consecutive' => false,
],
[
'id' => 'core',
'label' => 'Core',
'icon' => 'core',
'location' => '',
'recovery_group' => 'core',
'bonus_points' => 2,
'allow_consecutive' => true,
@@ -62,6 +118,7 @@ final class Defaults
'mood_multiplier' => 3,
'energy_multiplier' => 2,
'stress_multiplier' => 2,
'pain_multiplier' => 3,
'sleep_feeling_multiplier' => 2,
'sleep_duration_points' => [
'lt4' => 0,
@@ -85,7 +142,18 @@ final class Defaults
['min' => 16, 'max' => 40, 'points' => 5],
['min' => 41, 'max' => 10000, 'points' => 7],
],
'walk_step_targets' => [
['steps' => 0, 'points' => 0],
['steps' => 3000, 'points' => 0],
['steps' => 5000, 'points' => 2],
['steps' => 7500, 'points' => 5],
['steps' => 10000, 'points' => 7],
['steps' => 12500, 'points' => 6],
['steps' => 15000, 'points' => 4],
['steps' => 20000, 'points' => 0],
],
'journal_points' => 2,
'alcohol_penalty' => 5,
],
'ratings' => [
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
@@ -106,6 +174,10 @@ final class Defaults
'cap_label' => 'schwerer Tag',
],
],
'notifications' => [
'enabled' => false,
'time' => '20:30',
],
];
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryCrypto
{
private const HEADER = "MOODENC1\n";
private string $fallbackKeyPath;
public function __construct()
{
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
}
public function isAvailable(): bool
{
return function_exists('openssl_encrypt')
&& function_exists('openssl_decrypt')
&& function_exists('random_bytes');
}
public function shouldEncrypt(): bool
{
return $this->isAvailable();
}
public function isEncrypted(string $content): bool
{
return str_starts_with($content, self::HEADER);
}
public function encrypt(string $plaintext): string
{
if (!$this->shouldEncrypt()) {
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
if (!is_string($ciphertext) || $tag === '') {
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
}
$payload = json_encode([
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
}
return self::HEADER . $payload;
}
public function decrypt(string $content): string
{
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
return $content;
}
$payload = substr($content, strlen(self::HEADER));
$decoded = json_decode($payload, true);
if (
!is_array($decoded)
|| !is_string($decoded['iv'] ?? null)
|| !is_string($decoded['tag'] ?? null)
|| !is_string($decoded['data'] ?? null)
) {
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
}
$plaintext = openssl_decrypt(
(string) base64_decode($decoded['data'], true),
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
(string) base64_decode($decoded['iv'], true),
(string) base64_decode($decoded['tag'], true)
);
if (!is_string($plaintext)) {
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function key(): string
{
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
if ($configured !== '') {
return hash('sha256', $configured, true);
}
$stored = $this->readFallbackKey();
if ($stored !== null) {
return $stored;
}
$raw = random_bytes(32);
$directory = dirname($this->fallbackKeyPath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
@chmod($this->fallbackKeyPath, 0600);
return $raw;
}
private function readFallbackKey(): ?string
{
if (!is_file($this->fallbackKeyPath)) {
return null;
}
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
if ($raw === '') {
return null;
}
$decoded = base64_decode($raw, true);
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
}
}
+328
View File
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
final class WebPushService
{
private NotificationRepository $notifications;
public function __construct(NotificationRepository $notifications)
{
$this->notifications = $notifications;
}
public function isAvailable(): bool
{
return function_exists('openssl_pkey_new')
&& function_exists('openssl_sign')
&& function_exists('openssl_encrypt')
&& function_exists('openssl_pkey_derive')
&& function_exists('curl_init');
}
public function publicKey(): ?string
{
$keys = $this->keys();
return $keys['public'] ?? null;
}
public function cronToken(): string
{
return (string) ($this->notifications->systemConfig()['cron_token'] ?? '');
}
public function send(array $subscription, array $message): array
{
if (!$this->isAvailable()) {
throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.');
}
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
$p256dh = trim((string) ($subscription['keys']['p256dh'] ?? ''));
$auth = trim((string) ($subscription['keys']['auth'] ?? ''));
if ($endpoint === '' || $p256dh === '' || $auth === '') {
throw new RuntimeException('Die Push-Subscription ist unvollständig.');
}
$payload = json_encode([
'title' => (string) ($message['title'] ?? 'Mood-Board'),
'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'),
'icon' => '/assets/branding/logo-mark.svg',
'badge' => '/assets/branding/favicon.svg',
'url' => (string) ($message['url'] ?? '/track'),
'tag' => (string) ($message['tag'] ?? 'mood-reminder'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.');
}
$encrypted = $this->encrypt($payload, $p256dh, $auth);
$audience = $this->audienceForEndpoint($endpoint);
$authorization = $this->authorizationHeader($audience);
$headers = [
'TTL: 3600',
'Urgency: normal',
'Content-Encoding: aes128gcm',
'Content-Type: application/octet-stream',
'Authorization: ' . $authorization['header'],
'Crypto-Key: p256ecdsa=' . $authorization['public'],
];
$handle = curl_init($endpoint);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['body'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => 12,
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'remove' => in_array($status, [404, 410], true),
'error' => $error !== '' ? $error : null,
'response' => is_string($responseBody) ? $responseBody : null,
];
}
private function keys(): array
{
$config = $this->notifications->systemConfig();
$public = trim((string) ($config['vapid_public_key'] ?? ''));
$private = trim((string) ($config['vapid_private_key'] ?? ''));
if ($public !== '' && $private !== '') {
return ['public' => $public, 'private' => $private];
}
if (!$this->isAvailable()) {
return ['public' => null, 'private' => null];
}
$resource = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($resource === false) {
throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.');
}
$details = openssl_pkey_get_details($resource);
if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) {
throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.');
}
$publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y'];
$privateKey = $details['ec']['d'];
$encodedPublic = base64url_encode($publicKey);
$encodedPrivate = base64url_encode($privateKey);
$this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate);
return ['public' => $encodedPublic, 'private' => $encodedPrivate];
}
private function encrypt(string $payload, string $userPublicKey, string $authSecret): array
{
$salt = random_bytes(16);
$userPublicRaw = base64url_decode($userPublicKey);
$authRaw = base64url_decode($authSecret);
$localKey = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($localKey === false) {
throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.');
}
$localDetails = openssl_pkey_get_details($localKey);
if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) {
throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.');
}
$localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y'];
$userPem = $this->publicKeyPemFromRaw($userPublicRaw);
$sharedSecret = openssl_pkey_derive($userPem, $localKey, 32);
if (!is_string($sharedSecret) || $sharedSecret === '') {
throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.');
}
$context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw;
$keyMaterial = $this->hkdfExpand(
$this->hkdfExtract($authRaw, $sharedSecret),
$context,
32
);
$contentPrk = $this->hkdfExtract($salt, $keyMaterial);
$contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16);
$nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12);
$recordSize = 4096;
$plaintext = $payload . "\x02";
$tag = '';
$ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag);
if (!is_string($ciphertext)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.');
}
$body = $salt
. pack('N', $recordSize)
. chr(strlen($localPublicRaw))
. $localPublicRaw
. $ciphertext
. $tag;
return [
'body' => $body,
'local_public' => $localPublicRaw,
];
}
private function authorizationHeader(string $audience): array
{
$keys = $this->keys();
$header = base64url_encode((string) json_encode([
'typ' => 'JWT',
'alg' => 'ES256',
], JSON_UNESCAPED_SLASHES));
$payload = base64url_encode((string) json_encode([
'aud' => $audience,
'exp' => time() + 3600,
'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'),
], JSON_UNESCAPED_SLASHES));
$signingInput = $header . '.' . $payload;
$signatureDer = '';
$privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private']));
if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.');
}
$jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64));
return [
'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'],
'public' => (string) $keys['public'],
];
}
private function audienceForEndpoint(string $endpoint): string
{
$parts = parse_url($endpoint);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
throw new RuntimeException('Der Push-Endpunkt ist ungültig.');
}
return $parts['scheme'] . '://' . $parts['host'];
}
private function hkdfExtract(string $salt, string $ikm): string
{
return hash_hmac('sha256', $ikm, $salt, true);
}
private function hkdfExpand(string $prk, string $info, int $length): string
{
$output = '';
$block = '';
$counter = 1;
while (strlen($output) < $length) {
$block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true);
$output .= $block;
$counter++;
}
return substr($output, 0, $length);
}
private function publicKeyPemFromRaw(string $raw): string
{
$der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw;
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
private function privateKeyPemFromRaw(string $raw): string
{
$der = hex2bin('30770201010420')
. $raw
. hex2bin('A00A06082A8648CE3D030107A14403420004')
. substr(base64url_decode((string) $this->keys()['public']), 1);
return "-----BEGIN EC PRIVATE KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END EC PRIVATE KEY-----\n";
}
private function derSignatureToJose(string $der, int $partLength): string
{
$offset = 0;
if (ord($der[$offset]) !== 0x30) {
throw new RuntimeException('Ungültige DER-Signatur.');
}
$offset++;
$this->readAsnLength($der, $offset);
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-R-Teil.');
}
$offset++;
$rLength = $this->readAsnLength($der, $offset);
$r = substr($der, $offset, $rLength);
$offset += $rLength;
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-S-Teil.');
}
$offset++;
$sLength = $this->readAsnLength($der, $offset);
$s = substr($der, $offset, $sLength);
return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
}
private function readAsnLength(string $der, int &$offset): int
{
$length = ord($der[$offset]);
$offset++;
if (($length & 0x80) === 0) {
return $length;
}
$numberOfBytes = $length & 0x7F;
$length = 0;
for ($index = 0; $index < $numberOfBytes; $index++) {
$length = ($length << 8) | ord($der[$offset]);
$offset++;
}
return $length;
}
}
+5 -16
View File
@@ -5,36 +5,25 @@ declare(strict_types=1);
require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.php';
require __DIR__ . '/Support/EntryCrypto.php';
require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/UserRepository.php';
require __DIR__ . '/Domain/SettingsRepository.php';
require __DIR__ . '/Domain/EntryRepository.php';
require __DIR__ . '/Domain/LoginThrottle.php';
require __DIR__ . '/Domain/NotificationRepository.php';
require __DIR__ . '/Domain/ScoringService.php';
require __DIR__ . '/App.php';
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin');
$forwardedProto = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
$forwardedSsl = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_SSL'] ?? ''));
$isSecure = (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| $forwardedProto === 'https'
|| $forwardedSsl === 'on'
);
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
ini_set('session.gc_maxlifetime', (string) remember_me_lifetime());
session_name('mood_session');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_set_cookie_params(session_cookie_params_for());
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
+199 -2
View File
@@ -29,6 +29,14 @@ function redirect(string $path): never
exit;
}
function json_response(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function request_path(): string
{
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
@@ -80,6 +88,17 @@ function verify_csrf(?string $token): bool
return hash_equals(csrf_token(), $token);
}
function verify_request_csrf(): bool
{
$headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (is_string($headerToken) && $headerToken !== '') {
return verify_csrf($headerToken);
}
return verify_csrf($_POST['_token'] ?? null);
}
function is_active_path(string $path): bool
{
return request_path() === $path;
@@ -117,6 +136,18 @@ function decode_json_file(string $path, array $fallback = []): array
return is_array($decoded) ? $decoded : $fallback;
}
function request_json_body(): array
{
$raw = file_get_contents('php://input');
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function encode_payload(array $payload): string
{
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
@@ -176,6 +207,55 @@ function mood_icon_path(string $sentiment): string
return icon_path('mood-' . $sentiment);
}
function request_is_secure(): bool
{
$forwardedProto = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
$forwardedSsl = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_SSL'] ?? ''));
return (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| $forwardedProto === 'https'
|| $forwardedSsl === 'on'
);
}
function app_origin(): string
{
$host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
$scheme = request_is_secure() ? 'https' : 'http';
return $scheme . '://' . $host;
}
function remember_me_lifetime(): int
{
return 60 * 60 * 24 * 30;
}
function session_cookie_params_for(int $lifetime = 0): array
{
return [
'lifetime' => $lifetime,
'path' => '/',
'domain' => '',
'secure' => request_is_secure(),
'httponly' => true,
'samesite' => 'Lax',
];
}
function session_cookie_options_for(int $expires = 0): array
{
return [
'expires' => $expires,
'path' => '/',
'domain' => '',
'secure' => request_is_secure(),
'httponly' => true,
'samesite' => 'Lax',
];
}
function sport_icon_path(string $icon): string
{
return icon_path('sport-' . $icon);
@@ -184,14 +264,125 @@ function sport_icon_path(string $icon): string
function sport_icon_options(): array
{
return [
'strength-home' => 'Kraftsport daheim',
'strength-gym' => 'Kraftsport im Gym',
'strength' => 'Krafttraining',
'bike' => 'Radfahren',
'run' => 'Joggen',
'hike' => 'Wandern',
'swim' => 'Schwimmen',
'yoga' => 'Yoga',
'hiit' => 'HIIT / Workout',
'row' => 'Rudergerät',
'dance' => 'Tanzen',
'core' => 'Core',
'strength-home' => 'Krafttraining Zuhause',
'strength-gym' => 'Krafttraining Auswärts',
];
}
function sport_location_options(): array
{
return [
'' => 'ohne Angabe',
'home' => 'Zuhause',
'away' => 'Auswärts',
];
}
function sport_location_label(?string $value): string
{
$options = sport_location_options();
$value = is_string($value) ? trim($value) : '';
return $options[$value] ?? '';
}
function walk_mode_options(): array
{
return [
'time' => 'Spaziergang nach Zeit',
'steps' => 'Spaziergang nach Schritten',
];
}
function walk_mode_label(string $mode): string
{
return walk_mode_options()[$mode] ?? walk_mode_options()['time'];
}
function format_walk_value(array $entry): string
{
$mode = (string) ($entry['walk_mode'] ?? 'time');
if ($mode === 'steps') {
return number_format((int) ($entry['walk_steps'] ?? 0), 0, ',', '.') . ' Schritte';
}
return (string) ((int) ($entry['walk_minutes'] ?? 0)) . ' min';
}
function walk_chart_value(array $entry): int
{
$mode = (string) ($entry['walk_mode'] ?? 'time');
if ($mode === 'steps') {
return max(0, (int) round(((int) ($entry['walk_steps'] ?? 0)) / 200));
}
return max(0, (int) ($entry['walk_minutes'] ?? 0));
}
function base64url_encode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64url_decode(string $data): string
{
$padding = strlen($data) % 4;
if ($padding > 0) {
$data .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(strtr($data, '-_', '+/'), true);
if ($decoded === false) {
throw new RuntimeException('Ungültige Base64url-Daten.');
}
return $decoded;
}
function uploaded_files(string $field): array
{
$raw = $_FILES[$field] ?? null;
if (!is_array($raw) || !isset($raw['name'])) {
return [];
}
if (!is_array($raw['name'])) {
return [[
'name' => (string) ($raw['name'] ?? ''),
'type' => (string) ($raw['type'] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'] ?? ''),
'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'] ?? 0),
]];
}
$files = [];
foreach ($raw['name'] as $index => $name) {
$files[] = [
'name' => (string) ($name ?? ''),
'type' => (string) ($raw['type'][$index] ?? ''),
'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''),
'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($raw['size'][$index] ?? 0),
];
}
return $files;
}
function normalize_sport_type_id(string $value): string
{
$value = trim(strtr($value, [
@@ -252,6 +443,11 @@ function normalized_sport_types(array $settings): array
$icon = 'run';
}
$location = trim((string) ($type['location'] ?? ''));
if (!array_key_exists($location, sport_location_options())) {
$location = '';
}
$group = trim((string) ($type['recovery_group'] ?? ''));
if ($group === '') {
$group = $id;
@@ -261,6 +457,7 @@ function normalized_sport_types(array $settings): array
'id' => $id,
'label' => $label,
'icon' => $icon,
'location' => $location,
'recovery_group' => normalize_sport_type_id($group) ?: $id,
'bonus_points' => max(0, min(20, (int) ($type['bonus_points'] ?? 0))),
'allow_consecutive' => !empty($type['allow_consecutive']),
+43 -4
View File
@@ -6,7 +6,7 @@ $brandSubtitle = match ($page) {
'dashboard' => 'Statistiken und Verlauf',
'track' => 'Tag erfassen und bewerten',
'archive' => 'Rückblick auf vergangene Tage',
'options' => 'Logik, Sicherheit und Accounts',
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
'login' => 'Geschützter Zugang',
'setup' => 'Erstkonfiguration',
default => 'Stimmungstracker',
@@ -18,15 +18,28 @@ $brandSubtitle = match ($page) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1e2e">
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mood-Board">
<meta name="csrf-token" content="<?= e(csrf_token()) ?>">
<?php if (!empty($pushPublicKey)): ?>
<meta name="mood-push-public-key" content="<?= e((string) $pushPublicKey) ?>">
<?php endif; ?>
<title><?= e($pageTitle) ?> · Mood</title>
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/branding/favicon-32.png?v=20260412">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/branding/favicon-16.png?v=20260412">
<link rel="shortcut icon" href="/favicon.ico?v=20260412">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/branding/apple-touch-icon.png?v=20260412">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/css/app.css">
<script defer src="/assets/js/app.js"></script>
</head>
<body class="app-body page-<?= e($page) ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
<div class="aurora aurora-one"></div>
<div class="aurora aurora-two"></div>
<div class="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
<div class="shell">
<?php if ($authUser !== null): ?>
<aside class="sidebar glass-panel">
@@ -99,7 +112,33 @@ $brandSubtitle = match ($page) {
<?php endforeach; ?>
<?= $content ?>
<footer class="site-footer glass-panel">
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
</footer>
</main>
<?php if ($authUser !== null): ?>
<nav class="mobile-nav glass-panel" aria-label="Mobile Hauptnavigation">
<a class="<?= is_active_path('/') ? 'active' : '' ?>" href="/">
<img class="nav-icon" src="<?= e(icon_path('dashboard')) ?>" alt="">
<span>Dashboard</span>
</a>
<a class="<?= is_active_path('/track') ? 'active' : '' ?>" href="/track">
<img class="nav-icon" src="<?= e(icon_path('track')) ?>" alt="">
<span>Tracken</span>
</a>
<a class="<?= is_active_path('/archive') ? 'active' : '' ?>" href="/archive">
<img class="nav-icon" src="<?= e(icon_path('archive')) ?>" alt="">
<span>Archiv</span>
</a>
<a class="<?= is_active_path('/options') ? 'active' : '' ?>" href="/options">
<img class="nav-icon" src="<?= e(icon_path('options')) ?>" alt="">
<span>Optionen</span>
</a>
</nav>
<?php endif; ?>
</div>
</body>
</html>
+7 -3
View File
@@ -22,7 +22,7 @@
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<?php endforeach; ?>
</span>
@@ -54,6 +54,9 @@
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
<?php endif; ?>
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
@@ -65,7 +68,7 @@
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
<span class="sport-pill sport-pill--inline">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<?php endforeach; ?>
</span>
@@ -75,7 +78,8 @@
</dd>
</div>
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
</dl>
<div class="note-box">
+14 -1
View File
@@ -73,13 +73,26 @@
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<article class="glass-panel chart-card">
<div class="section-head">
<div>
<p class="eyebrow">Körper</p>
<h3>Schmerzverlauf</h3>
</div>
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
</div>
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
</article>
<?php endif; ?>
<article class="glass-panel chart-card chart-card--wide">
<div class="section-head">
<div>
<p class="eyebrow">Aktivität</p>
<h3>Sport und Spaziergang</h3>
</div>
<span class="chart-chip chart-chip--cool">Minuten pro Tag</span>
<span class="chart-chip chart-chip--cool">Aktivität pro Tag</span>
</div>
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
</article>
+5
View File
@@ -16,6 +16,11 @@
<input type="password" name="password" autocomplete="current-password" required>
</label>
<label class="checkbox-row">
<input type="checkbox" name="remember_me" value="1">
<span>Angemeldet bleiben</span>
</label>
<button class="primary-button" type="submit">Anmelden</button>
</form>
</div>
+188 -10
View File
@@ -2,8 +2,9 @@
<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>
<p class="eyebrow">Dein Account</p>
<h3>Score und Sportarten persönlich anpassen</h3>
<p class="helper-text">Alle Einstellungen auf dieser Seite gelten nur für deinen eigenen Account und beeinflussen keine anderen Nutzer.</p>
</div>
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
</div>
@@ -22,6 +23,30 @@
</div>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Tracking-Felder</h4>
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
</div>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
<span>
<strong>Schmerzen aktivieren</strong>
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
</span>
</label>
<label>
<span>Schmerzfaktor</span>
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
</label>
</div>
</div>
<div class="settings-section">
<h4>Schlafdauerpunkte</h4>
<div class="field-grid field-grid--four">
@@ -48,7 +73,29 @@
</div>
<div class="settings-section">
<h4>Spaziergang-Bänder</h4>
<div class="section-head section-head--compact">
<div>
<h4>Spaziergang</h4>
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
</div>
</div>
<label>
<span>Spaziergang auswerten nach</span>
<select name="settings[walk][mode]">
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
<div class="band-card">
<h5>Schritte mit Bestwert bei 10.000</h5>
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
</div>
<?php else: ?>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
@@ -58,17 +105,42 @@
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Sportarten und Bonuspunkte</h4>
<p class="helper-text">Lege fest, welche Sportarten im Tracking auswählbar sind. Der Bonus gilt nur, wenn am Vortag keine gleiche Erholungsgruppe trainiert wurde.</p>
<p class="helper-text">Lege fest, welche Sportarten nur in deinem eigenen Tracking auswählbar sind. Entfernte Sportarten kannst du darunter jederzeit wieder für deinen Account hinzufügen.</p>
</div>
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
</div>
<input type="hidden" name="settings[sport_types_present]" value="1">
<?php if (!empty($sportTypePresets)): ?>
<div class="preset-list">
<?php foreach ($sportTypePresets as $preset): ?>
<button
class="preset-pill"
type="button"
data-sport-preset
data-id="<?= e($preset['id']) ?>"
data-label="<?= e($preset['label']) ?>"
data-icon="<?= e($preset['icon']) ?>"
data-location="<?= e($preset['location'] ?? '') ?>"
data-recovery-group="<?= e($preset['recovery_group']) ?>"
data-bonus-points="<?= e((string) $preset['bonus_points']) ?>"
data-allow-consecutive="<?= !empty($preset['allow_consecutive']) ? '1' : '0' ?>"
>
<img src="<?= e(sport_icon_path($preset['icon'])) ?>" alt="">
<span><?= e($preset['label']) ?></span>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="sport-type-list" data-sport-type-list>
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
<div class="sport-type-card band-card" data-sport-type-row>
@@ -90,10 +162,21 @@
</label>
<label>
<span>Erholungsgruppe</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
<span>Ort</span>
<select name="settings[sport_types][<?= e((string) $index) ?>][location]" data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>" <?= ($sportType['location'] ?? '') === $locationValue ? 'selected' : '' ?>><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" name="settings[sport_types][<?= e((string) $index) ?>][recovery_group]" value="<?= e($sportType['recovery_group']) ?>" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
@@ -105,10 +188,12 @@
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe brauchst du nur, wenn mehrere Varianten dieselbe Belastung teilen sollen, zum Beispiel Krafttraining Zuhause und Krafttraining Auswärts. Wenn du nichts Besonderes einträgst, reicht die Sportart selbst.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<span><?= e($sportType['label']) ?></span>
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
</span>
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
</div>
@@ -116,6 +201,10 @@
<?php endforeach; ?>
</div>
<div class="section-actions">
<button class="primary-button" type="submit">Sportarten speichern</button>
</div>
<template id="sport-type-row-template">
<div class="sport-type-card band-card" data-sport-type-row>
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
@@ -123,7 +212,7 @@
<div class="field-grid field-grid--four">
<label>
<span>Bezeichnung</span>
<input type="text" value="" placeholder="z. B. Mobility" data-name-template="settings[sport_types][__INDEX__][label]">
<input type="text" value="" placeholder="z. B. Krafttraining Zuhause" data-name-template="settings[sport_types][__INDEX__][label]">
</label>
<label>
@@ -136,10 +225,21 @@
</label>
<label>
<span>Erholungsgruppe</span>
<input type="text" value="" placeholder="z. B. kraftsport" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
<span>Ort</span>
<select data-name-template="settings[sport_types][__INDEX__][location]">
<?php foreach ($sportLocationOptions as $locationValue => $locationLabel): ?>
<option value="<?= e($locationValue) ?>"><?= e($locationLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Erholungsgruppe optional</span>
<input type="text" value="" placeholder="z. B. krafttraining" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
</label>
</div>
<div class="field-grid field-grid--four">
<label>
<span>Bonuspunkte</span>
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
@@ -151,6 +251,8 @@
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
</label>
<p class="helper-text">Die Erholungsgruppe ist nur dann nützlich, wenn mehrere Varianten sportlich gleich belastend sind und denselben Bonusrhythmus teilen sollen.</p>
<div class="sport-type-card__actions">
<span class="sport-pill sport-pill--soft">
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
@@ -162,8 +264,58 @@
</template>
</div>
<div class="settings-section">
<div class="section-head section-head--compact">
<div>
<h4>Erinnerungen</h4>
<p class="helper-text">Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.</p>
</div>
<span class="chart-chip"><?= e((string) $pushSubscriptionCount) ?> Gerät<?= $pushSubscriptionCount === 1 ? '' : 'e' ?></span>
</div>
<div class="field-grid field-grid--two">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="settings[notifications][enabled]" value="1" <?= !empty($settings['notifications']['enabled']) ? 'checked' : '' ?>>
<span>Tägliche Push-Erinnerung aktivieren</span>
</label>
<label>
<span>Uhrzeit der Erinnerung</span>
<input type="time" name="settings[notifications][time]" value="<?= e((string) ($settings['notifications']['time'] ?? '20:30')) ?>">
</label>
</div>
<div
class="push-panel band-card"
data-push-panel
data-push-ready="<?= !empty($pushAvailable) && !empty($pushPublicKey) ? '1' : '0' ?>"
>
<div>
<h5>Push auf diesem Gerät</h5>
<p class="helper-text" data-push-status>
<?php if (!empty($pushAvailable) && !empty($pushPublicKey)): ?>
Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät.
<?php else: ?>
Push ist auf diesem Server gerade noch nicht verfügbar.
<?php endif; ?>
</p>
</div>
<div class="push-actions">
<button class="ghost-button" type="button" data-push-enable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Push aktivieren</button>
<button class="ghost-button" type="button" data-push-disable <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Auf diesem Gerät entfernen</button>
<button class="ghost-button" type="button" data-push-test <?= empty($pushAvailable) || empty($pushPublicKey) ? 'disabled' : '' ?>>Test senden</button>
</div>
</div>
<div class="section-actions">
<button class="primary-button" type="submit">Erinnerungen speichern</button>
</div>
</div>
<div class="settings-section">
<h4>Bewertungsskala</h4>
<p class="helper-text">Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.</p>
<div class="band-grid">
<?php foreach ($settings['ratings'] as $index => $rating): ?>
<div class="band-card">
@@ -198,6 +350,32 @@
</article>
<aside class="stack-column">
<article class="glass-panel detail-card">
<p class="eyebrow">Backup</p>
<h3>Eigene Einträge sichern</h3>
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="export_backup">
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
<?php if (empty($backupAvailable)): ?>
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
<?php endif; ?>
</form>
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="import_backup">
<label>
<span>Backup importieren</span>
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
</label>
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
<button class="ghost-button" type="submit">Backup importieren</button>
</form>
</article>
<article class="glass-panel detail-card">
<p class="eyebrow">Sicherheit</p>
<h3>Passwort ändern</h3>
+45 -2
View File
@@ -14,6 +14,8 @@
<form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="date" value="<?= e($entry['date']) ?>">
<?php $walkMode = ($entry['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time'; ?>
<input type="hidden" name="walk_mode" value="<?= e($walkMode) ?>">
<div class="field-grid field-grid--three">
<label class="range-card">
@@ -35,6 +37,34 @@
</label>
</div>
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
<div class="field-grid field-grid--two">
<label class="range-card">
<span>Schmerzen</span>
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
</label>
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php else: ?>
<div class="field-grid field-grid--single">
<label class="checkbox-row checkbox-row--panel">
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
<span>
<strong>Alkohol</strong>
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
</span>
</label>
</div>
<?php endif; ?>
<div class="field-grid field-grid--two">
<label>
<span>Schlafdauer in Stunden</span>
@@ -60,8 +90,16 @@
</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>
<span><?= $walkMode === 'steps' ? 'Spaziergang in Schritten' : 'Spaziergang in Minuten' ?></span>
<input
type="number"
min="0"
max="<?= $walkMode === 'steps' ? '50000' : '1440' ?>"
step="1"
name="<?= $walkMode === 'steps' ? 'walk_steps' : 'walk_minutes' ?>"
value="<?= e((string) ($walkMode === 'steps' ? ($entry['walk_steps'] ?? 0) : $entry['walk_minutes'])) ?>"
required
>
</label>
</div>
@@ -76,6 +114,9 @@
<span class="sport-choice__card">
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
<strong><?= e($sportType['label']) ?></strong>
<?php if (!empty($sportType['location'])): ?>
<small><?= e(sport_location_label((string) $sportType['location'])) ?></small>
<?php endif; ?>
</span>
</label>
<?php endforeach; ?>
@@ -114,11 +155,13 @@
'mood' => 'Stimmung',
'energy' => 'Energie',
'stress' => 'Stress',
'pain' => 'Schmerzen',
'sleep_hours' => 'Schlafdauer',
'sleep_feeling' => 'Schlafgefühl',
'sport_minutes' => 'Sport',
'sport_bonus' => 'Sportbonus',
'walk_minutes' => 'Spaziergang',
'alcohol' => 'Alkohol',
'note' => 'Notiz',
];
?>