Merge branch 'feature/birthday-reminders'

This commit is contained in:
Ulas Kalayci
2026-05-04 20:31:48 +02:00
14 changed files with 1705 additions and 14 deletions
@@ -0,0 +1,777 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos UX/UI Analyse</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f0f0e;
color: #f0ede8;
min-height: 100vh;
padding: 32px 24px 64px;
}
.header {
max-width: 900px;
margin: 0 auto 40px;
}
.header__eyebrow {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #818CF8;
margin-bottom: 8px;
}
h1 {
font-size: clamp(28px, 5vw, 42px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
margin-bottom: 12px;
}
h1 span { color: #818CF8; }
.header__sub {
font-size: 15px;
color: #8E8D89;
line-height: 1.5;
}
/* ---- Section ---- */
.section {
max-width: 900px;
margin: 0 auto 48px;
}
.section__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.section__label::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255,255,255,0.08);
}
.label--critical { color: #FCA5A5; }
.label--high { color: #FCD34D; }
.label--medium { color: #6EE7B7; }
/* ---- Issue Cards ---- */
.issues {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.issue {
background: #1a1a18;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.06);
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.15s, transform 0.15s;
cursor: default;
}
.issue:hover {
border-color: rgba(255,255,255,0.12);
transform: translateY(-2px);
}
.issue__header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.issue__icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.icon--critical { background: rgba(252,165,165,0.15); }
.icon--high { background: rgba(252,211,77,0.12); }
.icon--medium { background: rgba(110,231,183,0.12); }
.issue__title {
font-size: 14px;
font-weight: 600;
color: #f0ede8;
line-height: 1.35;
}
.issue__body {
font-size: 13px;
color: #8E8D89;
line-height: 1.55;
}
.issue__badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
align-self: flex-start;
margin-top: auto;
padding-top: 8px;
}
.badge--critical { color: #FCA5A5; background: rgba(252,165,165,0.12); }
.badge--high { color: #FCD34D; background: rgba(252,211,77,0.10); }
.badge--medium { color: #6EE7B7; background: rgba(110,231,183,0.10); }
/* ---- Visual Demo: Navigation ---- */
.demo-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 32px;
}
@media (max-width: 600px) {
.demo-grid { grid-template-columns: 1fr; }
}
.demo-box {
background: #1a1a18;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.06);
overflow: hidden;
}
.demo-box__label {
padding: 12px 16px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid rgba(255,255,255,0.06);
display: flex;
align-items: center;
gap: 8px;
}
.demo-box__label .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.dot--bad { background: #FCA5A5; }
.dot--good { background: #6EE7B7; }
.demo-box__body { padding: 16px; }
/* Sidebar simulation */
.sim-sidebar {
display: flex;
flex-direction: column;
gap: 4px;
width: 64px;
background: #222220;
border-radius: 12px;
padding: 12px 8px;
margin: 0 auto;
}
.sim-sidebar-wide {
width: 180px;
}
.sim-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
font-size: 12px;
color: #8E8D89;
}
.sim-nav-item--active {
background: rgba(129,140,248,0.15);
color: #818CF8;
}
.sim-nav-icon {
width: 20px; height: 20px;
background: currentColor;
border-radius: 4px;
opacity: 0.5;
flex-shrink: 0;
}
.sim-nav-item--active .sim-nav-icon { opacity: 1; }
/* Touch target demo */
.touch-demo {
display: flex;
flex-direction: column;
gap: 12px;
}
.touch-row {
display: flex;
align-items: center;
gap: 12px;
}
.touch-btn {
border-radius: 8px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #f0ede8;
font-weight: 500;
position: relative;
}
.touch-btn--bad {
width: 40px; height: 40px;
border-color: rgba(252,165,165,0.4);
background: rgba(252,165,165,0.08);
color: #FCA5A5;
}
.touch-btn--good {
width: 44px; height: 44px;
border-color: rgba(110,231,183,0.4);
background: rgba(110,231,183,0.08);
color: #6EE7B7;
}
.touch-label {
font-size: 12px;
color: #8E8D89;
}
.touch-label strong { color: #f0ede8; font-weight: 600; }
/* Widget touch demo */
.widget-link-demo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #222220;
border-radius: 10px;
margin-bottom: 8px;
}
.wl-title { font-size: 13px; font-weight: 600; }
.wl-link--bad {
font-size: 11px;
color: #818CF8;
padding: 2px 0;
/* No min-height — very small tap target */
}
.wl-link--good {
font-size: 11px;
color: #6EE7B7;
padding: 8px 10px;
background: rgba(110,231,183,0.1);
border-radius: 6px;
}
/* Onboarding demo */
.onboarding-demo {
background: #222220;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.onboarding-steps {
display: flex;
justify-content: center;
gap: 6px;
margin-bottom: 16px;
}
.o-step {
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.15);
}
.o-step--active { background: #818CF8; width: 20px; border-radius: 4px; }
.onboarding-icon {
width: 48px; height: 48px;
border-radius: 14px;
background: rgba(129,140,248,0.15);
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.o-count {
font-size: 11px;
color: #8E8D89;
margin-top: 8px;
}
/* Module grid demo (more-sheet) */
.module-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 12px;
background: #222220;
border-radius: 12px;
}
.module-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
border-radius: 10px;
background: #2A2A28;
font-size: 10px;
color: #8E8D89;
text-align: center;
}
.module-icon {
width: 24px; height: 24px;
border-radius: 6px;
margin-bottom: 2px;
}
/* Divider */
.divider {
max-width: 900px;
margin: 0 auto 40px;
height: 1px;
background: rgba(255,255,255,0.06);
}
/* Summary bar */
.summary {
max-width: 900px;
margin: 0 auto;
background: linear-gradient(135deg, rgba(129,140,248,0.15), rgba(167,139,250,0.08));
border: 1px solid rgba(129,140,248,0.2);
border-radius: 16px;
padding: 24px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
text-align: center;
}
@media (max-width: 500px) {
.summary { grid-template-columns: 1fr; }
}
.summary__num {
font-size: 36px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
}
.summary__label {
font-size: 12px;
color: #8E8D89;
margin-top: 4px;
}
.num--critical { color: #FCA5A5; }
.num--high { color: #FCD34D; }
.num--medium { color: #6EE7B7; }
</style>
</head>
<body>
<div class="header">
<div class="header__eyebrow">UX/UI Analyse · Oikos · April 2026</div>
<h1>Was funktioniert gut —<br>und was bremst <span>Oikos</span> aus</h1>
<p class="header__sub">70 % Mobile-PWA · 30 % Desktop · 11 Module · Vanilla JS · Kein Build-Step</p>
</div>
<!-- Summary -->
<div class="section">
<div class="summary">
<div>
<div class="summary__num num--critical">4</div>
<div class="summary__label">Kritische Probleme<br>(sofort beheben)</div>
</div>
<div>
<div class="summary__num num--high">5</div>
<div class="summary__label">Hohe Priorität<br>(nächster Sprint)</div>
</div>
<div>
<div class="summary__num num--medium">4</div>
<div class="summary__label">Mittlere Priorität<br>(Backlog)</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- KRITISCH -->
<div class="section">
<div class="section__label label--critical">🔴 Kritisch — direkt sichtbare UX-Brüche</div>
<div class="issues">
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--critical">🧭</div>
<div class="issue__title">Sidebar 10241279 px: Icons ohne Labels oder Tooltips</div>
</div>
<div class="issue__body">
Bei der häufigsten Desktop-Auflösung zeigt die Sidebar nur Icons — kein Tooltip, kein Label.
Neue Nutzer können die 11 Module nicht erkennen. Das verletzt das Grundprinzip
«nav-label-icon» (HIG/Material).
</div>
<div class="issue__badge badge--critical">Keine Discoverability</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--critical">👆</div>
<div class="issue__title">Modal-Close: 40 px statt 44 px Minimum</div>
</div>
<div class="issue__body">
<code>.modal-panel__close</code> nutzt <code>--target-md</code> (40 px).
Das iOS-Minimum liegt bei 44 pt. Auf kleinen Displays — gerade beim
Schließen tippend — ist das ein spürbares Frustrationspotenzial.
</div>
<div class="issue__badge badge--critical">Apple HIG Violation</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--critical">🔗</div>
<div class="issue__title">Widget-Links: kein Min-Height-Tap-Target</div>
</div>
<div class="issue__body">
<code>.widget__link</code> hat 12 px Text, kein explizites <code>min-height</code>.
Der effektive Tippbereich ist ~1618 px — weit unter 44 px.
Auf dem Dashboard ist dieser Link auf jedem Widget sichtbar.
</div>
<div class="issue__badge badge--critical">Tap-Target &lt; 44 px</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--critical">🔁</div>
<div class="issue__title">Doppelter FAB: .fab vs .page-fab</div>
</div>
<div class="issue__body">
In <code>layout.css</code> existieren zwei nahezu identische FAB-Klassen (<code>.fab</code>
und <code>.page-fab</code>) mit unterschiedlicher <code>bottom</code>-Berechnung.
Das erzeugt inkonsistente Positionierung auf verschiedenen Seiten.
</div>
<div class="issue__badge badge--critical">Inkonsistente UI</div>
</div>
</div>
</div>
<!-- Visuelle Demo: Navigation -->
<div class="section">
<div class="section__label label--critical" style="color:#8E8D89">↳ Visuell: Navigation-Problem & Lösung</div>
<div class="demo-grid">
<div class="demo-box">
<div class="demo-box__label">
<span class="dot dot--bad"></span>
Ist-Zustand: 10241279 px (nur Icons)
</div>
<div class="demo-box__body">
<div class="sim-sidebar">
<div class="sim-nav-item sim-nav-item--active">
<div class="sim-nav-icon"></div>
</div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
</div>
<p style="font-size:11px;color:#8E8D89;text-align:center;margin-top:10px">Welcher Icon ist "Geburtstage"?</p>
</div>
</div>
<div class="demo-box">
<div class="demo-box__label">
<span class="dot dot--good"></span>
Soll-Zustand: Tooltip bei Hover (min)
</div>
<div class="demo-box__body">
<div class="sim-sidebar" style="position:relative">
<div class="sim-nav-item sim-nav-item--active" style="position:relative">
<div class="sim-nav-icon"></div>
<div style="position:absolute;left:52px;top:50%;transform:translateY(-50%);background:#333;padding:4px 10px;border-radius:6px;font-size:11px;color:#f0ede8;white-space:nowrap;z-index:10;border:1px solid rgba(255,255,255,0.1)">Dashboard</div>
</div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
<div class="sim-nav-item"><div class="sim-nav-icon"></div></div>
</div>
<p style="font-size:11px;color:#6EE7B7;text-align:center;margin-top:10px">Sofort klar durch title-Tooltip</p>
</div>
</div>
</div>
</div>
<!-- Touch Demo -->
<div class="section" style="margin-top:0">
<div class="section__label label--critical" style="color:#8E8D89">↳ Visuell: Touch-Target Größen</div>
<div class="demo-grid">
<div class="demo-box">
<div class="demo-box__label">
<span class="dot dot--bad"></span>
Ist-Zustand: Modal-Close 40 × 40 px
</div>
<div class="demo-box__body">
<div class="touch-demo">
<div class="touch-row">
<div class="touch-btn touch-btn--bad"></div>
<div class="touch-label"><strong>40 × 40 px</strong> — 4 px unter iOS-Minimum</div>
</div>
<div class="touch-row" style="margin-top:4px">
<div style="font-size:11px;color:#FCA5A5;padding:4px 10px;background:rgba(252,165,165,0.08);border-radius:6px">
Fingertipp ~4450 px → Fehlklick-Rate steigt
</div>
</div>
</div>
</div>
</div>
<div class="demo-box">
<div class="demo-box__label">
<span class="dot dot--good"></span>
Fix: --target-base (44 px)
</div>
<div class="demo-box__body">
<div class="touch-demo">
<div class="touch-row">
<div class="touch-btn touch-btn--good"></div>
<div class="touch-label"><strong>44 × 44 px</strong> — Apple HIG Minimum</div>
</div>
<div class="touch-row" style="margin-top:4px">
<div style="font-size:11px;color:#6EE7B7;padding:4px 10px;background:rgba(110,231,183,0.08);border-radius:6px">
Einzeiler-Fix: <code>--target-base</code> statt <code>--target-md</code>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- HOCH -->
<div class="section">
<div class="section__label label--high">🟡 Hohe Priorität — spürbare UX-Einschränkungen</div>
<div class="issues">
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--high">🗂️</div>
<div class="issue__title">Onboarding: 3 Schritte für 11 Module</div>
</div>
<div class="issue__body">
Neue Nutzer sehen einmalig 3 generische Onboarding-Screens.
Module wie Budget, RRule-Wiederholungen oder Google Calendar-Sync
werden nie erklärt. Kein Feature-Discovery-Mechanismus danach.
</div>
<div class="issue__badge badge--high">Discoverability</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--high">📊</div>
<div class="issue__title">Dashboard-Widgets: keine Reihenfolge-Anpassung</div>
</div>
<div class="issue__body">
Widgets können ein-/ausgeblendet werden, aber nicht umsortiert.
Die Widget-Config speichert nur <code>{ id, visible }</code> — kein
Reihenfolge-Feld. Für Familien mit verschiedenen Prioritäten ist das stark limitierend.
</div>
<div class="issue__badge badge--high">Personalisierung</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--high">↩️</div>
<div class="issue__title">Inkonsistentes Undo-Verhalten</div>
</div>
<div class="issue__body">
Einige Aktionen zeigen einen Undo-Toast, andere nicht.
Es gibt kein zentrales Undo-System. Bei destruktiven Aktionen
(z. B. Kontakt löschen) fehlt der Undo-Pfad komplett.
</div>
<div class="issue__badge badge--high">Fehlererholung</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--high">📡</div>
<div class="issue__title">Kein sichtbarer Offline-Indikator</div>
</div>
<div class="issue__body">
Der Service Worker existiert, aber der App-Shell fehlt ein
Offline-Banner. Nutzer bemerken den Offline-Zustand erst,
wenn eine API-Anfrage fehlschlägt — zu spät.
</div>
<div class="issue__badge badge--high">PWA-Erfahrung</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--high">⌨️</div>
<div class="issue__title">Desktop: keine Keyboard Shortcuts</div>
</div>
<div class="issue__body">
Bei 30 % Desktop-Nutzung fehlen globale Shortcuts.
Kein „N" für neue Aufgabe, kein „/" für Suche, kein
Escape-Verhalten im globalem Kontext. Power-User müssen
alles mit der Maus bedienen.
</div>
<div class="issue__badge badge--high">Desktop-Ergonomie</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- MITTEL -->
<div class="section">
<div class="section__label label--medium">🟢 Mittlere Priorität — qualitative Verbesserungen</div>
<div class="issues">
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--medium">💬</div>
<div class="issue__title">reminders.css global geladen</div>
</div>
<div class="issue__body">
<code>reminders.css</code> wird laut Observations global geladen,
nicht lazy. Auf Seiten ohne Reminder-UI werden unnötige Styles
geparst. Kein Blocking-Problem, aber vermeidbare CSS-Last.
</div>
<div class="issue__badge badge--medium">Performance</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--medium">🃏</div>
<div class="issue__title">Swipe-Geste: nur Tasks & Shopping</div>
</div>
<div class="issue__body">
Swipe-Reveal ist für Tasks und Shopping implementiert,
fehlt aber bei Kontakten, Notizen und Geburtstagen — obwohl
die Interaktion dort genauso wertvoll wäre.
Inkonsistente Erwartungshaltung.
</div>
<div class="issue__badge badge--medium">Interaktions-Konsistenz</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--medium">🎨</div>
<div class="issue__title">11 Modulfarben gleichzeitig im Dashboard</div>
</div>
<div class="issue__body">
Wenn alle Widgets sichtbar sind, treffen 11 verschiedene
Akzentfarben aufeinander. Das Dashboard wirkt farblich
überladen. Weniger Kontrast zwischen den Modulen würde
die Ruhewirkung verbessern.
</div>
<div class="issue__badge badge--medium">Visuelle Ruhe</div>
</div>
<div class="issue">
<div class="issue__header">
<div class="issue__icon icon--medium">📱</div>
<div class="issue__title">Toast: kein Swipe-to-Dismiss auf Mobile</div>
</div>
<div class="issue__body">
Toasts können nicht weggewischt werden — nur auto-dismiss
oder Undo-Button. Auf Mobile ist Swipe-to-Dismiss eine
etablierte Konvention (iOS, Android), deren Fehlen auffällt.
</div>
<div class="issue__badge badge--medium">Mobile-Konvention</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Stärken -->
<div class="section">
<div class="section__label" style="color:#818CF8">✨ Was bereits ausgezeichnet ist</div>
<div class="issues">
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">🌙 Dark Mode</div>
<div class="issue__body">Private-/Public-Token-Architektur ist mustergültig. Alle Kontrastverhältnisse WCAG-geprüft, Toast-Texte passen sich an.</div>
</div>
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">♿ Accessibility-Schichten</div>
<div class="issue__body">prefers-reduced-motion, prefers-reduced-transparency, prefers-contrast, forced-colors — alle implementiert. Selten in selbstgehosteten Apps.</div>
</div>
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">🍎 iOS-PWA-Bewusstsein</div>
<div class="issue__body">100dvh + -webkit-fill-available Fallback, safe-area-inset-*, Flex-Kind statt fixed für Bottom-Nav — solide PWA-Grundlage.</div>
</div>
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">💎 Glass Design System</div>
<div class="issue__body">@supports-basiert mit korrekten webkit-Fallbacks, opake Fallbacks für reduced-transparency. Konsistente Token-Hierarchie.</div>
</div>
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">🎭 Modul-Theming</div>
<div class="issue__body">--active-module-accent + --module-accent System ist elegant. FAB, Nav-Item und Toggles reflektieren automatisch die aktive Seite.</div>
</div>
<div class="issue" style="border-color:rgba(129,140,248,0.15)">
<div class="issue__title" style="margin-bottom:6px">📐 Design Tokens</div>
<div class="issue__body">Vollständige Skala für Farben, Radien, Schatten, Spacing, Typografie. Konsistente Anwendung — kaum Hardcoding gefunden.</div>
</div>
</div>
</div>
<script>
// Inject into brainstorm frame if available
if (window.parent !== window) {
// signal ready
window.parent.postMessage({ type: 'screen-ready' }, '*');
}
</script>
</body>
</html>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777305983821}
@@ -0,0 +1 @@
947907
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos UX/UI Analyse</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f0f0e;
color: #f0ede8;
min-height: 100vh;
padding: 32px 24px 80px;
}
.header { max-width: 920px; margin: 0 auto 40px; }
.eyebrow { font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: #818CF8; margin-bottom: 8px; }
h1 { font-size: clamp(26px, 4vw, 40px); font-weight: 700; letter-spacing: -.03em; line-height: 1.1; margin-bottom: 10px; }
h1 span { color: #818CF8; }
.sub { font-size: 14px; color: #8E8D89; line-height: 1.5; }
.summary { max-width: 920px; margin: 0 auto 40px; background: rgba(129,140,248,.08); border: 1px solid rgba(129,140,248,.2); border-radius: 16px; padding: 24px; display: grid; grid-template-columns: repeat(3,1fr); gap: 12px; text-align: center; }
@media(max-width:480px){ .summary { grid-template-columns: 1fr; } }
.sum-num { font-size: 38px; font-weight: 700; letter-spacing: -.03em; line-height: 1; }
.sum-lbl { font-size: 11px; color: #8E8D89; margin-top: 4px; }
.c { color: #FCA5A5; } .h { color: #FCD34D; } .m { color: #6EE7B7; }
hr { max-width: 920px; margin: 0 auto 36px; border: none; border-top: 1px solid rgba(255,255,255,.06); }
.section { max-width: 920px; margin: 0 auto 40px; }
.slbl { font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.slbl::after { content:''; flex:1; height:1px; background:rgba(255,255,255,.06); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: 14px; }
.card { background: #1a1a18; border-radius: 14px; border: 1px solid rgba(255,255,255,.06); padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.card:hover { border-color: rgba(255,255,255,.12); }
.card-ico { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; margin-bottom: 2px; }
.ico-c { background: rgba(252,165,165,.12); }
.ico-h { background: rgba(252,211,77,.1); }
.ico-m { background: rgba(110,231,183,.1); }
.card-title { font-size: 13px; font-weight: 600; line-height: 1.35; }
.card-body { font-size: 12px; color: #8E8D89; line-height: 1.55; }
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 99px; font-size: 10px; font-weight: 600; margin-top: auto; align-self: flex-start; }
.bc { color:#FCA5A5; background:rgba(252,165,165,.1); }
.bh { color:#FCD34D; background:rgba(252,211,77,.08); }
.bm { color:#6EE7B7; background:rgba(110,231,183,.08); }
.strengths { display: grid; grid-template-columns: repeat(auto-fill,minmax(200px,1fr)); gap: 10px; }
.str { background: rgba(129,140,248,.06); border: 1px solid rgba(129,140,248,.12); border-radius: 12px; padding: 14px; }
.str-title { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.str-body { font-size: 11px; color: #8E8D89; line-height: 1.5; }
</style>
</head>
<body>
<div class="header">
<div class="eyebrow">Oikos UX/UI Analyse · April 2026</div>
<h1>Was funktioniert — was bremst <span>Oikos</span></h1>
<p class="sub">70 % Mobile-PWA · 30 % Desktop · 11 Module · Vanilla JS</p>
</div>
<div class="summary">
<div><div class="sum-num c">4</div><div class="sum-lbl">Kritisch<br>sofort beheben</div></div>
<div><div class="sum-num h">5</div><div class="sum-lbl">Hohe Priorität<br>nächster Sprint</div></div>
<div><div class="sum-num m">4</div><div class="sum-lbl">Mittlere Priorität<br>Backlog</div></div>
</div>
<hr>
<div class="section">
<div class="slbl" style="color:#FCA5A5">🔴 Kritisch</div>
<div class="grid">
<div class="card">
<div class="card-ico ico-c">🧭</div>
<div class="card-title">Sidebar 10241279 px: Icons ohne Labels/Tooltips</div>
<div class="card-body">Bei häufigster Desktop-Auflösung sind Modul-Icons ohne jede Beschriftung — kein Tooltip, kein Label. 11 Module sind nicht erkennbar.</div>
<div class="badge bc">Keine Discoverability</div>
</div>
<div class="card">
<div class="card-ico ico-c">👆</div>
<div class="card-title">Modal-Close: 40 px statt 44 px Minimum</div>
<div class="card-body"><code>.modal-panel__close</code> nutzt <code>--target-md</code> (40 px). iOS-Minimum ist 44 pt. Auf kleinen Screens spürbares Frustrationspotenzial.</div>
<div class="badge bc">Apple HIG Violation</div>
</div>
<div class="card">
<div class="card-ico ico-c">🔗</div>
<div class="card-title">Widget-Links: Tap-Target &lt; 44 px</div>
<div class="card-body"><code>.widget__link</code>: 12 px Text, kein <code>min-height</code>. Effektiver Tippbereich ~1618 px. Auf jedem Dashboard-Widget vorhanden.</div>
<div class="badge bc">Tap-Target Violation</div>
</div>
<div class="card">
<div class="card-ico ico-c">🔁</div>
<div class="card-title">Doppelter FAB: .fab vs .page-fab</div>
<div class="card-body">Zwei nahezu identische FAB-Klassen mit unterschiedlicher <code>bottom</code>-Berechnung erzeugen inkonsistente Positionierung auf verschiedenen Seiten.</div>
<div class="badge bc">Inkonsistente UI</div>
</div>
</div>
</div>
<div class="section">
<div class="slbl" style="color:#FCD34D">🟡 Hoch</div>
<div class="grid">
<div class="card">
<div class="card-ico ico-h">🗂️</div>
<div class="card-title">Onboarding: 3 Screens für 11 Module</div>
<div class="card-body">3 generische Steps erklären nicht Budget, RRule-Wiederholungen oder Calendar-Sync. Kein Feature-Discovery danach.</div>
<div class="badge bh">Discoverability</div>
</div>
<div class="card">
<div class="card-ico ico-h">📊</div>
<div class="card-title">Widget-Reihenfolge nicht anpassbar</div>
<div class="card-body">Config speichert nur <code>{ id, visible }</code> — kein order-Feld. Familien mit verschiedenen Prioritäten können das Dashboard nicht sinnvoll anpassen.</div>
<div class="badge bh">Personalisierung</div>
</div>
<div class="card">
<div class="card-ico ico-h">↩️</div>
<div class="card-title">Inkonsistentes Undo</div>
<div class="card-body">Manche Aktionen zeigen Undo-Toast, andere nicht. Kein zentrales Undo-System. Bei Kontakt/Notiz löschen fehlt der Weg zurück.</div>
<div class="badge bh">Fehlererholung</div>
</div>
<div class="card">
<div class="card-ico ico-h">📡</div>
<div class="card-title">Kein sichtbarer Offline-Indikator</div>
<div class="card-body">Service Worker existiert, aber kein Offline-Banner in der App-Shell. Nutzer merken Offline erst beim API-Fehler — zu spät.</div>
<div class="badge bh">PWA-Erfahrung</div>
</div>
<div class="card">
<div class="card-ico ico-h">⌨️</div>
<div class="card-title">Keine Keyboard Shortcuts</div>
<div class="card-body">30 % Desktop-Nutzung ohne globale Shortcuts. Kein „N" für neu, kein „/" für Suche. Power-User auf Maus angewiesen.</div>
<div class="badge bh">Desktop-Ergonomie</div>
</div>
</div>
</div>
<div class="section">
<div class="slbl" style="color:#6EE7B7">🟢 Mittel</div>
<div class="grid">
<div class="card">
<div class="card-ico ico-m">📦</div>
<div class="card-title">reminders.css global geladen</div>
<div class="card-body">Wird auf allen Seiten geparst, nicht lazy. Kein Blocking-Problem, aber vermeidbare CSS-Last auf Seiten ohne Reminder-UI.</div>
<div class="badge bm">Performance</div>
</div>
<div class="card">
<div class="card-ico ico-m">👉</div>
<div class="card-title">Swipe nur Tasks & Shopping</div>
<div class="card-body">Swipe-Reveal fehlt bei Kontakten, Notizen, Geburtstagen. Inkonsistente Erwartungshaltung im selben App-Kontext.</div>
<div class="badge bm">Interaktions-Konsistenz</div>
</div>
<div class="card">
<div class="card-ico ico-m">🎨</div>
<div class="card-title">11 Modulfarben gleichzeitig im Dashboard</div>
<div class="card-body">Wenn alle Widgets sichtbar sind, treffen 11 Akzentfarben aufeinander. Das Dashboard kann farblich überladen wirken.</div>
<div class="badge bm">Visuelle Ruhe</div>
</div>
<div class="card">
<div class="card-ico ico-m">💬</div>
<div class="card-title">Toast: kein Swipe-to-Dismiss</div>
<div class="card-body">Toasts sind nicht wegwischbar. Auf iOS/Android ist Swipe-to-Dismiss eine fest etablierte Konvention — ihr Fehlen fällt auf.</div>
<div class="badge bm">Mobile-Konvention</div>
</div>
</div>
</div>
<hr>
<div class="section">
<div class="slbl" style="color:#818CF8">✨ Was bereits ausgezeichnet ist</div>
<div class="strengths">
<div class="str"><div class="str-title">🌙 Dark Mode Architektur</div><div class="str-body">Private/Public-Token-Pattern ist mustergültig. Alle Kontraste WCAG-geprüft.</div></div>
<div class="str"><div class="str-title">♿ 4 A11y-Schichten</div><div class="str-body">reduced-motion, reduced-transparency, prefers-contrast, forced-colors — alle implementiert.</div></div>
<div class="str"><div class="str-title">🍎 iOS-PWA-Bewusstsein</div><div class="str-body">100dvh + webkit-Fallback, safe-area-inset, Flex-Kind statt fixed nav.</div></div>
<div class="str"><div class="str-title">💎 Glass Design System</div><div class="str-body">@supports-basiert, opake Fallbacks für reduced-transparency, konsistente Token.</div></div>
<div class="str"><div class="str-title">🎭 Modul-Theming</div><div class="str-body">--active-module-accent System elegant — FAB, Nav, Toggles spiegeln aktive Seite.</div></div>
<div class="str"><div class="str-title">📐 Design Tokens</div><div class="str-body">Vollständige Skala, kaum Hardcoding. Basis für alles weitere.</div></div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,629 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos — Lösungsvorschläge</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f0f0e; color: #f0ede8;
min-height: 100vh; padding: 32px 24px 100px;
}
code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: .9em; background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; }
a { color: #818CF8; text-decoration: none; }
.page-title { max-width: 960px; margin: 0 auto 48px; }
.eyebrow { font-size: 11px; font-weight: 600; letter-spacing:.1em; text-transform:uppercase; color:#818CF8; margin-bottom:8px; }
h1 { font-size: clamp(24px,4vw,38px); font-weight:700; letter-spacing:-.03em; line-height:1.1; margin-bottom:10px; }
h1 span { color:#818CF8; }
.sub { font-size:14px; color:#8E8D89; line-height:1.5; }
/* Fix block */
.fix { max-width: 960px; margin: 0 auto 56px; }
.fix__header { display:flex; align-items:flex-start; gap:16px; margin-bottom:24px; }
.fix__num { width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; font-weight:700; flex-shrink:0; margin-top:2px; }
.num-c { background:rgba(252,165,165,.15); color:#FCA5A5; }
.num-h { background:rgba(252,211,77,.12); color:#FCD34D; }
.num-m { background:rgba(110,231,183,.1); color:#6EE7B7; }
.fix__meta { flex:1; }
.fix__tag { font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-bottom:4px; }
.fix__title { font-size:20px; font-weight:700; letter-spacing:-.02em; line-height:1.2; }
.fix__why { font-size:13px; color:#8E8D89; line-height:1.6; margin-top:6px; }
/* Split layout */
.split { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media(max-width:640px){ .split { grid-template-columns:1fr; } }
.panel { background:#1a1a18; border-radius:14px; border:1px solid rgba(255,255,255,.06); overflow:hidden; }
.panel__head { padding:10px 16px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; border-bottom:1px solid rgba(255,255,255,.06); display:flex; align-items:center; gap:8px; }
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.dot-r { background:#FCA5A5; } .dot-g { background:#6EE7B7; }
.panel__body { padding:20px; }
/* Code panel */
.code-panel { background:#111110; border-radius:14px; border:1px solid rgba(255,255,255,.06); overflow:hidden; }
.code-panel__head { padding:10px 16px; font-size:11px; font-weight:600; color:#8E8D89; border-bottom:1px solid rgba(255,255,255,.06); display:flex; align-items:center; justify-content:space-between; }
.code-panel__body { padding:16px 20px; font-family:'SF Mono','Fira Code',monospace; font-size:12px; line-height:1.7; overflow-x:auto; }
.del { color:#FCA5A5; } .add { color:#6EE7B7; } .cmt { color:#636360; }
/* Sidebar mock */
.mock-sidebar { display:flex; flex-direction:column; gap:4px; width:60px; background:#222220; border-radius:10px; padding:10px 6px; }
.mock-sidebar-wide { width:190px; }
.mock-si { display:flex; align-items:center; gap:10px; padding:8px; border-radius:8px; color:#8E8D89; font-size:12px; position:relative; }
.mock-si--active { background:rgba(129,140,248,.15); color:#818CF8; }
.mock-si__ico { width:18px; height:18px; background:currentColor; border-radius:4px; opacity:.5; flex-shrink:0; }
.mock-si--active .mock-si__ico { opacity:1; }
.mock-tooltip { position:absolute; left:calc(100% + 10px); top:50%; transform:translateY(-50%); background:#2A2A28; border:1px solid rgba(255,255,255,.12); color:#f0ede8; font-size:11px; padding:4px 10px; border-radius:6px; white-space:nowrap; z-index:10; pointer-events:none; }
.mock-tooltip::before { content:''; position:absolute; right:100%; top:50%; transform:translateY(-50%); border:5px solid transparent; border-right-color:#2A2A28; }
/* Touch mock */
.touch-compare { display:flex; gap:20px; align-items:flex-start; flex-wrap:wrap; }
.touch-item { display:flex; flex-direction:column; align-items:center; gap:8px; }
.touch-box { border-radius:10px; border:1.5px solid; display:flex; align-items:center; justify-content:center; font-weight:600; font-size:13px; }
.tb-bad { width:40px; height:40px; border-color:rgba(252,165,165,.4); background:rgba(252,165,165,.06); color:#FCA5A5; }
.tb-good { width:44px; height:44px; border-color:rgba(110,231,183,.4); background:rgba(110,231,183,.06); color:#6EE7B7; }
.touch-lbl { font-size:11px; color:#8E8D89; text-align:center; }
.touch-lbl strong { color:#f0ede8; display:block; }
/* Widget link mock */
.widget-mock { background:#222220; border-radius:10px; padding:12px 14px; }
.wm-row { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; }
.wm-title { font-size:13px; font-weight:600; }
.wm-link { font-size:11px; }
.wm-link--bad { color:#818CF8; padding:2px 0; } /* no min-height */
.wm-link--good { color:#818CF8; padding:8px 10px; background:rgba(129,140,248,.1); border-radius:6px; min-height:32px; display:flex; align-items:center; }
.wm-body { height:40px; background:rgba(255,255,255,.04); border-radius:6px; }
/* FAB comparison */
.fab-compare { display:flex; flex-direction:column; gap:12px; }
.fab-row { display:flex; align-items:center; gap:12px; padding:10px 12px; background:rgba(255,255,255,.04); border-radius:8px; border:1px solid rgba(255,255,255,.06); }
.fab-circle { width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:20px; flex-shrink:0; }
.fab-info { flex:1; }
.fab-name { font-size:12px; font-weight:600; font-family:monospace; }
.fab-desc { font-size:11px; color:#8E8D89; margin-top:2px; }
.fab-bad { background:rgba(252,165,165,.12); border:1.5px solid rgba(252,165,165,.3); }
.fab-good { background:rgba(110,231,183,.1); border:1.5px solid rgba(110,231,183,.25); }
/* Onboarding mock */
.ob-flow { display:flex; gap:12px; overflow-x:auto; padding-bottom:4px; }
.ob-card { background:#222220; border-radius:12px; padding:16px; width:140px; flex-shrink:0; text-align:center; border:1px solid rgba(255,255,255,.06); }
.ob-card--highlight { border-color:rgba(129,140,248,.3); background:rgba(129,140,248,.06); }
.ob-ico { width:36px; height:36px; border-radius:10px; background:rgba(129,140,248,.15); margin:0 auto 8px; display:flex; align-items:center; justify-content:center; font-size:18px; }
.ob-title { font-size:11px; font-weight:600; margin-bottom:4px; }
.ob-desc { font-size:10px; color:#8E8D89; line-height:1.4; }
.ob-dots { display:flex; justify-content:center; gap:4px; margin-top:8px; }
.ob-dot { width:6px; height:6px; border-radius:50%; background:rgba(255,255,255,.15); }
.ob-dot--on { background:#818CF8; width:16px; border-radius:3px; }
/* Widget order mock */
.widget-order { display:flex; flex-direction:column; gap:8px; }
.wo-item { display:flex; align-items:center; gap:10px; padding:10px 12px; background:#222220; border-radius:8px; border:1px solid rgba(255,255,255,.06); cursor:grab; }
.wo-handle { color:#636360; font-size:14px; }
.wo-dot { width:10px; height:10px; border-radius:3px; flex-shrink:0; }
.wo-label { font-size:13px; font-weight:500; flex:1; }
.wo-vis { font-size:10px; color:#8E8D89; }
/* Offline banner mock */
.offline-banner { padding:10px 16px; background:rgba(161,98,7,.2); border:1px solid rgba(161,98,7,.3); border-radius:10px; display:flex; align-items:center; gap:10px; margin-bottom:10px; }
.ob-icon { font-size:16px; }
.ob-text { font-size:12px; color:#FCD34D; flex:1; }
.ob-pill { font-size:10px; font-weight:600; padding:2px 8px; background:rgba(161,98,7,.25); color:#FCD34D; border-radius:99px; }
/* Keyboard shortcut mock */
.kbd-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:8px; }
.kbd-row { display:flex; align-items:center; gap:10px; padding:8px 12px; background:#1a1a18; border-radius:8px; border:1px solid rgba(255,255,255,.06); }
.kbd { padding:2px 7px; background:#2A2A28; border:1px solid rgba(255,255,255,.15); border-bottom:2px solid rgba(255,255,255,.08); border-radius:5px; font-size:11px; font-family:monospace; color:#f0ede8; }
.kbd-label { font-size:12px; color:#8E8D89; }
/* Divider */
hr { max-width:960px; margin:0 auto 48px; border:none; border-top:1px solid rgba(255,255,255,.06); }
/* Undo */
.undo-arch { display:flex; flex-direction:column; gap:8px; }
.undo-row { display:flex; align-items:center; gap:10px; padding:10px 14px; background:#1a1a18; border-radius:8px; border:1px solid rgba(255,255,255,.06); }
.undo-action { font-size:12px; font-weight:600; flex:1; }
.undo-badge { font-size:10px; padding:2px 8px; border-radius:99px; }
.ub-yes { color:#6EE7B7; background:rgba(110,231,183,.1); }
.ub-no { color:#FCA5A5; background:rgba(252,165,165,.1); }
.ub-new { color:#FCD34D; background:rgba(252,211,77,.1); }
</style>
</head>
<body>
<div class="page-title">
<div class="eyebrow">Lösungsvorschläge · Oikos · April 2026</div>
<h1>Konkrete <span>Optimierungen</span> — sortiert nach Impact</h1>
<p class="sub">13 Punkte · 4 kritisch · 5 hoch · 4 mittel</p>
</div>
<!-- ════════════════════════════════ KRITISCH ════════════════════════════════ -->
<!-- FIX 1: Sidebar Tooltips -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-c">1</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCA5A5">Kritisch · 12 h</div>
<div class="fix__title">Sidebar: title-Tooltips für icon-only Modus</div>
<div class="fix__why">Nutzer bei 10241279 px sehen 11 Icons ohne jede Beschriftung. Ein einfaches <code>title</code>-Attribut auf jedem Nav-Item genügt als sofortiger Fix. Langfristig: expanded sidebar ab 1280 px früher aktivieren.</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head"><span class="dot dot-r"></span> Ist-Zustand</div>
<div class="panel__body" style="display:flex;justify-content:center">
<div class="mock-sidebar">
<div class="mock-si mock-si--active"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
</div>
<p style="font-size:11px;color:#FCA5A5;margin-left:16px;align-self:center">Kein Hinweis<br>was die Icons bedeuten</p>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand</div>
<div class="panel__body" style="display:flex;justify-content:center">
<div class="mock-sidebar" style="position:relative;overflow:visible">
<div class="mock-si mock-si--active" style="position:relative">
<div class="mock-si__ico"></div>
<div class="mock-tooltip">Dashboard</div>
</div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
<div class="mock-si"><div class="mock-si__ico"></div></div>
</div>
<p style="font-size:11px;color:#6EE7B7;margin-left:24px;align-self:center">Tooltip bei<br>Hover/Focus sichtbar</p>
</div>
</div>
</div>
<div class="code-panel" style="margin-top:14px">
<div class="code-panel__head"><span>router.js — Nav-Item Rendering</span><span style="color:#6EE7B7">+1 Zeile</span></div>
<div class="code-panel__body">
<span class="cmt">// In renderAppShell() oder wo nav-items erzeugt werden:</span>
<span class="del">- a.setAttribute('aria-label', label);</span>
<span class="add">+ a.setAttribute('aria-label', label);</span>
<span class="add">+ a.setAttribute('title', label); <span class="cmt">// Tooltip für collapsed sidebar</span></span>
<br>
<span class="cmt">/* Optional: CSS-Tooltip statt native title (für Styling) */</span>
<span class="add">+ .nav-sidebar .nav-item[title]:hover::after {</span>
<span class="add">+ content: attr(title);</span>
<span class="add">+ position: absolute;</span>
<span class="add">+ left: calc(100% + 10px);</span>
<span class="add">+ /* ... Tooltip-Styles */</span>
<span class="add">+ }</span>
</div>
</div>
</div>
<hr>
<!-- FIX 2: Modal Close Button -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-c">2</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCA5A5">Kritisch · 5 min</div>
<div class="fix__title">Modal-Close: von 40 px auf 44 px</div>
<div class="fix__why">Ein Einzeiler in <code>layout.css</code>. Der Fehler liegt darin, dass <code>--target-md</code> (40 px) statt <code>--target-base</code> (44 px) verwendet wird. Das Token existiert bereits — es muss nur gewechselt werden.</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head"><span class="dot dot-r"></span> Problem: 40 × 40 px</div>
<div class="panel__body">
<div class="touch-compare">
<div class="touch-item">
<div class="touch-box tb-bad"></div>
<div class="touch-lbl"><strong>40 × 40 px</strong>target-md<br>4 px unter Minimum</div>
</div>
<div style="flex:1;font-size:12px;color:#8E8D89;line-height:1.6;align-self:center">
Ein durchschnittlicher Fingertipp belegt 4450 px. Beim Schließen unter Stress (mit einer Hand, unterwegs) erhöht sich die Fehlklickrate spürbar.
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Fix: 44 × 44 px</div>
<div class="panel__body">
<div class="touch-compare">
<div class="touch-item">
<div class="touch-box tb-good"></div>
<div class="touch-lbl"><strong>44 × 44 px</strong>--target-base<br>Apple HIG ✓</div>
</div>
</div>
</div>
</div>
</div>
<div class="code-panel" style="margin-top:14px">
<div class="code-panel__head"><span>layout.css · .modal-panel__close</span><span style="color:#6EE7B7">1 Zeile</span></div>
<div class="code-panel__body">
<span class="del">- width: var(--target-md); /* 40px */</span>
<span class="del">- height: var(--target-md); /* 40px */</span>
<span class="add">+ width: var(--target-base); /* 44px — Apple HIG Minimum */</span>
<span class="add">+ height: var(--target-base); /* 44px */</span>
</div>
</div>
</div>
<hr>
<!-- FIX 3: Widget Links -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-c">3</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCA5A5">Kritisch · 15 min</div>
<div class="fix__title">Widget-Links: Tap-Target auf 44 px bringen</div>
<div class="fix__why">Der „Alle anzeigen →"-Link in jedem Dashboard-Widget hat keinen definierten <code>min-height</code>. Bei 12 px Text liegt der tatsächliche Klickbereich bei etwa 1618 px — weit unter iOS-Minimum. Fix: padding + min-height in <code>dashboard.css</code>.</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head"><span class="dot dot-r"></span> Ist-Zustand</div>
<div class="panel__body">
<div class="widget-mock">
<div class="wm-row">
<div class="wm-title">Aufgaben</div>
<a class="wm-link wm-link--bad">Alle →</a>
</div>
<div class="wm-body"></div>
</div>
<p style="font-size:11px;color:#FCA5A5;margin-top:8px">Tap-Target ≈ 16 px Höhe</p>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand</div>
<div class="panel__body">
<div class="widget-mock">
<div class="wm-row">
<div class="wm-title">Aufgaben</div>
<a class="wm-link wm-link--good">Alle →</a>
</div>
<div class="wm-body"></div>
</div>
<p style="font-size:11px;color:#6EE7B7;margin-top:8px">Tap-Target ≥ 32 px mit Padding</p>
</div>
</div>
</div>
<div class="code-panel" style="margin-top:14px">
<div class="code-panel__head"><span>dashboard.css · .widget__link</span></div>
<div class="code-panel__body">
<span class="add">+ .widget__link {</span>
<span class="add">+ min-height: var(--target-base); <span class="cmt">/* 44px */</span></span>
<span class="add">+ display: inline-flex;</span>
<span class="add">+ align-items: center;</span>
<span class="add">+ padding: 0 var(--space-2);</span>
<span class="add">+ border-radius: var(--radius-sm);</span>
<span class="add">+ }</span>
<span class="add">+ .widget__link:hover {</span>
<span class="add">+ background: color-mix(in srgb, var(--widget-accent) 10%, transparent);</span>
<span class="add">+ }</span>
</div>
</div>
</div>
<hr>
<!-- FIX 4: FAB -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-c">4</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCA5A5">Kritisch · 30 min</div>
<div class="fix__title">FAB konsolidieren: .fab + .page-fab → eine Klasse</div>
<div class="fix__why">Beide Klassen definieren fast denselben Button mit leicht abweichender <code>bottom</code>-Berechnung. Das führt zu inkonsistenter Positionierung. Die Lösung: eine Klasse, ein Token, alle Seiten konsistent.</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head"><span class="dot dot-r"></span> Ist-Zustand: 2 Klassen</div>
<div class="panel__body">
<div class="fab-compare">
<div class="fab-row">
<div class="fab-circle fab-bad" style="background:rgba(252,165,165,.12)"></div>
<div class="fab-info">
<div class="fab-name">.fab</div>
<div class="fab-desc">bottom: nav-height + safe-area + space-4</div>
</div>
</div>
<div class="fab-row">
<div class="fab-circle fab-bad" style="background:rgba(252,165,165,.12)"></div>
<div class="fab-info">
<div class="fab-name">.page-fab</div>
<div class="fab-desc">bottom: nav-bottom-height + 24px + safe-area</div>
</div>
</div>
</div>
<p style="font-size:11px;color:#FCA5A5;margin-top:10px">Verschiedene bottom-Werte → visuell inkonsistent je nach Seite</p>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand: 1 Klasse</div>
<div class="panel__body">
<div class="fab-compare">
<div class="fab-row">
<div class="fab-circle fab-good"></div>
<div class="fab-info">
<div class="fab-name">.page-fab (Canonical)</div>
<div class="fab-desc">Einheitliche bottom-Formel, alle Seiten</div>
</div>
</div>
</div>
<p style="font-size:11px;color:#6EE7B7;margin-top:10px">Alle Seiten nutzen .page-fab, .fab wird entfernt oder als Alias gesetzt</p>
</div>
</div>
</div>
</div>
<hr>
<!-- ════════════════════════════════ HOCH ════════════════════════════════ -->
<!-- FIX 5: Widget Reorder -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-h">5</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCD34D">Hoch · 24 h</div>
<div class="fix__title">Dashboard: Widget-Reihenfolge anpassbar machen</div>
<div class="fix__why">Die Widget-Config speichert aktuell nur <code>{ id, visible }</code>. Ein <code>order</code>-Feld hinzufügen und im Customization-Modal drag-sortierbar machen (per <code>touch-action: none</code> + pointer events, kein externes Drag-Library nötig).</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head"><span class="dot dot-r"></span> Ist-Zustand: Feste Reihenfolge</div>
<div class="panel__body">
<div class="widget-order">
<div class="wo-item" style="opacity:.5;cursor:default">
<div class="wo-dot" style="background:#818CF8"></div>
<div class="wo-label">Dashboard</div>
<div class="wo-vis">⊙ sichtbar</div>
</div>
<div class="wo-item" style="opacity:.5;cursor:default">
<div class="wo-dot" style="background:#15803D"></div>
<div class="wo-label">Aufgaben</div>
<div class="wo-vis">⊙ sichtbar</div>
</div>
<div class="wo-item" style="opacity:.5;cursor:default">
<div class="wo-dot" style="background:#8250DF"></div>
<div class="wo-label">Kalender</div>
<div class="wo-vis">⊙ sichtbar</div>
</div>
</div>
<p style="font-size:11px;color:#FCA5A5;margin-top:8px">Reihenfolge fest codiert — nicht änderbar</p>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand: Drag-to-reorder</div>
<div class="panel__body">
<div class="widget-order">
<div class="wo-item">
<div class="wo-handle"></div>
<div class="wo-dot" style="background:#818CF8"></div>
<div class="wo-label">Dashboard</div>
<div class="wo-vis"></div>
</div>
<div class="wo-item" style="background:rgba(129,140,248,.08);border-color:rgba(129,140,248,.2)">
<div class="wo-handle"></div>
<div class="wo-dot" style="background:#8250DF"></div>
<div class="wo-label">Kalender</div>
<div class="wo-vis"></div>
</div>
<div class="wo-item">
<div class="wo-handle"></div>
<div class="wo-dot" style="background:#15803D"></div>
<div class="wo-label">Aufgaben</div>
<div class="wo-vis"></div>
</div>
</div>
<p style="font-size:11px;color:#6EE7B7;margin-top:8px">⠿ Handle zum Sortieren — gespeichert in localStorage</p>
</div>
</div>
</div>
<div class="code-panel" style="margin-top:14px">
<div class="code-panel__head"><span>dashboard.js — Config-Schema</span></div>
<div class="code-panel__body">
<span class="del">- const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));</span>
<span class="add">+ const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id, i) => ({ id, visible: true, order: i }));</span>
<br>
<span class="cmt">// Beim Laden: nach order sortieren</span>
<span class="add">+ config.sort((a, b) => a.order - b.order);</span>
</div>
</div>
</div>
<hr>
<!-- FIX 6: Offline Indicator -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-h">6</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCD34D">Hoch · 1 h</div>
<div class="fix__title">Offline-Banner in App-Shell</div>
<div class="fix__why">Der Service Worker ist vorhanden, aber die App gibt kein visuelles Feedback zum Offline-Zustand. Ein kleines Banner direkt unter der Navigation (wenn <code>navigator.onLine</code> false ist) ist die robusteste Lösung — kein Flickering, immer sichtbar.</div>
</div>
</div>
<div class="panel" style="margin-bottom:14px">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand: Shell-Level Banner</div>
<div class="panel__body">
<div class="offline-banner">
<span class="ob-icon">📡</span>
<span class="ob-text">Offline — Änderungen werden gespeichert und beim nächsten Verbindungsaufbau synchronisiert.</span>
<span class="ob-pill">Offline</span>
</div>
<p style="font-size:11px;color:#8E8D89">Erscheint unter Nav-Bar, verschwindet automatisch wenn online</p>
</div>
</div>
<div class="code-panel">
<div class="code-panel__head"><span>router.js — App-Shell Setup</span></div>
<div class="code-panel__body">
<span class="add">+ function initOfflineBanner() {</span>
<span class="add">+ const banner = document.getElementById('offline-banner');</span>
<span class="add">+ const update = () => banner.hidden = navigator.onLine;</span>
<span class="add">+ window.addEventListener('online', update);</span>
<span class="add">+ window.addEventListener('offline', update);</span>
<span class="add">+ update();</span>
<span class="add">+ }</span>
</div>
</div>
</div>
<hr>
<!-- FIX 7: Keyboard Shortcuts -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-h">7</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCD34D">Hoch · 23 h</div>
<div class="fix__title">Globale Keyboard Shortcuts (Desktop)</div>
<div class="fix__why">30 % der Nutzer verwenden Desktop. Ein zentrales Keyboard-Shortcut-System im Router beschleunigt häufige Aktionen erheblich. Alle Shortcuts per <code>?</code> einsehbar.</div>
</div>
</div>
<div class="kbd-grid">
<div class="kbd-row"><kbd class="kbd">/</kbd><span class="kbd-label">Suche öffnen</span></div>
<div class="kbd-row"><kbd class="kbd">N</kbd><span class="kbd-label">Neu (kontextabhängig)</span></div>
<div class="kbd-row"><kbd class="kbd">G D</kbd><span class="kbd-label">→ Dashboard</span></div>
<div class="kbd-row"><kbd class="kbd">G T</kbd><span class="kbd-label">→ Tasks</span></div>
<div class="kbd-row"><kbd class="kbd">G C</kbd><span class="kbd-label">→ Kalender</span></div>
<div class="kbd-row"><kbd class="kbd">Esc</kbd><span class="kbd-label">Modal / Sheet schließen</span></div>
<div class="kbd-row"><kbd class="kbd">?</kbd><span class="kbd-label">Shortcut-Übersicht</span></div>
<div class="kbd-row"><kbd class="kbd">⌘K</kbd><span class="kbd-label">Command Palette</span></div>
</div>
</div>
<hr>
<!-- FIX 8: Undo System -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-h">8</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCD34D">Hoch · 34 h</div>
<div class="fix__title">Zentrales Undo-System für destruktive Aktionen</div>
<div class="fix__why">Aktuell haben manche Aktionen Undo-Toasts, andere nicht. Eine zentrale <code>undoStack</code>-Utility mit standardisiertem Toast-Muster schafft konsistente Sicherheit über alle Module hinweg.</div>
</div>
</div>
<div class="panel">
<div class="panel__head"><span class="dot dot-g"></span> Soll-Zustand: konsistente Undo-Abdeckung</div>
<div class="panel__body">
<div class="undo-arch">
<div class="undo-row"><div class="undo-action">Aufgabe löschen</div><span class="undo-badge ub-yes">✓ Undo vorhanden</span></div>
<div class="undo-row"><div class="undo-action">Eintrag Einkaufsliste löschen</div><span class="undo-badge ub-yes">✓ Undo vorhanden</span></div>
<div class="undo-row"><div class="undo-action">Kontakt löschen</div><span class="undo-badge ub-no">✗ fehlt</span></div>
<div class="undo-row"><div class="undo-action">Notiz löschen</div><span class="undo-badge ub-no">✗ fehlt</span></div>
<div class="undo-row"><div class="undo-action">Geburtstag löschen</div><span class="undo-badge ub-no">✗ fehlt</span></div>
<div class="undo-row"><div class="undo-action">Mahlzeit löschen</div><span class="undo-badge ub-no">✗ fehlt</span></div>
</div>
<p style="font-size:11px;color:#8E8D89;margin-top:10px">→ Alle DELETE-Aktionen über eine zentrale <code>deleteWithUndo(url, label)</code> Funktion routen</p>
</div>
</div>
</div>
<hr>
<!-- FIX 9: Onboarding -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-h">9</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#FCD34D">Hoch · 3 h</div>
<div class="fix__title">Onboarding: Modul-spezifische Erst-Nutzung</div>
<div class="fix__why">Statt 3 generischen Screens beim ersten Start: leere Zustände auf jeder Seite mit einem konkreten Tipp für genau dieses Modul. Zusätzlich: ein permanentes „?" / Hilfe-Icon in der Toolbar für den Onboarding-Replay.</div>
</div>
</div>
<div class="ob-flow">
<div class="ob-card ob-card--highlight">
<div class="ob-ico">📋</div>
<div class="ob-title">Aufgaben</div>
<div class="ob-desc">Tippe um deine erste Aufgabe zu erstellen. Wische links zum Löschen.</div>
<div class="ob-dots"><div class="ob-dot ob-dot--on"></div><div class="ob-dot"></div><div class="ob-dot"></div></div>
</div>
<div class="ob-card">
<div class="ob-ico">📅</div>
<div class="ob-title">Kalender</div>
<div class="ob-desc">Verbinde deinen Google Kalender unter Einstellungen → Kalender-Sync.</div>
<div class="ob-dots"><div class="ob-dot"></div><div class="ob-dot ob-dot--on"></div><div class="ob-dot"></div></div>
</div>
<div class="ob-card">
<div class="ob-ico">💰</div>
<div class="ob-title">Budget</div>
<div class="ob-desc">Lege Kategorien und ein Monatsbudget fest. Einnahmen und Ausgaben werden automatisch summiert.</div>
<div class="ob-dots"><div class="ob-dot"></div><div class="ob-dot"></div><div class="ob-dot ob-dot--on"></div></div>
</div>
<div class="ob-card">
<div class="ob-ico">🔔</div>
<div class="ob-title">Erinnerungen</div>
<div class="ob-desc">RRule ermöglicht Wiederholungen: täglich, wöchentlich, oder komplex wie „jeden 2. Montag".</div>
</div>
<div class="ob-card" style="opacity:.5">
<div class="ob-ico"></div>
<div class="ob-title">+7 weitere</div>
<div class="ob-desc">Jedes Modul erklärt sich beim ersten Besuch selbst.</div>
</div>
</div>
</div>
<hr>
<!-- ════════════════════════════════ MITTEL ════════════════════════════════ -->
<div class="fix">
<div class="fix__header">
<div class="fix__num num-m">1013</div>
<div class="fix__meta">
<div class="fix__tag" style="color:#6EE7B7">Mittel · Backlog</div>
<div class="fix__title">Weitere Optimierungen</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel__head">🟢 Toast Swipe-to-Dismiss</div>
<div class="panel__body">
<p style="font-size:12px;color:#8E8D89;line-height:1.6">
<code>pointerdown</code> + <code>pointermove</code> auf <code>.toast</code> → bei
&gt;40 px horizontaler Bewegung <code>toast--out</code> Klasse setzen und entfernen.
Entspricht iOS/Android-Konvention. Ca. 30 Zeilen JS.
</p>
</div>
</div>
<div class="panel">
<div class="panel__head">🟢 Swipe-Reveal auf alle Listen</div>
<div class="panel__body">
<p style="font-size:12px;color:#8E8D89;line-height:1.6">
Das bestehende <code>.swipe-row</code> Pattern auf Kontakte, Notizen und Geburtstage
ausweiten. Die Basis-CSS existiert bereits in <code>layout.css</code>
nur modul-spezifische Reveal-Farben und JS-Handler ergänzen.
</p>
</div>
</div>
<div class="panel">
<div class="panel__head">🟢 Dashboard: weniger Farbrauschen</div>
<div class="panel__body">
<p style="font-size:12px;color:#8E8D89;line-height:1.6">
Widget-Icons auf neutrale <code>--color-text-secondary</code> setzen, Widget-Akzentfarbe
nur für Badge und Link reservieren. Reduziert visuelle Überforderung wenn alle
11 Widgets gleichzeitig sichtbar sind.
</p>
</div>
</div>
<div class="panel">
<div class="panel__head">🟢 reminders.css lazy laden</div>
<div class="panel__body">
<p style="font-size:12px;color:#8E8D89;line-height:1.6">
<code>reminders.css</code> dynamisch per <code>&lt;link rel="stylesheet"&gt;</code>
nur in den Seiten laden, die Reminders anzeigen — analog zum bestehenden
Lazy-Loading-Pattern für Page-Module.
</p>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777311074842}
@@ -0,0 +1 @@
958547
+55
View File
@@ -20,6 +20,50 @@ function initials(name) {
.join('') || '?';
}
const REMINDER_OFFSETS = () => [
{ value: '', label: t('reminders.offsetNone') },
{ value: '0', label: t('reminders.offsetAtTime') },
{ value: '15', label: t('reminders.offset15min') },
{ value: '60', label: t('reminders.offset1hour') },
{ value: '1440', label: t('reminders.offset1day') },
{ value: '2880', label: t('reminders.offset2days') },
{ value: '10080', label: t('reminders.offset1week') },
{ value: '20160', label: t('reminders.offset2weeks') },
{ value: 'custom', label: t('reminders.offsetCustom') },
];
function renderBirthdayReminderSection(birthday = null) {
const currentOffset = birthday?.reminder_offset ?? '0';
const customAmount = birthday?.reminder_custom_amount || 1;
const customUnit = birthday?.reminder_custom_unit || 'days';
return `
<div class="reminder-section">
<div class="form-group" style="margin:0">
<label class="form-label" for="bd-reminder-offset">${t('reminders.offsetLabel')}</label>
<select class="form-input" id="bd-reminder-offset" style="min-height:44px">
${REMINDER_OFFSETS().map((o) =>
`<option value="${o.value}" ${currentOffset === o.value ? 'selected' : ''}>${esc(o.label)}</option>`
).join('')}
</select>
</div>
<div class="modal-grid modal-grid--2 reminder-custom" id="bd-reminder-custom" ${currentOffset === 'custom' ? '' : 'hidden'}>
<div class="form-group" style="margin:0">
<label class="form-label" for="bd-reminder-custom-amount">${t('reminders.customAmountLabel')}</label>
<input class="form-input" type="number" id="bd-reminder-custom-amount" min="1" max="999" value="${customAmount}">
</div>
<div class="form-group" style="margin:0">
<label class="form-label" for="bd-reminder-custom-unit">${t('reminders.customUnitLabel')}</label>
<select class="form-input" id="bd-reminder-custom-unit">
<option value="minutes" ${customUnit === 'minutes' ? 'selected' : ''}>${t('reminders.customMinutes')}</option>
<option value="hours" ${customUnit === 'hours' ? 'selected' : ''}>${t('reminders.customHours')}</option>
<option value="days" ${customUnit === 'days' ? 'selected' : ''}>${t('reminders.customDays')}</option>
<option value="weeks" ${customUnit === 'weeks' ? 'selected' : ''}>${t('reminders.customWeeks')}</option>
</select>
</div>
</div>
</div>`;
}
function ageNote(birthday) {
if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age });
if (birthday.days_until === 1) return t('birthdays.ageNoteTomorrow', { age: birthday.next_age });
@@ -327,6 +371,7 @@ function openBirthdayModal({ mode, birthday = null }) {
<label class="form-label" for="bd-notes">${t('birthdays.notesLabel')}</label>
<textarea class="form-input" id="bd-notes" rows="3" placeholder="${t('birthdays.notesPlaceholder')}">${esc(birthday?.notes || '')}</textarea>
</div>
${renderBirthdayReminderSection(birthday)}
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
${isEdit ? `<button class="btn btn--danger" id="bd-delete">${t('common.delete')}</button>` : '<div></div>'}
@@ -365,6 +410,13 @@ function openBirthdayModal({ mode, birthday = null }) {
if (fileInput) fileInput.value = '';
renderPreview();
});
const reminderOffset = panel.querySelector('#bd-reminder-offset');
const reminderCustom = panel.querySelector('#bd-reminder-custom');
reminderOffset?.addEventListener('change', () => {
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
});
panel.querySelector('#bd-cancel').addEventListener('click', closeModal);
panel.querySelector('#bd-delete')?.addEventListener('click', async () => {
closeModal();
@@ -379,6 +431,9 @@ function openBirthdayModal({ mode, birthday = null }) {
birth_date: birthDate,
notes: panel.querySelector('#bd-notes').value.trim(),
photo_data: photoData,
reminder_offset: panel.querySelector('#bd-reminder-offset').value,
reminder_custom_amount: panel.querySelector('#bd-reminder-custom-amount').value,
reminder_custom_unit: panel.querySelector('#bd-reminder-custom-unit').value,
};
if (!body.name || !body.birth_date || !isDateInputValid(birthDateRaw)) {
-8
View File
@@ -754,14 +754,6 @@
height: 20px;
}
.reminder-custom {
margin-top: var(--space-3);
}
.reminder-custom[hidden] {
display: none !important;
}
.agenda-event__meta {
font-size: var(--text-sm);
color: var(--color-text-secondary);
+8
View File
@@ -92,3 +92,11 @@
.reminder-fields .modal-grid {
align-items: end;
}
.reminder-custom {
margin-top: var(--space-3);
}
.reminder-custom[hidden] {
display: none !important;
}
+1
View File
@@ -184,6 +184,7 @@ self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (!url.protocol.startsWith('http')) return;
if (url.pathname.startsWith('/api/')) return;
if (request.method !== 'GET') return;
+9
View File
@@ -1180,6 +1180,15 @@ const MIGRATIONS = [
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
`,
},
{
version: 31,
description: 'Advanced reminder options for birthdays',
up: `
ALTER TABLE birthdays ADD COLUMN reminder_offset TEXT;
ALTER TABLE birthdays ADD COLUMN reminder_custom_amount INTEGER;
ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT;
`,
},
];
/**
+18 -3
View File
@@ -75,9 +75,18 @@ router.post('/', (req, res) => {
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = db.get().prepare(`
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by)
VALUES (?, ?, ?, ?, ?)
`).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId);
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by, reminder_offset, reminder_custom_amount, reminder_custom_unit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
vName.value,
vBirthDate.value,
vNotes.value,
vPhoto.value ?? null,
req.authUserId || req.session.userId,
req.body.reminder_offset ?? null,
req.body.reminder_custom_amount ?? null,
req.body.reminder_custom_unit ?? null
);
const birthday = loadBirthday(result.lastInsertRowid);
const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday));
@@ -111,6 +120,9 @@ router.put('/:id', (req, res) => {
birth_date = COALESCE(?, birth_date),
notes = ?,
photo_data = ?,
reminder_offset = ?,
reminder_custom_amount = ?,
reminder_custom_unit = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
`).run(
@@ -118,6 +130,9 @@ router.put('/:id', (req, res) => {
req.body.birth_date ?? null,
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes,
req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data,
req.body.reminder_offset !== undefined ? req.body.reminder_offset : existing.reminder_offset,
req.body.reminder_custom_amount !== undefined ? req.body.reminder_custom_amount : existing.reminder_custom_amount,
req.body.reminder_custom_unit !== undefined ? req.body.reminder_custom_unit : existing.reminder_custom_unit,
id,
);
+25 -3
View File
@@ -43,9 +43,22 @@ function daysUntilBirthday(birthDate, from = new Date()) {
return Math.round((nextUtc - todayUtc) / 86400000);
}
function birthdayReminderAt(birthDate, from = new Date()) {
function getOffsetMinutes(birthday) {
if (birthday.reminder_offset === 'custom') {
const amount = parseInt(birthday.reminder_custom_amount, 10) || 1;
const unit = birthday.reminder_custom_unit || 'days';
if (unit === 'weeks') return amount * 10080;
if (unit === 'days') return amount * 1440;
if (unit === 'hours') return amount * 60;
return amount;
}
return parseInt(birthday.reminder_offset, 10) || 0;
}
function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) {
const next = nextBirthdayDate(birthDate, from);
return `${next}T12:00:00Z`;
const baseTime = new Date(`${next}T12:00:00Z`).getTime();
return new Date(baseTime - (offsetMin || 0) * 60000).toISOString();
}
function eventTitle(name) {
@@ -125,7 +138,16 @@ function syncBirthdayCalendarEvent(database, birthday) {
function syncBirthdayReminder(database, birthday, from = new Date()) {
if (!birthday.calendar_event_id) return null;
const desired = birthdayReminderAt(birthday.birth_date, from);
if (birthday.reminder_offset === '') {
database.prepare(`
DELETE FROM reminders
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
`).run(birthday.calendar_event_id, birthday.created_by);
return null;
}
const offsetMin = getOffsetMinutes(birthday);
const desired = birthdayReminderAt(birthday.birth_date, offsetMin, from);
const existing = database.prepare(`
SELECT * FROM reminders
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?