Compare commits
9 Commits
4a884dd166
...
V1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e953d0fd42 | |||
| ab1d8bc677 | |||
| 297f63c7d5 | |||
| 889f5ffa8a | |||
| 41183f04db | |||
| 796e5b23d2 | |||
| af84243866 | |||
| 9e79e93724 | |||
| 0a8ccef5a7 |
@@ -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,9 +31,24 @@ 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
|
## Lizenz
|
||||||
|
|
||||||
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
|
- Dieses Projekt steht unter der `PolyForm Noncommercial License 1.0.0`.
|
||||||
|
|||||||
@@ -48,6 +48,30 @@
|
|||||||
--control-soft-bg: rgba(255, 255, 255, 0.08);
|
--control-soft-bg: rgba(255, 255, 255, 0.08);
|
||||||
--control-soft-border: rgba(255, 255, 255, 0.16);
|
--control-soft-border: rgba(255, 255, 255, 0.16);
|
||||||
--brand-shadow: 0 10px 24px rgba(9, 25, 40, 0.22);
|
--brand-shadow: 0 10px 24px rgba(9, 25, 40, 0.22);
|
||||||
|
--archive-shell-bg:
|
||||||
|
linear-gradient(180deg, rgba(22, 38, 58, 0.94), rgba(10, 25, 41, 0.92)),
|
||||||
|
radial-gradient(circle at top right, rgba(59, 173, 212, 0.12), transparent 42%);
|
||||||
|
--archive-shell-border: rgba(148, 198, 228, 0.18);
|
||||||
|
--archive-toolbar-bg:
|
||||||
|
linear-gradient(180deg, rgba(34, 57, 79, 0.82), rgba(22, 40, 58, 0.76)),
|
||||||
|
radial-gradient(circle at top right, rgba(75, 203, 223, 0.1), transparent 48%);
|
||||||
|
--archive-toolbar-border: rgba(148, 198, 228, 0.14);
|
||||||
|
--archive-switcher-bg: rgba(12, 24, 38, 0.34);
|
||||||
|
--archive-switcher-border: rgba(148, 198, 228, 0.12);
|
||||||
|
--archive-switcher-active-bg: rgba(173, 213, 245, 0.14);
|
||||||
|
--archive-row-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--archive-row-border: rgba(255, 255, 255, 0.05);
|
||||||
|
--archive-row-active-bg: rgba(255, 255, 255, 0.09);
|
||||||
|
--archive-detail-bg:
|
||||||
|
linear-gradient(180deg, rgba(40, 62, 86, 0.88), rgba(24, 41, 60, 0.82)),
|
||||||
|
radial-gradient(circle at top left, rgba(135, 217, 255, 0.12), transparent 42%);
|
||||||
|
--archive-select-bg: rgba(30, 51, 72, 0.84);
|
||||||
|
--archive-select-border: rgba(148, 198, 228, 0.18);
|
||||||
|
--archive-select-focus-bg: rgba(35, 59, 83, 0.94);
|
||||||
|
--archive-mobile-overlay-bg: rgba(6, 14, 24, 0.36);
|
||||||
|
--archive-mobile-top-bg:
|
||||||
|
linear-gradient(180deg, rgba(17, 33, 50, 0.96), rgba(17, 33, 50, 0.78)),
|
||||||
|
radial-gradient(circle at top left, rgba(135, 217, 255, 0.1), transparent 42%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
@@ -95,6 +119,30 @@
|
|||||||
--control-soft-bg: rgba(255, 255, 255, 0.58);
|
--control-soft-bg: rgba(255, 255, 255, 0.58);
|
||||||
--control-soft-border: rgba(123, 153, 182, 0.22);
|
--control-soft-border: rgba(123, 153, 182, 0.22);
|
||||||
--brand-shadow: 0 10px 24px rgba(82, 111, 138, 0.14);
|
--brand-shadow: 0 10px 24px rgba(82, 111, 138, 0.14);
|
||||||
|
--archive-shell-bg:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 252, 255, 0.72)),
|
||||||
|
radial-gradient(circle at top right, rgba(123, 190, 255, 0.16), transparent 46%);
|
||||||
|
--archive-shell-border: rgba(120, 146, 172, 0.2);
|
||||||
|
--archive-toolbar-bg:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(245, 251, 255, 0.64)),
|
||||||
|
radial-gradient(circle at top right, rgba(106, 203, 219, 0.12), transparent 48%);
|
||||||
|
--archive-toolbar-border: rgba(120, 146, 172, 0.16);
|
||||||
|
--archive-switcher-bg: rgba(255, 255, 255, 0.34);
|
||||||
|
--archive-switcher-border: rgba(120, 146, 172, 0.18);
|
||||||
|
--archive-switcher-active-bg: rgba(255, 255, 255, 0.72);
|
||||||
|
--archive-row-bg: rgba(255, 255, 255, 0.38);
|
||||||
|
--archive-row-border: rgba(120, 146, 172, 0.14);
|
||||||
|
--archive-row-active-bg: rgba(255, 255, 255, 0.56);
|
||||||
|
--archive-detail-bg:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(244, 250, 255, 0.72)),
|
||||||
|
radial-gradient(circle at top left, rgba(141, 205, 255, 0.16), transparent 42%);
|
||||||
|
--archive-select-bg: rgba(255, 255, 255, 0.62);
|
||||||
|
--archive-select-border: rgba(123, 153, 182, 0.2);
|
||||||
|
--archive-select-focus-bg: rgba(255, 255, 255, 0.84);
|
||||||
|
--archive-mobile-overlay-bg: rgba(236, 243, 249, 0.42);
|
||||||
|
--archive-mobile-top-bg:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 251, 255, 0.84)),
|
||||||
|
radial-gradient(circle at top left, rgba(141, 205, 255, 0.16), transparent 42%);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -104,6 +152,31 @@
|
|||||||
select {
|
select {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-nav .nav-icon {
|
||||||
|
opacity: 1;
|
||||||
|
filter:
|
||||||
|
saturate(2.1)
|
||||||
|
contrast(1.16)
|
||||||
|
brightness(0.76)
|
||||||
|
drop-shadow(0 1px 0 rgba(255, 255, 255, 0.5))
|
||||||
|
drop-shadow(0 0 8px rgba(110, 214, 255, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav a.active .nav-icon {
|
||||||
|
filter:
|
||||||
|
saturate(2.35)
|
||||||
|
contrast(1.2)
|
||||||
|
brightness(0.68)
|
||||||
|
drop-shadow(0 1px 0 rgba(255, 255, 255, 0.56))
|
||||||
|
drop-shadow(0 0 10px rgba(110, 214, 255, 0.24));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive-day div {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(244, 249, 255, 0.64));
|
||||||
|
border: 1px solid rgba(126, 156, 184, 0.18);
|
||||||
|
box-shadow: 0 8px 22px rgba(138, 167, 194, 0.1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -851,6 +924,10 @@ button:disabled {
|
|||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sport-choice-list--single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.sport-choice {
|
.sport-choice {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -890,6 +967,11 @@ button:disabled {
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sport-choice__card--toggle {
|
||||||
|
min-height: 100%;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.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));
|
||||||
@@ -1150,6 +1232,452 @@ input[type="range"] {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-summary-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-page {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--archive-shell-bg);
|
||||||
|
border-color: var(--archive-shell-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--archive-toolbar-bg);
|
||||||
|
border: 1px solid var(--archive-toolbar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-toolbar--compact {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-switcher {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--archive-switcher-bg);
|
||||||
|
border: 1px solid var(--archive-switcher-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-switcher__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.45rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-switcher__item.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--archive-switcher-active-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-filter label {
|
||||||
|
min-width: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-filter select {
|
||||||
|
background: var(--archive-select-bg);
|
||||||
|
border-color: var(--archive-select-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-filter select:focus {
|
||||||
|
background: var(--archive-select-focus-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.78fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rows {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--archive-row-bg);
|
||||||
|
border: 1px solid var(--archive-row-border);
|
||||||
|
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row:hover,
|
||||||
|
.archive-row.active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(139, 228, 255, 0.28);
|
||||||
|
background: var(--archive-row-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__main,
|
||||||
|
.archive-row__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__main strong,
|
||||||
|
.archive-row__title-group strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__main span,
|
||||||
|
.archive-row__meta span,
|
||||||
|
.archive-row__title-group span,
|
||||||
|
.archive-row__hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__meta {
|
||||||
|
justify-items: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__meta--stack {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__hint {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__title-group span,
|
||||||
|
.archive-row__meta span {
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary {
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.95fr) auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary .archive-row__main {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary .status-badge {
|
||||||
|
justify-self: end;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--week .archive-row__main--week {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--week .archive-row__main--week .status-badge,
|
||||||
|
.archive-row--month .archive-row__main--month .status-badge {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--month .archive-row__main--month {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary .archive-row__hint {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__title-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.7rem;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--ready {
|
||||||
|
color: #082336;
|
||||||
|
background: rgba(127, 243, 187, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--pending {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(139, 228, 255, 0.14);
|
||||||
|
border-color: rgba(139, 228, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--blocked {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.15rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
position: sticky;
|
||||||
|
top: 1.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--archive-detail-bg);
|
||||||
|
border-color: var(--archive-shell-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__status-row,
|
||||||
|
.archive-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__single-action form,
|
||||||
|
.archive-detail__actions form {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__single-action {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__week-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__status-note {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__status-note p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-mini-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-mini-list__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.72rem 0.85rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__panel > *,
|
||||||
|
.archive-detail__top > *,
|
||||||
|
.archive-detail__status-row > *,
|
||||||
|
.archive-detail__actions > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__actions .ghost-link,
|
||||||
|
.archive-detail__actions .ghost-button,
|
||||||
|
.archive-detail__single-action .primary-button {
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__status-row .chart-chip {
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail__single-action .primary-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive-day {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive-day div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive-day dt,
|
||||||
|
.detail-grid--archive-day dd {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive-day dd {
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive dd,
|
||||||
|
.archive-mini-list__row span,
|
||||||
|
.note-box p {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid--archive dd {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
@@ -1185,6 +1713,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;
|
||||||
@@ -1242,6 +1779,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;
|
||||||
}
|
}
|
||||||
@@ -1536,6 +2077,79 @@ input[type="range"] {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header,
|
||||||
|
.archive-toolbar,
|
||||||
|
.archive-workspace,
|
||||||
|
.archive-list-header,
|
||||||
|
.archive-row,
|
||||||
|
.archive-row--summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-header,
|
||||||
|
.archive-toolbar,
|
||||||
|
.archive-list-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-workspace {
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row__meta {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary .archive-row__main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--summary .status-badge {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.9rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day .archive-row__main {
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day .archive-row__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
text-align: right;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day .archive-row__hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day .archive-row__main strong {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-row--day .archive-row__main span,
|
||||||
|
.archive-row--day .archive-row__meta span {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-detail {
|
||||||
|
scroll-margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-detail {
|
.calendar-detail {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1610,4 +2224,19 @@ input[type="range"] {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-shell {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-switcher,
|
||||||
|
.archive-filter,
|
||||||
|
.archive-header__meta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-filter label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M23 11C23.4 9.8 24.5 9 25.8 9H38.2C39.5 9 40.6 9.8 41 11L44.4 22.7C44.8 24.2 45 25.8 45 27.3C45 33.9 39.8 39.3 33.3 39.8V51H39.5C40.9 51 42 52.1 42 53.5C42 54.9 40.9 56 39.5 56H24.5C23.1 56 22 54.9 22 53.5C22 52.1 23.1 51 24.5 51H30.7V39.8C24.2 39.3 19 33.9 19 27.3C19 25.8 19.2 24.2 19.6 22.7L23 11Z" fill="#EFF7FF"/>
|
||||||
|
<path d="M22.4 20H41.6L41.2 22C41 22.9 41 23.8 41 24.7C41 29.8 36.8 34 31.7 34H32.3C37.2 34 41.2 30 41.2 25.1C41.2 24 41.1 23 40.8 22L40.3 20H22.4Z" fill="#8BE4FF" opacity="0.95"/>
|
||||||
|
<path d="M22 21.5C22 20.7 22.7 20 23.5 20H40.5C41.3 20 42 20.7 42 21.5C42 27.3 37.3 32 31.5 32C25.7 32 21 27.3 21 21.5H22Z" fill="#7FF3BB" opacity="0.8"/>
|
||||||
|
<path d="M24 15H40" stroke="#8BE4FF" stroke-width="2.8" stroke-linecap="round"/>
|
||||||
|
<path d="M27.5 44H36.5" stroke="#8BE4FF" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M25 53H39" stroke="#7FF3BB" stroke-width="3.2" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
+27
-1
@@ -70,6 +70,25 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initArchiveMobileDetail() {
|
||||||
|
if (!document.body.classList.contains("page-archive")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobileViewport = () => window.matchMedia("(max-width: 820px)").matches;
|
||||||
|
const detail = document.querySelector("#archive-detail-panel[data-detail-open='1']");
|
||||||
|
|
||||||
|
if (!detail || !isMobileViewport()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
detail.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sleepDurationPoints(hours, points) {
|
function sleepDurationPoints(hours, points) {
|
||||||
if (hours < 4) {
|
if (hours < 4) {
|
||||||
return Number(points.lt4 || 0);
|
return Number(points.lt4 || 0);
|
||||||
@@ -542,8 +561,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calendarColor(entry) {
|
function calendarColor(entry) {
|
||||||
|
const isLightMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return "rgba(255, 255, 255, 0.06)";
|
return isLightMode
|
||||||
|
? "rgba(86, 124, 156, 0.11)"
|
||||||
|
: "rgba(255, 255, 255, 0.06)";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = Math.max(0, Math.min(1, Number(entry.score) / Math.max(Number(entry.max || 1), 1)));
|
const ratio = Math.max(0, Math.min(1, Number(entry.score) / Math.max(Number(entry.max || 1), 1)));
|
||||||
@@ -644,6 +667,7 @@
|
|||||||
const yOffset = config.yOffset;
|
const yOffset = config.yOffset;
|
||||||
const gridHeight = (7 * cellSize) + (6 * verticalGap);
|
const gridHeight = (7 * cellSize) + (6 * verticalGap);
|
||||||
const height = yOffset + gridHeight + 8;
|
const height = yOffset + gridHeight + 8;
|
||||||
|
const sundayLabelY = yOffset + (6 * (cellSize + verticalGap)) + (cellSize * 0.78);
|
||||||
const rightPadding = 4;
|
const rightPadding = 4;
|
||||||
const naturalWidth = xOffset + (totalWeeks * cellSize) + ((totalWeeks - 1) * baseCellGap) + rightPadding;
|
const naturalWidth = xOffset + (totalWeeks * cellSize) + ((totalWeeks - 1) * baseCellGap) + rightPadding;
|
||||||
const availableWidth = Math.floor(container.clientWidth || 0);
|
const availableWidth = Math.floor(container.clientWidth || 0);
|
||||||
@@ -718,6 +742,7 @@
|
|||||||
<text class="calendar-tooltip" x="0" y="34">Mo</text>
|
<text class="calendar-tooltip" x="0" y="34">Mo</text>
|
||||||
<text class="calendar-tooltip" x="0" y="68">Mi</text>
|
<text class="calendar-tooltip" x="0" y="68">Mi</text>
|
||||||
<text class="calendar-tooltip" x="0" y="102">Fr</text>
|
<text class="calendar-tooltip" x="0" y="102">Fr</text>
|
||||||
|
<text class="calendar-tooltip" x="0" y="${sundayLabelY}">So</text>
|
||||||
${cells}
|
${cells}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="calendar-legend">
|
<div class="calendar-legend">
|
||||||
@@ -1273,6 +1298,7 @@
|
|||||||
updateRangeOutputs();
|
updateRangeOutputs();
|
||||||
initHeaderDatePicker();
|
initHeaderDatePicker();
|
||||||
initTrackPreview();
|
initTrackPreview();
|
||||||
|
initArchiveMobileDetail();
|
||||||
initDashboardCharts();
|
initDashboardCharts();
|
||||||
initSportTypeManager();
|
initSportTypeManager();
|
||||||
initPwaShell();
|
initPwaShell();
|
||||||
|
|||||||
+930
-7
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class EntryCrypto
|
||||||
|
{
|
||||||
|
private const HEADER = "MOODENC1\n";
|
||||||
|
|
||||||
|
private string $fallbackKeyPath;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->fallbackKeyPath = storage_path('system/entry-encryption.key');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return function_exists('openssl_encrypt')
|
||||||
|
&& function_exists('openssl_decrypt')
|
||||||
|
&& function_exists('random_bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldEncrypt(): bool
|
||||||
|
{
|
||||||
|
return $this->isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEncrypted(string $content): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($content, self::HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
if (!$this->shouldEncrypt()) {
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iv = random_bytes(12);
|
||||||
|
$tag = '';
|
||||||
|
$ciphertext = openssl_encrypt(
|
||||||
|
$plaintext,
|
||||||
|
'aes-256-gcm',
|
||||||
|
$this->key(),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
$iv,
|
||||||
|
$tag
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_string($ciphertext) || $tag === '') {
|
||||||
|
throw new RuntimeException('Die Tagesdatei konnte nicht verschlüsselt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'iv' => base64_encode($iv),
|
||||||
|
'tag' => base64_encode($tag),
|
||||||
|
'data' => base64_encode($ciphertext),
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
if (!is_string($payload)) {
|
||||||
|
throw new RuntimeException('Die verschlüsselte Tagesdatei konnte nicht kodiert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::HEADER . $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrypt(string $content): string
|
||||||
|
{
|
||||||
|
if (!$this->isEncrypted($content) || !$this->shouldEncrypt()) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = substr($content, strlen(self::HEADER));
|
||||||
|
$decoded = json_decode($payload, true);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!is_array($decoded)
|
||||||
|
|| !is_string($decoded['iv'] ?? null)
|
||||||
|
|| !is_string($decoded['tag'] ?? null)
|
||||||
|
|| !is_string($decoded['data'] ?? null)
|
||||||
|
) {
|
||||||
|
throw new RuntimeException('Die verschlüsselte Tagesdatei ist ungültig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plaintext = openssl_decrypt(
|
||||||
|
(string) base64_decode($decoded['data'], true),
|
||||||
|
'aes-256-gcm',
|
||||||
|
$this->key(),
|
||||||
|
OPENSSL_RAW_DATA,
|
||||||
|
(string) base64_decode($decoded['iv'], true),
|
||||||
|
(string) base64_decode($decoded['tag'], true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_string($plaintext)) {
|
||||||
|
throw new RuntimeException('Die Tagesdatei konnte nicht entschlüsselt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(): string
|
||||||
|
{
|
||||||
|
$configured = trim((string) ($_ENV['MOOD_STORAGE_KEY'] ?? getenv('MOOD_STORAGE_KEY') ?: ''));
|
||||||
|
if ($configured !== '') {
|
||||||
|
return hash('sha256', $configured, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stored = $this->readFallbackKey();
|
||||||
|
if ($stored !== null) {
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = random_bytes(32);
|
||||||
|
$directory = dirname($this->fallbackKeyPath);
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($this->fallbackKeyPath, base64_encode($raw), LOCK_EX);
|
||||||
|
@chmod($this->fallbackKeyPath, 0600);
|
||||||
|
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readFallbackKey(): ?string
|
||||||
|
{
|
||||||
|
if (!is_file($this->fallbackKeyPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = trim((string) file_get_contents($this->fallbackKeyPath));
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($raw, true);
|
||||||
|
|
||||||
|
return is_string($decoded) && strlen($decoded) === 32 ? $decoded : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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') ?: ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@ 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__ . '/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/NotificationRepository.php';
|
||||||
require __DIR__ . '/Domain/ScoringService.php';
|
require __DIR__ . '/Domain/ScoringService.php';
|
||||||
|
|||||||
+134
@@ -197,6 +197,109 @@ 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_compact_date(string $date): string
|
||||||
|
{
|
||||||
|
$current = DateTimeImmutable::createFromFormat('Y-m-d', $date);
|
||||||
|
|
||||||
|
if ($current === false) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current->format('d.m.Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 format_compact_datetime(string $value): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$current = new DateTimeImmutable($value);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current->format('d.m.Y · 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';
|
||||||
@@ -352,6 +455,37 @@ function base64url_decode(string $data): string
|
|||||||
return $decoded;
|
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, [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
$brandSubtitle = match ($page) {
|
$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' => '',
|
||||||
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
'options' => 'Logik, Erinnerungen, Sicherheit und Accounts',
|
||||||
'login' => 'Geschützter Zugang',
|
'login' => 'Geschützter Zugang',
|
||||||
'setup' => 'Erstkonfiguration',
|
'setup' => 'Erstkonfiguration',
|
||||||
@@ -36,7 +36,7 @@ $brandSubtitle = match ($page) {
|
|||||||
<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) ?><?= $authUser !== null ? ' is-authenticated' : '' ?>" data-authenticated="<?= $authUser !== null ? '1' : '0' ?>"<?= isset($trackMood) ? ' data-track-mood="' . e($trackMood) . '"' : '' ?>>
|
<body class="app-body page-<?= e($page) ?><?= $authUser !== null ? ' is-authenticated' : '' ?><?= !empty($pageBodyClass) ? ' ' . e((string) $pageBodyClass) : '' ?>" 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="pull-refresh-indicator glass-panel" data-pull-refresh-indicator aria-hidden="true">Zum Aktualisieren ziehen</div>
|
||||||
@@ -89,7 +89,9 @@ $brandSubtitle = match ($page) {
|
|||||||
<?php if ($authUser !== null): ?>
|
<?php if ($authUser !== null): ?>
|
||||||
<header class="topbar glass-panel">
|
<header class="topbar glass-panel">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
<?php if ($brandSubtitle !== ''): ?>
|
||||||
|
<p class="eyebrow"><?= e($brandSubtitle) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<h2><?= e($pageTitle) ?></h2>
|
<h2><?= e($pageTitle) ?></h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar__meta">
|
<div class="topbar__meta">
|
||||||
@@ -114,7 +116,7 @@ $brandSubtitle = match ($page) {
|
|||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
|
|
||||||
<footer class="site-footer glass-panel">
|
<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.0</a>
|
<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>
|
<a class="site-footer__link" href="https://hnz.io" target="_blank" rel="noreferrer">(c) 2026 @hnz.io</a>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+290
-90
@@ -1,98 +1,298 @@
|
|||||||
<section class="page-grid">
|
<?php
|
||||||
<article class="glass-panel archive-list">
|
$baseParams = ['view' => $archiveView];
|
||||||
<div class="section-head">
|
if ($archiveFilterMonth !== '') {
|
||||||
<div>
|
$baseParams['filter_month'] = $archiveFilterMonth;
|
||||||
<p class="eyebrow">Archiv</p>
|
}
|
||||||
<h3>Alle gespeicherten Tage</h3>
|
|
||||||
</div>
|
$archiveUrl = static function (array $params = []) use ($baseParams): string {
|
||||||
<span class="chart-chip"><?= e((string) count($entries)) ?> Einträge</span>
|
$query = array_filter(array_merge($baseParams, $params), static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
return $query === [] ? '/archive' : '/archive?' . http_build_query($query);
|
||||||
|
};
|
||||||
|
|
||||||
|
$detailType = $selectedEntry !== null
|
||||||
|
? 'day'
|
||||||
|
: ($selectedWeek !== null
|
||||||
|
? 'week'
|
||||||
|
: ($selectedMonth !== null ? 'month' : null));
|
||||||
|
|
||||||
|
$detailOpen = $detailType !== null;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="archive-page">
|
||||||
|
<article class="glass-panel archive-shell">
|
||||||
|
<div class="archive-toolbar archive-toolbar--compact">
|
||||||
|
<nav class="archive-switcher" aria-label="Archivansicht">
|
||||||
|
<a class="archive-switcher__item <?= $archiveView === 'days' ? 'active' : '' ?>" href="<?= e('/archive?view=days' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Tage</a>
|
||||||
|
<a class="archive-switcher__item <?= $archiveView === 'weeks' ? 'active' : '' ?>" href="<?= e('/archive?view=weeks' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Wochen</a>
|
||||||
|
<a class="archive-switcher__item <?= $archiveView === 'months' ? 'active' : '' ?>" href="<?= e('/archive?view=months' . ($archiveFilterMonth !== '' ? '&filter_month=' . rawurlencode($archiveFilterMonth) : '')) ?>">Monate</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form method="get" action="/archive" class="archive-filter">
|
||||||
|
<input type="hidden" name="view" value="<?= e($archiveView) ?>">
|
||||||
|
<label>
|
||||||
|
<span>Zeitraum</span>
|
||||||
|
<select name="filter_month" onchange="this.form.submit()">
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
<?php foreach ($archiveMonthOptions as $monthOption): ?>
|
||||||
|
<option value="<?= e($monthOption) ?>" <?= $archiveFilterMonth === $monthOption ? 'selected' : '' ?>><?= e(month_label($monthOption)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($entries === []): ?>
|
<div class="archive-workspace">
|
||||||
<p class="empty-state">Noch keine Einträge vorhanden. Auf der Tracking-Seite kannst du den ersten Tag anlegen.</p>
|
<section class="archive-main">
|
||||||
<?php else: ?>
|
<?php if ($archiveView === 'days'): ?>
|
||||||
<div class="archive-items">
|
<div class="archive-list-header">
|
||||||
<?php foreach ($entries as $entry): ?>
|
|
||||||
<article class="archive-item <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>">
|
|
||||||
<div>
|
<div>
|
||||||
<strong><?= e(format_display_date($entry['date'], false)) ?></strong>
|
<p class="eyebrow">Tage</p>
|
||||||
<span><?= e($entry['evaluation']['label']) ?></span>
|
<h4>Gespeicherte Tage</h4>
|
||||||
<?php if ((int) $entry['sport_minutes'] > 0 && !empty($entry['sport_type_meta'])): ?>
|
|
||||||
<span class="sport-pill-group">
|
|
||||||
<?php foreach ($entry['sport_type_meta'] as $sportType): ?>
|
|
||||||
<span class="sport-pill">
|
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
|
||||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="archive-item__meta">
|
<span class="chart-chip"><?= e((string) count($entries)) ?> Tage</span>
|
||||||
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?></span>
|
|
||||||
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
|
||||||
</div>
|
|
||||||
<div class="archive-item__actions">
|
|
||||||
<a class="ghost-link archive-action" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Ansehen</a>
|
|
||||||
<a class="ghost-link archive-action" href="/track?date=<?= e(rawurlencode($entry['date'])) ?>">Bearbeiten</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="stack-column">
|
|
||||||
<?php if ($selectedEntry !== null): ?>
|
|
||||||
<article class="glass-panel detail-card">
|
|
||||||
<p class="eyebrow">Ausgewählt</p>
|
|
||||||
<h3><?= e(format_display_date($selectedEntry['date'])) ?></h3>
|
|
||||||
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
|
|
||||||
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
|
|
||||||
|
|
||||||
<dl class="detail-grid">
|
|
||||||
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
|
||||||
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
|
||||||
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
|
||||||
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
|
||||||
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
|
||||||
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
|
||||||
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
|
||||||
<div>
|
|
||||||
<dt>Sportarten</dt>
|
|
||||||
<dd>
|
|
||||||
<?php if ((int) $selectedEntry['sport_minutes'] > 0 && !empty($selectedEntry['sport_type_meta'])): ?>
|
|
||||||
<span class="sport-pill-group sport-pill-group--inline">
|
|
||||||
<?php foreach ($selectedEntry['sport_type_meta'] as $sportType): ?>
|
|
||||||
<span class="sport-pill sport-pill--inline">
|
|
||||||
<img src="<?= e(sport_icon_path($sportType['icon'])) ?>" alt="">
|
|
||||||
<span><?= e($sportType['label']) ?><?= !empty($sportType['location']) ? ' · ' . e(sport_location_label((string) $sportType['location'])) : '' ?></span>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</span>
|
|
||||||
<?php else: ?>
|
|
||||||
keine
|
|
||||||
<?php endif; ?>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div><dt>Sportbonus</dt><dd><?= e(format_points((float) ($selectedEntry['evaluation']['components']['sport_bonus'] ?? 0))) ?></dd></div>
|
|
||||||
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
|
|
||||||
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="note-box">
|
<?php if ($entries === []): ?>
|
||||||
<h4>Notiz</h4>
|
<p class="empty-state">Für diesen Zeitraum gibt es noch keine getrackten Tage.</p>
|
||||||
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
<?php else: ?>
|
||||||
|
<div class="archive-rows">
|
||||||
|
<?php foreach ($entries as $entry): ?>
|
||||||
|
<a class="archive-row archive-row--day <?= $selectedEntry !== null && $selectedEntry['date'] === $entry['date'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['date' => $entry['date'], 'week' => null, 'month_key' => null])) ?>">
|
||||||
|
<div class="archive-row__main">
|
||||||
|
<strong><?= e(format_compact_date($entry['date'])) ?></strong>
|
||||||
|
<span><?= e($entry['evaluation']['label']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-row__meta">
|
||||||
|
<span><?= e(format_points((float) $entry['evaluation']['total'])) ?> Punkte</span>
|
||||||
|
<span>Stimmung <?= e((string) $entry['mood']) ?>/10</span>
|
||||||
|
</div>
|
||||||
|
<span class="archive-row__hint">Ansehen</span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($archiveView === 'weeks'): ?>
|
||||||
|
<div class="archive-list-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Wochen</p>
|
||||||
|
<h4>Wöchentliche KI-Rückblicke</h4>
|
||||||
|
</div>
|
||||||
|
<span class="chart-chip"><?= e((string) count($weeklyArchive)) ?> Wochen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($weeklyArchive === []): ?>
|
||||||
|
<p class="empty-state">Für diesen Zeitraum sind noch keine Wochen im Archiv vorhanden.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="archive-rows archive-rows--summary">
|
||||||
|
<?php foreach ($weeklyArchive as $week): ?>
|
||||||
|
<a class="archive-row archive-row--summary archive-row--week <?= $selectedWeek !== null && $selectedWeek['summary_key'] === $week['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['week' => $week['summary_key'], 'date' => null, 'month_key' => null])) ?>">
|
||||||
|
<div class="archive-row__main archive-row__main--week">
|
||||||
|
<div class="archive-row__title-group">
|
||||||
|
<strong><?= e($week['label']) ?></strong>
|
||||||
|
<span><?= e(format_compact_date((string) $week['date_from'])) ?> bis <?= e(format_compact_date((string) $week['date_to'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-badge--<?= e($week['status_tone']) ?>"><?= e($week['status_label']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-row__meta archive-row__meta--stack">
|
||||||
|
<span><?= e((string) $week['note_entries_count']) ?> Texteinträge</span>
|
||||||
|
<span><?= e((string) $week['tracked_days']) ?> getrackte Tage</span>
|
||||||
|
<span><?= e($week['trend_label']) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="archive-row__hint"><?= !empty($week['has_summary']) ? 'Öffnen' : 'Details' ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="archive-list-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Monate</p>
|
||||||
|
<h4>Monatliche KI-Rückblicke</h4>
|
||||||
|
</div>
|
||||||
|
<span class="chart-chip"><?= e((string) count($monthlyArchive)) ?> Monate</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($monthlyArchive === []): ?>
|
||||||
|
<p class="empty-state">Für diesen Zeitraum sind noch keine Monatsobjekte im Archiv vorhanden.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="archive-rows archive-rows--summary">
|
||||||
|
<?php foreach ($monthlyArchive as $month): ?>
|
||||||
|
<a class="archive-row archive-row--summary archive-row--month <?= $selectedMonth !== null && $selectedMonth['summary_key'] === $month['summary_key'] ? 'active' : '' ?>" href="<?= e($archiveUrl(['month_key' => $month['summary_key'], 'date' => null, 'week' => null])) ?>">
|
||||||
|
<div class="archive-row__main archive-row__main--month">
|
||||||
|
<div class="archive-row__title-group">
|
||||||
|
<strong><?= e($month['label']) ?></strong>
|
||||||
|
<span><?= e(format_compact_date((string) $month['date_from'])) ?> bis <?= e(format_compact_date((string) $month['date_to'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-badge--<?= e($month['status_tone']) ?>"><?= e($month['status_label']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="archive-row__meta archive-row__meta--stack">
|
||||||
|
<span><?= e($month['weekly_progress_label']) ?></span>
|
||||||
|
<span><?= e((string) $month['tracked_days']) ?> getrackte Tage</span>
|
||||||
|
</div>
|
||||||
|
<span class="archive-row__hint"><?= !empty($month['has_summary']) ? 'Öffnen' : 'Details' ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="archive-detail <?= $detailOpen ? 'is-open' : '' ?>" id="archive-detail-panel" data-detail-open="<?= $detailOpen ? '1' : '0' ?>">
|
||||||
|
<div class="glass-panel archive-detail__panel">
|
||||||
|
<div class="archive-detail__top">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Details</p>
|
||||||
|
<?php if ($detailType === 'day'): ?>
|
||||||
|
<h3><?= e(format_compact_date($selectedEntry['date'])) ?></h3>
|
||||||
|
<?php elseif ($detailType === 'week'): ?>
|
||||||
|
<h3><?= e($selectedWeek['label']) ?></h3>
|
||||||
|
<?php elseif ($detailType === 'month'): ?>
|
||||||
|
<h3><?= e($selectedMonth['label']) ?></h3>
|
||||||
|
<?php else: ?>
|
||||||
|
<h3>Archivansicht</h3>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($detailOpen): ?>
|
||||||
|
<a class="ghost-link archive-detail__close" href="<?= e($archiveUrl(['date' => null, 'week' => null, 'month_key' => null])) ?>">Schließen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($detailType === 'day'): ?>
|
||||||
|
<p class="hero-label"><?= e($selectedEntry['evaluation']['label']) ?> · <?= e(format_points((float) $selectedEntry['evaluation']['total'])) ?> Punkte</p>
|
||||||
|
<a class="primary-button button-link" href="/track?date=<?= e(rawurlencode($selectedEntry['date'])) ?>">Diesen Tag bearbeiten</a>
|
||||||
|
|
||||||
|
<dl class="detail-grid detail-grid--archive-day">
|
||||||
|
<div><dt>Stimmung</dt><dd><?= e((string) $selectedEntry['mood']) ?>/10</dd></div>
|
||||||
|
<div><dt>Energie</dt><dd><?= e((string) $selectedEntry['energy']) ?>/10</dd></div>
|
||||||
|
<div><dt>Stress</dt><dd><?= e((string) $selectedEntry['stress']) ?>/10</dd></div>
|
||||||
|
<?php if (!empty($settings['tracking']['pain_enabled'])): ?>
|
||||||
|
<div><dt>Schmerzen</dt><dd><?= e((string) $selectedEntry['pain']) ?>/10</dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div><dt>Schlaf</dt><dd><?= e((string) $selectedEntry['sleep_hours']) ?> h</dd></div>
|
||||||
|
<div><dt>Schlafgefühl</dt><dd><?= e((string) $selectedEntry['sleep_feeling']) ?>/5</dd></div>
|
||||||
|
<div><dt>Sport</dt><dd><?= e((string) $selectedEntry['sport_minutes']) ?> min</dd></div>
|
||||||
|
<div><dt>Spaziergang</dt><dd><?= e(format_walk_value($selectedEntry)) ?></dd></div>
|
||||||
|
<div><dt>Alkohol</dt><dd><?= !empty($selectedEntry['alcohol']) ? 'ja' : 'nein' ?></dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="note-box">
|
||||||
|
<h4>Notiz</h4>
|
||||||
|
<p><?= nl2br(e($selectedEntry['note'] !== '' ? $selectedEntry['note'] : 'Keine Notiz hinterlegt.')) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($detailType === 'week'): ?>
|
||||||
|
<p class="hero-label"><?= e(format_compact_date((string) $selectedWeek['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedWeek['date_to'])) ?></p>
|
||||||
|
|
||||||
|
<div class="archive-detail__status-row">
|
||||||
|
<span class="status-badge status-badge--<?= e($selectedWeek['status_tone']) ?>"><?= e($selectedWeek['status_label']) ?></span>
|
||||||
|
<span class="chart-chip"><?= e($selectedWeek['trend_label']) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="detail-grid detail-grid--archive">
|
||||||
|
<div><dt>Texteinträge</dt><dd><?= e((string) $selectedWeek['note_entries_count']) ?></dd></div>
|
||||||
|
<div><dt>Getrackte Tage</dt><dd><?= e((string) $selectedWeek['tracked_days']) ?></dd></div>
|
||||||
|
<?php if (!empty($selectedWeek['summary'])): ?>
|
||||||
|
<div><dt>Erstellt am</dt><dd><?= e(format_compact_datetime((string) $selectedWeek['summary']['created_at'])) ?></dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="note-box archive-detail__status-note">
|
||||||
|
<h4>KI-Status</h4>
|
||||||
|
<p><?= e($selectedWeek['status_hint']) ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($selectedWeek['summary'])): ?>
|
||||||
|
<div class="archive-detail__actions">
|
||||||
|
<a class="ghost-link archive-action" href="<?= e($archiveUrl(['week' => $selectedWeek['summary_key'], 'date' => null, 'month_key' => null])) ?>">Öffnen</a>
|
||||||
|
<form method="post" action="/archive">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="generate_weekly_summary">
|
||||||
|
<input type="hidden" name="view" value="weeks">
|
||||||
|
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
|
||||||
|
<input type="hidden" name="week_key" value="<?= e((string) $selectedWeek['summary_key']) ?>">
|
||||||
|
<button class="ghost-button" type="submit" <?= empty($aiAvailable) ? 'disabled' : '' ?>>Neu generieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-box note-box--summary">
|
||||||
|
<h4>KI-Wochenzusammenfassung</h4>
|
||||||
|
<p><?= e((string) ($selectedWeek['summary']['text'] ?? '')) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="/archive" class="archive-detail__single-action">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="generate_weekly_summary">
|
||||||
|
<input type="hidden" name="view" value="weeks">
|
||||||
|
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
|
||||||
|
<input type="hidden" name="week_key" value="<?= e((string) $selectedWeek['summary_key']) ?>">
|
||||||
|
<button class="primary-button" type="submit" <?= !$selectedWeek['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>KI-Wochenzusammenfassung erzeugen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($detailType === 'month'): ?>
|
||||||
|
<p class="hero-label"><?= e(format_compact_date((string) $selectedMonth['date_from'])) ?> bis <?= e(format_compact_date((string) $selectedMonth['date_to'])) ?></p>
|
||||||
|
|
||||||
|
<div class="archive-detail__status-row">
|
||||||
|
<span class="status-badge status-badge--<?= e($selectedMonth['status_tone']) ?>"><?= e($selectedMonth['status_label']) ?></span>
|
||||||
|
<span class="chart-chip"><?= e($selectedMonth['weekly_progress_label']) ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="detail-grid detail-grid--archive">
|
||||||
|
<div><dt>KI-Wochen vorhanden</dt><dd><?= e((string) $selectedMonth['weekly_summary_count']) ?> / <?= e((string) ((int) $selectedMonth['weekly_total_count'])) ?></dd></div>
|
||||||
|
<?php if (!empty($selectedMonth['summary'])): ?>
|
||||||
|
<div><dt>Erstellt am</dt><dd><?= e(format_compact_datetime((string) $selectedMonth['summary']['created_at'])) ?></dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="note-box archive-detail__status-note">
|
||||||
|
<h4>Monatsstatus</h4>
|
||||||
|
<p><?= e($selectedMonth['status_hint']) ?></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-box archive-detail__week-status">
|
||||||
|
<h4>Wochen in diesem Monat</h4>
|
||||||
|
<div class="archive-mini-list">
|
||||||
|
<?php foreach ($selectedMonth['weeks'] as $week): ?>
|
||||||
|
<div class="archive-mini-list__row">
|
||||||
|
<span><?= e($week['label']) ?></span>
|
||||||
|
<span class="status-badge status-badge--<?= !empty($week['has_summary']) ? 'ready' : 'blocked' ?>"><?= !empty($week['has_summary']) ? 'vorhanden' : 'fehlt' ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($selectedMonth['summary'])): ?>
|
||||||
|
<div class="archive-detail__actions">
|
||||||
|
<a class="ghost-link archive-action" href="<?= e($archiveUrl(['month_key' => $selectedMonth['summary_key'], 'date' => null, 'week' => null])) ?>">Öffnen</a>
|
||||||
|
<form method="post" action="/archive">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="generate_monthly_summary">
|
||||||
|
<input type="hidden" name="view" value="months">
|
||||||
|
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
|
||||||
|
<input type="hidden" name="month_key" value="<?= e((string) $selectedMonth['summary_key']) ?>">
|
||||||
|
<button class="ghost-button" type="submit" <?= empty($aiAvailable) ? 'disabled' : '' ?>>Neu generieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note-box note-box--summary">
|
||||||
|
<h4>KI-Monatszusammenfassung</h4>
|
||||||
|
<p><?= e((string) ($selectedMonth['summary']['text'] ?? '')) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="/archive" class="archive-detail__single-action">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="form_name" value="generate_monthly_summary">
|
||||||
|
<input type="hidden" name="view" value="months">
|
||||||
|
<input type="hidden" name="filter_month" value="<?= e($archiveFilterMonth) ?>">
|
||||||
|
<input type="hidden" name="month_key" value="<?= e((string) $selectedMonth['summary_key']) ?>">
|
||||||
|
<button class="primary-button" type="submit" <?= !$selectedMonth['can_generate'] || empty($aiAvailable) ? 'disabled' : '' ?>>KI-Monatszusammenfassung erzeugen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="helper-text">Wähle links einen Tag, eine Woche oder einen Monat aus.</p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</aside>
|
||||||
<?php else: ?>
|
</div>
|
||||||
<article class="glass-panel detail-card">
|
</article>
|
||||||
<p class="eyebrow">Details</p>
|
|
||||||
<h3>Archivansicht</h3>
|
|
||||||
<p>Wähle links einen Tag aus, um alle Werte anzuzeigen oder direkt in die Bearbeitung zu springen.</p>
|
|
||||||
</article>
|
|
||||||
<?php endif; ?>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -350,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>
|
||||||
@@ -364,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>
|
||||||
|
|||||||
+23
-15
@@ -6,7 +6,7 @@
|
|||||||
<h3><?= e(format_display_date($entry['date'])) ?></h3>
|
<h3><?= e(format_display_date($entry['date'])) ?></h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-head__actions">
|
<div class="section-head__actions">
|
||||||
<a class="ghost-link" href="/archive?date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
|
<a class="ghost-link" href="/archive?view=days&date=<?= e(rawurlencode($entry['date'])) ?>">Im Archiv ansehen</a>
|
||||||
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
|
<a class="ghost-link" href="/track?date=<?= e(today()) ?>">Heute</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,23 +45,31 @@
|
|||||||
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
|
<input type="range" min="1" max="10" step="1" name="pain" value="<?= e((string) $entry['pain']) ?>">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="checkbox-row checkbox-row--panel checkbox-row--tall">
|
<div class="sport-choice-field sport-choice-field--single">
|
||||||
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
<div class="sport-choice-list sport-choice-list--single">
|
||||||
<span>
|
<label class="sport-choice">
|
||||||
<strong>Alkohol</strong>
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
<span class="sport-choice__card sport-choice__card--toggle">
|
||||||
</span>
|
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
|
||||||
</label>
|
<strong>Alkohol</strong>Heute was getrunken?
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="field-grid field-grid--single">
|
<div class="field-grid field-grid--single">
|
||||||
<label class="checkbox-row checkbox-row--panel">
|
<div class="sport-choice-field sport-choice-field--single">
|
||||||
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
<div class="sport-choice-list sport-choice-list--single">
|
||||||
<span>
|
<label class="sport-choice">
|
||||||
<strong>Alkohol</strong>
|
<input type="checkbox" name="alcohol" value="1" <?= !empty($entry['alcohol']) ? 'checked' : '' ?>>
|
||||||
<small>Wenn du heute Alkohol getrunken hast, werden 5 Punkte abgezogen.</small>
|
<span class="sport-choice__card sport-choice__card--toggle">
|
||||||
</span>
|
<img src="<?= e(icon_path('alcohol')) ?>" alt="">
|
||||||
</label>
|
<strong>Alkohol</strong>Heute was getrunken?
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user