16 Commits

Author SHA1 Message Date
hnzio 297f63c7d5 Refine AI summary tone to reduce generic therapeutic phrasing 2026-04-14 10:25:16 +02:00
hnzio 889f5ffa8a Tighten weekly AI summary length for denser output 2026-04-14 10:20:59 +02:00
hnzio 41183f04db Refine weekly AI prompts for more natural, non-chronological summaries 2026-04-14 10:18:15 +02:00
hnzio 796e5b23d2 Refine weekly AI summary prompts to reduce diary-style output 2026-04-14 10:14:15 +02:00
hnzio af84243866 Refine weekly AI summary prompts to reduce diary-style output 2026-04-14 10:09:36 +02:00
hnzio 9e79e93724 Add AI weekly and monthly summaries with archive UI and backup support 2026-04-14 09:57:53 +02:00
hnzio 0a8ccef5a7 Add encrypted day storage and personal backups 2026-04-13 12:04:17 +02:00
hnzio 4a884dd166 Add dashboard pain chart and version footer 2026-04-13 10:30:51 +02:00
hnzio 5ea1b56649 Add optional pain tracking and fix reminder delivery 2026-04-13 10:22:41 +02:00
hnzio abc0766f16 Restrict the project license to noncommercial use 2026-04-12 20:42:50 +02:00
hnzio 4e9fe2de6a Add iOS pull-to-refresh and PNG app icons 2026-04-12 20:39:56 +02:00
hnzio cd7526bd80 Add PWA reminders and flexible walk scoring 2026-04-12 19:40:40 +02:00
hnzio 2cd00b1bf6 Refine sport selection cards on the tracking page 2026-04-12 11:59:09 +02:00
hnzio 1080dd9d82 Add per-sport location settings and clarify recovery groups 2026-04-12 11:57:13 +02:00
hnzio 80f649c547 Make score and sport settings fully account-specific 2026-04-12 11:52:57 +02:00
hnzio a1135d37d8 Add remember-me login and personalize sport presets 2026-04-12 11:47:59 +02:00
40 changed files with 4483 additions and 158 deletions
+1 -1
View File
@@ -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>
+15
View File
@@ -0,0 +1,15 @@
Mood-Board
Copyright (c) 2026 HNZIO
Licensed under the PolyForm Noncommercial License 1.0.0.
You may use, copy, modify, and distribute this software only for permitted
noncommercial purposes under the terms of that license.
Commercial use is not allowed without a separate written agreement from the
copyright holder.
Required Notice: Copyright (c) 2026 HNZIO
Full license text:
https://polyformproject.org/licenses/noncommercial/1.0.0/
+23
View File
@@ -7,6 +7,7 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
- Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern - Geschützter Login mit Session, `password_hash`, CSRF-Schutz und Security-Headern
- Vier Bereiche: Dashboard, Tracking, Optionen, Archiv - Vier Bereiche: Dashboard, Tracking, Optionen, Archiv
- Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt` - Speicherung aller Tage als Markdown in `storage/users/<user>/days/YYYY-MM-DD.txt`
- KI-Wochen- und Monatszusammenfassungen im Archiv mit verschlüsselter dateibasierter Ablage
- Pro Nutzer eigene Einstellungen für die Bewertungslogik - Pro Nutzer eigene Einstellungen für die Bewertungslogik
- Admin kann weitere Accounts direkt in der Weboberfläche anlegen - Admin kann weitere Accounts direkt in der Weboberfläche anlegen
- Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs - Moderner, responsiver Liquid-Glass-Look mit lokalen Assets und ohne externe CDNs
@@ -30,5 +31,27 @@ Dateibasierter Stimmungstracker für LAMP/Cloudron ohne Datenbank.
## Hinweise ## Hinweise
- 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.
- Tagesdateien und KI-Zusammenfassungen werden serverseitig verschlüsselt gespeichert und im Backup wieder als lesbare TXT-Dateien exportiert.
- 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.
## KI-Zusammenfassungen
- Für KI-Zusammenfassungen im Archiv wird ein OpenAI-Modell aus der Mini-Klasse verwendet.
- Der API-Key kommt aus der Server-Umgebung, das Modell und der Timeout können zusätzlich zentral durch einen Admin in den Optionen angepasst werden.
- Wochenzusammenfassungen werden als `storage/users/<user>/summaries/weekly/YYYY-KW-XX.txt` gespeichert.
- Monatszusammenfassungen werden als `storage/users/<user>/summaries/monthly/YYYY-MM.txt` gespeichert.
- Der Backup-Export nimmt diese Dateien automatisch mit und legt sie im ZIP unter `summaries/weekly/` und `summaries/monthly/` ab.
### Benötigte Umgebungsvariablen
- `OPENAI_API_KEY` (erforderlich)
- `OPENAI_MODEL` (optional, Standard: `gpt-4o-mini`)
- `OPENAI_TIMEOUT` (optional, Standard: `25`)
## Lizenz
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
- Nicht-kommerzielle Nutzung ist erlaubt.
- Kommerzielle Nutzung ist ohne separate schriftliche Freigabe nicht erlaubt.
- Details siehe [LICENSE](/home/hnzio/Projekte/mood/LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

After

Width:  |  Height:  |  Size: 1005 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+306 -5
View File
@@ -128,6 +128,34 @@ body {
color: var(--text); 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);
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -144,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;
@@ -210,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;
@@ -314,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;
@@ -805,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);
@@ -815,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));
@@ -1002,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 {
@@ -1056,6 +1150,59 @@ input[type="range"] {
gap: 0.75rem; gap: 0.75rem;
} }
.archive-summary-section {
display: grid;
gap: 0.9rem;
margin-bottom: 1.2rem;
}
.archive-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 0.85rem;
}
.archive-summary-card {
display: grid;
gap: 0.7rem;
padding: 1rem 1.05rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.archive-summary-card__head {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: flex-start;
}
.archive-summary-card__head strong {
display: block;
margin-top: 0.35rem;
}
.summary-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.15rem;
min-height: 1.55rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(139, 228, 255, 0.16);
border: 1px solid rgba(139, 228, 255, 0.24);
color: var(--text);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
}
.chart-chip--muted {
opacity: 0.82;
}
.archive-item { .archive-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1091,6 +1238,15 @@ input[type="range"] {
flex-wrap: wrap; flex-wrap: wrap;
} }
.archive-item__actions--stack {
justify-content: flex-start;
align-items: stretch;
}
.archive-item__actions--stack form {
margin: 0;
}
.archive-action { .archive-action {
min-height: 2.4rem; min-height: 2.4rem;
padding-inline: 0.85rem; padding-inline: 0.85rem;
@@ -1148,6 +1304,10 @@ input[type="range"] {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
} }
.note-box--summary p {
white-space: pre-wrap;
}
.note-box h4 { .note-box h4 {
margin: 0 0 0.55rem; margin: 0 0 0.55rem;
} }
@@ -1174,6 +1334,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;
} }
@@ -1192,6 +1387,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;
} }
@@ -1293,6 +1513,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 {
@@ -1315,6 +1539,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;
@@ -1327,6 +1602,10 @@ input[type="range"] {
align-items: flex-start; align-items: flex-start;
} }
.archive-summary-grid {
grid-template-columns: 1fr;
}
.calendar-detail { .calendar-detail {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -1379,4 +1658,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;
}
} }
+8
View File
@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="44" r="8" stroke="#8BE4FF" stroke-width="3"/>
<circle cx="46" cy="44" r="8" stroke="#8BE4FF" stroke-width="3"/>
<path d="M18 44L28 28H37L46 44" stroke="#EFF7FF" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M28 28L24 21H17" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33 20H41" stroke="#7FF3BB" stroke-width="3" stroke-linecap="round"/>
<path d="M31 28L38 36" stroke="#EFF7FF" stroke-width="3.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

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

After

Width:  |  Height:  |  Size: 681 B

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

After

Width:  |  Height:  |  Size: 416 B

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

After

Width:  |  Height:  |  Size: 671 B

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

After

Width:  |  Height:  |  Size: 651 B

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

After

Width:  |  Height:  |  Size: 657 B

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

After

Width:  |  Height:  |  Size: 618 B

+452 -26
View File
@@ -113,6 +113,38 @@
return last ? Number(last.points || 0) : 0; 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();
})(); })();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"id": "/",
"name": "Mood-Board",
"short_name": "Mood",
"description": "Persönlicher Stimmungstracker mit Archiv, Dashboard und Erinnerungen.",
"lang": "de-DE",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b1e2e",
"theme_color": "#0b1e2e",
"icons": [
{
"src": "/assets/branding/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/branding/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
self.addEventListener("install", event => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", event => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch (error) {
payload = {};
}
const title = payload.title || "Mood-Board";
const options = {
body: payload.body || "Zeit für deinen heutigen Eintrag.",
icon: payload.icon || "/assets/branding/logo-mark.svg",
badge: payload.badge || "/assets/branding/favicon.svg",
tag: payload.tag || "mood-reminder",
data: {
url: payload.url || "/track",
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
const targetUrl = event.notification.data?.url || "/track";
event.waitUntil((async () => {
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clients) {
const clientUrl = new URL(client.url);
const target = new URL(targetUrl, self.location.origin);
if (clientUrl.pathname === target.pathname) {
await client.focus();
return;
}
}
await self.clients.openWindow(targetUrl);
})());
});
+1133 -19
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
final class AiConfigRepository
{
private string $path;
public function __construct()
{
$this->path = storage_path('system/ai.json');
}
public function get(): array
{
$saved = decode_json_file($this->path, []);
$config = array_replace($this->defaults(), is_array($saved) ? $saved : []);
$config['model'] = trim((string) ($config['model'] ?? $this->defaults()['model']));
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$config['timeout'] = max(5, min(120, (int) ($config['timeout'] ?? $this->defaults()['timeout'])));
return $config;
}
public function save(array $input): array
{
$config = [
'model' => trim((string) ($input['model'] ?? $this->defaults()['model'])),
'timeout' => max(5, min(120, (int) ($input['timeout'] ?? $this->defaults()['timeout']))),
];
if ($config['model'] === '') {
$config['model'] = $this->defaults()['model'];
}
$directory = dirname($this->path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$this->path,
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die KI-Konfiguration konnte nicht gespeichert werden.');
}
return $config;
}
private function defaults(): array
{
$model = trim((string) ($_ENV['OPENAI_MODEL'] ?? getenv('OPENAI_MODEL') ?: 'gpt-4o-mini'));
$timeout = (int) ($_ENV['OPENAI_TIMEOUT'] ?? getenv('OPENAI_TIMEOUT') ?: 25);
return [
'model' => $model !== '' ? $model : 'gpt-4o-mini',
'timeout' => max(5, min(120, $timeout > 0 ? $timeout : 25)),
];
}
}
+55 -5
View File
@@ -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',
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
final class NotificationRepository
{
private string $systemPath;
public function __construct()
{
$this->systemPath = storage_path('system/notifications.json');
}
public function systemConfig(): array
{
$config = decode_json_file($this->systemPath, []);
$changed = false;
if (!isset($config['cron_token']) || !is_string($config['cron_token']) || $config['cron_token'] === '') {
$config['cron_token'] = bin2hex(random_bytes(24));
$changed = true;
}
if (!isset($config['subject']) || !is_string($config['subject']) || $config['subject'] === '') {
$host = parse_url(app_origin(), PHP_URL_HOST);
$host = is_string($host) && $host !== '' ? $host : 'localhost';
$config['subject'] = 'mailto:hello@' . $host;
$changed = true;
}
if ($changed) {
$this->writeJson($this->systemPath, $config);
}
return $config;
}
public function saveVapidKeys(string $publicKey, string $privateKey): void
{
$config = $this->systemConfig();
$config['vapid_public_key'] = $publicKey;
$config['vapid_private_key'] = $privateKey;
$this->writeJson($this->systemPath, $config);
}
public function subscriptionsForUser(string $username): array
{
$payload = decode_json_file($this->subscriptionsPath($username), ['subscriptions' => []]);
return array_values(array_filter($payload['subscriptions'] ?? [], 'is_array'));
}
public function saveSubscription(string $username, array $subscription): void
{
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
if ($endpoint === '') {
throw new RuntimeException('Die Subscription ist unvollständig.');
}
$subscriptions = $this->subscriptionsForUser($username);
$saved = false;
foreach ($subscriptions as &$entry) {
if (($entry['endpoint'] ?? '') === $endpoint) {
$entry = array_merge($entry, $subscription, [
'updated_at' => date(DATE_ATOM),
]);
$saved = true;
break;
}
}
unset($entry);
if (!$saved) {
$subscription['created_at'] = date(DATE_ATOM);
$subscription['updated_at'] = date(DATE_ATOM);
$subscriptions[] = $subscription;
}
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => array_values($subscriptions)]);
}
public function removeSubscription(string $username, string $endpoint): void
{
$endpoint = trim($endpoint);
if ($endpoint === '') {
return;
}
$subscriptions = array_values(array_filter(
$this->subscriptionsForUser($username),
static fn (array $entry): bool => ($entry['endpoint'] ?? '') !== $endpoint
));
$this->writeJson($this->subscriptionsPath($username), ['subscriptions' => $subscriptions]);
}
public function removeInvalidSubscriptions(string $username, array $endpoints): void
{
foreach ($endpoints as $endpoint) {
if (is_string($endpoint) && $endpoint !== '') {
$this->removeSubscription($username, $endpoint);
}
}
}
public function reminderState(string $username): array
{
return decode_json_file($this->reminderStatePath($username), []);
}
public function saveReminderState(string $username, array $state): void
{
$this->writeJson($this->reminderStatePath($username), $state);
}
public function subscriptionCount(string $username): int
{
return count($this->subscriptionsForUser($username));
}
private function subscriptionsPath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/push-subscriptions.json');
}
private function reminderStatePath(string $username): string
{
return storage_path('users/' . normalize_username($username) . '/notification-state.json');
}
private function writeJson(string $path, array $payload): void
{
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
$bytes = file_put_contents(
$path,
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
);
if ($bytes === false) {
throw new RuntimeException('Die Benachrichtigungsdaten konnten nicht gespeichert werden.');
}
}
}
+102 -2
View File
@@ -13,12 +13,17 @@ final class ScoringService
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))), '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);
}
} }
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
final class SummaryRepository
{
private EntryCrypto $crypto;
public function __construct()
{
$this->crypto = new EntryCrypto();
}
public function all(string $username): array
{
$items = array_merge(
$this->readKind($username, 'weekly'),
$this->readKind($username, 'monthly')
);
usort($items, static function (array $left, array $right): int {
$leftDate = (string) ($left['date_to'] ?? '');
$rightDate = (string) ($right['date_to'] ?? '');
$byDate = strcmp($rightDate, $leftDate);
if ($byDate !== 0) {
return $byDate;
}
return strcmp((string) ($right['summary_key'] ?? ''), (string) ($left['summary_key'] ?? ''));
});
return $items;
}
public function weekly(string $username): array
{
return $this->readKind($username, 'weekly');
}
public function monthly(string $username): array
{
return $this->readKind($username, 'monthly');
}
public function find(string $username, string $kind, string $key): ?array
{
$path = $this->pathFor($username, $kind, $key);
if (!is_file($path)) {
return null;
}
$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, $kind, $key);
}
public function save(string $username, string $kind, string $key, array $summary): void
{
$normalized = $this->normalizeSummary($kind, $key, $summary);
$path = $this->pathFor($username, $kind, $key);
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($path, $this->crypto->encrypt($this->toText($normalized)), LOCK_EX);
}
public function exportBackupFiles(string $username): array
{
$exports = [];
foreach (['weekly', 'monthly'] as $kind) {
foreach ($this->readKind($username, $kind) as $summary) {
$exports[] = [
'path' => 'summaries/' . $kind . '/' . (string) $summary['summary_key'] . '.txt',
'content' => $this->toText($summary),
];
}
}
return $exports;
}
public function importBackupFile(string $username, string $fileName, string $content): bool
{
$detected = $this->detectBackupFile($fileName);
if ($detected === null) {
return false;
}
$summary = $this->parse($content, $detected['kind'], $detected['key']);
if ($summary === null) {
throw new RuntimeException('Eine KI-Zusammenfassung aus dem Backup konnte nicht gelesen werden.');
}
$this->save($username, $detected['kind'], $detected['key'], $summary);
return true;
}
private function readKind(string $username, string $kind): array
{
$directory = $this->directoryFor($username, $kind);
if (!is_dir($directory)) {
return [];
}
$files = glob($directory . '/*.txt') ?: [];
rsort($files, SORT_STRING);
$summaries = [];
foreach ($files as $file) {
$key = basename($file, '.txt');
$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);
}
$summary = $this->parse($plaintext, $kind, $key);
if ($summary !== null) {
$summaries[] = $summary;
}
}
return $summaries;
}
private function parse(string $content, string $kind, string $key): ?array
{
$plaintext = $this->crypto->decrypt($content);
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
return null;
}
$title = $this->extract('/^Titel:\s*(.+)$/mu', $plaintext);
$type = $this->extract('/^Typ:\s*(.+)$/mu', $plaintext);
$createdAt = $this->extract('/^Erstellt am:\s*(.+)$/mu', $plaintext);
if (!preg_match('/^Zeitraum:\s*(\d{4}-\d{2}-\d{2})\s+bis\s+(\d{4}-\d{2}-\d{2})$/mu', $plaintext, $rangeMatch)) {
return null;
}
if (!preg_match('/^Zeitraum:\s*.+$\R\R([\s\S]*)\z/mu', $plaintext, $textMatch)) {
return null;
}
if ($title === null || $type === null || $createdAt === null) {
return null;
}
$expectedType = $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung';
if (trim($type) !== $expectedType) {
return null;
}
$summary = [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim($title),
'type' => $expectedType,
'created_at' => trim($createdAt),
'date_from' => trim((string) ($rangeMatch[1] ?? '')),
'date_to' => trim((string) ($rangeMatch[2] ?? '')),
'text' => trim((string) ($textMatch[1] ?? '')),
];
if (!$this->isValidDate($summary['date_from']) || !$this->isValidDate($summary['date_to'])) {
return null;
}
return $summary;
}
private function toText(array $summary): string
{
$normalized = $this->normalizeSummary(
(string) $summary['summary_kind'],
(string) $summary['summary_key'],
$summary
);
return implode("\n", [
'Titel: ' . $normalized['title'],
'Typ: ' . $normalized['type'],
'Erstellt am: ' . $normalized['created_at'],
'Zeitraum: ' . $normalized['date_from'] . ' bis ' . $normalized['date_to'],
'',
trim((string) $normalized['text']),
'',
]);
}
private function normalizeSummary(string $kind, string $key, array $summary): array
{
$kind = $this->normalizeKind($kind);
if ($kind === null || !$this->isValidKey($kind, $key)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Schlüssel.');
}
$dateFrom = trim((string) ($summary['date_from'] ?? ''));
$dateTo = trim((string) ($summary['date_to'] ?? ''));
$createdAt = trim((string) ($summary['created_at'] ?? date(DATE_ATOM)));
$text = trim((string) ($summary['text'] ?? ''));
if (!$this->isValidDate($dateFrom) || !$this->isValidDate($dateTo)) {
throw new RuntimeException('Die Zusammenfassung hat einen ungültigen Zeitraum.');
}
if ($text === '') {
throw new RuntimeException('Die Zusammenfassung darf nicht leer sein.');
}
return [
'summary_kind' => $kind,
'summary_key' => $key,
'title' => trim((string) ($summary['title'] ?? $this->defaultTitle($kind, $key))),
'type' => $kind === 'weekly' ? 'ki-wochenauswertung' : 'ki-monatsauswertung',
'created_at' => $createdAt,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'text' => $text,
];
}
private function defaultTitle(string $kind, string $key): string
{
if ($kind === 'weekly' && preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches)) {
return 'Wochenzusammenfassung KW ' . $matches[2] . ' / ' . $matches[1];
}
if ($kind === 'monthly' && preg_match('/^(\d{4})-(\d{2})$/', $key, $matches)) {
return 'Monatszusammenfassung ' . $matches[2] . ' / ' . $matches[1];
}
return 'KI-Zusammenfassung';
}
private function detectBackupFile(string $fileName): ?array
{
$normalized = str_replace('\\', '/', trim($fileName));
$baseName = basename($normalized);
if (preg_match('/^(\d{4}-KW-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'weekly',
'key' => (string) $matches[1],
];
}
if (preg_match('/^(\d{4}-\d{2})\.txt$/', $baseName, $matches)) {
return [
'kind' => 'monthly',
'key' => (string) $matches[1],
];
}
return null;
}
private function directoryFor(string $username, string $kind): string
{
return storage_path('users/' . normalize_username($username) . '/summaries/' . $kind);
}
private function pathFor(string $username, string $kind, string $key): string
{
return $this->directoryFor($username, $kind) . '/' . $key . '.txt';
}
private function normalizeKind(string $kind): ?string
{
$kind = trim($kind);
return in_array($kind, ['weekly', 'monthly'], true) ? $kind : null;
}
private function isValidKey(string $kind, string $key): bool
{
if ($kind === 'weekly' && preg_match('/^\d{4}-KW-(\d{2})$/', $key, $matches) === 1) {
$week = (int) ($matches[1] ?? 0);
return $week >= 1 && $week <= 53;
}
if ($kind === 'monthly' && preg_match('/^\d{4}-(\d{2})$/', $key, $matches) === 1) {
$month = (int) ($matches[1] ?? 0);
return $month >= 1 && $month <= 12;
}
return false;
}
private function isValidDate(string $date): bool
{
$parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
private function extract(string $pattern, string $content): ?string
{
if (preg_match($pattern, $content, $matches) !== 1) {
return null;
}
return trim((string) ($matches[1] ?? ''));
}
}
+19 -4
View File
@@ -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);
} }
} }
+89 -17
View File
@@ -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',
],
]; ];
} }
} }
+140
View File
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
final class EntryCrypto
{
private const HEADER = "MOODENC1\n";
private string $fallbackKeyPath;
public function __construct()
{
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
}
public function isAvailable(): bool
{
return function_exists('openssl_encrypt')
&& function_exists('openssl_decrypt')
&& function_exists('random_bytes');
}
public function shouldEncrypt(): bool
{
return $this->isAvailable();
}
public function isEncrypted(string $content): bool
{
return str_starts_with($content, self::HEADER);
}
public function encrypt(string $plaintext): string
{
if (!$this->shouldEncrypt()) {
return $plaintext;
}
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
$iv,
$tag
);
if (!is_string($ciphertext) || $tag === '') {
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
}
$payload = json_encode([
'iv' => base64_encode($iv),
'tag' => base64_encode($tag),
'data' => base64_encode($ciphertext),
], JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
}
return self::HEADER . $payload;
}
public function decrypt(string $content): string
{
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
return $content;
}
$payload = substr($content, strlen(self::HEADER));
$decoded = json_decode($payload, true);
if (
!is_array($decoded)
|| !is_string($decoded['iv'] ?? null)
|| !is_string($decoded['tag'] ?? null)
|| !is_string($decoded['data'] ?? null)
) {
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
}
$plaintext = openssl_decrypt(
(string) base64_decode($decoded['data'], true),
'aes-256-gcm',
$this->key(),
OPENSSL_RAW_DATA,
(string) base64_decode($decoded['iv'], true),
(string) base64_decode($decoded['tag'], true)
);
if (!is_string($plaintext)) {
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
}
return $plaintext;
}
private function key(): string
{
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
if ($configured !== '') {
return hash('sha256', $configured, true);
}
$stored = $this->readFallbackKey();
if ($stored !== null) {
return $stored;
}
$raw = random_bytes(32);
$directory = dirname($this->fallbackKeyPath);
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
@chmod($this->fallbackKeyPath, 0600);
return $raw;
}
private function readFallbackKey(): ?string
{
if (!is_file($this->fallbackKeyPath)) {
return null;
}
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
if ($raw === '') {
return null;
}
$decoded = base64_decode($raw, true);
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
}
}
+335
View File
@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
final class OpenAiSummaryService
{
private const CHAT_COMPLETIONS_ENDPOINT = 'https://api.openai.com/v1/chat/completions';
private const WEEK_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus den Einträgen einer Kalenderwoche eine ruhige, dichte und psychologisch plausible Wochenzusammenfassung zu schreiben. Du sollst Muster erkennen, Belastungen benennen, Ressourcen sichtbar machen und die Tagebuchtexte mit dem Gefühlsbild der Woche in Beziehung setzen.
Die Zusammenfassung soll nicht wie ein Tagebuch, nicht wie ein Bericht und nicht wie ein Ratgebertext klingen, sondern wie eine verdichtete persönliche Einordnung der Woche.
Verbindliche Stilregeln:
- Schreibe konsequent in der Du-Form.
- Schreibe in natürlichem, ruhigem Fließtext.
- Schreibe dicht, konkret, unaufgeregt und persönlich.
- Klinge reflektiert, aber nicht klinisch.
- Klinge verhaltenstherapeutisch orientiert, aber nicht wie ein Therapiebericht.
Inhaltliche Regeln:
- Nutze sowohl die Tagebuchtexte als auch die Stimmungs- und Belastungswerte.
- Übersetze Werte und Skalen in sprachliche Einordnungen wie „stark schwankend“, „deutlich belastet“, „wenig erholt“, „etwas stabiler“ oder „spürbar entlastet“.
- Nenne keine konkreten Zahlenwerte im Fließtext.
- Nenne keine konkreten Kalenderdaten im Fließtext.
- Wenn zeitliche Orientierung wirklich nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
- Schreibe nicht chronologisch und gehe nicht Tag für Tag durch die Woche.
- Verdichte stattdessen die Woche zu Mustern, Spannungen, Auslösern, Belastungen, Gegenpolen und stabilisierenden Momenten.
- Einzelne Tage sollen nur erwähnt werden, wenn sie für das Verständnis der ganzen Woche wirklich zentral sind.
- Beschreibe nicht nur, was passiert ist, sondern ordne ein, wie es auf das Erleben gewirkt hat.
- Benenne Belastungen klar, ohne zu dramatisieren.
- Benenne Ressourcen klar, ohne sie künstlich aufzuwerten.
- Verharmlose Warnsignale nicht.
- Erfinde nichts, was nicht aus den Daten ableitbar ist.
- Wenn die Datenlage lückenhaft ist, erwähne das kurz und unaufgeregt.
Was vermieden werden soll:
- Keine Listen.
- Keine Emojis.
- Keine Kalendersprüche.
- Keine direkten Handlungsanweisungen im Befehlston.
- Keine pauschalen Beziehungstipps.
- Keine künstlich optimistischen Schlüsse über die Beziehung.
- Keine klinisch-distanzierten Formulierungen wie „deine Einträge zeigen“, „die durchschnittliche Stimmung betrug“ oder „es äußerten sich deutliche Schwankungen“.
- Keine formelhaften Sätze wie „es ist wichtig zu erkennen“, „es ist verständlich“, „es wäre hilfreich“, „könnte helfen“ oder „Zeichen von Selbstwirksamkeit“, wenn sie nicht wirklich natürlich klingen.
- Kein schulbuchhafter Ton.
- Kein Auswertungs- oder Gutachtenstil.
- Vermeide weichgespülte oder formelhafte Wendungen wie „es zeigt sich, dass“, „es bleibt zu beobachten“, „könnte hilfreich sein“, „könnte wertvoll sein“, „könnte als Belastungsfaktor wahrgenommen werden“ oder „es ist spürbar“.
- Schreibe nicht wie ein psychologischer Infotext, sondern wie eine dichte persönliche Einordnung.
- Vermeide allgemeine Schlussformeln über „Ressourcen“, „Dynamiken“ oder „Rituale“, wenn sie nicht konkret aus der Woche entstehen.
Zusätzliche Regeln:
- Formuliere klarer und direkter, ohne hart oder anklagend zu werden.
- Du kannst den Nutzer ruhig mit du ansprechen, um persönlicher zu wirken
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, darf am Ende höchstens ein kleiner, alltagsnaher und motivierender Impuls für die kommende Woche stehen.
- Dieser Impuls soll kurz bleiben und nicht wie ein Ratschlagstext klingen.
Aufbau:
- Beginne mit einer knappen Einordnung des Gesamtmusters der Woche.
- Verdichte danach die wichtigsten Belastungen und Gegenpole.
- Schließe mit einer kurzen, vorsichtigen Einordnung, was für die nächste Woche eher im Vordergrund stehen könnte, zum Beispiel Stabilisierung, Entlastung, Struktur oder Aktivierung.
- Diese Schlusspassage soll beobachtend klingen, nicht belehrend.
Länge: etwa 180 bis 280 Wörter.
TEXT;
private const WEEK_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Wochenzusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 3 ausgefüllte Tagebucheinträge in dieser Woche vor.
Zeitraum:
{{WEEK_LABEL}}
Wochendaten:
- Anzahl ausgefüllter Einträge: {{ENTRY_COUNT}}
- Getrackte Tage insgesamt: {{TRACKED_DAYS}}
- Durchschnittliche Stimmung: {{AVG_MOOD}}
- Durchschnittlicher Stress: {{AVG_STRESS}}
- Durchschnittliche Energie: {{AVG_ENERGY}}
- Durchschnittlicher Schlaf: {{AVG_SLEEP}}
- Anzahl Spaziergänge: {{WALK_DAYS}}
- Anzahl Sporttage: {{SPORT_DAYS}}
- Alkoholtage: {{ALCOHOL_DAYS}}
- Bester Tag: {{BEST_DAY}}
- Schwerster Tag: {{WORST_DAY}}
Tägliche Einträge:
{{DAILY_ENTRIES}}
Aufgabe:
Schreibe keine tagebuchartige oder chronologische Nacherzählung. Fasse die Woche als Gesamtbild zusammen. Arbeite heraus, welche Belastungen, Konflikte, Aktivitäten, Gedankenlagen oder kleinen Gegenpole das Erleben geprägt haben und wie sie mit dem Gefühlsbild der Woche zusammenhängen.
Wichtige Vorgaben:
- Verwandle Zahlen und Skalenwerte in sprachliche Einordnungen, statt sie direkt zu nennen.
- Verwende keine konkreten Datumsangaben.
- Schreibe nicht Tag für Tag.
- Verdichte Muster statt Abläufe.
- Wenn überhaupt zeitliche Einordnung nötig ist, nutze höchstens Formulierungen wie „zu Wochenbeginn“, „zur Wochenmitte“ oder „gegen Ende der Woche“.
- Gib keine pauschalen Beziehungstipps.
- Bleibe wohlwollend, ruhig und klar.
- Klinge nicht klinisch und nicht schulbuchhaft.
- Vermeide Floskeln und Standardformulierungen aus Ratgeber- oder Therapietexten.
- Wenn an weniger als 2 Tagen Alkohol eingetragen wurde, erwähne das höchstens knapp und ohne Warnung.
- Wenn an 3 oder mehr Tagen Alkohol eingetragen wurde, benenne das ruhig als möglichen Belastungsfaktor.
- Wenn in dieser Woche weniger als 2 Mal Sport gemacht wurde, formuliere am Ende höchstens einen kurzen, alltagsnahen Impuls für die kommende Woche.
- Schreibe klar und möglichst konkret statt vorsichtig-abstrakt.
- Verwende wenige Konjunktive.
- Vermeide therapeutische Standardformulierungen und allgemeine Lebenshilfe-Sprache.
- Wenn Alkohol nur an einem Tag vorkam und nicht zentral für die Woche war, erwähne ihn nicht.
- Der Schlussteil soll kurz sein und nicht wie ein Coaching-Impuls klingen.
- Der letzte Absatz darf höchstens 2 Sätze lang sein.
- Er soll eher eine ruhige Einordnung des nächsten Schwerpunkts geben als konkrete Tipps.
Die Zusammenfassung soll wie eine verdichtete persönliche Einordnung der Woche klingen, nicht wie ein Bericht.
Schreibe einen zusammenhängenden Fließtext mit etwa 180 bis 280 Wörtern.
TEXT;
private const MONTH_SYSTEM_PROMPT = <<<'TEXT'
Du bist ein verhaltenstherapeutisch orientierter Assistent für einen persönlichen Mood-Tracker.
Deine Aufgabe ist es, aus bereits vorhandenen KI-Wochenzusammenfassungen eine Monatszusammenfassung zu erstellen. Du sollst keine Tagesdetails neu auswerten, sondern die vorhandenen Wochenrückblicke verdichten, Muster über mehrere Wochen erkennen und einen übergeordneten Verlauf beschreiben.
Wichtige Regeln:
- Schreibe empathisch, klar, ruhig und konkret.
- Schreibe in natürlichem Fließtext.
- Arbeite nur mit den vorliegenden Wochenzusammenfassungen und den zugehörigen Wochenkennzahlen.
- Suche nach Entwicklungen über den Monat hinweg: Stabilisierung, Verschlechterung, Schwankungen, wiederkehrende Konflikte, Ressourcen, Belastungsschwerpunkte.
- Stelle keine Diagnosen.
- Kein Fachjargon-Overkill.
- Keine Listen.
- Keine Emojis.
- Keine Floskeln.
- Erfinde nichts, was nicht aus den Wochenzusammenfassungen ableitbar ist.
- Wenn die Datengrundlage schmal ist, erwähne das kurz.
Am Ende soll eine vorsichtige therapeutische Einordnung stehen, was sich im Monatsverlauf als besonders relevant gezeigt hat.
Länge: etwa 300 bis 500 Wörter.
TEXT;
private const MONTH_USER_TEMPLATE = <<<'TEXT'
Bitte schreibe eine Monatszusammenfassung für den folgenden Zeitraum.
Voraussetzung:
Es liegen mindestens 2 bereits erzeugte KI-Wochenzusammenfassungen für diesen Monat vor.
Zeitraum:
{{MONTH_LABEL}}
Monatsdaten:
- Anzahl verfügbarer KI-Wochenzusammenfassungen: {{WEEKLY_SUMMARY_COUNT}}
- Durchschnittliche Stimmung im Monat: {{AVG_MOOD_MONTH}}
- Durchschnittlicher Stress im Monat: {{AVG_STRESS_MONTH}}
- Durchschnittliche Energie im Monat: {{AVG_ENERGY_MONTH}}
- Durchschnittlicher Schlaf im Monat: {{AVG_SLEEP_MONTH}}
Vorliegende KI-Wochenzusammenfassungen:
{{WEEKLY_SUMMARIES}}
Aufgabe:
Verdichte die Wochenzusammenfassungen zu einem stimmigen Monatsrückblick. Beschreibe, welche Muster sich über mehrere Wochen zeigen, welche Belastungen besonders prägend waren, welche Ressourcen erkennbar wurden und ob sich eher Stabilisierung, Zuspitzung oder starke Schwankung zeigt.
Schreibe einen zusammenhängenden Fließtext mit etwa 300 bis 500 Wörtern.
TEXT;
private AiConfigRepository $config;
public function __construct(AiConfigRepository $config)
{
$this->config = $config;
}
public function configuration(): array
{
$config = $this->config->get();
return [
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'timeout' => (int) ($config['timeout'] ?? 25),
'has_api_key' => $this->apiKey() !== '',
'available' => $this->isAvailable(),
];
}
public function isAvailable(): bool
{
return function_exists('curl_init') && $this->apiKey() !== '';
}
public function generateWeekly(array $payload): string
{
return $this->requestSummary(
self::WEEK_SYSTEM_PROMPT,
$this->renderTemplate(self::WEEK_USER_TEMPLATE, $payload)
);
}
public function generateMonthly(array $payload): string
{
return $this->requestSummary(
self::MONTH_SYSTEM_PROMPT,
$this->renderTemplate(self::MONTH_USER_TEMPLATE, $payload)
);
}
private function requestSummary(string $systemPrompt, string $userPrompt): string
{
if (!function_exists('curl_init')) {
throw new RuntimeException('Die KI-Zusammenfassung ist auf diesem Server aktuell nicht verfügbar.');
}
$apiKey = $this->apiKey();
if ($apiKey === '') {
throw new RuntimeException('Für KI-Zusammenfassungen fehlt der OpenAI API-Key.');
}
$config = $this->config->get();
$body = json_encode([
'model' => (string) ($config['model'] ?? 'gpt-4o-mini'),
'messages' => [
[
'role' => 'system',
'content' => $systemPrompt,
],
[
'role' => 'user',
'content' => $userPrompt,
],
],
'temperature' => 0.8,
'max_completion_tokens' => 900,
'store' => false,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($body)) {
throw new RuntimeException('Die KI-Anfrage konnte nicht vorbereitet werden.');
}
$handle = curl_init(self::CHAT_COMPLETIONS_ENDPOINT);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zur KI konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => (int) ($config['timeout'] ?? 25),
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($responseBody === false || $error !== '') {
throw new RuntimeException('Die KI-Anfrage ist fehlgeschlagen oder hat zu lange gedauert.');
}
$decoded = json_decode((string) $responseBody, true);
if (!is_array($decoded)) {
throw new RuntimeException('Die KI-Antwort konnte nicht gelesen werden.');
}
if ($status < 200 || $status >= 300) {
$message = trim((string) ($decoded['error']['message'] ?? ''));
if ($message === '') {
$message = 'Die KI-Anfrage konnte gerade nicht verarbeitet werden.';
}
throw new RuntimeException($message);
}
$text = trim($this->extractResponseText($decoded));
if ($text === '') {
throw new RuntimeException('Die KI hat keine verwertbare Zusammenfassung zurückgegeben.');
}
return $text;
}
private function extractResponseText(array $response): string
{
$content = $response['choices'][0]['message']['content'] ?? null;
if (is_string($content)) {
return $content;
}
if (is_array($content)) {
$parts = [];
foreach ($content as $item) {
if (is_string($item)) {
$parts[] = $item;
continue;
}
if (is_array($item) && is_string($item['text'] ?? null)) {
$parts[] = (string) $item['text'];
}
}
return trim(implode("\n\n", array_filter($parts)));
}
return '';
}
private function renderTemplate(string $template, array $payload): string
{
$replacements = [];
foreach ($payload as $key => $value) {
$replacements['{{' . strtoupper((string) $key) . '}}'] = is_scalar($value)
? (string) $value
: '';
}
return strtr($template, $replacements);
}
private function apiKey(): string
{
return trim((string) ($_ENV['OPENAI_API_KEY'] ?? getenv('OPENAI_API_KEY') ?: ''));
}
}
+328
View File
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
final class WebPushService
{
private NotificationRepository $notifications;
public function __construct(NotificationRepository $notifications)
{
$this->notifications = $notifications;
}
public function isAvailable(): bool
{
return function_exists('openssl_pkey_new')
&& function_exists('openssl_sign')
&& function_exists('openssl_encrypt')
&& function_exists('openssl_pkey_derive')
&& function_exists('curl_init');
}
public function publicKey(): ?string
{
$keys = $this->keys();
return $keys['public'] ?? null;
}
public function cronToken(): string
{
return (string) ($this->notifications->systemConfig()['cron_token'] ?? '');
}
public function send(array $subscription, array $message): array
{
if (!$this->isAvailable()) {
throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.');
}
$endpoint = trim((string) ($subscription['endpoint'] ?? ''));
$p256dh = trim((string) ($subscription['keys']['p256dh'] ?? ''));
$auth = trim((string) ($subscription['keys']['auth'] ?? ''));
if ($endpoint === '' || $p256dh === '' || $auth === '') {
throw new RuntimeException('Die Push-Subscription ist unvollständig.');
}
$payload = json_encode([
'title' => (string) ($message['title'] ?? 'Mood-Board'),
'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'),
'icon' => '/assets/branding/logo-mark.svg',
'badge' => '/assets/branding/favicon.svg',
'url' => (string) ($message['url'] ?? '/track'),
'tag' => (string) ($message['tag'] ?? 'mood-reminder'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($payload)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.');
}
$encrypted = $this->encrypt($payload, $p256dh, $auth);
$audience = $this->audienceForEndpoint($endpoint);
$authorization = $this->authorizationHeader($audience);
$headers = [
'TTL: 3600',
'Urgency: normal',
'Content-Encoding: aes128gcm',
'Content-Type: application/octet-stream',
'Authorization: ' . $authorization['header'],
'Crypto-Key: p256ecdsa=' . $authorization['public'],
];
$handle = curl_init($endpoint);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $encrypted['body'],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_TIMEOUT => 12,
]);
$responseBody = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'remove' => in_array($status, [404, 410], true),
'error' => $error !== '' ? $error : null,
'response' => is_string($responseBody) ? $responseBody : null,
];
}
private function keys(): array
{
$config = $this->notifications->systemConfig();
$public = trim((string) ($config['vapid_public_key'] ?? ''));
$private = trim((string) ($config['vapid_private_key'] ?? ''));
if ($public !== '' && $private !== '') {
return ['public' => $public, 'private' => $private];
}
if (!$this->isAvailable()) {
return ['public' => null, 'private' => null];
}
$resource = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($resource === false) {
throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.');
}
$details = openssl_pkey_get_details($resource);
if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) {
throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.');
}
$publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y'];
$privateKey = $details['ec']['d'];
$encodedPublic = base64url_encode($publicKey);
$encodedPrivate = base64url_encode($privateKey);
$this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate);
return ['public' => $encodedPublic, 'private' => $encodedPrivate];
}
private function encrypt(string $payload, string $userPublicKey, string $authSecret): array
{
$salt = random_bytes(16);
$userPublicRaw = base64url_decode($userPublicKey);
$authRaw = base64url_decode($authSecret);
$localKey = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($localKey === false) {
throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.');
}
$localDetails = openssl_pkey_get_details($localKey);
if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) {
throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.');
}
$localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y'];
$userPem = $this->publicKeyPemFromRaw($userPublicRaw);
$sharedSecret = openssl_pkey_derive($userPem, $localKey, 32);
if (!is_string($sharedSecret) || $sharedSecret === '') {
throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.');
}
$context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw;
$keyMaterial = $this->hkdfExpand(
$this->hkdfExtract($authRaw, $sharedSecret),
$context,
32
);
$contentPrk = $this->hkdfExtract($salt, $keyMaterial);
$contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16);
$nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12);
$recordSize = 4096;
$plaintext = $payload . "\x02";
$tag = '';
$ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag);
if (!is_string($ciphertext)) {
throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.');
}
$body = $salt
. pack('N', $recordSize)
. chr(strlen($localPublicRaw))
. $localPublicRaw
. $ciphertext
. $tag;
return [
'body' => $body,
'local_public' => $localPublicRaw,
];
}
private function authorizationHeader(string $audience): array
{
$keys = $this->keys();
$header = base64url_encode((string) json_encode([
'typ' => 'JWT',
'alg' => 'ES256',
], JSON_UNESCAPED_SLASHES));
$payload = base64url_encode((string) json_encode([
'aud' => $audience,
'exp' => time() + 3600,
'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'),
], JSON_UNESCAPED_SLASHES));
$signingInput = $header . '.' . $payload;
$signatureDer = '';
$privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private']));
if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) {
throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.');
}
$jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64));
return [
'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'],
'public' => (string) $keys['public'],
];
}
private function audienceForEndpoint(string $endpoint): string
{
$parts = parse_url($endpoint);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
throw new RuntimeException('Der Push-Endpunkt ist ungültig.');
}
return $parts['scheme'] . '://' . $parts['host'];
}
private function hkdfExtract(string $salt, string $ikm): string
{
return hash_hmac('sha256', $ikm, $salt, true);
}
private function hkdfExpand(string $prk, string $info, int $length): string
{
$output = '';
$block = '';
$counter = 1;
while (strlen($output) < $length) {
$block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true);
$output .= $block;
$counter++;
}
return substr($output, 0, $length);
}
private function publicKeyPemFromRaw(string $raw): string
{
$der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw;
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
private function privateKeyPemFromRaw(string $raw): string
{
$der = hex2bin('30770201010420')
. $raw
. hex2bin('A00A06082A8648CE3D030107A14403420004')
. substr(base64url_decode((string) $this->keys()['public']), 1);
return "-----BEGIN EC PRIVATE KEY-----\n"
. chunk_split(base64_encode((string) $der), 64, "\n")
. "-----END EC PRIVATE KEY-----\n";
}
private function derSignatureToJose(string $der, int $partLength): string
{
$offset = 0;
if (ord($der[$offset]) !== 0x30) {
throw new RuntimeException('Ungültige DER-Signatur.');
}
$offset++;
$this->readAsnLength($der, $offset);
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-R-Teil.');
}
$offset++;
$rLength = $this->readAsnLength($der, $offset);
$r = substr($der, $offset, $rLength);
$offset += $rLength;
if (ord($der[$offset]) !== 0x02) {
throw new RuntimeException('Ungültiger DER-S-Teil.');
}
$offset++;
$sLength = $this->readAsnLength($der, $offset);
$s = substr($der, $offset, $sLength);
return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT)
. str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
}
private function readAsnLength(string $der, int &$offset): int
{
$length = ord($der[$offset]);
$offset++;
if (($length & 0x80) === 0) {
return $length;
}
$numberOfBytes = $length & 0x7F;
$length = 0;
for ($index = 0; $index < $numberOfBytes; $index++) {
$length = ($length << 8) | ord($der[$offset]);
$offset++;
}
return $length;
}
}
+8 -16
View File
@@ -5,36 +5,28 @@ 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/OpenAiSummaryService.php';
require __DIR__ . '/Support/View.php'; require __DIR__ . '/Support/View.php';
require __DIR__ . '/Support/WebPushService.php';
require __DIR__ . '/Domain/AiConfigRepository.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/SummaryRepository.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();
+280 -2
View File
@@ -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));
@@ -166,6 +197,87 @@ function format_display_date(string $date, bool $withWeekday = true): string
return $weekdays[(int) $current->format('w')] . ', ' . $label; return $weekdays[(int) $current->format('w')] . ', ' . $label;
} }
function format_display_datetime(string $value): string
{
try {
$current = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
return $current->format('j.') . ' ' . $months[(int) $current->format('n')] . ' ' . $current->format('Y') . ' um ' . $current->format('H:i');
}
function iso_week_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('o-\K\W-W');
}
return $current->format('o-\K\W-W');
}
function month_key(string $date): string
{
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($current === false) {
return date('Y-m');
}
return $current->format('Y-m');
}
function iso_week_label(string $key): string
{
if (preg_match('/^(\d{4})-KW-(\d{2})$/', $key, $matches) === 1) {
return 'KW ' . $matches[2] . ' / ' . $matches[1];
}
return $key;
}
function month_label(string $key): string
{
if (preg_match('/^(\d{4})-(\d{2})$/', $key, $matches) !== 1) {
return $key;
}
$months = [
'01' => 'Januar',
'02' => 'Februar',
'03' => 'März',
'04' => 'April',
'05' => 'Mai',
'06' => 'Juni',
'07' => 'Juli',
'08' => 'August',
'09' => 'September',
'10' => 'Oktober',
'11' => 'November',
'12' => 'Dezember',
];
return ($months[$matches[2]] ?? $matches[2]) . ' ' . $matches[1];
}
function icon_path(string $name): string function icon_path(string $name): string
{ {
return '/assets/icons/' . $name . '.svg'; return '/assets/icons/' . $name . '.svg';
@@ -176,6 +288,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 +345,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 +524,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 +538,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']),
+42 -4
View File
@@ -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',
@@ -19,15 +19,27 @@ $brandSubtitle = match ($page) {
<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="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">
@@ -100,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>
+144 -6
View File
@@ -3,11 +3,132 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow">Archiv</p> <p class="eyebrow">Archiv</p>
<h3>Alle gespeicherten Tage</h3> <h3>KI-Rückblicke und gespeicherte Tage</h3>
</div> </div>
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span> <span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
</div> </div>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Monatszusammenfassungen</h4>
</div>
<?php if (empty($aiAvailable)): ?>
<span class="chart-chip chart-chip--muted">API nicht bereit</span>
<?php endif; ?>
</div>
<?php if ($monthlyArchive === []): ?>
<p class="empty-state">Sobald genügend Wochenzusammenfassungen vorliegen, erscheinen hier die Monatsrückblicke.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($monthlyArchive as $month): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($month['label']) ?></strong>
</div>
<?php if (!empty($month['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($month['date_from']) ?> bis <?= e($month['date_to']) ?></p>
<p class="helper-text"><?= e((string) $month['weekly_summary_count']) ?> KI-Wochenzusammenfassungen im Monat verfügbar</p>
<?php if (!empty($month['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $month['summary']['created_at'])) ?></p>
<?php else: ?>
<p class="helper-text">Mindestens 2 KI-Wochenzusammenfassungen nötig.</p>
<?php endif; ?>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($month['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=monthly&amp;summary_key=<?= e(rawurlencode((string) $month['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_monthly_summary">
<input type="hidden" name="month_key" value="<?= e((string) $month['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$month['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($month['has_summary']) ? 'Neu generieren' : 'KI-Monatszusammenfassung erzeugen' ?>
</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">KI</p>
<h4>Wochenzusammenfassungen</h4>
</div>
</div>
<?php if ($weeklyArchive === []): ?>
<p class="empty-state">Noch keine Wochen verfügbar. Sobald Einträge vorliegen, kannst du hier Wochenrückblicke erzeugen.</p>
<?php else: ?>
<div class="archive-summary-grid">
<?php foreach ($weeklyArchive as $week): ?>
<article class="archive-summary-card">
<div class="archive-summary-card__head">
<div>
<span class="summary-badge">KI</span>
<strong><?= e($week['label']) ?></strong>
</div>
<?php if (!empty($week['has_summary'])): ?>
<span class="chart-chip">vorhanden</span>
<?php else: ?>
<span class="chart-chip chart-chip--muted">offen</span>
<?php endif; ?>
</div>
<p class="helper-text"><?= e($week['date_from']) ?> bis <?= e($week['date_to']) ?></p>
<p class="helper-text"><?= e((string) $week['note_entries_count']) ?> Texteinträge · <?= e((string) $week['tracked_days']) ?> getrackte Tage</p>
<?php if (!empty($week['summary'])): ?>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $week['summary']['created_at'])) ?></p>
<?php else: ?>
<p class="helper-text">Mindestens 3 Texteinträge nötig.</p>
<?php endif; ?>
<div class="archive-item__actions archive-item__actions--stack">
<?php if (!empty($week['summary'])): ?>
<a class="ghost-link archive-action" href="/archive?summary_kind=weekly&amp;summary_key=<?= e(rawurlencode((string) $week['summary_key'])) ?>">Öffnen</a>
<?php endif; ?>
<form method="post" action="/archive">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="generate_weekly_summary">
<input type="hidden" name="week_key" value="<?= e((string) $week['summary_key']) ?>">
<button class="ghost-button ghost-button--small" type="submit" <?= !$week['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>
<?= !empty($week['has_summary']) ? 'Neu generieren' : 'KI-Wochenzusammenfassung erzeugen' ?>
</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="archive-summary-section">
<div class="section-head section-head--compact">
<div>
<p class="eyebrow">Tage</p>
<h4>Alle gespeicherten Tage</h4>
</div>
</div>
<?php if ($entries === []): ?> <?php if ($entries === []): ?>
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p> <p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
<?php else: ?> <?php else: ?>
@@ -22,7 +143,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>
@@ -40,10 +161,23 @@
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</section>
</article> </article>
<aside class="stack-column"> <aside class="stack-column">
<?php if ($selectedEntry !== null): ?> <?php if ($selectedSummary !== null): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI-Zusammenfassung</p>
<h3><?= e($selectedSummary['title']) ?></h3>
<p class="hero-label"><?= e($selectedSummary['date_from']) ?> bis <?= e($selectedSummary['date_to']) ?></p>
<p class="helper-text">Erstellt am <?= e(format_display_datetime((string) $selectedSummary['created_at'])) ?></p>
<div class="note-box note-box--summary">
<h4>Text</h4>
<p><?= e($selectedSummary['text']) ?></p>
</div>
</article>
<?php elseif ($selectedEntry !== null): ?>
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Ausgewählt</p> <p class="eyebrow">Ausgewählt</p>
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3> <h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
@@ -54,6 +188,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 +202,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 +212,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">
@@ -87,7 +225,7 @@
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Details</p> <p class="eyebrow">Details</p>
<h3>Archivansicht</h3> <h3>Archivansicht</h3>
<p>Wähle links einen Tag aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</p> <p>Wähle links einen Tag oder eine KI-Zusammenfassung aus. Wochenrückblicke benötigen mindestens 3 Texteinträge, Monatsrückblicke mindestens 2 vorhandene KI-Wochenzusammenfassungen.</p>
</article> </article>
<?php endif; ?> <?php endif; ?>
</aside> </aside>
+14 -1
View File
@@ -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>
+5
View File
@@ -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>
+219 -10
View File
@@ -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,7 +73,29 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h4>Spaziergang-Bänder</h4> <div class="section-head section-head--compact">
<div>
<h4>Spaziergang</h4>
<p class="helper-text">Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.</p>
</div>
</div>
<label>
<span>Spaziergang auswerten nach</span>
<select name="settings[walk][mode]">
<?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?>
<option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if (($settings['walk']['mode'] ?? 'time') === 'steps'): ?>
<div class="band-card">
<h5>Schritte mit Bestwert bei 10.000</h5>
<p class="helper-text">Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.</p>
<p class="helper-text">Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte</p>
</div>
<?php else: ?>
<div class="band-grid"> <div class="band-grid">
<?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?> <?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?>
<div class="band-card"> <div class="band-card">
@@ -58,17 +105,42 @@
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </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>
@@ -212,6 +390,37 @@
</article> </article>
<?php if (!empty($authUser['is_admin'])): ?> <?php if (!empty($authUser['is_admin'])): ?>
<article class="glass-panel detail-card">
<p class="eyebrow">KI</p>
<h3>OpenAI für Zusammenfassungen</h3>
<p class="helper-text">Diese Einstellung gilt zentral für alle Nutzer. Der API-Key bleibt in der Umgebung des Servers und wird hier bewusst nicht gespeichert.</p>
<?php if (!empty($aiStatus)): ?>
<div class="user-list">
<div class="user-row">
<strong>API-Key</strong>
<span><?= !empty($aiStatus['has_api_key']) ? 'vorhanden' : 'fehlt' ?></span>
</div>
<div class="user-row">
<strong>Aktuelles Modell</strong>
<span><?= e((string) ($aiStatus['model'] ?? '')) ?></span>
</div>
<div class="user-row">
<strong>Timeout</strong>
<span><?= e((string) ($aiStatus['timeout'] ?? '')) ?> s</span>
</div>
</div>
<?php endif; ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="ai_config">
<label><span>OpenAI-Modell</span><input type="text" name="ai[model]" value="<?= e((string) ($aiConfig['model'] ?? 'gpt-4o-mini')) ?>" required></label>
<label><span>Timeout in Sekunden</span><input type="number" name="ai[timeout]" value="<?= e((string) ($aiConfig['timeout'] ?? 25)) ?>" min="5" max="120" required></label>
<button class="primary-button" type="submit">KI-Konfiguration speichern</button>
</form>
</article>
<article class="glass-panel detail-card"> <article class="glass-panel detail-card">
<p class="eyebrow">Mehrere Accounts</p> <p class="eyebrow">Mehrere Accounts</p>
<h3>Neuen Nutzer anlegen</h3> <h3>Neuen Nutzer anlegen</h3>
+45 -2
View File
@@ -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',
]; ];
?> ?>