9 Commits

37 changed files with 2304 additions and 145 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

+301 -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);
@@ -149,9 +265,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 +283,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 +311,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 +338,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 +348,10 @@ button {
margin-top: 2rem;
}
.mobile-nav {
display: none;
}
.main-nav a {
display: flex;
align-items: center;
@@ -244,7 +364,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 +389,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 +535,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 +545,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 +657,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 +686,7 @@ button {
}
.line-point {
stroke: rgba(7, 17, 27, 0.9);
stroke: var(--line-point-stroke);
stroke-width: 1.5;
}
@@ -579,7 +699,7 @@ button {
}
.chart-axis {
stroke: rgba(255, 255, 255, 0.1);
stroke: var(--chart-axis-color);
stroke-width: 1;
}
@@ -589,12 +709,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 +727,7 @@ button {
}
.bar-grid {
fill: rgba(255, 255, 255, 0.08);
fill: var(--bar-grid-color);
}
.bar-segment--sport {
@@ -619,12 +739,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 +843,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 +853,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 +899,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 +914,7 @@ select {
option,
optgroup {
background: #10253a;
background: var(--bg-soft);
color: var(--text);
}
@@ -796,7 +922,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 +1046,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 +1106,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 +1248,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 +1301,14 @@ input[type="range"] {
gap: 0.7rem;
}
.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 input {
width: auto;
}
@@ -1211,6 +1410,10 @@ input[type="range"] {
}
@media (max-width: 820px) {
.shell {
grid-template-columns: 1fr;
}
.topbar,
.section-head,
.form-actions {
@@ -1233,6 +1436,53 @@ 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));
}
.bar-chart {
overflow-x: auto;
padding-bottom: 0.4rem;
@@ -1297,4 +1547,20 @@ 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;
}
}
+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

+439 -23
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,7 @@
function evaluateEntry(entry, settings, previousEntry = null) {
const ratings = sortedRatings(settings.ratings || []);
const scoring = settings.scoring || {};
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
const components = {
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
@@ -239,7 +272,9 @@
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 || []),
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
};
@@ -297,7 +332,9 @@
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),
note: form.elements.note.value || "",
});
@@ -452,6 +489,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 +507,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 +790,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 +827,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 +911,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 +928,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 +1265,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);
})());
});
+342 -15
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
@@ -29,6 +33,7 @@ final class App
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$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 +44,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 +95,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 +206,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');
@@ -219,7 +257,9 @@ final class App
'sport_minutes' => 0,
'sport_type' => '',
'sport_types' => [],
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'walk_minutes' => 0,
'walk_steps' => 0,
'note' => '',
];
@@ -261,7 +301,9 @@ final class App
'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,
'note' => $_POST['note'] ?? '',
]);
@@ -309,12 +351,40 @@ 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']),
'users' => $user['is_admin'] ? $this->users->all() : [],
'maxScore' => $this->scoring->evaluate([
'mood' => 10,
@@ -327,7 +397,9 @@ 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,
'note' => 'x',
], $settings)['max_total'],
]);
@@ -341,9 +413,10 @@ 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');
}
@@ -462,11 +535,17 @@ final class App
'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,10 +586,14 @@ 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)));
@@ -524,10 +607,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 +634,39 @@ 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['notifications'] = array_replace(
Defaults::settings()['notifications'],
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
);
return $settings;
}
@@ -594,6 +700,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 +714,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 +752,220 @@ 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);
}
$now = new DateTimeImmutable('now');
$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' => date(DATE_ATOM),
]);
}
}
json_response([
'ok' => true,
'processed' => $processed,
'sent_users' => $sentUsers,
'already_tracked' => $alreadyTracked,
'skipped' => $skipped,
'removed_subscriptions' => $removed,
]);
}
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;
}
}
+9 -2
View File
@@ -77,6 +77,10 @@ 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);
$entry = [
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
@@ -87,7 +91,9 @@ final class EntryRepository
'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,
'note' => $this->extractNote($content),
];
@@ -138,7 +144,8 @@ final class EntryRepository
'- 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)),
'',
'## Bewertung',
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
+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.');
}
}
}
+75 -2
View File
@@ -18,7 +18,9 @@ final class ScoringService
'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))),
'note' => trim((string) ($input['note'] ?? '')),
];
}
@@ -40,7 +42,7 @@ 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),
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
];
@@ -53,7 +55,7 @@ final class ScoringService
(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 +148,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 +348,9 @@ final class ScoringService
default => 'radiant',
};
}
private function normalizeWalkMode(string $mode): string
{
return $mode === 'steps' ? 'steps' : 'time';
}
}
+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);
}
}
+84 -17
View File
@@ -16,43 +16,96 @@ final class Defaults
5 => 'sehr ausgeschlafen',
],
],
'walk' => [
'mode' => 'time',
],
'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,
@@ -85,6 +138,16 @@ 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,
],
'ratings' => [
@@ -106,6 +169,10 @@ final class Defaults
'cap_label' => 'schwerer Tag',
],
],
'notifications' => [
'enabled' => false,
'time' => '20:30',
],
];
}
}
+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;
}
}
+4 -16
View File
@@ -6,35 +6,23 @@ require __DIR__ . '/helpers.php';
require __DIR__ . '/Support/Defaults.php';
require __DIR__ . '/Support/Auth.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();
+168 -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,94 @@ 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 normalize_sport_type_id(string $value): string
{
$value = trim(strtr($value, [
@@ -252,6 +412,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 +426,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']),
+38 -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">
@@ -100,6 +113,27 @@ $brandSubtitle = match ($page) {
<?= $content ?>
</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>
+3 -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>
@@ -65,7 +65,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 +75,7 @@
</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>
</dl>
<div class="note-box">
+1 -1
View File
@@ -79,7 +79,7 @@
<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>
+146 -18
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>
@@ -48,27 +49,74 @@
</div>
<div class="settings-section">
<h4>Spaziergang-Bänder</h4>
<div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
<div 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">
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
</div>
<?php endforeach; ?>
</div>
<?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 +138,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 +164,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 +177,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 +188,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 +201,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 +227,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 +240,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">
+15 -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">
@@ -60,8 +62,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 +86,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; ?>