Compare commits
12 Commits
308e733e3b
..
V1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8ccef5a7 | |||
| 4a884dd166 | |||
| 5ea1b56649 | |||
| abc0766f16 | |||
| 4e9fe2de6a | |||
| cd7526bd80 | |||
| 2cd00b1bf6 | |||
| 1080dd9d82 | |||
| 80f649c547 | |||
| a1135d37d8 | |||
| 0e00dfdc40 | |||
| 6b8af4f801 |
@@ -1,5 +1,6 @@
|
|||||||
Options -Indexes
|
Options -Indexes
|
||||||
DirectoryIndex index.php
|
DirectoryIndex index.php
|
||||||
|
AddType application/manifest+json .webmanifest
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
@@ -11,4 +12,3 @@ DirectoryIndex index.php
|
|||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [QSA,L]
|
RewriteRule ^ index.php [QSA,L]
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 655 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -22,6 +22,88 @@
|
|||||||
--track-accent: rgba(139, 228, 255, 0.34);
|
--track-accent: rgba(139, 228, 255, 0.34);
|
||||||
--track-surface: rgba(255, 255, 255, 0.08);
|
--track-surface: rgba(255, 255, 255, 0.08);
|
||||||
--track-glow: rgba(139, 228, 255, 0.18);
|
--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;
|
min-height: 100vh;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 18% 18%, rgba(76, 171, 255, 0.24), transparent 34%),
|
var(--body-radial-one),
|
||||||
radial-gradient(circle at 82% 12%, rgba(113, 255, 210, 0.18), transparent 28%),
|
var(--body-radial-two),
|
||||||
linear-gradient(145deg, #07111b 0%, #0b1e2e 35%, #13273b 65%, #08111b 100%);
|
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);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +172,12 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.aurora {
|
.aurora {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto;
|
inset: auto;
|
||||||
@@ -103,8 +219,8 @@ button {
|
|||||||
.glass-panel {
|
.glass-panel {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.08)),
|
var(--panel-gradient-top),
|
||||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.2), transparent 48%);
|
var(--panel-gradient-accent);
|
||||||
backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
||||||
-webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
-webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -128,6 +244,26 @@ button {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__link {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
transition: color 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer__link:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -149,9 +285,9 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.48rem 0.8rem;
|
padding: 0.48rem 0.8rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: var(--pill-bg);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--pill-border);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@@ -167,10 +303,10 @@ button {
|
|||||||
.topbar-date-input {
|
.topbar-date-input {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 2.2rem;
|
min-height: 2.2rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid var(--input-border-soft);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.45rem 0.9rem;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--control-soft-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +331,7 @@ button {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 10px 24px rgba(9, 25, 40, 0.22);
|
box-shadow: var(--brand-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark img {
|
.brand-mark img {
|
||||||
@@ -222,7 +358,7 @@ button {
|
|||||||
margin: 0 0 0.28rem;
|
margin: 0 0 0.28rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
color: rgba(239, 247, 255, 0.62);
|
color: var(--eyebrow-color);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +368,10 @@ button {
|
|||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.main-nav a {
|
.main-nav a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -244,7 +384,7 @@ button {
|
|||||||
|
|
||||||
.main-nav a:hover,
|
.main-nav a:hover,
|
||||||
.main-nav a.active {
|
.main-nav a.active {
|
||||||
background: rgba(255, 255, 255, 0.13);
|
background: var(--nav-hover-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
@@ -269,7 +409,7 @@ button {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--user-chip-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip__name {
|
.user-chip__name {
|
||||||
@@ -415,8 +555,8 @@ button {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--calendar-detail-bg);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--calendar-detail-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-detail__meta {
|
.calendar-detail__meta {
|
||||||
@@ -425,7 +565,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-detail__eyebrow {
|
.calendar-detail__eyebrow {
|
||||||
color: rgba(239, 247, 255, 0.58);
|
color: var(--calendar-detail-eyebrow);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -537,7 +677,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-cell--selected {
|
.calendar-cell--selected {
|
||||||
stroke: rgba(255, 255, 255, 0.9);
|
stroke: var(--calendar-selected-stroke);
|
||||||
stroke-width: 1.2;
|
stroke-width: 1.2;
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
@@ -566,7 +706,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.line-point {
|
.line-point {
|
||||||
stroke: rgba(7, 17, 27, 0.9);
|
stroke: var(--line-point-stroke);
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +719,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-axis {
|
.chart-axis {
|
||||||
stroke: rgba(255, 255, 255, 0.1);
|
stroke: var(--chart-axis-color);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,12 +729,12 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chart-label {
|
.chart-label {
|
||||||
fill: rgba(239, 247, 255, 0.65);
|
fill: var(--chart-label-color);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-value {
|
.chart-value {
|
||||||
fill: rgba(239, 247, 255, 0.9);
|
fill: var(--chart-value-color);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -607,7 +747,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-grid {
|
.bar-grid {
|
||||||
fill: rgba(255, 255, 255, 0.08);
|
fill: var(--bar-grid-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-segment--sport {
|
.bar-segment--sport {
|
||||||
@@ -619,12 +759,12 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-label {
|
.bar-label {
|
||||||
fill: rgba(239, 247, 255, 0.62);
|
fill: var(--chart-label-color);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-value {
|
.bar-value {
|
||||||
fill: rgba(239, 247, 255, 0.82);
|
fill: var(--chart-value-color);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
}
|
}
|
||||||
@@ -723,9 +863,9 @@ button {
|
|||||||
|
|
||||||
.sport-choice__card {
|
.sport-choice__card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.35rem;
|
gap: 0.5rem;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 0.85rem 0.95rem;
|
padding: 1rem 1.05rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
@@ -733,17 +873,23 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sport-choice__card img {
|
.sport-choice__card img {
|
||||||
width: 1.2rem;
|
width: 1.7rem;
|
||||||
height: 1.2rem;
|
height: 1.7rem;
|
||||||
opacity: 0.96;
|
opacity: 0.96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sport-choice__card strong {
|
.sport-choice__card strong {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.92rem;
|
font-size: 1rem;
|
||||||
line-height: 1.35;
|
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 {
|
.sport-choice input:checked + .sport-choice__card {
|
||||||
border-color: rgba(139, 228, 255, 0.44);
|
border-color: rgba(139, 228, 255, 0.44);
|
||||||
background: linear-gradient(180deg, rgba(96, 184, 255, 0.18), rgba(255, 255, 255, 0.08));
|
background: linear-gradient(180deg, rgba(96, 184, 255, 0.18), rgba(255, 255, 255, 0.08));
|
||||||
@@ -773,9 +919,9 @@ input[type="date"],
|
|||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid var(--input-border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(255, 255, 255, 0.09);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -788,7 +934,7 @@ select {
|
|||||||
|
|
||||||
option,
|
option,
|
||||||
optgroup {
|
optgroup {
|
||||||
background: #10253a;
|
background: var(--bg-soft);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +942,7 @@ input:focus,
|
|||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: rgba(139, 228, 255, 0.5);
|
border-color: rgba(139, 228, 255, 0.5);
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: var(--input-bg-focus);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,6 +1066,36 @@ input[type="range"] {
|
|||||||
flex-wrap: wrap;
|
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,
|
.primary-button,
|
||||||
.ghost-button,
|
.ghost-button,
|
||||||
.button-link {
|
.button-link {
|
||||||
@@ -950,8 +1126,8 @@ input[type="range"] {
|
|||||||
.ghost-button,
|
.ghost-button,
|
||||||
.ghost-link {
|
.ghost-link {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid var(--control-soft-border);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--control-soft-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost-link {
|
.ghost-link {
|
||||||
@@ -1092,6 +1268,41 @@ input[type="range"] {
|
|||||||
gap: 0.9rem;
|
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 {
|
.sport-type-card {
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1321,31 @@ input[type="range"] {
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row--panel {
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row--tall {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 1.05rem;
|
||||||
|
padding-bottom: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-row input {
|
.checkbox-row input {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1447,10 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.section-head,
|
.section-head,
|
||||||
.form-actions {
|
.form-actions {
|
||||||
@@ -1233,6 +1473,57 @@ input[type="range"] {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 0.8rem;
|
||||||
|
right: 0.8rem;
|
||||||
|
bottom: max(0.8rem, env(safe-area-inset-bottom));
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.7rem 0.5rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a.active {
|
||||||
|
background: var(--nav-hover-bg);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a span {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav .nav-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-authenticated .content {
|
||||||
|
padding-bottom: calc(6.8rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.bar-chart {
|
.bar-chart {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 0.4rem;
|
padding-bottom: 0.4rem;
|
||||||
@@ -1297,4 +1588,26 @@ input[type="range"] {
|
|||||||
.bar-chart {
|
.bar-chart {
|
||||||
min-height: 9.5rem;
|
min-height: 9.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-nav {
|
||||||
|
left: 0.7rem;
|
||||||
|
right: 0.7rem;
|
||||||
|
bottom: max(0.7rem, env(safe-area-inset-bottom));
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a {
|
||||||
|
padding: 0.62rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a span {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -113,6 +113,38 @@
|
|||||||
return last ? Number(last.points || 0) : 0;
|
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) {
|
function sortedRatings(ratings) {
|
||||||
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
|
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
|
||||||
}
|
}
|
||||||
@@ -231,6 +263,8 @@
|
|||||||
function evaluateEntry(entry, settings, previousEntry = null) {
|
function evaluateEntry(entry, settings, previousEntry = null) {
|
||||||
const ratings = sortedRatings(settings.ratings || []);
|
const ratings = sortedRatings(settings.ratings || []);
|
||||||
const scoring = settings.scoring || {};
|
const scoring = settings.scoring || {};
|
||||||
|
const walkMode = entry.walk_mode === "steps" ? "steps" : "time";
|
||||||
|
const painEnabled = Boolean(settings.tracking?.pain_enabled);
|
||||||
const components = {
|
const components = {
|
||||||
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
|
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
|
||||||
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
|
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
|
||||||
@@ -239,10 +273,17 @@
|
|||||||
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
|
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
|
||||||
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
|
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
|
||||||
sport_bonus: sportBonusPoints(entry, settings, previousEntry),
|
sport_bonus: sportBonusPoints(entry, settings, previousEntry),
|
||||||
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
|
walk_minutes: walkMode === "steps"
|
||||||
|
? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || [])
|
||||||
|
: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
|
||||||
|
alcohol: entry.alcohol ? (Number(scoring.alcohol_penalty || 5) * -1) : 0,
|
||||||
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
|
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (painEnabled) {
|
||||||
|
components.pain = (11 - Number(entry.pain || 1)) * Number(scoring.pain_multiplier || 0);
|
||||||
|
}
|
||||||
|
|
||||||
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
|
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
|
||||||
let label = labelForScore(total, ratings);
|
let label = labelForScore(total, ratings);
|
||||||
|
|
||||||
@@ -281,11 +322,13 @@
|
|||||||
mood: "Stimmung",
|
mood: "Stimmung",
|
||||||
energy: "Energie",
|
energy: "Energie",
|
||||||
stress: "Stress",
|
stress: "Stress",
|
||||||
|
pain: "Schmerzen",
|
||||||
sleep_hours: "Schlafdauer",
|
sleep_hours: "Schlafdauer",
|
||||||
sleep_feeling: "Schlafgefühl",
|
sleep_feeling: "Schlafgefühl",
|
||||||
sport_minutes: "Sport",
|
sport_minutes: "Sport",
|
||||||
sport_bonus: "Sportbonus",
|
sport_bonus: "Sportbonus",
|
||||||
walk_minutes: "Spaziergang",
|
walk_minutes: "Spaziergang",
|
||||||
|
alcohol: "Alkohol",
|
||||||
note: "Notiz",
|
note: "Notiz",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -293,11 +336,15 @@
|
|||||||
mood: Number(form.elements.mood.value),
|
mood: Number(form.elements.mood.value),
|
||||||
energy: Number(form.elements.energy.value),
|
energy: Number(form.elements.energy.value),
|
||||||
stress: Number(form.elements.stress.value),
|
stress: Number(form.elements.stress.value),
|
||||||
|
pain: Number(form.elements.pain?.value || 1),
|
||||||
sleep_hours: Number(form.elements.sleep_hours.value || 0),
|
sleep_hours: Number(form.elements.sleep_hours.value || 0),
|
||||||
sleep_feeling: Number(form.elements.sleep_feeling.value),
|
sleep_feeling: Number(form.elements.sleep_feeling.value),
|
||||||
sport_minutes: Number(form.elements.sport_minutes.value || 0),
|
sport_minutes: Number(form.elements.sport_minutes.value || 0),
|
||||||
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value),
|
sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value),
|
||||||
walk_minutes: Number(form.elements.walk_minutes.value || 0),
|
walk_mode: form.elements.walk_mode?.value || "time",
|
||||||
|
walk_minutes: Number(form.elements.walk_minutes?.value || 0),
|
||||||
|
walk_steps: Number(form.elements.walk_steps?.value || 0),
|
||||||
|
alcohol: Boolean(form.elements.alcohol?.checked),
|
||||||
note: form.elements.note.value || "",
|
note: form.elements.note.value || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,7 +394,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seriesName = container.dataset.series || "";
|
const seriesName = container.dataset.series || "";
|
||||||
const invertScale = seriesName === "stress";
|
const invertScale = seriesName === "stress" || seriesName === "pain";
|
||||||
const values = items.map(item => Number(item.value));
|
const values = items.map(item => Number(item.value));
|
||||||
const width = 760;
|
const width = 760;
|
||||||
const height = 196;
|
const height = 196;
|
||||||
@@ -355,7 +402,7 @@
|
|||||||
let minValue = Math.min(...values);
|
let minValue = Math.min(...values);
|
||||||
let maxValue = Math.max(...values);
|
let maxValue = Math.max(...values);
|
||||||
|
|
||||||
if (seriesName === "mood" || seriesName === "stress") {
|
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||||
minValue = Math.max(1, minValue - 1.5);
|
minValue = Math.max(1, minValue - 1.5);
|
||||||
maxValue = Math.min(10, maxValue + 1.5);
|
maxValue = Math.min(10, maxValue + 1.5);
|
||||||
} else {
|
} else {
|
||||||
@@ -365,7 +412,7 @@
|
|||||||
|
|
||||||
if ((maxValue - minValue) < 3) {
|
if ((maxValue - minValue) < 3) {
|
||||||
const center = (maxValue + minValue) / 2;
|
const center = (maxValue + minValue) / 2;
|
||||||
if (seriesName === "mood" || seriesName === "stress") {
|
if (seriesName === "mood" || seriesName === "stress" || seriesName === "pain") {
|
||||||
minValue = Math.max(1, center - 1.5);
|
minValue = Math.max(1, center - 1.5);
|
||||||
maxValue = Math.min(10, center + 1.5);
|
maxValue = Math.min(10, center + 1.5);
|
||||||
} else {
|
} else {
|
||||||
@@ -452,6 +499,7 @@
|
|||||||
const sportY = walkY - sportHeight;
|
const sportY = walkY - sportHeight;
|
||||||
const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20);
|
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 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 label = labels.length ? ` · ${labels.join(", ")}` : "";
|
||||||
const bonus = Number(item.sport_bonus || 0);
|
const bonus = Number(item.sport_bonus || 0);
|
||||||
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
|
const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : [];
|
||||||
@@ -469,7 +517,7 @@
|
|||||||
return `
|
return `
|
||||||
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
|
<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">
|
<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>
|
||||||
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
|
<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>
|
<title>${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""}</title>
|
||||||
@@ -752,19 +800,27 @@
|
|||||||
const list = document.querySelector("[data-sport-type-list]");
|
const list = document.querySelector("[data-sport-type-list]");
|
||||||
const addButton = document.querySelector("[data-add-sport-type]");
|
const addButton = document.querySelector("[data-add-sport-type]");
|
||||||
const template = document.querySelector("#sport-type-row-template");
|
const template = document.querySelector("#sport-type-row-template");
|
||||||
|
const presetButtons = [...document.querySelectorAll("[data-sport-preset]")];
|
||||||
|
|
||||||
if (!list || !addButton || !template) {
|
if (!list || !addButton || !template) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRows = () => [...list.querySelectorAll("[data-sport-type-row]")];
|
||||||
|
|
||||||
const syncPreview = row => {
|
const syncPreview = row => {
|
||||||
const labelInput = row.querySelector('input[data-name-template$="[label]"]');
|
const labelInput = row.querySelector('input[data-name-template$="[label]"]');
|
||||||
const iconSelect = row.querySelector('select[data-name-template$="[icon]"]');
|
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 previewText = row.querySelector(".sport-pill span:last-child");
|
||||||
const previewImage = row.querySelector(".sport-pill img");
|
const previewImage = row.querySelector(".sport-pill img");
|
||||||
|
|
||||||
if (previewText) {
|
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) {
|
if (previewImage && iconSelect) {
|
||||||
@@ -781,9 +837,77 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
addButton.addEventListener("click", () => {
|
const syncPresets = () => {
|
||||||
|
const selectedIds = new Set(
|
||||||
|
getRows()
|
||||||
|
.map(row => row.querySelector('input[data-name-template$="[id]"]'))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(input => String(input.value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
presetButtons.forEach(button => {
|
||||||
|
button.hidden = selectedIds.has(button.dataset.id || "");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRow = values => {
|
||||||
list.append(template.content.cloneNode(true));
|
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();
|
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 => {
|
list.addEventListener("click", event => {
|
||||||
@@ -797,32 +921,16 @@
|
|||||||
return;
|
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();
|
row.remove();
|
||||||
renumber();
|
renumber();
|
||||||
|
syncPresets();
|
||||||
});
|
});
|
||||||
|
|
||||||
list.addEventListener("input", event => {
|
list.addEventListener("input", event => {
|
||||||
const row = event.target.closest("[data-sport-type-row]");
|
const row = event.target.closest("[data-sport-type-row]");
|
||||||
if (row) {
|
if (row) {
|
||||||
syncPreview(row);
|
syncPreview(row);
|
||||||
|
syncPresets();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -830,10 +938,325 @@
|
|||||||
const row = event.target.closest("[data-sport-type-row]");
|
const row = event.target.closest("[data-sport-type-row]");
|
||||||
if (row) {
|
if (row) {
|
||||||
syncPreview(row);
|
syncPreview(row);
|
||||||
|
syncPresets();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renumber();
|
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", () => {
|
window.addEventListener("resize", () => {
|
||||||
@@ -852,4 +1275,7 @@
|
|||||||
initTrackPreview();
|
initTrackPreview();
|
||||||
initDashboardCharts();
|
initDashboardCharts();
|
||||||
initSportTypeManager();
|
initSportTypeManager();
|
||||||
|
initPwaShell();
|
||||||
|
initPullToRefresh();
|
||||||
|
initPushControls();
|
||||||
})();
|
})();
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -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);
|
||||||
|
})());
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ final class App
|
|||||||
private LoginThrottle $throttle;
|
private LoginThrottle $throttle;
|
||||||
private ScoringService $scoring;
|
private ScoringService $scoring;
|
||||||
private Auth $auth;
|
private Auth $auth;
|
||||||
|
private NotificationRepository $notifications;
|
||||||
|
private WebPushService $webPush;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,8 @@ final class App
|
|||||||
$this->throttle = new LoginThrottle();
|
$this->throttle = new LoginThrottle();
|
||||||
$this->scoring = new ScoringService();
|
$this->scoring = new ScoringService();
|
||||||
$this->auth = new Auth($this->users);
|
$this->auth = new Auth($this->users);
|
||||||
|
$this->notifications = new NotificationRepository();
|
||||||
|
$this->webPush = new WebPushService($this->notifications);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
@@ -27,8 +31,10 @@ final class App
|
|||||||
|
|
||||||
$path = request_path();
|
$path = request_path();
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
$this->triggerReminderCheckFromTraffic($method, $path);
|
||||||
$hasUsers = $this->users->hasAnyUsers();
|
$hasUsers = $this->users->hasAnyUsers();
|
||||||
$isAuthenticated = $this->auth->check();
|
$isAuthenticated = $this->auth->check();
|
||||||
|
$systemPaths = ['/reminders/run'];
|
||||||
|
|
||||||
// A failed setup must never leave the app in a half-authenticated redirect loop.
|
// A failed setup must never leave the app in a half-authenticated redirect loop.
|
||||||
if (!$hasUsers && $isAuthenticated) {
|
if (!$hasUsers && $isAuthenticated) {
|
||||||
@@ -39,13 +45,13 @@ final class App
|
|||||||
if (!$hasUsers) {
|
if (!$hasUsers) {
|
||||||
if ($path === '/login') {
|
if ($path === '/login') {
|
||||||
$path = '/setup';
|
$path = '/setup';
|
||||||
} elseif ($path !== '/setup') {
|
} elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) {
|
||||||
redirect('/setup');
|
redirect('/setup');
|
||||||
}
|
}
|
||||||
} elseif (!$isAuthenticated) {
|
} elseif (!$isAuthenticated) {
|
||||||
if ($path === '/setup') {
|
if ($path === '/setup') {
|
||||||
$path = '/login';
|
$path = '/login';
|
||||||
} elseif ($path !== '/login') {
|
} elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) {
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +96,37 @@ final class App
|
|||||||
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
|
$method === 'POST' ? $this->handleOptions() : $this->showOptions();
|
||||||
return;
|
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:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
View::render('not-found', [
|
View::render('not-found', [
|
||||||
@@ -170,7 +207,9 @@ final class App
|
|||||||
redirect('/login');
|
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);
|
$this->throttle->hit($throttleKey);
|
||||||
flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.');
|
flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.');
|
||||||
redirect('/login');
|
redirect('/login');
|
||||||
@@ -195,6 +234,7 @@ final class App
|
|||||||
'pageTitle' => 'Dashboard',
|
'pageTitle' => 'Dashboard',
|
||||||
'page' => 'dashboard',
|
'page' => 'dashboard',
|
||||||
'authUser' => $user,
|
'authUser' => $user,
|
||||||
|
'settings' => $settings,
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
'entries' => array_reverse($evaluatedEntries),
|
'entries' => array_reverse($evaluatedEntries),
|
||||||
'chartPayload' => encode_payload($chartData),
|
'chartPayload' => encode_payload($chartData),
|
||||||
@@ -214,15 +254,21 @@ final class App
|
|||||||
'mood' => 6,
|
'mood' => 6,
|
||||||
'energy' => 6,
|
'energy' => 6,
|
||||||
'stress' => 4,
|
'stress' => 4,
|
||||||
|
'pain' => 1,
|
||||||
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
||||||
'sleep_hours' => 7,
|
'sleep_hours' => 7,
|
||||||
'sleep_feeling' => 3,
|
'sleep_feeling' => 3,
|
||||||
'sport_minutes' => 0,
|
'sport_minutes' => 0,
|
||||||
'sport_type' => '',
|
'sport_type' => '',
|
||||||
'sport_types' => [],
|
'sport_types' => [],
|
||||||
|
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||||
'walk_minutes' => 0,
|
'walk_minutes' => 0,
|
||||||
|
'walk_steps' => 0,
|
||||||
|
'alcohol' => false,
|
||||||
'note' => '',
|
'note' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$entry['pain_enabled'] = !empty($settings['tracking']['pain_enabled']);
|
||||||
$entry = $this->scoring->normalize($entry);
|
$entry = $this->scoring->normalize($entry);
|
||||||
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
|
$previousEntry = $this->entries->find($user['username'], shift_date($date, -1));
|
||||||
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
||||||
@@ -257,11 +303,16 @@ final class App
|
|||||||
'mood' => $_POST['mood'] ?? 5,
|
'mood' => $_POST['mood'] ?? 5,
|
||||||
'energy' => $_POST['energy'] ?? 5,
|
'energy' => $_POST['energy'] ?? 5,
|
||||||
'stress' => $_POST['stress'] ?? 5,
|
'stress' => $_POST['stress'] ?? 5,
|
||||||
|
'pain' => $_POST['pain'] ?? 1,
|
||||||
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
||||||
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
|
'sleep_hours' => $_POST['sleep_hours'] ?? 0,
|
||||||
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
|
'sleep_feeling' => $_POST['sleep_feeling'] ?? 3,
|
||||||
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
|
'sport_minutes' => $_POST['sport_minutes'] ?? 0,
|
||||||
'sport_types' => $_POST['sport_types'] ?? [],
|
'sport_types' => $_POST['sport_types'] ?? [],
|
||||||
|
'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'),
|
||||||
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
|
'walk_minutes' => $_POST['walk_minutes'] ?? 0,
|
||||||
|
'walk_steps' => $_POST['walk_steps'] ?? 0,
|
||||||
|
'alcohol' => $_POST['alcohol'] ?? false,
|
||||||
'note' => $_POST['note'] ?? '',
|
'note' => $_POST['note'] ?? '',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -270,9 +321,15 @@ final class App
|
|||||||
redirect('/track');
|
redirect('/track');
|
||||||
}
|
}
|
||||||
|
|
||||||
$previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1));
|
$entries = $this->entries->all($user['username']);
|
||||||
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
$entryMap = [];
|
||||||
$this->entries->save($user['username'], $entry['date'], $entry, $evaluation);
|
|
||||||
|
foreach ($entries as $existingEntry) {
|
||||||
|
$entryMap[(string) ($existingEntry['date'] ?? '')] = $existingEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entryMap[$entry['date']] = $entry;
|
||||||
|
$this->persistUserEntries($user['username'], $settings, array_values($entryMap));
|
||||||
|
|
||||||
flash('success', 'Der Tag wurde gespeichert.');
|
flash('success', 'Der Tag wurde gespeichert.');
|
||||||
redirect('/track?date=' . rawurlencode($entry['date']));
|
redirect('/track?date=' . rawurlencode($entry['date']));
|
||||||
@@ -302,6 +359,7 @@ final class App
|
|||||||
'authUser' => $user,
|
'authUser' => $user,
|
||||||
'entries' => $archive,
|
'entries' => $archive,
|
||||||
'selectedEntry' => $selectedEntry,
|
'selectedEntry' => $selectedEntry,
|
||||||
|
'settings' => $settings,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,17 +367,48 @@ final class App
|
|||||||
{
|
{
|
||||||
$user = $this->requireUser();
|
$user = $this->requireUser();
|
||||||
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
$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', [
|
View::render('options', [
|
||||||
'pageTitle' => 'Optionen',
|
'pageTitle' => 'Optionen',
|
||||||
'page' => 'options',
|
'page' => 'options',
|
||||||
'authUser' => $user,
|
'authUser' => $user,
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
|
'sportTypePresets' => $sportTypePresets,
|
||||||
|
'sportLocationOptions' => sport_location_options(),
|
||||||
|
'walkModeOptions' => walk_mode_options(),
|
||||||
|
'pushAvailable' => $pushAvailable,
|
||||||
|
'pushPublicKey' => $pushPublicKey,
|
||||||
|
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
|
||||||
|
'backupAvailable' => class_exists('ZipArchive'),
|
||||||
'users' => $user['is_admin'] ? $this->users->all() : [],
|
'users' => $user['is_admin'] ? $this->users->all() : [],
|
||||||
'maxScore' => $this->scoring->evaluate([
|
'maxScore' => $this->scoring->evaluate([
|
||||||
'mood' => 10,
|
'mood' => 10,
|
||||||
'energy' => 10,
|
'energy' => 10,
|
||||||
'stress' => 1,
|
'stress' => 1,
|
||||||
|
'pain' => 1,
|
||||||
|
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
|
||||||
'sleep_hours' => 7,
|
'sleep_hours' => 7,
|
||||||
'sleep_feeling' => 5,
|
'sleep_feeling' => 5,
|
||||||
'sport_minutes' => 999,
|
'sport_minutes' => 999,
|
||||||
@@ -327,7 +416,10 @@ final class App
|
|||||||
static fn (array $type): string => (string) ($type['id'] ?? ''),
|
static fn (array $type): string => (string) ($type['id'] ?? ''),
|
||||||
normalized_sport_types($settings)
|
normalized_sport_types($settings)
|
||||||
),
|
),
|
||||||
|
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
|
||||||
'walk_minutes' => 999,
|
'walk_minutes' => 999,
|
||||||
|
'walk_steps' => 10000,
|
||||||
|
'alcohol' => false,
|
||||||
'note' => 'x',
|
'note' => 'x',
|
||||||
], $settings)['max_total'],
|
], $settings)['max_total'],
|
||||||
]);
|
]);
|
||||||
@@ -341,9 +433,28 @@ final class App
|
|||||||
$form = (string) ($_POST['form_name'] ?? '');
|
$form = (string) ($_POST['form_name'] ?? '');
|
||||||
|
|
||||||
if ($form === 'settings') {
|
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);
|
$this->settings->saveForUser($user['username'], $settings);
|
||||||
flash('success', 'Die Bewertungslogik wurde aktualisiert.');
|
flash('success', 'Deine persönlichen Optionen wurden aktualisiert.');
|
||||||
|
redirect('/options');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form === 'export_backup') {
|
||||||
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||||
|
$this->downloadUserBackup($user, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($form === 'import_backup') {
|
||||||
|
$settings = $this->hydrateSettings($this->settings->forUser($user['username']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$imported = $this->importUserBackup($user, $settings);
|
||||||
|
flash('success', $imported . ' Tag' . ($imported === 1 ? '' : 'e') . ' aus dem Backup importiert.');
|
||||||
|
} catch (RuntimeException $exception) {
|
||||||
|
flash('error', $exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
redirect('/options');
|
redirect('/options');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +544,220 @@ final class App
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function persistUserEntries(string $username, array $settings, array $entries): void
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (!is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedEntry = $this->scoring->normalize($entry);
|
||||||
|
if (!$this->isValidDate((string) ($normalizedEntry['date'] ?? ''))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$normalizedEntry['date']] = $normalizedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($normalized, SORT_STRING);
|
||||||
|
|
||||||
|
$previousEntry = null;
|
||||||
|
foreach ($normalized as $date => $entry) {
|
||||||
|
$evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry);
|
||||||
|
$this->entries->save($username, $date, $entry, $evaluation);
|
||||||
|
$previousEntry = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadUserBackup(array $user, array $settings): never
|
||||||
|
{
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
flash('error', 'Für den Backup-Download fehlt auf diesem Server die ZIP-Erweiterung.');
|
||||||
|
redirect('/options');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = $this->evaluateEntriesWithContext($this->entries->all($user['username']), $settings);
|
||||||
|
$tempPath = tempnam(sys_get_temp_dir(), 'mood-backup-');
|
||||||
|
|
||||||
|
if ($tempPath === false) {
|
||||||
|
throw new RuntimeException('Das Backup konnte gerade nicht vorbereitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$opened = $zip->open($tempPath, ZipArchive::OVERWRITE);
|
||||||
|
|
||||||
|
if ($opened !== true) {
|
||||||
|
@unlink($tempPath);
|
||||||
|
throw new RuntimeException('Das Backup konnte nicht als ZIP erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$date = (string) ($entry['date'] ?? '');
|
||||||
|
if (!$this->isValidDate($date)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = $this->entries->exportMarkdown(
|
||||||
|
(string) ($user['username'] ?? ''),
|
||||||
|
$date,
|
||||||
|
$entry,
|
||||||
|
$entry['evaluation'] ?? $this->scoring->evaluate($entry, $settings)
|
||||||
|
);
|
||||||
|
|
||||||
|
$zip->addFromString($date . '.txt', $markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$fileName = 'mood-board-backup-' . normalize_username((string) ($user['username'] ?? 'user')) . '-' . today() . '.zip';
|
||||||
|
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $fileName . '"');
|
||||||
|
header('Content-Length: ' . (string) filesize($tempPath));
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
|
readfile($tempPath);
|
||||||
|
@unlink($tempPath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function importUserBackup(array $user, array $settings): int
|
||||||
|
{
|
||||||
|
$files = uploaded_files('backup_files');
|
||||||
|
if ($files === []) {
|
||||||
|
throw new RuntimeException('Bitte wähle mindestens eine Backup-Datei aus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$importedEntries = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||||
|
if ($error === UPLOAD_ERR_NO_FILE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Eine Backup-Datei konnte nicht hochgeladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpName = (string) ($file['tmp_name'] ?? '');
|
||||||
|
$name = trim((string) ($file['name'] ?? ''));
|
||||||
|
|
||||||
|
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||||
|
throw new RuntimeException('Eine Backup-Datei ist ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if ($extension === 'zip') {
|
||||||
|
foreach ($this->entriesFromZip($tmpName) as $date => $entry) {
|
||||||
|
$importedEntries[$date] = $entry;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($extension !== 'txt') {
|
||||||
|
throw new RuntimeException('Es werden nur .txt-Dateien oder ZIP-Backups unterstützt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $this->dateFromBackupFileName($name);
|
||||||
|
$content = (string) file_get_contents($tmpName);
|
||||||
|
$entry = $this->entries->parseMarkdown($content, $date);
|
||||||
|
|
||||||
|
if ($entry === null) {
|
||||||
|
throw new RuntimeException('Mindestens eine Tagesdatei konnte nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$importedEntries[$date] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($importedEntries === []) {
|
||||||
|
throw new RuntimeException('Im Backup wurden keine gültigen Tagesdateien gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingEntries = $this->entries->all((string) ($user['username'] ?? ''));
|
||||||
|
$entryMap = [];
|
||||||
|
|
||||||
|
foreach ($existingEntries as $entry) {
|
||||||
|
if (!is_array($entry) || !$this->isValidDate((string) ($entry['date'] ?? ''))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entryMap[$entry['date']] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($importedEntries as $date => $entry) {
|
||||||
|
$entryMap[$date] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->persistUserEntries((string) ($user['username'] ?? ''), $settings, array_values($entryMap));
|
||||||
|
|
||||||
|
return count($importedEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entriesFromZip(string $path): array
|
||||||
|
{
|
||||||
|
if (!class_exists('ZipArchive')) {
|
||||||
|
throw new RuntimeException('ZIP-Import ist auf diesem Server nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$opened = $zip->open($path);
|
||||||
|
|
||||||
|
if ($opened !== true) {
|
||||||
|
throw new RuntimeException('Das ZIP-Backup konnte nicht geöffnet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$name = (string) $zip->getNameIndex($index);
|
||||||
|
if ($name === '' || str_ends_with($name, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseName = basename($name);
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}\.txt$/', $baseName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = $this->dateFromBackupFileName($baseName);
|
||||||
|
$content = $zip->getFromIndex($index);
|
||||||
|
|
||||||
|
if (!is_string($content)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = $this->entries->parseMarkdown($content, $date);
|
||||||
|
if ($entry !== null) {
|
||||||
|
$entries[$date] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dateFromBackupFileName(string $fileName): string
|
||||||
|
{
|
||||||
|
$baseName = basename($fileName);
|
||||||
|
|
||||||
|
if (!preg_match('/^(\d{4}-\d{2}-\d{2})\.txt$/', $baseName, $matches)) {
|
||||||
|
throw new RuntimeException('Backup-Dateien müssen als YYYY-MM-DD.txt benannt sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = (string) ($matches[1] ?? '');
|
||||||
|
if (!$this->isValidDate($date)) {
|
||||||
|
throw new RuntimeException('Eine Backup-Datei enthält ein ungültiges Datum.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
private function buildDashboardCharts(array $entries): array
|
private function buildDashboardCharts(array $entries): array
|
||||||
{
|
{
|
||||||
$recent = array_slice($entries, -30);
|
$recent = array_slice($entries, -30);
|
||||||
@@ -459,14 +784,26 @@ final class App
|
|||||||
'value' => $entry['stress'],
|
'value' => $entry['stress'],
|
||||||
];
|
];
|
||||||
}, $recent),
|
}, $recent),
|
||||||
|
'pain' => array_map(static function (array $entry): array {
|
||||||
|
return [
|
||||||
|
'date' => $entry['date'],
|
||||||
|
'value' => $entry['pain'],
|
||||||
|
];
|
||||||
|
}, $recent),
|
||||||
'sport' => array_map(static function (array $entry): array {
|
'sport' => array_map(static function (array $entry): array {
|
||||||
return [
|
return [
|
||||||
'date' => $entry['date'],
|
'date' => $entry['date'],
|
||||||
'value' => $entry['sport_minutes'] + $entry['walk_minutes'],
|
'value' => $entry['sport_minutes'] + walk_chart_value($entry),
|
||||||
'sport' => $entry['sport_minutes'],
|
'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(
|
'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'] ?? []
|
$entry['sport_type_meta'] ?? []
|
||||||
))),
|
))),
|
||||||
'sport_icons' => array_values(array_filter(array_map(
|
'sport_icons' => array_values(array_filter(array_map(
|
||||||
@@ -507,16 +844,24 @@ final class App
|
|||||||
return $streak;
|
return $streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sanitizeSettings(array $input): array
|
private function sanitizeSettings(array $input, ?array $existingSettings = null): array
|
||||||
{
|
{
|
||||||
$defaults = Defaults::settings();
|
$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']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3)));
|
||||||
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
|
$settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2)));
|
||||||
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
|
$settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2)));
|
||||||
|
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
|
||||||
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
|
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
|
||||||
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
|
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
|
||||||
|
$settings['tracking'] = [
|
||||||
|
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
|
foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) {
|
||||||
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
|
$settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default)));
|
||||||
@@ -524,10 +869,12 @@ final class App
|
|||||||
|
|
||||||
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
|
foreach (['sport_bands', 'walk_bands'] as $bandKey) {
|
||||||
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
|
foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) {
|
||||||
|
$currentBand = $settings['scoring'][$bandKey][$index] ?? $defaultBand;
|
||||||
|
|
||||||
$settings['scoring'][$bandKey][$index] = [
|
$settings['scoring'][$bandKey][$index] = [
|
||||||
'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))),
|
'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'] ?? $defaultBand['max']))),
|
'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'] ?? $defaultBand['points']))),
|
'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,18 +896,43 @@ final class App
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$sportTypesProvided = array_key_exists('sport_types_present', $input)
|
||||||
|
|| array_key_exists('sport_types', $input);
|
||||||
|
|
||||||
$settings['sport_types'] = normalized_sport_types([
|
$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']
|
? $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;
|
return $settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hydrateSettings(array $settings): array
|
private function hydrateSettings(array $settings): array
|
||||||
{
|
{
|
||||||
$settings['sport_types'] = normalized_sport_types($settings);
|
$settings['sport_types'] = normalized_sport_types($settings);
|
||||||
|
$settings['walk'] = array_replace(
|
||||||
|
Defaults::settings()['walk'],
|
||||||
|
is_array($settings['walk'] ?? null) ? $settings['walk'] : []
|
||||||
|
);
|
||||||
|
$settings['tracking'] = array_replace(
|
||||||
|
Defaults::settings()['tracking'],
|
||||||
|
is_array($settings['tracking'] ?? null) ? $settings['tracking'] : []
|
||||||
|
);
|
||||||
|
$settings['notifications'] = array_replace(
|
||||||
|
Defaults::settings()['notifications'],
|
||||||
|
is_array($settings['notifications'] ?? null) ? $settings['notifications'] : []
|
||||||
|
);
|
||||||
|
|
||||||
return $settings;
|
return $settings;
|
||||||
}
|
}
|
||||||
@@ -594,6 +966,7 @@ final class App
|
|||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
header('X-Frame-Options: DENY');
|
header('X-Frame-Options: DENY');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');
|
||||||
header('Cross-Origin-Opener-Policy: same-origin');
|
header('Cross-Origin-Opener-Policy: same-origin');
|
||||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
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'");
|
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
|
||||||
@@ -607,6 +980,16 @@ final class App
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function enforceRequestCsrf(): void
|
||||||
|
{
|
||||||
|
if (!verify_request_csrf()) {
|
||||||
|
json_response([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Ungültiges Formular-Token.',
|
||||||
|
], 419);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function requireUser(): array
|
private function requireUser(): array
|
||||||
{
|
{
|
||||||
$user = $this->auth->user();
|
$user = $this->auth->user();
|
||||||
@@ -635,10 +1018,259 @@ final class App
|
|||||||
return strlen($password) >= 10;
|
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
|
private function throttleKey(string $username): string
|
||||||
{
|
{
|
||||||
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||||
|
|
||||||
return sha1($remoteAddress . '|' . normalize_username($username));
|
return sha1($remoteAddress . '|' . normalize_username($username));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handlePushSubscribe(): void
|
||||||
|
{
|
||||||
|
$this->enforceRequestCsrf();
|
||||||
|
|
||||||
|
$user = $this->requireUser();
|
||||||
|
$payload = request_json_body();
|
||||||
|
$subscription = $payload['subscription'] ?? null;
|
||||||
|
|
||||||
|
if (!is_array($subscription)) {
|
||||||
|
json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->notifications->saveSubscription($user['username'], [
|
||||||
|
'endpoint' => trim((string) ($subscription['endpoint'] ?? '')),
|
||||||
|
'keys' => [
|
||||||
|
'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')),
|
||||||
|
'auth' => trim((string) ($subscription['keys']['auth'] ?? '')),
|
||||||
|
],
|
||||||
|
'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')),
|
||||||
|
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||||
|
]);
|
||||||
|
} catch (RuntimeException $exception) {
|
||||||
|
json_response(['ok' => false, 'message' => $exception->getMessage()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Push ist auf diesem Gerät aktiviert.',
|
||||||
|
'count' => $this->notifications->subscriptionCount($user['username']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handlePushUnsubscribe(): void
|
||||||
|
{
|
||||||
|
$this->enforceRequestCsrf();
|
||||||
|
|
||||||
|
$user = $this->requireUser();
|
||||||
|
$payload = request_json_body();
|
||||||
|
$endpoint = trim((string) ($payload['endpoint'] ?? ''));
|
||||||
|
|
||||||
|
if ($endpoint === '') {
|
||||||
|
json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifications->removeSubscription($user['username'], $endpoint);
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Push wurde für dieses Gerät entfernt.',
|
||||||
|
'count' => $this->notifications->subscriptionCount($user['username']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handlePushTest(): void
|
||||||
|
{
|
||||||
|
$this->enforceRequestCsrf();
|
||||||
|
|
||||||
|
$user = $this->requireUser();
|
||||||
|
$result = $this->sendNotificationsForUser($user['username'], [
|
||||||
|
'title' => 'Mood-Board',
|
||||||
|
'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.',
|
||||||
|
'url' => '/options',
|
||||||
|
'tag' => 'mood-push-test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($result['sent'] <= 0) {
|
||||||
|
json_response([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.',
|
||||||
|
'removed' => $result['removed'],
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => 'Die Test-Benachrichtigung wurde verschickt.',
|
||||||
|
'sent' => $result['sent'],
|
||||||
|
'removed' => $result['removed'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleReminderRun(): void
|
||||||
|
{
|
||||||
|
$providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? '')));
|
||||||
|
$expectedToken = $this->webPush->cronToken();
|
||||||
|
|
||||||
|
if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) {
|
||||||
|
json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $this->runDueReminders(new DateTimeImmutable('now'));
|
||||||
|
|
||||||
|
json_response([
|
||||||
|
'ok' => true,
|
||||||
|
'processed' => $stats['processed'],
|
||||||
|
'sent_users' => $stats['sent_users'],
|
||||||
|
'already_tracked' => $stats['already_tracked'],
|
||||||
|
'skipped' => $stats['skipped'],
|
||||||
|
'removed_subscriptions' => $stats['removed_subscriptions'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendNotificationsForUser(string $username, array $message): array
|
||||||
|
{
|
||||||
|
$subscriptions = $this->notifications->subscriptionsForUser($username);
|
||||||
|
$sent = 0;
|
||||||
|
$removedEndpoints = [];
|
||||||
|
|
||||||
|
foreach ($subscriptions as $subscription) {
|
||||||
|
try {
|
||||||
|
$result = $this->webPush->send($subscription, $message);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
$result = [
|
||||||
|
'ok' => false,
|
||||||
|
'remove' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($result['ok'])) {
|
||||||
|
$sent++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($result['remove'])) {
|
||||||
|
$endpoint = (string) ($subscription['endpoint'] ?? '');
|
||||||
|
if ($endpoint !== '') {
|
||||||
|
$removedEndpoints[] = $endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($removedEndpoints !== []) {
|
||||||
|
$this->notifications->removeInvalidSubscriptions($username, $removedEndpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sent' => $sent,
|
||||||
|
'removed' => count($removedEndpoints),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool
|
||||||
|
{
|
||||||
|
if (empty($settings['notifications']['enabled'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reminderTime = (string) ($settings['notifications']['time'] ?? '');
|
||||||
|
if (!$this->isValidTime($reminderTime)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentTime < $reminderTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ($state['last_sent_date'] ?? '') !== $today;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function triggerReminderCheckFromTraffic(string $method, string $path): void
|
||||||
|
{
|
||||||
|
if ($method !== 'GET' || $path === '/reminders/run') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockPath = storage_path('system/reminder-traffic.lock');
|
||||||
|
$handle = fopen($lockPath, 'c+');
|
||||||
|
if ($handle === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flock($handle, LOCK_EX | LOCK_NB)) {
|
||||||
|
fclose($handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->runDueReminders(new DateTimeImmutable('now'));
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Reminder checks should never break normal page delivery.
|
||||||
|
}
|
||||||
|
|
||||||
|
flock($handle, LOCK_UN);
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runDueReminders(DateTimeImmutable $now): array
|
||||||
|
{
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
|
$currentTime = $now->format('H:i');
|
||||||
|
$processed = 0;
|
||||||
|
$sentUsers = 0;
|
||||||
|
$alreadyTracked = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$removed = 0;
|
||||||
|
|
||||||
|
foreach ($this->users->all() as $account) {
|
||||||
|
$username = (string) ($account['username'] ?? '');
|
||||||
|
if ($username === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
$settings = $this->hydrateSettings($this->settings->forUser($username));
|
||||||
|
$state = $this->notifications->reminderState($username);
|
||||||
|
|
||||||
|
if (!$this->isReminderDue($settings, $state, $today, $currentTime)) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->entries->find($username, $today) !== null) {
|
||||||
|
$alreadyTracked++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->sendNotificationsForUser($username, [
|
||||||
|
'title' => 'Mood-Board',
|
||||||
|
'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.',
|
||||||
|
'url' => '/track?date=' . rawurlencode($today),
|
||||||
|
'tag' => 'mood-reminder-' . $today,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$removed += $result['removed'];
|
||||||
|
|
||||||
|
if ($result['sent'] > 0) {
|
||||||
|
$sentUsers++;
|
||||||
|
$this->notifications->saveReminderState($username, [
|
||||||
|
'last_sent_date' => $today,
|
||||||
|
'last_sent_at' => $now->format(DATE_ATOM),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => $processed,
|
||||||
|
'sent_users' => $sentUsers,
|
||||||
|
'already_tracked' => $alreadyTracked,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'removed_subscriptions' => $removed,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
final class EntryRepository
|
final class EntryRepository
|
||||||
{
|
{
|
||||||
|
private EntryCrypto $crypto;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->crypto = new EntryCrypto();
|
||||||
|
}
|
||||||
|
|
||||||
public function save(string $username, string $date, array $entry, array $evaluation): void
|
public function save(string $username, string $date, array $entry, array $evaluation): void
|
||||||
{
|
{
|
||||||
$path = $this->pathFor($username, $date);
|
$path = $this->pathFor($username, $date);
|
||||||
@@ -13,7 +20,8 @@ final class EntryRepository
|
|||||||
mkdir($directory, 0775, true);
|
mkdir($directory, 0775, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
|
$markdown = $this->toMarkdown($username, $date, $entry, $evaluation);
|
||||||
|
file_put_contents($path, $this->crypto->encrypt($markdown), LOCK_EX);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(string $username, string $date): ?array
|
public function find(string $username, string $date): ?array
|
||||||
@@ -24,7 +32,14 @@ final class EntryRepository
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->parse((string) file_get_contents($path), $date);
|
$content = (string) file_get_contents($path);
|
||||||
|
$plaintext = $this->crypto->decrypt($content);
|
||||||
|
|
||||||
|
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
|
||||||
|
file_put_contents($path, $this->crypto->encrypt($plaintext), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parse($plaintext, $date);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function all(string $username): array
|
public function all(string $username): array
|
||||||
@@ -41,7 +56,14 @@ final class EntryRepository
|
|||||||
$entries = [];
|
$entries = [];
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$date = basename($file, '.txt');
|
$date = basename($file, '.txt');
|
||||||
$parsed = $this->parse((string) file_get_contents($file), $date);
|
$content = (string) file_get_contents($file);
|
||||||
|
$plaintext = $this->crypto->decrypt($content);
|
||||||
|
|
||||||
|
if ($this->crypto->shouldEncrypt() && !$this->crypto->isEncrypted($content)) {
|
||||||
|
file_put_contents($file, $this->crypto->encrypt($plaintext), LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = $this->parse($plaintext, $date);
|
||||||
if ($parsed !== null) {
|
if ($parsed !== null) {
|
||||||
$entries[] = $parsed;
|
$entries[] = $parsed;
|
||||||
}
|
}
|
||||||
@@ -50,6 +72,18 @@ final class EntryRepository
|
|||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function parseMarkdown(string $content, string $fallbackDate): ?array
|
||||||
|
{
|
||||||
|
$plaintext = $this->crypto->decrypt($content);
|
||||||
|
|
||||||
|
return $this->parse($plaintext, $fallbackDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||||
|
{
|
||||||
|
return $this->toMarkdown($username, $date, $entry, $evaluation);
|
||||||
|
}
|
||||||
|
|
||||||
private function directoryFor(string $username): string
|
private function directoryFor(string $username): string
|
||||||
{
|
{
|
||||||
return storage_path('users/' . normalize_username($username) . '/days');
|
return storage_path('users/' . normalize_username($username) . '/days');
|
||||||
@@ -77,17 +111,28 @@ final class EntryRepository
|
|||||||
$sportTypes = normalize_sport_type_selection($sportType);
|
$sportTypes = normalize_sport_type_selection($sportType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit'));
|
||||||
|
$walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time';
|
||||||
|
$walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0);
|
||||||
|
$painRaw = $this->extract('/^- Schmerzen:\s*(.+)$/mu', $content);
|
||||||
|
$alcoholRaw = strtolower((string) ($this->extract('/^- Alkohol:\s*(.+)$/mu', $content) ?? 'nein'));
|
||||||
|
|
||||||
$entry = [
|
$entry = [
|
||||||
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
|
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
|
||||||
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
|
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
|
||||||
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
|
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
|
||||||
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
|
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
|
||||||
|
'pain' => $painRaw !== null ? (int) $painRaw : 1,
|
||||||
|
'pain_enabled' => $painRaw !== null,
|
||||||
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
|
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
|
||||||
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
|
'sleep_feeling' => (int) ($this->extract('/^- Schlaf(?:gefühl|gefuehl):\s*(.+)$/mu', $content) ?? 3),
|
||||||
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
|
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
|
||||||
'sport_type' => $sportTypes[0] ?? '',
|
'sport_type' => $sportTypes[0] ?? '',
|
||||||
'sport_types' => $sportTypes,
|
'sport_types' => $sportTypes,
|
||||||
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
|
'walk_mode' => $walkMode,
|
||||||
|
'walk_minutes' => $walkMode === 'time' ? $walkValue : 0,
|
||||||
|
'walk_steps' => $walkMode === 'steps' ? $walkValue : 0,
|
||||||
|
'alcohol' => in_array($alcoholRaw, ['ja', 'yes', 'true', '1'], true),
|
||||||
'note' => $this->extractNote($content),
|
'note' => $this->extractNote($content),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -134,11 +179,14 @@ final class EntryRepository
|
|||||||
'- Stimmung: ' . $entry['mood'],
|
'- Stimmung: ' . $entry['mood'],
|
||||||
'- Energie: ' . $entry['energy'],
|
'- Energie: ' . $entry['energy'],
|
||||||
'- Stress: ' . $entry['stress'],
|
'- Stress: ' . $entry['stress'],
|
||||||
|
...(!empty($entry['pain_enabled']) ? ['- Schmerzen: ' . $entry['pain']] : []),
|
||||||
'- Schlafdauer: ' . $entry['sleep_hours'],
|
'- Schlafdauer: ' . $entry['sleep_hours'],
|
||||||
'- Schlafgefühl: ' . $entry['sleep_feeling'],
|
'- Schlafgefühl: ' . $entry['sleep_feeling'],
|
||||||
'- Sport: ' . $entry['sport_minutes'],
|
'- Sport: ' . $entry['sport_minutes'],
|
||||||
'- Sportarten: ' . implode(', ', $sportTypeValues),
|
'- Sportarten: ' . implode(', ', $sportTypeValues),
|
||||||
'- Spaziergang: ' . $entry['walk_minutes'],
|
'- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'),
|
||||||
|
'- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)),
|
||||||
|
'- Alkohol: ' . (!empty($entry['alcohol']) ? 'ja' : 'nein'),
|
||||||
'',
|
'',
|
||||||
'## Bewertung',
|
'## Bewertung',
|
||||||
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
|
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
|
||||||
@@ -148,11 +196,13 @@ final class EntryRepository
|
|||||||
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
|
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
|
||||||
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
|
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
|
||||||
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
|
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
|
||||||
|
...(array_key_exists('pain', $evaluation['components']) ? ['- Schmerzen: ' . format_points((float) $evaluation['components']['pain'])] : []),
|
||||||
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
|
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
|
||||||
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
|
'- Schlafgefühl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
|
||||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||||
|
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||||
'',
|
'',
|
||||||
'## Notiz',
|
'## Notiz',
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,17 @@ final class ScoringService
|
|||||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||||
|
'pain' => max(1, min(10, (int) ($input['pain'] ?? 1))),
|
||||||
|
'pain_enabled' => $this->normalizeBoolean($input['pain_enabled'] ?? false),
|
||||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||||
'sport_type' => $sportTypes[0] ?? '',
|
'sport_type' => $sportTypes[0] ?? '',
|
||||||
'sport_types' => $sportTypes,
|
'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_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||||
|
'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))),
|
||||||
|
'alcohol' => $this->normalizeBoolean($input['alcohol'] ?? false),
|
||||||
'note' => trim((string) ($input['note'] ?? '')),
|
'note' => trim((string) ($input['note'] ?? '')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -31,6 +36,7 @@ final class ScoringService
|
|||||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||||
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
$sportTypes = find_sport_types($settings, $entry['sport_types']);
|
||||||
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
$sportBonus = $this->sportBonusPoints($entry, $previousEntry, $settings, $sportTypes);
|
||||||
|
$painEnabled = !empty($settings['tracking']['pain_enabled']);
|
||||||
|
|
||||||
$components = [
|
$components = [
|
||||||
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
|
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
|
||||||
@@ -40,20 +46,26 @@ final class ScoringService
|
|||||||
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
|
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
|
||||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||||
'sport_bonus' => $sportBonus,
|
'sport_bonus' => $sportBonus,
|
||||||
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
|
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||||
|
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($painEnabled) {
|
||||||
|
$components['pain'] = (11 - $entry['pain']) * (float) $scoring['pain_multiplier'];
|
||||||
|
}
|
||||||
|
|
||||||
$total = round(array_sum($components), 1);
|
$total = round(array_sum($components), 1);
|
||||||
$maxTotal = round(
|
$maxTotal = round(
|
||||||
(10 * (float) $scoring['mood_multiplier']) +
|
(10 * (float) $scoring['mood_multiplier']) +
|
||||||
(10 * (float) $scoring['energy_multiplier']) +
|
(10 * (float) $scoring['energy_multiplier']) +
|
||||||
(10 * (float) $scoring['stress_multiplier']) +
|
(10 * (float) $scoring['stress_multiplier']) +
|
||||||
|
($painEnabled ? (10 * (float) $scoring['pain_multiplier']) : 0.0) +
|
||||||
max(array_map('floatval', $scoring['sleep_duration_points'])) +
|
max(array_map('floatval', $scoring['sleep_duration_points'])) +
|
||||||
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
||||||
$this->maxBandPoints($scoring['sport_bands']) +
|
$this->maxBandPoints($scoring['sport_bands']) +
|
||||||
$this->maxSportBonusPoints($settings) +
|
$this->maxSportBonusPoints($settings) +
|
||||||
$this->maxBandPoints($scoring['walk_bands']) +
|
$this->maxWalkPoints($entry, $settings) +
|
||||||
(float) $scoring['journal_points'],
|
(float) $scoring['journal_points'],
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
@@ -146,6 +158,72 @@ final class ScoringService
|
|||||||
return $max;
|
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
|
private function maxSportBonusPoints(array $settings): float
|
||||||
{
|
{
|
||||||
$max = 0.0;
|
$max = 0.0;
|
||||||
@@ -280,4 +358,26 @@ final class ScoringService
|
|||||||
default => 'radiant',
|
default => 'radiant',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeWalkMode(string $mode): string
|
||||||
|
{
|
||||||
|
return $mode === 'steps' ? 'steps' : 'time';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBoolean(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (int) $value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'ja', 'on'], true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ final class Auth
|
|||||||
|
|
||||||
$username = $_SESSION['user']['username'] ?? null;
|
$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
|
public function user(): ?array
|
||||||
@@ -28,7 +34,7 @@ final class Auth
|
|||||||
return $_SESSION['user'];
|
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);
|
$user = $this->users->verify($username, $password);
|
||||||
|
|
||||||
@@ -36,12 +42,12 @@ final class Auth
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->login($user);
|
$this->login($user, $remember);
|
||||||
|
|
||||||
return true;
|
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'] === '') {
|
if (!isset($user['username']) || !is_string($user['username']) || $user['username'] === '') {
|
||||||
throw new RuntimeException('Der Benutzer konnte nicht angemeldet werden.');
|
throw new RuntimeException('Der Benutzer konnte nicht angemeldet werden.');
|
||||||
@@ -53,11 +59,20 @@ final class Auth
|
|||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
'is_admin' => (bool) ($user['is_admin'] ?? false),
|
'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
|
public function logout(): void
|
||||||
{
|
{
|
||||||
unset($_SESSION['user']);
|
unset($_SESSION['user']);
|
||||||
|
unset($_SESSION['remember_me']);
|
||||||
|
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,43 +16,99 @@ final class Defaults
|
|||||||
5 => 'sehr ausgeschlafen',
|
5 => 'sehr ausgeschlafen',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'walk' => [
|
||||||
|
'mode' => 'time',
|
||||||
|
],
|
||||||
|
'tracking' => [
|
||||||
|
'pain_enabled' => false,
|
||||||
|
],
|
||||||
'sport_types' => [
|
'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',
|
'id' => 'running',
|
||||||
'label' => 'Joggen',
|
'label' => 'Joggen',
|
||||||
'icon' => 'run',
|
'icon' => 'run',
|
||||||
|
'location' => '',
|
||||||
'recovery_group' => 'joggen',
|
'recovery_group' => 'joggen',
|
||||||
'bonus_points' => 2,
|
'bonus_points' => 2,
|
||||||
'allow_consecutive' => false,
|
'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',
|
'id' => 'rowing',
|
||||||
'label' => 'Rudergerät',
|
'label' => 'Rudern',
|
||||||
'icon' => 'row',
|
'icon' => 'row',
|
||||||
|
'location' => '',
|
||||||
'recovery_group' => 'rudern',
|
'recovery_group' => 'rudern',
|
||||||
'bonus_points' => 2,
|
'bonus_points' => 2,
|
||||||
'allow_consecutive' => false,
|
'allow_consecutive' => false,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'id' => 'dance',
|
||||||
|
'label' => 'Tanzen',
|
||||||
|
'icon' => 'dance',
|
||||||
|
'location' => '',
|
||||||
|
'recovery_group' => 'tanzen',
|
||||||
|
'bonus_points' => 2,
|
||||||
|
'allow_consecutive' => false,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'id' => 'core',
|
'id' => 'core',
|
||||||
'label' => 'Core',
|
'label' => 'Core',
|
||||||
'icon' => 'core',
|
'icon' => 'core',
|
||||||
|
'location' => '',
|
||||||
'recovery_group' => 'core',
|
'recovery_group' => 'core',
|
||||||
'bonus_points' => 2,
|
'bonus_points' => 2,
|
||||||
'allow_consecutive' => true,
|
'allow_consecutive' => true,
|
||||||
@@ -62,6 +118,7 @@ final class Defaults
|
|||||||
'mood_multiplier' => 3,
|
'mood_multiplier' => 3,
|
||||||
'energy_multiplier' => 2,
|
'energy_multiplier' => 2,
|
||||||
'stress_multiplier' => 2,
|
'stress_multiplier' => 2,
|
||||||
|
'pain_multiplier' => 3,
|
||||||
'sleep_feeling_multiplier' => 2,
|
'sleep_feeling_multiplier' => 2,
|
||||||
'sleep_duration_points' => [
|
'sleep_duration_points' => [
|
||||||
'lt4' => 0,
|
'lt4' => 0,
|
||||||
@@ -85,7 +142,18 @@ final class Defaults
|
|||||||
['min' => 16, 'max' => 40, 'points' => 5],
|
['min' => 16, 'max' => 40, 'points' => 5],
|
||||||
['min' => 41, 'max' => 10000, 'points' => 7],
|
['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,
|
'journal_points' => 2,
|
||||||
|
'alcohol_penalty' => 5,
|
||||||
],
|
],
|
||||||
'ratings' => [
|
'ratings' => [
|
||||||
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
|
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
|
||||||
@@ -106,6 +174,10 @@ final class Defaults
|
|||||||
'cap_label' => 'schwerer Tag',
|
'cap_label' => 'schwerer Tag',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'notifications' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'time' => '20:30',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class EntryCrypto
|
||||||
|
{
|
||||||
|
private const HEADER = "MOODENC1\n";
|
||||||
|
|
||||||
|
private string $fallbackKeyPath;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return function_exists('openssl_encrypt')
|
||||||
|
&& function_exists('openssl_decrypt')
|
||||||
|
&& function_exists('random_bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldEncrypt(): bool
|
||||||
|
{
|
||||||
|
return $this->isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEncrypted(string $content): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($content, self::HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
if (!$this->shouldEncrypt()) {
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iv = random_bytes(12);
|
||||||
|
$tag = '';
|
||||||
|
$ciphertext = openssl_encrypt(
|
||||||
|
$plaintext,
|
||||||
|
'aes-256-gcm',
|
||||||
|
$this->key(),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv,
|
||||||
|
$tag
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_string($ciphertext) || $tag === '') {
|
||||||
|
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'iv' => base64_encode($iv),
|
||||||
|
'tag' => base64_encode($tag),
|
||||||
|
'data' => base64_encode($ciphertext),
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (!is_string($payload)) {
|
||||||
|
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::HEADER . $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrypt(string $content): string
|
||||||
|
{
|
||||||
|
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = substr($content, strlen(self::HEADER));
|
||||||
|
$decoded = json_decode($payload, true);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!is_array($decoded)
|
||||||
|
|| !is_string($decoded['iv'] ?? null)
|
||||||
|
|| !is_string($decoded['tag'] ?? null)
|
||||||
|
|| !is_string($decoded['data'] ?? null)
|
||||||
|
) {
|
||||||
|
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
(string) base64_decode($decoded['data'], true),
|
||||||
|
'aes-256-gcm',
|
||||||
|
$this->key(),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
(string) base64_decode($decoded['iv'], true),
|
||||||
|
(string) base64_decode($decoded['tag'], true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_string($plaintext)) {
|
||||||
|
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(): string
|
||||||
|
{
|
||||||
|
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
|
||||||
|
if ($configured !== '') {
|
||||||
|
return hash('sha256', $configured, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $this->readFallbackKey();
|
||||||
|
if ($stored !== null) {
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = random_bytes(32);
|
||||||
|
$directory = dirname($this->fallbackKeyPath);
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
|
||||||
|
@chmod($this->fallbackKeyPath, 0600);
|
||||||
|
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readFallbackKey(): ?string
|
||||||
|
{
|
||||||
|
if (!is_file($this->fallbackKeyPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($raw, true);
|
||||||
|
|
||||||
|
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class WebPushService
|
||||||
|
{
|
||||||
|
private NotificationRepository $notifications;
|
||||||
|
|
||||||
|
public function __construct(NotificationRepository $notifications)
|
||||||
|
{
|
||||||
|
$this->notifications = $notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return function_exists('openssl_pkey_new')
|
||||||
|
&& function_exists('openssl_sign')
|
||||||
|
&& function_exists('openssl_encrypt')
|
||||||
|
&& function_exists('openssl_pkey_derive')
|
||||||
|
&& function_exists('curl_init');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publicKey(): ?string
|
||||||
|
{
|
||||||
|
$keys = $this->keys();
|
||||||
|
|
||||||
|
return $keys['public'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cronToken(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->notifications->systemConfig()['cron_token'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(array $subscription, array $message): array
|
||||||
|
{
|
||||||
|
if (!$this->isAvailable()) {
|
||||||
|
throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
|
||||||
|
$p256dh = trim((string) ($subscription['keys']['p256dh'] ?? ''));
|
||||||
|
$auth = trim((string) ($subscription['keys']['auth'] ?? ''));
|
||||||
|
|
||||||
|
if ($endpoint === '' || $p256dh === '' || $auth === '') {
|
||||||
|
throw new RuntimeException('Die Push-Subscription ist unvollständig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'title' => (string) ($message['title'] ?? 'Mood-Board'),
|
||||||
|
'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'),
|
||||||
|
'icon' => '/assets/branding/logo-mark.svg',
|
||||||
|
'badge' => '/assets/branding/favicon.svg',
|
||||||
|
'url' => (string) ($message['url'] ?? '/track'),
|
||||||
|
'tag' => (string) ($message['tag'] ?? 'mood-reminder'),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (!is_string($payload)) {
|
||||||
|
throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = $this->encrypt($payload, $p256dh, $auth);
|
||||||
|
$audience = $this->audienceForEndpoint($endpoint);
|
||||||
|
$authorization = $this->authorizationHeader($audience);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'TTL: 3600',
|
||||||
|
'Urgency: normal',
|
||||||
|
'Content-Encoding: aes128gcm',
|
||||||
|
'Content-Type: application/octet-stream',
|
||||||
|
'Authorization: ' . $authorization['header'],
|
||||||
|
'Crypto-Key: p256ecdsa=' . $authorization['public'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$handle = curl_init($endpoint);
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_setopt_array($handle, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $encrypted['body'],
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => false,
|
||||||
|
CURLOPT_TIMEOUT => 12,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$responseBody = curl_exec($handle);
|
||||||
|
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||||
|
$error = curl_error($handle);
|
||||||
|
curl_close($handle);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $status >= 200 && $status < 300,
|
||||||
|
'status' => $status,
|
||||||
|
'remove' => in_array($status, [404, 410], true),
|
||||||
|
'error' => $error !== '' ? $error : null,
|
||||||
|
'response' => is_string($responseBody) ? $responseBody : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keys(): array
|
||||||
|
{
|
||||||
|
$config = $this->notifications->systemConfig();
|
||||||
|
$public = trim((string) ($config['vapid_public_key'] ?? ''));
|
||||||
|
$private = trim((string) ($config['vapid_private_key'] ?? ''));
|
||||||
|
|
||||||
|
if ($public !== '' && $private !== '') {
|
||||||
|
return ['public' => $public, 'private' => $private];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAvailable()) {
|
||||||
|
return ['public' => null, 'private' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resource = openssl_pkey_new([
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||||
|
'curve_name' => 'prime256v1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($resource === false) {
|
||||||
|
throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = openssl_pkey_get_details($resource);
|
||||||
|
if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) {
|
||||||
|
throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y'];
|
||||||
|
$privateKey = $details['ec']['d'];
|
||||||
|
|
||||||
|
$encodedPublic = base64url_encode($publicKey);
|
||||||
|
$encodedPrivate = base64url_encode($privateKey);
|
||||||
|
|
||||||
|
$this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate);
|
||||||
|
|
||||||
|
return ['public' => $encodedPublic, 'private' => $encodedPrivate];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encrypt(string $payload, string $userPublicKey, string $authSecret): array
|
||||||
|
{
|
||||||
|
$salt = random_bytes(16);
|
||||||
|
$userPublicRaw = base64url_decode($userPublicKey);
|
||||||
|
$authRaw = base64url_decode($authSecret);
|
||||||
|
|
||||||
|
$localKey = openssl_pkey_new([
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||||
|
'curve_name' => 'prime256v1',
|
||||||
|
]);
|
||||||
|
if ($localKey === false) {
|
||||||
|
throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$localDetails = openssl_pkey_get_details($localKey);
|
||||||
|
if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) {
|
||||||
|
throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y'];
|
||||||
|
$userPem = $this->publicKeyPemFromRaw($userPublicRaw);
|
||||||
|
$sharedSecret = openssl_pkey_derive($userPem, $localKey, 32);
|
||||||
|
|
||||||
|
if (!is_string($sharedSecret) || $sharedSecret === '') {
|
||||||
|
throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw;
|
||||||
|
$keyMaterial = $this->hkdfExpand(
|
||||||
|
$this->hkdfExtract($authRaw, $sharedSecret),
|
||||||
|
$context,
|
||||||
|
32
|
||||||
|
);
|
||||||
|
|
||||||
|
$contentPrk = $this->hkdfExtract($salt, $keyMaterial);
|
||||||
|
$contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16);
|
||||||
|
$nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12);
|
||||||
|
|
||||||
|
$recordSize = 4096;
|
||||||
|
$plaintext = $payload . "\x02";
|
||||||
|
$tag = '';
|
||||||
|
$ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag);
|
||||||
|
|
||||||
|
if (!is_string($ciphertext)) {
|
||||||
|
throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $salt
|
||||||
|
. pack('N', $recordSize)
|
||||||
|
. chr(strlen($localPublicRaw))
|
||||||
|
. $localPublicRaw
|
||||||
|
. $ciphertext
|
||||||
|
. $tag;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'body' => $body,
|
||||||
|
'local_public' => $localPublicRaw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizationHeader(string $audience): array
|
||||||
|
{
|
||||||
|
$keys = $this->keys();
|
||||||
|
$header = base64url_encode((string) json_encode([
|
||||||
|
'typ' => 'JWT',
|
||||||
|
'alg' => 'ES256',
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
$payload = base64url_encode((string) json_encode([
|
||||||
|
'aud' => $audience,
|
||||||
|
'exp' => time() + 3600,
|
||||||
|
'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'),
|
||||||
|
], JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
$signingInput = $header . '.' . $payload;
|
||||||
|
$signatureDer = '';
|
||||||
|
$privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private']));
|
||||||
|
|
||||||
|
if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) {
|
||||||
|
throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'],
|
||||||
|
'public' => (string) $keys['public'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function audienceForEndpoint(string $endpoint): string
|
||||||
|
{
|
||||||
|
$parts = parse_url($endpoint);
|
||||||
|
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
throw new RuntimeException('Der Push-Endpunkt ist ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts['scheme'] . '://' . $parts['host'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hkdfExtract(string $salt, string $ikm): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $ikm, $salt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hkdfExpand(string $prk, string $info, int $length): string
|
||||||
|
{
|
||||||
|
$output = '';
|
||||||
|
$block = '';
|
||||||
|
$counter = 1;
|
||||||
|
|
||||||
|
while (strlen($output) < $length) {
|
||||||
|
$block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true);
|
||||||
|
$output .= $block;
|
||||||
|
$counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($output, 0, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publicKeyPemFromRaw(string $raw): string
|
||||||
|
{
|
||||||
|
$der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw;
|
||||||
|
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n"
|
||||||
|
. chunk_split(base64_encode((string) $der), 64, "\n")
|
||||||
|
. "-----END PUBLIC KEY-----\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function privateKeyPemFromRaw(string $raw): string
|
||||||
|
{
|
||||||
|
$der = hex2bin('30770201010420')
|
||||||
|
. $raw
|
||||||
|
. hex2bin('A00A06082A8648CE3D030107A14403420004')
|
||||||
|
. substr(base64url_decode((string) $this->keys()['public']), 1);
|
||||||
|
|
||||||
|
return "-----BEGIN EC PRIVATE KEY-----\n"
|
||||||
|
. chunk_split(base64_encode((string) $der), 64, "\n")
|
||||||
|
. "-----END EC PRIVATE KEY-----\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function derSignatureToJose(string $der, int $partLength): string
|
||||||
|
{
|
||||||
|
$offset = 0;
|
||||||
|
if (ord($der[$offset]) !== 0x30) {
|
||||||
|
throw new RuntimeException('Ungültige DER-Signatur.');
|
||||||
|
}
|
||||||
|
$offset++;
|
||||||
|
$this->readAsnLength($der, $offset);
|
||||||
|
|
||||||
|
if (ord($der[$offset]) !== 0x02) {
|
||||||
|
throw new RuntimeException('Ungültiger DER-R-Teil.');
|
||||||
|
}
|
||||||
|
$offset++;
|
||||||
|
$rLength = $this->readAsnLength($der, $offset);
|
||||||
|
$r = substr($der, $offset, $rLength);
|
||||||
|
$offset += $rLength;
|
||||||
|
|
||||||
|
if (ord($der[$offset]) !== 0x02) {
|
||||||
|
throw new RuntimeException('Ungültiger DER-S-Teil.');
|
||||||
|
}
|
||||||
|
$offset++;
|
||||||
|
$sLength = $this->readAsnLength($der, $offset);
|
||||||
|
$s = substr($der, $offset, $sLength);
|
||||||
|
|
||||||
|
return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT)
|
||||||
|
. str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readAsnLength(string $der, int &$offset): int
|
||||||
|
{
|
||||||
|
$length = ord($der[$offset]);
|
||||||
|
$offset++;
|
||||||
|
|
||||||
|
if (($length & 0x80) === 0) {
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberOfBytes = $length & 0x7F;
|
||||||
|
$length = 0;
|
||||||
|
for ($index = 0; $index < $numberOfBytes; $index++) {
|
||||||
|
$length = ($length << 8) | ord($der[$offset]);
|
||||||
|
$offset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,36 +5,25 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/helpers.php';
|
require __DIR__ . '/helpers.php';
|
||||||
require __DIR__ . '/Support/Defaults.php';
|
require __DIR__ . '/Support/Defaults.php';
|
||||||
require __DIR__ . '/Support/Auth.php';
|
require __DIR__ . '/Support/Auth.php';
|
||||||
|
require __DIR__ . '/Support/EntryCrypto.php';
|
||||||
require __DIR__ . '/Support/View.php';
|
require __DIR__ . '/Support/View.php';
|
||||||
|
require __DIR__ . '/Support/WebPushService.php';
|
||||||
require __DIR__ . '/Domain/UserRepository.php';
|
require __DIR__ . '/Domain/UserRepository.php';
|
||||||
require __DIR__ . '/Domain/SettingsRepository.php';
|
require __DIR__ . '/Domain/SettingsRepository.php';
|
||||||
require __DIR__ . '/Domain/EntryRepository.php';
|
require __DIR__ . '/Domain/EntryRepository.php';
|
||||||
require __DIR__ . '/Domain/LoginThrottle.php';
|
require __DIR__ . '/Domain/LoginThrottle.php';
|
||||||
|
require __DIR__ . '/Domain/NotificationRepository.php';
|
||||||
require __DIR__ . '/Domain/ScoringService.php';
|
require __DIR__ . '/Domain/ScoringService.php';
|
||||||
require __DIR__ . '/App.php';
|
require __DIR__ . '/App.php';
|
||||||
|
|
||||||
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin');
|
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_only_cookies', '1');
|
||||||
ini_set('session.use_strict_mode', '1');
|
ini_set('session.use_strict_mode', '1');
|
||||||
|
ini_set('session.gc_maxlifetime', (string) remember_me_lifetime());
|
||||||
|
|
||||||
session_name('mood_session');
|
session_name('mood_session');
|
||||||
session_set_cookie_params([
|
session_set_cookie_params(session_cookie_params_for());
|
||||||
'lifetime' => 0,
|
|
||||||
'path' => '/',
|
|
||||||
'domain' => '',
|
|
||||||
'secure' => $isSecure,
|
|
||||||
'httponly' => true,
|
|
||||||
'samesite' => 'Lax',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
session_start();
|
session_start();
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ function redirect(string $path): never
|
|||||||
exit;
|
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
|
function request_path(): string
|
||||||
{
|
{
|
||||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
$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);
|
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
|
function is_active_path(string $path): bool
|
||||||
{
|
{
|
||||||
return request_path() === $path;
|
return request_path() === $path;
|
||||||
@@ -117,6 +136,18 @@ function decode_json_file(string $path, array $fallback = []): array
|
|||||||
return is_array($decoded) ? $decoded : $fallback;
|
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
|
function encode_payload(array $payload): string
|
||||||
{
|
{
|
||||||
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
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);
|
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
|
function sport_icon_path(string $icon): string
|
||||||
{
|
{
|
||||||
return icon_path('sport-' . $icon);
|
return icon_path('sport-' . $icon);
|
||||||
@@ -184,14 +264,125 @@ function sport_icon_path(string $icon): string
|
|||||||
function sport_icon_options(): array
|
function sport_icon_options(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'strength-home' => 'Kraftsport daheim',
|
'strength' => 'Krafttraining',
|
||||||
'strength-gym' => 'Kraftsport im Gym',
|
'bike' => 'Radfahren',
|
||||||
'run' => 'Joggen',
|
'run' => 'Joggen',
|
||||||
|
'hike' => 'Wandern',
|
||||||
|
'swim' => 'Schwimmen',
|
||||||
|
'yoga' => 'Yoga',
|
||||||
|
'hiit' => 'HIIT / Workout',
|
||||||
'row' => 'Rudergerät',
|
'row' => 'Rudergerät',
|
||||||
|
'dance' => 'Tanzen',
|
||||||
'core' => 'Core',
|
'core' => 'Core',
|
||||||
|
'strength-home' => 'Krafttraining Zuhause',
|
||||||
|
'strength-gym' => 'Krafttraining Auswärts',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sport_location_options(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'' => 'ohne Angabe',
|
||||||
|
'home' => 'Zuhause',
|
||||||
|
'away' => 'Auswärts',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sport_location_label(?string $value): string
|
||||||
|
{
|
||||||
|
$options = sport_location_options();
|
||||||
|
$value = is_string($value) ? trim($value) : '';
|
||||||
|
|
||||||
|
return $options[$value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk_mode_options(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'time' => 'Spaziergang nach Zeit',
|
||||||
|
'steps' => 'Spaziergang nach Schritten',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk_mode_label(string $mode): string
|
||||||
|
{
|
||||||
|
return walk_mode_options()[$mode] ?? walk_mode_options()['time'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function format_walk_value(array $entry): string
|
||||||
|
{
|
||||||
|
$mode = (string) ($entry['walk_mode'] ?? 'time');
|
||||||
|
|
||||||
|
if ($mode === 'steps') {
|
||||||
|
return number_format((int) ($entry['walk_steps'] ?? 0), 0, ',', '.') . ' Schritte';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) ((int) ($entry['walk_minutes'] ?? 0)) . ' min';
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk_chart_value(array $entry): int
|
||||||
|
{
|
||||||
|
$mode = (string) ($entry['walk_mode'] ?? 'time');
|
||||||
|
|
||||||
|
if ($mode === 'steps') {
|
||||||
|
return max(0, (int) round(((int) ($entry['walk_steps'] ?? 0)) / 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int) ($entry['walk_minutes'] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url_encode(string $data): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url_decode(string $data): string
|
||||||
|
{
|
||||||
|
$padding = strlen($data) % 4;
|
||||||
|
if ($padding > 0) {
|
||||||
|
$data .= str_repeat('=', 4 - $padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode(strtr($data, '-_', '+/'), true);
|
||||||
|
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw new RuntimeException('Ungültige Base64url-Daten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploaded_files(string $field): array
|
||||||
|
{
|
||||||
|
$raw = $_FILES[$field] ?? null;
|
||||||
|
if (!is_array($raw) || !isset($raw['name'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($raw['name'])) {
|
||||||
|
return [[
|
||||||
|
'name' => (string) ($raw['name'] ?? ''),
|
||||||
|
'type' => (string) ($raw['type'] ?? ''),
|
||||||
|
'tmp_name' => (string) ($raw['tmp_name'] ?? ''),
|
||||||
|
'error' => (int) ($raw['error'] ?? UPLOAD_ERR_NO_FILE),
|
||||||
|
'size' => (int) ($raw['size'] ?? 0),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
foreach ($raw['name'] as $index => $name) {
|
||||||
|
$files[] = [
|
||||||
|
'name' => (string) ($name ?? ''),
|
||||||
|
'type' => (string) ($raw['type'][$index] ?? ''),
|
||||||
|
'tmp_name' => (string) ($raw['tmp_name'][$index] ?? ''),
|
||||||
|
'error' => (int) ($raw['error'][$index] ?? UPLOAD_ERR_NO_FILE),
|
||||||
|
'size' => (int) ($raw['size'][$index] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
function normalize_sport_type_id(string $value): string
|
function normalize_sport_type_id(string $value): string
|
||||||
{
|
{
|
||||||
$value = trim(strtr($value, [
|
$value = trim(strtr($value, [
|
||||||
@@ -252,6 +443,11 @@ function normalized_sport_types(array $settings): array
|
|||||||
$icon = 'run';
|
$icon = 'run';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$location = trim((string) ($type['location'] ?? ''));
|
||||||
|
if (!array_key_exists($location, sport_location_options())) {
|
||||||
|
$location = '';
|
||||||
|
}
|
||||||
|
|
||||||
$group = trim((string) ($type['recovery_group'] ?? ''));
|
$group = trim((string) ($type['recovery_group'] ?? ''));
|
||||||
if ($group === '') {
|
if ($group === '') {
|
||||||
$group = $id;
|
$group = $id;
|
||||||
@@ -261,6 +457,7 @@ function normalized_sport_types(array $settings): array
|
|||||||
'id' => $id,
|
'id' => $id,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'icon' => $icon,
|
'icon' => $icon,
|
||||||
|
'location' => $location,
|
||||||
'recovery_group' => normalize_sport_type_id($group) ?: $id,
|
'recovery_group' => normalize_sport_type_id($group) ?: $id,
|
||||||
'bonus_points' => max(0, min(20, (int) ($type['bonus_points'] ?? 0))),
|
'bonus_points' => max(0, min(20, (int) ($type['bonus_points'] ?? 0))),
|
||||||
'allow_consecutive' => !empty($type['allow_consecutive']),
|
'allow_consecutive' => !empty($type['allow_consecutive']),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ $brandSubtitle = match ($page) {
|
|||||||
'dashboard' => 'Statistiken und Verlauf',
|
'dashboard' => 'Statistiken und Verlauf',
|
||||||
'track' => 'Tag erfassen und bewerten',
|
'track' => 'Tag erfassen und bewerten',
|
||||||
'archive' => 'Rückblick auf vergangene Tage',
|
'archive' => 'Rückblick auf vergangene Tage',
|
||||||
'options' => 'Logik, Sicherheit und Accounts',
|
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||||
'login' => 'Geschützter Zugang',
|
'login' => 'Geschützter Zugang',
|
||||||
'setup' => 'Erstkonfiguration',
|
'setup' => 'Erstkonfiguration',
|
||||||
default => 'Stimmungstracker',
|
default => 'Stimmungstracker',
|
||||||
@@ -18,15 +18,28 @@ $brandSubtitle = match ($page) {
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#0b1e2e">
|
<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>
|
<title><?= e($pageTitle) ?> · Mood</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg?v=20260412">
|
||||||
<link rel="shortcut icon" href="/assets/branding/favicon.svg">
|
<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">
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
<script defer src="/assets/js/app.js"></script>
|
<script defer src="/assets/js/app.js"></script>
|
||||||
</head>
|
</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-one"></div>
|
||||||
<div class="aurora aurora-two"></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">
|
<div class="shell">
|
||||||
<?php if ($authUser !== null): ?>
|
<?php if ($authUser !== null): ?>
|
||||||
<aside class="sidebar glass-panel">
|
<aside class="sidebar glass-panel">
|
||||||
@@ -99,7 +112,33 @@ $brandSubtitle = match ($page) {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
|
|
||||||
|
<footer class="site-footer glass-panel">
|
||||||
|
<a class="site-footer__link" href="https://git.hnz.io/hnzio/mood-tracking/releases" target="_blank" rel="noreferrer">Version 1.2.1</a>
|
||||||
|
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||||
|
</footer>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
|
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
|
||||||
<span class="sport-pill">
|
<span class="sport-pill">
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
<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>
|
</span>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -54,6 +54,9 @@
|
|||||||
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
||||||
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
||||||
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
||||||
|
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||||
|
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
||||||
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
||||||
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
|
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
|
||||||
<span class="sport-pill sport-pill--inline">
|
<span class="sport-pill sport-pill--inline">
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
<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>
|
</span>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -75,7 +78,8 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
|
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
|
||||||
<div><dt>Spaziergang</dt><dd><?= e((string) $selectedEntry['walk_minutes']) ?> min</dd></div>
|
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
|
||||||
|
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="note-box">
|
<div class="note-box">
|
||||||
|
|||||||
@@ -73,13 +73,26 @@
|
|||||||
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
|
<div class="line-chart" data-chart-type="line" data-series="stress" data-payload="<?= e($chartPayload) ?>"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||||
|
<article class="glass-panel chart-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Körper</p>
|
||||||
|
<h3>Schmerzverlauf</h3>
|
||||||
|
</div>
|
||||||
|
<span class="chart-chip chart-chip--warm">weniger ist besser</span>
|
||||||
|
</div>
|
||||||
|
<div class="line-chart" data-chart-type="line" data-series="pain" data-payload="<?= e($chartPayload) ?>"></div>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<article class="glass-panel chart-card chart-card--wide">
|
<article class="glass-panel chart-card chart-card--wide">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Aktivität</p>
|
<p class="eyebrow">Aktivität</p>
|
||||||
<h3>Sport und Spaziergang</h3>
|
<h3>Sport und Spaziergang</h3>
|
||||||
</div>
|
</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>
|
||||||
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
|
<div class="bar-chart" data-chart-type="bars" data-series="sport" data-payload="<?= e($chartPayload) ?>"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
<input type="password" name="password" autocomplete="current-password" required>
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
</label>
|
</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>
|
<button class="primary-button" type="submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
<article class="glass-panel form-panel form-panel--wide">
|
<article class="glass-panel form-panel form-panel--wide">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Bewertungslogik</p>
|
<p class="eyebrow">Dein Account</p>
|
||||||
<h3>Score und Schutzregeln anpassen</h3>
|
<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>
|
</div>
|
||||||
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
<span class="chart-chip">Maximal <?= e(format_points((float) $maxScore)) ?> Punkte</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,6 +23,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-head section-head--compact">
|
||||||
|
<div>
|
||||||
|
<h4>Tracking-Felder</h4>
|
||||||
|
<p class="helper-text">Hier steuerst du, welche Zusatzfelder auf deiner Tracking-Seite sichtbar und in die Bewertung einbezogen werden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
|
<label class="checkbox-row checkbox-row--panel">
|
||||||
|
<input type="checkbox" name="settings[tracking][pain_enabled]" value="1" <?= !empty($settings['tracking']['pain_enabled']) ? 'checked' : '' ?>>
|
||||||
|
<span>
|
||||||
|
<strong>Schmerzen aktivieren</strong>
|
||||||
|
<small>1 bedeutet keine Schmerzen, 10 starke Schmerzen. Der Wert wird wie bei Stress positiv gewichtet, wenn die Schmerzen niedrig sind.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Schmerzfaktor</span>
|
||||||
|
<input type="number" name="settings[scoring][pain_multiplier]" value="<?= e((string) $settings['scoring']['pain_multiplier']) ?>" min="0" max="10">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>Schlafdauerpunkte</h4>
|
<h4>Schlafdauerpunkte</h4>
|
||||||
<div class="field-grid field-grid--four">
|
<div class="field-grid field-grid--four">
|
||||||
@@ -48,27 +73,74 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h4>Spaziergang-Bänder</h4>
|
<div class="section-head section-head--compact">
|
||||||
<div class="band-grid">
|
<div>
|
||||||
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
|
<h4>Spaziergang</h4>
|
||||||
<div class="band-card">
|
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
|
||||||
<label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label>
|
</div>
|
||||||
<label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label>
|
|
||||||
<label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<div class="section-head section-head--compact">
|
<div class="section-head section-head--compact">
|
||||||
<div>
|
<div>
|
||||||
<h4>Sportarten und Bonuspunkte</h4>
|
<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>
|
</div>
|
||||||
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
<button class="ghost-button" type="button" data-add-sport-type>Sportart hinzufügen</button>
|
||||||
</div>
|
</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>
|
<div class="sport-type-list" data-sport-type-list>
|
||||||
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
<?php foreach ($settings['sport_types'] as $index => $sportType): ?>
|
||||||
<div class="sport-type-card band-card" data-sport-type-row>
|
<div class="sport-type-card band-card" data-sport-type-row>
|
||||||
@@ -90,10 +162,21 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Erholungsgruppe</span>
|
<span>Ort</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]">
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<label>
|
||||||
<span>Bonuspunkte</span>
|
<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]">
|
<input type="number" name="settings[sport_types][<?= e((string) $index) ?>][bonus_points]" value="<?= e((string) $sportType['bonus_points']) ?>" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||||
@@ -105,10 +188,12 @@
|
|||||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||||
</label>
|
</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">
|
<div class="sport-type-card__actions">
|
||||||
<span class="sport-pill sport-pill--soft">
|
<span class="sport-pill sport-pill--soft">
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
<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>
|
</span>
|
||||||
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
<button class="ghost-button ghost-button--small" type="button" data-remove-sport-type>Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +201,10 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-actions">
|
||||||
|
<button class="primary-button" type="submit">Sportarten speichern</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template id="sport-type-row-template">
|
<template id="sport-type-row-template">
|
||||||
<div class="sport-type-card band-card" data-sport-type-row>
|
<div class="sport-type-card band-card" data-sport-type-row>
|
||||||
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
<input type="hidden" value="" data-name-template="settings[sport_types][__INDEX__][id]">
|
||||||
@@ -123,7 +212,7 @@
|
|||||||
<div class="field-grid field-grid--four">
|
<div class="field-grid field-grid--four">
|
||||||
<label>
|
<label>
|
||||||
<span>Bezeichnung</span>
|
<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>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -136,10 +225,21 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Erholungsgruppe</span>
|
<span>Ort</span>
|
||||||
<input type="text" value="" placeholder="z. B. kraftsport" data-name-template="settings[sport_types][__INDEX__][recovery_group]">
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<label>
|
||||||
<span>Bonuspunkte</span>
|
<span>Bonuspunkte</span>
|
||||||
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
<input type="number" value="2" min="0" max="20" data-name-template="settings[sport_types][__INDEX__][bonus_points]">
|
||||||
@@ -151,6 +251,8 @@
|
|||||||
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
<span>Darf an aufeinanderfolgenden Tagen Bonus geben</span>
|
||||||
</label>
|
</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">
|
<div class="sport-type-card__actions">
|
||||||
<span class="sport-pill sport-pill--soft">
|
<span class="sport-pill sport-pill--soft">
|
||||||
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
|
<img src="<?= e(sport_icon_path('run')) ?>" alt="">
|
||||||
@@ -162,8 +264,58 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<div class="settings-section">
|
||||||
<h4>Bewertungsskala</h4>
|
<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">
|
<div class="band-grid">
|
||||||
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
<?php foreach ($settings['ratings'] as $index => $rating): ?>
|
||||||
<div class="band-card">
|
<div class="band-card">
|
||||||
@@ -198,6 +350,32 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<aside class="stack-column">
|
<aside class="stack-column">
|
||||||
|
<article class="glass-panel detail-card">
|
||||||
|
<p class="eyebrow">Backup</p>
|
||||||
|
<h3>Eigene Einträge sichern</h3>
|
||||||
|
<p class="helper-text">Deine Tagesdateien liegen auf dem Server verschlüsselt. Beim Download bekommst du automatisch ein lesbares Backup mit allen Tagen als `.txt`-Dateien in einer ZIP-Datei. Beim Import werden diese Dateien wieder still im Hintergrund geschützt gespeichert.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/options" class="stack-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="export_backup">
|
||||||
|
<button class="primary-button" type="submit" <?= empty($backupAvailable) ? 'disabled' : '' ?>>Backup herunterladen</button>
|
||||||
|
<?php if (empty($backupAvailable)): ?>
|
||||||
|
<p class="helper-text">Auf diesem Server fehlt gerade die ZIP-Erweiterung für den Download.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/options" enctype="multipart/form-data" class="stack-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="import_backup">
|
||||||
|
<label>
|
||||||
|
<span>Backup importieren</span>
|
||||||
|
<input type="file" name="backup_files[]" accept=".zip,.txt" multiple>
|
||||||
|
</label>
|
||||||
|
<p class="helper-text">Du kannst ein komplettes ZIP-Backup oder einzelne Tagesdateien wie `2026-04-13.txt` importieren. Vorhandene Tage mit demselben Datum werden dabei aktualisiert.</p>
|
||||||
|
<button class="ghost-button" type="submit">Backup importieren</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="glass-panel detail-card">
|
<article class="glass-panel detail-card">
|
||||||
<p class="eyebrow">Sicherheit</p>
|
<p class="eyebrow">Sicherheit</p>
|
||||||
<h3>Passwort ändern</h3>
|
<h3>Passwort ändern</h3>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
|
<form method="post" action="/track" class="tracker-form" id="tracker-form" autocomplete="off">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="date" value="<?= e($entry['date']) ?>">
|
<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">
|
<div class="field-grid field-grid--three">
|
||||||
<label class="range-card">
|
<label class="range-card">
|
||||||
@@ -35,6 +37,34 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||||
|
<div class="field-grid field-grid--two">
|
||||||
|
<label class="range-card">
|
||||||
|
<span>Schmerzen</span>
|
||||||
|
<output data-output-for="pain"><?= e((string) $entry['pain']) ?></output>
|
||||||
|
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
|
||||||
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
|
<span>
|
||||||
|
<strong>Alkohol</strong>
|
||||||
|
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="field-grid field-grid--single">
|
||||||
|
<label class="checkbox-row checkbox-row--panel">
|
||||||
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
|
<span>
|
||||||
|
<strong>Alkohol</strong>
|
||||||
|
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="field-grid field-grid--two">
|
<div class="field-grid field-grid--two">
|
||||||
<label>
|
<label>
|
||||||
<span>Schlafdauer in Stunden</span>
|
<span>Schlafdauer in Stunden</span>
|
||||||
@@ -60,8 +90,16 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
<span>Spaziergang in Minuten</span>
|
<span><?= $walkMode === 'steps' ? 'Spaziergang in Schritten' : 'Spaziergang in Minuten' ?></span>
|
||||||
<input type="number" min="0" max="1440" step="1" name="walk_minutes" value="<?= e((string) $entry['walk_minutes']) ?>" required>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,6 +114,9 @@
|
|||||||
<span class="sport-choice__card">
|
<span class="sport-choice__card">
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
||||||
<strong><?= e($sportType['label']) ?></strong>
|
<strong><?= e($sportType['label']) ?></strong>
|
||||||
|
<?php if (!empty($sportType['location'])): ?>
|
||||||
|
<small><?= e(sport_location_label((string) $sportType['location'])) ?></small>
|
||||||
|
<?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -114,11 +155,13 @@
|
|||||||
'mood' => 'Stimmung',
|
'mood' => 'Stimmung',
|
||||||
'energy' => 'Energie',
|
'energy' => 'Energie',
|
||||||
'stress' => 'Stress',
|
'stress' => 'Stress',
|
||||||
|
'pain' => 'Schmerzen',
|
||||||
'sleep_hours' => 'Schlafdauer',
|
'sleep_hours' => 'Schlafdauer',
|
||||||
'sleep_feeling' => 'Schlafgefühl',
|
'sleep_feeling' => 'Schlafgefühl',
|
||||||
'sport_minutes' => 'Sport',
|
'sport_minutes' => 'Sport',
|
||||||
'sport_bonus' => 'Sportbonus',
|
'sport_bonus' => 'Sportbonus',
|
||||||
'walk_minutes' => 'Spaziergang',
|
'walk_minutes' => 'Spaziergang',
|
||||||
|
'alcohol' => 'Alkohol',
|
||||||
'note' => 'Notiz',
|
'note' => 'Notiz',
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
|
|||||||