feat: add flexible reminder options for birthdays
Add support for customizable birthday reminders with preset offsets (none, at time, 15min, 1h, 1d, 2d, 1w, 2w) and custom intervals. Users can now configure when to be reminded of upcoming birthdays. - Add migration 31: reminder_offset, reminder_custom_amount, reminder_custom_unit to birthdays table - Update POST/PUT /birthdays routes to accept reminder fields - Add getOffsetMinutes() helper in birthday service - Update birthdayReminderAt() to calculate reminder time with offset - Modify syncBirthdayReminder() to handle empty offset (no reminder) - Add renderBirthdayReminderSection() UI component - Move reminder-custom CSS from calendar.css to reminders.css - Add protocol check to service worker (non-http protocol guard) All translations already present in de.json. Tests: 109 passing, 0 failing. Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 1024–1279 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 ~16–18 px — weit unter 44 px.
|
||||||
|
Auf dem Dashboard ist dieser Link auf jedem Widget sichtbar.
|
||||||
|
</div>
|
||||||
|
<div class="issue__badge badge--critical">Tap-Target < 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: 1024–1279 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 ~44–50 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 1024–1279 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 < 44 px</div>
|
||||||
|
<div class="card-body"><code>.widget__link</code>: 12 px Text, kein <code>min-height</code>. Effektiver Tippbereich ~16–18 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 · 1–2 h</div>
|
||||||
|
<div class="fix__title">Sidebar: title-Tooltips für icon-only Modus</div>
|
||||||
|
<div class="fix__why">Nutzer bei 1024–1279 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 44–50 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 16–18 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 · 2–4 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 · 2–3 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 · 3–4 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">10–13</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
|
||||||
|
>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><link rel="stylesheet"></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
|
||||||
@@ -20,6 +20,50 @@ function initials(name) {
|
|||||||
.join('') || '?';
|
.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) {
|
function ageNote(birthday) {
|
||||||
if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age });
|
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 });
|
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>
|
<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>
|
<textarea class="form-input" id="bd-notes" rows="3" placeholder="${t('birthdays.notesPlaceholder')}">${esc(birthday?.notes || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
${renderBirthdayReminderSection(birthday)}
|
||||||
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
|
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
|
||||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
<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>'}
|
${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 = '';
|
if (fileInput) fileInput.value = '';
|
||||||
renderPreview();
|
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-cancel').addEventListener('click', closeModal);
|
||||||
panel.querySelector('#bd-delete')?.addEventListener('click', async () => {
|
panel.querySelector('#bd-delete')?.addEventListener('click', async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -379,6 +431,9 @@ function openBirthdayModal({ mode, birthday = null }) {
|
|||||||
birth_date: birthDate,
|
birth_date: birthDate,
|
||||||
notes: panel.querySelector('#bd-notes').value.trim(),
|
notes: panel.querySelector('#bd-notes').value.trim(),
|
||||||
photo_data: photoData,
|
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)) {
|
if (!body.name || !body.birth_date || !isDateInputValid(birthDateRaw)) {
|
||||||
|
|||||||
@@ -754,14 +754,6 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-custom {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reminder-custom[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agenda-event__meta {
|
.agenda-event__meta {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@@ -92,3 +92,11 @@
|
|||||||
.reminder-fields .modal-grid {
|
.reminder-fields .modal-grid {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reminder-custom {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-custom[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ self.addEventListener('fetch', (event) => {
|
|||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (!url.protocol.startsWith('http')) return;
|
||||||
if (url.pathname.startsWith('/api/')) return;
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
|||||||
@@ -1180,6 +1180,15 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
|
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;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,9 +75,18 @@ router.post('/', (req, res) => {
|
|||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by)
|
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by, reminder_offset, reminder_custom_amount, reminder_custom_unit)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId);
|
`).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 birthday = loadBirthday(result.lastInsertRowid);
|
||||||
const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday));
|
const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday));
|
||||||
@@ -111,6 +120,9 @@ router.put('/:id', (req, res) => {
|
|||||||
birth_date = COALESCE(?, birth_date),
|
birth_date = COALESCE(?, birth_date),
|
||||||
notes = ?,
|
notes = ?,
|
||||||
photo_data = ?,
|
photo_data = ?,
|
||||||
|
reminder_offset = ?,
|
||||||
|
reminder_custom_amount = ?,
|
||||||
|
reminder_custom_unit = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
@@ -118,6 +130,9 @@ router.put('/:id', (req, res) => {
|
|||||||
req.body.birth_date ?? null,
|
req.body.birth_date ?? null,
|
||||||
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes,
|
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes,
|
||||||
req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data,
|
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,
|
id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,22 @@ function daysUntilBirthday(birthDate, from = new Date()) {
|
|||||||
return Math.round((nextUtc - todayUtc) / 86400000);
|
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);
|
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) {
|
function eventTitle(name) {
|
||||||
@@ -125,7 +138,16 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
|||||||
function syncBirthdayReminder(database, birthday, from = new Date()) {
|
function syncBirthdayReminder(database, birthday, from = new Date()) {
|
||||||
if (!birthday.calendar_event_id) return null;
|
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(`
|
const existing = database.prepare(`
|
||||||
SELECT * FROM reminders
|
SELECT * FROM reminders
|
||||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||||
|
|||||||
Reference in New Issue
Block a user