${renderFab()}
`;
- let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
+ let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} };
let weather = null;
let widgetConfig = DEFAULT_WIDGET_CONFIG;
+ let currency = 'EUR';
try {
const [dashRes, weatherRes, prefsRes] = await Promise.all([
api.get('/dashboard'),
@@ -751,6 +1030,7 @@ export async function render(container, { user }) {
data = dashRes;
weather = weatherRes.data ?? null;
widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
+ currency = prefsRes.data?.currency ?? 'EUR';
} catch (err) {
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
@@ -772,52 +1052,45 @@ export async function render(container, { user }) {
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
?? (data.todayMeals ?? [])[0]?.title
?? null,
+ birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length,
+ familyCount: (data.users ?? []).length,
};
const rerender = () => render(container, { user });
- function rebuildGrid(cfg) {
- const grid = container.querySelector('.dashboard__grid');
- if (!grid) return;
- const greeting = grid.querySelector('.widget-greeting');
- grid.replaceChildren(...(greeting ? [greeting] : []));
- grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
+ function rebuildDashboard(cfg) {
+ const shell = container.querySelector('#dashboard-shell');
+ if (!shell) return;
+ shell.innerHTML = `
+ ${renderDashboardOverview(user, stats, weather)}
+ ${renderDashboardLayout(cfg, data, weather, currency)}
+ `;
wireLinks(container, rerender);
if (window.lucide) window.lucide.createIcons();
- wireWeatherRefresh(container);
+ wireWeatherRefresh(container, (updatedWeather) => {
+ weather = updatedWeather;
+ rebuildDashboard(cfg);
+ });
+ container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => {
+ openCustomizeModal(widgetConfig, (newConfig) => {
+ widgetConfig = newConfig;
+ rebuildDashboard(widgetConfig);
+ });
+ }, { signal: _fabController.signal });
}
- // Greeting in-place aktualisieren (Stats-Chips hinzufügen), kein Gesamt-Reset
- const greetingEl = container.querySelector('.widget-greeting');
- if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats);
-
- // Skeletons durch echte Widgets ersetzen
- rebuildGrid(widgetConfig);
+ rebuildDashboard(widgetConfig);
initFab(container, _fabController.signal);
- container.querySelector('#dashboard-customize-btn')?.addEventListener(
- 'click',
- () => openCustomizeModal(widgetConfig, (newConfig) => {
- widgetConfig = newConfig;
- rebuildGrid(widgetConfig);
- }),
- { signal: _fabController.signal },
- );
-
// 30-Minuten Auto-Refresh für Wetter
const refreshBtn = container.querySelector('#weather-refresh-btn');
if (refreshBtn) {
const doAutoRefresh = async () => {
try {
const res = await api.get('/weather').catch(() => ({ data: null }));
- const wWidget = container.querySelector('#weather-widget');
- if (wWidget) {
- wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
- const newWidget = container.querySelector('#weather-widget');
- if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
- wireWeatherRefresh(container);
- }
+ weather = res.data ?? null;
+ rebuildDashboard(widgetConfig);
} catch { /* silently ignore */ }
};
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
@@ -825,7 +1098,7 @@ export async function render(container, { user }) {
}
}
-function wireWeatherRefresh(container) {
+function wireWeatherRefresh(container, onUpdated = null) {
const refreshBtn = container.querySelector('#weather-refresh-btn');
if (!refreshBtn) return;
const doWeatherRefresh = async () => {
@@ -838,7 +1111,7 @@ function wireWeatherRefresh(container) {
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
const newWidget = container.querySelector('#weather-widget');
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
- wireWeatherRefresh(container);
+ onUpdated?.(res.data ?? null);
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
}
} catch { /* silently ignore */ }
diff --git a/public/pages/login.js b/public/pages/login.js
index 464317e..5e1d479 100644
--- a/public/pages/login.js
+++ b/public/pages/login.js
@@ -8,16 +8,30 @@ import { auth } from '/api.js';
import { t } from '/i18n.js';
const VERSION_URL = '/api/v1/version';
+const DEFAULT_APP_NAME = 'Oikos';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+
+function getStoredAppName() {
+ return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
+}
+
+function setAppBranding(appName) {
+ const name = String(appName || '').trim() || DEFAULT_APP_NAME;
+ document.title = name;
+ const titleEl = document.querySelector('.login-hero__title');
+ if (titleEl) titleEl.textContent = name;
+}
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
*/
export async function render(container) {
+ const storedAppName = getStoredAppName();
container.innerHTML = `
-
Oikos
+
${storedAppName}
${t('login.tagline')}
@@ -67,9 +81,17 @@ export async function render(container) {
const submitBtn = container.querySelector('#login-btn');
const versionEl = container.querySelector('#login-version');
- fetch(VERSION_URL)
+ setAppBranding(storedAppName);
+
+ fetch(VERSION_URL, { cache: 'no-store' })
.then((r) => r.json())
- .then((d) => { versionEl.textContent = t('login.version', { version: d.version }); })
+ .then((d) => {
+ if (d?.app_name) {
+ try { localStorage.setItem(APP_NAME_STORAGE_KEY, d.app_name); } catch (_) {}
+ setAppBranding(d.app_name);
+ }
+ versionEl.textContent = t('login.version', { version: d.version });
+ })
.catch(() => {});
form.addEventListener('submit', async (e) => {
diff --git a/public/router.js b/public/router.js
index 61b5dab..538fbdf 100644
--- a/public/router.js
+++ b/public/router.js
@@ -19,10 +19,10 @@ const ROUTES = [
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
+ { path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' },
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
- { path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
];
@@ -117,6 +117,7 @@ async function importPage(pagePath) {
let currentUser = null;
let currentPath = null;
let isNavigating = false;
+let _preferencesLoaded = false;
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
let _pendingLoginRedirect = false;
@@ -125,11 +126,14 @@ let _pendingLoginRedirect = false;
// Router
// --------------------------------------------------------
-const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
- '/notes', '/contacts', '/birthdays', '/budget', '/settings'];
+const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
+ '/notes', '/contacts', '/budget', '/settings'];
const PRIMARY_NAV = 4;
+const DEFAULT_APP_NAME = 'Oikos';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+
function getDirection(fromPath, toPath) {
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
const toIdx = ROUTE_ORDER.indexOf(toPath);
@@ -137,6 +141,53 @@ function getDirection(fromPath, toPath) {
return toIdx > fromIdx ? 'right' : 'left';
}
+function getAppName() {
+ return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
+}
+
+function setAppName(name) {
+ const next = String(name || '').trim();
+ if (next) {
+ localStorage.setItem(APP_NAME_STORAGE_KEY, next);
+ } else {
+ localStorage.removeItem(APP_NAME_STORAGE_KEY);
+ }
+}
+
+function routeTitle(path) {
+ const map = {
+ '/': t('dashboard.title'),
+ '/tasks': t('nav.tasks'),
+ '/calendar': t('nav.calendar'),
+ '/birthdays': t('nav.birthdays'),
+ '/meals': t('nav.meals'),
+ '/recipes': t('nav.recipes'),
+ '/shopping': t('nav.shopping'),
+ '/notes': t('nav.notes'),
+ '/contacts': t('nav.contacts'),
+ '/budget': t('nav.budget'),
+ '/settings': t('nav.settings'),
+ };
+ return map[path] || getAppName();
+}
+
+function updateBranding(path = currentPath) {
+ const appName = getAppName();
+ const sidebarLogoSpan = document.querySelector('.nav-sidebar__logo span');
+ if (sidebarLogoSpan) sidebarLogoSpan.textContent = appName;
+
+ const loginTitle = document.querySelector('.login-hero__title');
+ if (path === '/login' && loginTitle) loginTitle.textContent = appName;
+
+ document.title = path === '/login'
+ ? appName
+ : `${routeTitle(path || '/')} · ${appName}`;
+
+ document.querySelectorAll('meta[name="apple-mobile-web-app-title"]').forEach((meta) => {
+ meta.setAttribute('content', appName);
+ });
+}
+
/**
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
* @param {string} path
@@ -152,6 +203,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
currentUser = userOrPushState;
+ await syncPreferencesOnce();
initReminders();
} else {
pushState = userOrPushState;
@@ -169,6 +221,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
try {
const result = await auth.me();
currentUser = result.user;
+ await syncPreferencesOnce();
initReminders();
} catch {
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
@@ -199,6 +252,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
await renderPage(route, previousPath);
updateNav(basePath);
updateThemeColorForRoute(route);
+ updateBranding(basePath);
} finally {
isNavigating = false;
// auth:expired kann waehrend einer Navigation gefeuert haben (z.B. wenn ein
@@ -211,6 +265,24 @@ async function navigate(path, userOrPushState = true, pushState = true) {
}
}
+async function syncPreferencesOnce() {
+ if (_preferencesLoaded) return;
+ _preferencesLoaded = true;
+ try {
+ const res = await api.get('/preferences');
+ const dateFormat = res?.data?.date_format;
+ if (dateFormat) {
+ localStorage.setItem('oikos-date-format', dateFormat);
+ }
+ if (res?.data?.app_name) {
+ setAppName(res.data.app_name);
+ updateBranding();
+ }
+ } catch {
+ // Non-critical. The settings page can refresh this later.
+ }
+}
+
/**
* Lädt und rendert eine Seite dynamisch.
* @param {{ path: string, page: string }} route
@@ -345,7 +417,7 @@ function renderAppShell(container) {
sidebarLogo.appendChild(logomark);
const sidebarLogoSpan = document.createElement('span');
- sidebarLogoSpan.textContent = 'Oikos';
+ sidebarLogoSpan.textContent = getAppName();
sidebarLogo.appendChild(sidebarLogoSpan);
const sidebarItems = document.createElement('div');
sidebarItems.className = 'nav-sidebar__items';
@@ -443,6 +515,7 @@ function renderAppShell(container) {
toastContainer.setAttribute('aria-live', 'assertive');
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
+ updateBranding(currentPath || '/');
// Klick-Handler für alle Nav-Links
container.querySelectorAll('[data-route]').forEach((el) => {
@@ -646,13 +719,13 @@ function navItems() {
return [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
+ { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
- { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
];
@@ -876,6 +949,11 @@ window.addEventListener('locale-changed', () => {
});
updateNav(currentPath);
+ updateBranding(currentPath || '/');
+});
+
+window.addEventListener('app-name-changed', () => {
+ updateBranding(currentPath || '/');
});
// --------------------------------------------------------
diff --git a/public/styles/birthdays.css b/public/styles/birthdays.css
index 31f41a8..fec9324 100644
--- a/public/styles/birthdays.css
+++ b/public/styles/birthdays.css
@@ -9,6 +9,36 @@
padding-bottom: calc(var(--nav-bottom-height) + var(--space-6));
}
+.birthdays-grid {
+ display: grid;
+ grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+ gap: var(--space-4);
+ padding: 0 var(--space-4);
+}
+
+.birthdays-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: calc(var(--radius-md) + 4px);
+ background:
+ radial-gradient(circle at top left, color-mix(in srgb, var(--module-accent) 10%, transparent), transparent 45%),
+ var(--color-surface);
+ box-shadow: var(--shadow-sm);
+}
+
+.birthdays-panel--upcoming {
+ position: sticky;
+ top: var(--space-4);
+ align-self: start;
+}
+
+.birthdays-panel--list {
+ min-width: 0;
+}
+
.birthdays-toolbar {
display: flex;
align-items: center;
@@ -19,6 +49,35 @@
background: var(--color-surface);
}
+.birthdays-toolbar__title {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ flex: 1;
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+}
+
+.birthdays-toolbar__title-icon {
+ width: 20px;
+ height: 20px;
+ color: var(--module-accent);
+ flex-shrink: 0;
+}
+
+.birthdays-toolbar__subtitle {
+ margin: 0 var(--space-4) var(--space-2);
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.birthdays-toolbar--embedded {
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+
.birthdays-toolbar__search {
flex: 1;
position: relative;
@@ -48,7 +107,8 @@
padding: 0 var(--space-4);
}
-.birthdays-section__header h2 {
+.birthdays-section__header h2,
+.birthdays-section__header h3 {
margin: 0;
font-size: var(--text-lg);
}
@@ -61,16 +121,14 @@
.birthday-cards {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ grid-template-columns: 1fr;
gap: var(--space-3);
- margin-top: var(--space-3);
}
.birthday-card,
.birthday-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
- border-left: 3px solid var(--module-accent);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
@@ -80,6 +138,7 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
+ background: linear-gradient(180deg, color-mix(in srgb, var(--module-accent) 6%, var(--color-surface)), var(--color-surface));
}
.birthday-card__body,
@@ -88,6 +147,13 @@
flex: 1;
}
+.birthday-card__top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
.birthday-card__name,
.birthday-item__name {
font-size: var(--text-base);
@@ -108,6 +174,16 @@
font-size: var(--text-sm);
}
+.birthday-card__pill {
+ padding: 0.35rem 0.6rem;
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--module-accent) 12%, transparent);
+ color: var(--module-accent);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ white-space: nowrap;
+}
+
.birthday-item__notes {
color: var(--color-text-secondary);
}
@@ -116,7 +192,6 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
- margin-top: var(--space-3);
}
.birthday-item {
@@ -233,6 +308,15 @@
font-size: var(--text-sm);
}
+@media (max-width: 960px) {
+ .birthdays-grid {
+ grid-template-columns: 1fr;
+ }
+ .birthdays-panel--upcoming {
+ position: static;
+ }
+}
+
.contact-action-btn {
width: 36px;
height: 36px;
diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css
index b6edb8e..2f91b09 100644
--- a/public/styles/dashboard.css
+++ b/public/styles/dashboard.css
@@ -34,6 +34,121 @@
}
}
+.dashboard-hero {
+ display: grid;
+ gap: var(--space-4);
+ margin-bottom: var(--space-5);
+}
+
+@media (min-width: 1024px) {
+ .dashboard-hero {
+ grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
+ align-items: stretch;
+ }
+}
+
+.dashboard-hero .widget-greeting {
+ box-shadow: var(--glass-shadow-md);
+ min-height: 100%;
+}
+
+.dashboard-hero__rail {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+@media (max-width: 767px) {
+ .dashboard-hero__rail {
+ grid-template-columns: 1fr;
+ }
+}
+
+.dashboard-metric {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+ min-height: 96px;
+ padding: var(--space-4);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
+ border: 1px solid var(--glass-border-subtle);
+ box-shadow: var(--glass-shadow-sm);
+ text-align: left;
+ appearance: none;
+ color: inherit;
+ cursor: pointer;
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.dashboard-metric:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--glass-shadow-md);
+}
+
+.dashboard-metric__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-accent) 16%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-metric--warn .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
+}
+
+.dashboard-metric--calendar .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-calendar) 18%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-metric--meals .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-meals) 18%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-metric--weather .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 18%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-metric__body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.dashboard-metric__title {
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+}
+
+.dashboard-metric__value {
+ font-size: var(--text-base);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-metric__hint {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-metric--skeleton {
+ cursor: default;
+}
+
/* --------------------------------------------------------
* Widget-Grid
*
@@ -181,6 +296,8 @@
.widget--budget { --widget-accent: var(--module-budget); }
.widget--contacts { --widget-accent: var(--module-contacts); }
.widget--weather { --widget-accent: var(--module-dashboard); }
+.widget--birthdays { --widget-accent: var(--module-birthdays); }
+.widget--family { --widget-accent: var(--module-contacts); }
/* --------------------------------------------------------
* Basis-Widget (Card)
@@ -1220,3 +1337,1054 @@
opacity: 0.3;
cursor: not-allowed;
}
+
+/* --------------------------------------------------------
+ * Modern Dashboard Skin
+ * -------------------------------------------------------- */
+.dashboard {
+ position: relative;
+ isolation: isolate;
+ overflow: clip;
+}
+
+.dashboard::before,
+.dashboard::after {
+ content: '';
+ position: absolute;
+ inset: auto;
+ z-index: -1;
+ pointer-events: none;
+ filter: blur(28px);
+ opacity: 0.55;
+}
+
+.dashboard::before {
+ top: -80px;
+ right: -120px;
+ width: min(42vw, 520px);
+ height: min(42vw, 520px);
+ background: radial-gradient(circle, color-mix(in srgb, var(--module-dashboard) 22%, transparent) 0%, transparent 70%);
+}
+
+.dashboard::after {
+ left: -140px;
+ bottom: 8%;
+ width: min(36vw, 440px);
+ height: min(36vw, 440px);
+ background: radial-gradient(circle, color-mix(in srgb, var(--module-calendar) 18%, transparent) 0%, transparent 72%);
+}
+
+.dashboard-shell {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.dashboard-hero {
+ display: grid;
+ gap: var(--space-4);
+}
+
+@media (min-width: 1100px) {
+ .dashboard-hero {
+ grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.85fr);
+ }
+}
+
+.dashboard-hero__panel {
+ position: relative;
+ overflow: hidden;
+ border-radius: clamp(22px, 3vw, 34px);
+ padding: clamp(1.25rem, 2vw, 2rem);
+ border: 1px solid color-mix(in srgb, var(--color-border) 30%, transparent);
+ box-shadow: 0 18px 60px color-mix(in srgb, var(--color-shadow) 22%, transparent);
+ backdrop-filter: blur(18px);
+ -webkit-backdrop-filter: blur(18px);
+}
+
+.dashboard-hero__panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at top right, color-mix(in srgb, var(--module-dashboard) 20%, transparent), transparent 42%),
+ radial-gradient(circle at bottom left, color-mix(in srgb, var(--module-calendar) 14%, transparent), transparent 38%);
+ opacity: 0.9;
+ pointer-events: none;
+}
+
+.dashboard-hero__panel > * {
+ position: relative;
+ z-index: 1;
+}
+
+.dashboard-hero__panel--intro {
+ background:
+ linear-gradient(135deg,
+ color-mix(in srgb, var(--module-dashboard) 20%, var(--color-surface)) 0%,
+ color-mix(in srgb, var(--module-calendar) 10%, var(--color-surface)) 48%,
+ color-mix(in srgb, var(--module-shopping) 8%, var(--color-surface)) 100%);
+}
+
+.dashboard-hero__panel--summary {
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 90%, transparent) 0%,
+ color-mix(in srgb, var(--color-surface-2) 78%, transparent) 100%);
+}
+
+.dashboard-hero__eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-accent) 14%, transparent);
+ color: var(--color-accent);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.dashboard-hero__title {
+ margin: var(--space-3) 0 0;
+ font-size: clamp(2.1rem, 4.5vw, 4.3rem);
+ line-height: 0.98;
+ letter-spacing: -0.04em;
+ font-weight: var(--font-weight-bold);
+ max-width: 14ch;
+}
+
+.dashboard-hero__subtitle {
+ margin: var(--space-3) 0 0;
+ color: var(--color-text-secondary);
+ font-size: var(--text-base);
+}
+
+.dashboard-hero__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.dashboard-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
+ padding: 0 var(--space-3);
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-text-primary) 7%, transparent);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.dashboard-chip--danger {
+ background: color-mix(in srgb, var(--color-danger) 16%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-chip--warning {
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
+}
+
+.dashboard-chip--accent {
+ background: color-mix(in srgb, var(--module-dashboard) 16%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-hero__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-5);
+}
+
+.dashboard-action {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 44px;
+ padding: 0 var(--space-3);
+ border-radius: var(--radius-full);
+ border: 1px solid color-mix(in srgb, var(--color-border) 40%, transparent);
+ background: color-mix(in srgb, var(--color-surface) 76%, transparent);
+ color: var(--color-text-primary);
+ box-shadow: var(--shadow-xs);
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
+}
+
+.dashboard-action:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sm);
+ border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
+}
+
+.dashboard-action__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-accent) 10%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-action--blue .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-tasks) 14%, transparent);
+ color: var(--module-tasks);
+}
+
+.dashboard-action--violet .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-calendar) 14%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-action--green .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-shopping) 14%, transparent);
+ color: var(--module-shopping);
+}
+
+.dashboard-action--amber .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-notes) 14%, transparent);
+ color: var(--module-notes);
+}
+
+.dashboard-action__label {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+}
+
+.dashboard-hero__summary-head {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.dashboard-hero__summary-label {
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-hero__summary-value {
+ margin-top: var(--space-1);
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-primary);
+}
+
+.dashboard-hero__summary-grid {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: 1fr;
+ margin-top: var(--space-4);
+}
+
+@media (min-width: 680px) {
+ .dashboard-hero__summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-stat {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 88px;
+ padding: var(--space-3);
+ border-radius: 24px;
+ background: color-mix(in srgb, var(--color-surface) 88%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
+ box-shadow: var(--shadow-xs);
+}
+
+.dashboard-stat__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-accent) 14%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-stat--danger .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--color-danger) 16%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-stat--calendar .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-calendar) 16%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-stat--meals .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-meals) 16%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-stat--weather .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 16%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-stat__text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.dashboard-stat__title {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-stat__value {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-stat--skeleton {
+ min-height: 88px;
+ background: linear-gradient(90deg,
+ color-mix(in srgb, var(--color-surface-2) 40%, transparent),
+ color-mix(in srgb, var(--color-surface) 88%, transparent),
+ color-mix(in srgb, var(--color-surface-2) 40%, transparent));
+ background-size: 200% 100%;
+ animation: dashboard-sheen 1.8s ease-in-out infinite;
+}
+
+.dashboard-layout {
+ display: grid;
+ gap: var(--space-5);
+}
+
+@media (min-width: 1100px) {
+ .dashboard-layout {
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.92fr);
+ align-items: start;
+ }
+}
+
+.dashboard-layout__main,
+.dashboard-layout__side {
+ min-width: 0;
+}
+
+@media (min-width: 1100px) {
+ .dashboard-layout__side {
+ position: sticky;
+ top: calc(var(--space-4) + 72px);
+ align-self: start;
+ }
+}
+
+.dashboard-layout__grid {
+ display: grid;
+ gap: var(--space-4);
+ grid-template-columns: minmax(0, 1fr);
+}
+
+@media (min-width: 780px) {
+ .dashboard-layout__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-layout__stack {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.dashboard-tile {
+ min-width: 0;
+}
+
+.dashboard-tile--wide {
+ grid-column: 1 / -1;
+}
+
+.dashboard .widget {
+ border-radius: 28px;
+ border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 96%, transparent) 0%,
+ color-mix(in srgb, var(--color-surface-2) 88%, transparent) 100%);
+ box-shadow: 0 16px 48px color-mix(in srgb, var(--color-shadow) 18%, transparent);
+}
+
+.dashboard .widget::before {
+ height: 4px;
+ background: linear-gradient(90deg, var(--widget-accent, var(--color-accent)), transparent 78%);
+ opacity: 1;
+}
+
+.dashboard .widget__header {
+ padding: var(--space-4) var(--space-4) var(--space-2);
+}
+
+.dashboard .widget__body,
+.dashboard .notes-grid-widget {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .widget__empty {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .task-item,
+.dashboard .event-item,
+.dashboard .meal-slot,
+.dashboard .shopping-widget-list,
+.dashboard .note-item {
+ border-radius: 20px;
+}
+
+.dashboard .task-item:hover,
+.dashboard .event-item:hover,
+.dashboard .meal-slot:hover,
+.dashboard .shopping-widget-list:hover,
+.dashboard .note-item:hover {
+ transform: translateY(-2px);
+}
+
+.dashboard .weather-widget {
+ border-radius: 28px;
+}
+
+.dashboard .weather-widget__inner {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .weather-widget__refresh {
+ top: var(--space-3);
+ right: var(--space-3);
+}
+
+@keyframes dashboard-sheen {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* --------------------------------------------------------
+ * Admin Dashboard Layout
+ * -------------------------------------------------------- */
+.dashboard {
+ max-width: min(1680px, 100%);
+ overflow: visible;
+}
+
+.dashboard::before,
+.dashboard::after {
+ display: none;
+}
+
+.dashboard-shell {
+ gap: var(--space-5);
+}
+
+.dashboard-overview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ border-top: 3px solid var(--module-accent);
+ border-bottom: 1px solid var(--color-border);
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 96%, transparent),
+ color-mix(in srgb, var(--color-surface-2) 86%, transparent));
+ padding: var(--space-4);
+ box-shadow: var(--shadow-sm);
+}
+
+@media (min-width: 1024px) {
+ .dashboard-overview {
+ border: 1px solid var(--color-border);
+ border-top: 3px solid var(--module-accent);
+ border-radius: 8px;
+ padding: var(--space-5);
+ }
+}
+
+.dashboard-overview__header {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+@media (min-width: 900px) {
+ .dashboard-overview__header {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+
+.dashboard-overview__heading {
+ min-width: 0;
+}
+
+.dashboard-overview__date {
+ display: block;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.dashboard-overview__title {
+ margin: var(--space-1) 0 0;
+ color: var(--color-text-primary);
+ font-size: var(--text-2xl);
+ line-height: 1.2;
+ letter-spacing: 0;
+ font-weight: var(--font-weight-bold);
+ overflow-wrap: anywhere;
+}
+
+.dashboard-overview__tools {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.dashboard-overview__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ overflow-x: auto;
+ scrollbar-width: none;
+ min-width: 0;
+ padding-bottom: 1px;
+}
+
+.dashboard-overview__actions::-webkit-scrollbar {
+ display: none;
+}
+
+.dashboard-action {
+ min-height: 36px;
+ padding: 0 var(--space-2);
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ box-shadow: none;
+ flex: 0 0 auto;
+}
+
+.dashboard-action:hover {
+ transform: none;
+ box-shadow: var(--shadow-xs);
+ border-color: color-mix(in srgb, var(--module-accent) 42%, var(--color-border));
+}
+
+.dashboard-action__icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+}
+
+.dashboard-action__icon svg {
+ width: 15px;
+ height: 15px;
+}
+
+.dashboard-action__label {
+ font-size: var(--text-sm);
+ white-space: nowrap;
+}
+
+.dashboard-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-secondary);
+ flex: 0 0 auto;
+ transition: color var(--transition-fast), border-color var(--transition-fast), background-color var(--transition-fast);
+}
+
+.dashboard-icon-btn:hover,
+.dashboard-icon-btn:focus-visible {
+ color: var(--color-text-primary);
+ border-color: color-mix(in srgb, var(--module-accent) 42%, var(--color-border));
+ background: var(--color-surface-hover);
+}
+
+.dashboard-icon-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+.dashboard-kpi-grid {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
+}
+
+.dashboard-kpi {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 92px;
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ text-align: left;
+ box-shadow: var(--shadow-xs);
+ min-width: 0;
+}
+
+.dashboard-kpi:hover {
+ border-color: color-mix(in srgb, var(--module-accent) 36%, var(--color-border));
+ background: var(--color-surface-hover);
+}
+
+.dashboard-kpi__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--module-accent) 14%, transparent);
+ color: var(--module-accent);
+}
+
+.dashboard-kpi__icon svg {
+ width: 19px;
+ height: 19px;
+}
+
+.dashboard-kpi--danger .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--color-danger) 14%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-kpi--calendar .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-calendar) 14%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-kpi--meals .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-meals) 14%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-kpi--weather .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 14%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-kpi--birthdays .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-birthdays) 14%, transparent);
+ color: var(--module-birthdays);
+}
+
+.dashboard-kpi--family .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-contacts) 14%, transparent);
+ color: var(--module-contacts);
+}
+
+.dashboard-kpi__body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 2px;
+}
+
+.dashboard-kpi__label {
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+}
+
+.dashboard-kpi__value {
+ color: var(--color-text-primary);
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+ line-height: 1.25;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-kpi__meta {
+ color: var(--color-text-tertiary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-kpi--skeleton {
+ min-height: 92px;
+ background: linear-gradient(90deg, var(--color-surface-2), var(--color-surface), var(--color-surface-2));
+ background-size: 200% 100%;
+ animation: dashboard-sheen 1.6s ease-in-out infinite;
+}
+
+.dashboard-workspace {
+ display: grid;
+ gap: var(--space-4);
+ align-items: start;
+}
+
+@media (min-width: 1180px) {
+ .dashboard-workspace {
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
+ }
+}
+
+.dashboard-workspace__main,
+.dashboard-workspace__side {
+ min-width: 0;
+}
+
+.dashboard-widget-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-4);
+ align-items: start;
+}
+
+@media (min-width: 820px) {
+ .dashboard-widget-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-side-stack {
+ display: grid;
+ gap: var(--space-4);
+ align-items: start;
+}
+
+.dashboard-tile {
+ min-width: 0;
+}
+
+@media (min-width: 820px) {
+ .dashboard-tile--wide {
+ grid-column: 1 / -1;
+ }
+}
+
+.dashboard .widget,
+.dashboard .widget-skeleton {
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-sm);
+ min-width: 0;
+}
+
+.dashboard .widget::before {
+ height: 3px;
+ background: var(--widget-accent, var(--module-accent));
+ opacity: 1;
+}
+
+.dashboard .widget__header {
+ min-height: 54px;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
+}
+
+.dashboard .widget__title,
+.dashboard .widget__link {
+ min-width: 0;
+}
+
+.dashboard .widget__body,
+.dashboard .notes-grid-widget,
+.dashboard .weather-widget__inner {
+ padding: var(--space-3) var(--space-4) var(--space-4);
+}
+
+.dashboard .widget__empty {
+ padding: var(--space-4);
+}
+
+.dashboard .task-item,
+.dashboard .event-item,
+.dashboard .meal-slot,
+.dashboard .shopping-widget-list,
+.dashboard .note-item {
+ border-radius: 8px;
+}
+
+.dashboard .task-item:hover,
+.dashboard .event-item:hover,
+.dashboard .meal-slot:hover,
+.dashboard .shopping-widget-list:hover,
+.dashboard .note-item:hover {
+ transform: none;
+}
+
+.dashboard .weather-widget {
+ border-radius: 8px;
+}
+
+.dashboard .weather-widget__refresh {
+ top: var(--space-3);
+ right: var(--space-3);
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+}
+
+.birthday-widget-item {
+ display: grid;
+ grid-template-columns: 40px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2);
+ border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
+ background: var(--color-surface-2);
+ cursor: pointer;
+}
+
+.birthday-widget-item + .birthday-widget-item {
+ margin-top: var(--space-2);
+}
+
+.birthday-widget-item__avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--module-birthdays) 14%, transparent);
+ color: var(--module-birthdays);
+ font-weight: var(--font-weight-bold);
+ font-size: var(--text-sm);
+}
+
+.birthday-widget-item__avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.birthday-widget-item__body {
+ min-width: 0;
+}
+
+.birthday-widget-item__name {
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.birthday-widget-item__meta {
+ margin-top: 2px;
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.birthday-widget-item__age {
+ min-width: 34px;
+ text-align: center;
+ padding: var(--space-1) var(--space-2);
+ border-radius: 8px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-bold);
+}
+
+.family-widget {
+ padding: var(--space-4);
+}
+
+.family-widget__count {
+ color: var(--color-text-primary);
+ font-size: var(--text-3xl);
+ line-height: 1;
+ font-weight: var(--font-weight-bold);
+}
+
+.family-widget__meta {
+ margin-top: var(--space-1);
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.family-widget__avatars {
+ display: flex;
+ align-items: center;
+ margin-top: var(--space-4);
+ padding-left: var(--space-1);
+}
+
+.family-widget-avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: var(--font-weight-bold);
+ font-size: var(--text-xs);
+ border: 2px solid var(--color-surface);
+ box-shadow: var(--shadow-xs);
+}
+
+.family-widget-avatar + .family-widget-avatar {
+ margin-left: -8px;
+}
+
+.budget-widget {
+ padding: var(--space-4);
+}
+
+.budget-widget__headline {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-3);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.budget-widget__headline span {
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.budget-widget__balance {
+ color: var(--color-text-primary);
+ font-size: var(--text-xl);
+ white-space: nowrap;
+}
+
+.budget-widget__balance--positive {
+ color: var(--color-success);
+}
+
+.budget-widget__balance--negative {
+ color: var(--color-danger);
+}
+
+.budget-widget__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+}
+
+.budget-widget-metric {
+ min-width: 0;
+ padding: var(--space-3);
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface-2);
+}
+
+.budget-widget-metric span {
+ display: block;
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.budget-widget-metric strong {
+ display: block;
+ margin-top: 2px;
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.budget-widget-metric--income strong {
+ color: var(--color-success);
+}
+
+.budget-widget-metric--expense strong {
+ color: var(--color-danger);
+}
+
+.budget-widget__footer {
+ margin-top: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--module-budget) 10%, transparent);
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ line-height: 1.4;
+}
+
+@media (max-width: 520px) {
+ .dashboard {
+ padding-left: var(--space-3);
+ padding-right: var(--space-3);
+ }
+
+ .dashboard-overview {
+ margin-left: calc(var(--space-3) * -1);
+ margin-right: calc(var(--space-3) * -1);
+ border-left: 0;
+ border-right: 0;
+ }
+
+ .dashboard-overview__tools {
+ align-items: stretch;
+ }
+
+ .dashboard-overview__actions {
+ flex: 1;
+ }
+
+ .dashboard-action__label {
+ display: none;
+ }
+
+ .dashboard-action {
+ width: 38px;
+ justify-content: center;
+ padding: 0;
+ }
+
+ .dashboard-kpi {
+ min-height: 82px;
+ }
+}
diff --git a/public/sw-register.js b/public/sw-register.js
index 555823d..dab33ee 100644
--- a/public/sw-register.js
+++ b/public/sw-register.js
@@ -7,9 +7,11 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
- navigator.serviceWorker.register('/sw.js').catch((err) => {
- console.warn('[SW] Registrierung fehlgeschlagen:', err);
- });
+ navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
+ .then((registration) => registration.update())
+ .catch((err) => {
+ console.warn('[SW] Registrierung fehlgeschlagen:', err);
+ });
});
// SW-Update: Auf iOS-PWA fuehrt ein sofortiger Reload bei controllerchange
@@ -24,4 +26,15 @@ if ('serviceWorker' in navigator) {
// Auf iOS-Standalone verhindert das den "leere Seite"-Bug.
setTimeout(() => window.location.reload(), 200);
});
+
+ const refreshSw = () => {
+ navigator.serviceWorker.getRegistration()
+ .then((registration) => registration?.update())
+ .catch(() => {});
+ };
+
+ window.addEventListener('focus', refreshSw);
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') refreshSw();
+ });
}
diff --git a/public/sw.js b/public/sw.js
index a9342cc..1124058 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -13,11 +13,12 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/
-const SHELL_CACHE = 'oikos-shell-v53';
-const PAGES_CACHE = 'oikos-pages-v48';
-const ASSETS_CACHE = 'oikos-assets-v48';
+const SHELL_CACHE = 'oikos-shell-v56';
+const PAGES_CACHE = 'oikos-pages-v51';
+const LOCALES_CACHE = 'oikos-locales-v3';
+const ASSETS_CACHE = 'oikos-assets-v51';
const BYPASS_CACHE = 'oikos-bypass-flag';
-const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
+const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
// App-Shell: sofort benötigt für ersten Render
const APP_SHELL = [
@@ -27,12 +28,6 @@ const APP_SHELL = [
'/router.js',
'/i18n.js',
'/rrule-ui.js',
- '/locales/de.json',
- '/locales/en.json',
- '/locales/ja.json',
- '/locales/ar.json',
- '/locales/hi.json',
- '/locales/pt.json',
'/reminders.js',
'/sw-register.js',
'/lucide.min.js',
@@ -66,6 +61,24 @@ const APP_SHELL = [
'/icons/icon-maskable-512.png',
];
+const APP_LOCALES = [
+ '/locales/ar.json',
+ '/locales/de.json',
+ '/locales/el.json',
+ '/locales/en.json',
+ '/locales/es.json',
+ '/locales/fr.json',
+ '/locales/hi.json',
+ '/locales/it.json',
+ '/locales/ja.json',
+ '/locales/pt.json',
+ '/locales/ru.json',
+ '/locales/sv.json',
+ '/locales/tr.json',
+ '/locales/uk.json',
+ '/locales/zh.json',
+];
+
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
const PAGE_MODULES = [
'/pages/dashboard.js',
@@ -114,10 +127,12 @@ const _bypassInit = (async () => {
self.addEventListener('install', (event) => {
const freshShell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' }));
const freshModules = PAGE_MODULES.map((url) => new Request(url, { cache: 'reload' }));
+ const freshLocales = APP_LOCALES.map((url) => new Request(url, { cache: 'reload' }));
event.waitUntil(
Promise.all([
caches.open(SHELL_CACHE).then((c) => c.addAll(freshShell)),
caches.open(PAGES_CACHE).then((c) => c.addAll(freshModules)),
+ caches.open(LOCALES_CACHE).then((c) => c.addAll(freshLocales)),
]).then(() => self.skipWaiting())
);
});
@@ -207,12 +222,20 @@ function dispatchFetch(request, url) {
return networkFirst(request, SHELL_CACHE);
}
- if (isAsset(url.pathname) && url.origin === self.location.origin) {
- return cacheFirst(request, ASSETS_CACHE);
+ if (url.pathname.startsWith('/locales/')) {
+ return networkFirst(request, LOCALES_CACHE);
}
if (url.pathname.startsWith('/pages/')) {
- return cacheFirst(request, PAGES_CACHE);
+ return networkFirst(request, PAGES_CACHE);
+ }
+
+ if (url.origin === self.location.origin && isMutableAppResource(url.pathname)) {
+ return networkFirst(request, SHELL_CACHE);
+ }
+
+ if (isAsset(url.pathname) && url.origin === self.location.origin) {
+ return cacheFirst(request, ASSETS_CACHE);
}
return cacheFirst(request, SHELL_CACHE);
@@ -270,3 +293,10 @@ async function cacheFirst(request, cacheName) {
function isAsset(pathname) {
return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(pathname);
}
+
+function isMutableAppResource(pathname) {
+ return pathname === '/'
+ || pathname === '/index.html'
+ || pathname === '/manifest.json'
+ || /\.(css|js|json|html)$/i.test(pathname);
+}
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 77a13bc..4b7e186 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -22,6 +22,19 @@ const router = express.Router();
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
+function getUserId(req) {
+ const candidates = [req.authUserId, req.user?.id, req.session?.userId];
+ for (const value of candidates) {
+ const parsed = Number(value);
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
+ }
+ return null;
+}
+
+function isAdminUser(req) {
+ return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin';
+}
+
// --------------------------------------------------------
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
// innerhalb [from, to] generieren (inklusive beider Grenzen).
@@ -146,7 +159,7 @@ router.get('/', (req, res) => {
)
)
`;
- const params = [to, from, to, req.session.userId];
+ const params = [to, from, to, getUserId(req)];
if (req.query.assigned_to) {
sql += ' AND e.assigned_to = ?';
@@ -203,7 +216,7 @@ router.get('/upcoming', (req, res) => {
)
)
ORDER BY e.start_datetime ASC
- `).all(nowDate, future, future, req.session.userId);
+ `).all(nowDate, future, future, getUserId(req));
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
.filter((e) => e.start_datetime >= new Date().toISOString())
@@ -396,7 +409,7 @@ router.delete('/apple/disconnect', requireAdmin, (req, res) => {
router.get('/subscriptions', (req, res) => {
try {
- const subs = icsSubscription.getAll(req.session.userId);
+ const subs = icsSubscription.getAll(getUserId(req));
res.json({ data: subs });
} catch (err) {
log.error('', err);
@@ -416,7 +429,7 @@ router.post('/subscriptions', async (req, res) => {
if (!colorVal || !ICS_COLOR_RE.test(colorVal))
return res.status(400).json({ error: 'color: Pflichtfeld, muss #RRGGBB sein.', code: 400 });
- const { sub, syncError } = await icsSubscription.create(req.session.userId, {
+ const { sub, syncError } = await icsSubscription.create(getUserId(req), {
name: name.trim(), url, color: colorVal, shared: shared ? 1 : 0,
});
res.status(201).json({ data: sub, syncError: syncError || null });
@@ -432,7 +445,7 @@ router.patch('/subscriptions/:id', (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
+ const isAdmin = isAdminUser(req);
const fields = {};
if (req.body.name !== undefined) {
if (typeof req.body.name !== 'string' || req.body.name.trim().length === 0 || req.body.name.length > 100)
@@ -446,7 +459,7 @@ router.patch('/subscriptions/:id', (req, res) => {
}
if (req.body.shared !== undefined) fields.shared = req.body.shared;
- const updated = icsSubscription.update(req.session.userId, subId, fields, isAdmin);
+ const updated = icsSubscription.update(getUserId(req), subId, fields, isAdmin);
if (!updated) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
res.json({ data: updated });
} catch (err) {
@@ -460,8 +473,8 @@ router.delete('/subscriptions/:id', (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
- const ok = icsSubscription.remove(req.session.userId, subId, isAdmin);
+ const isAdmin = isAdminUser(req);
+ const ok = icsSubscription.remove(getUserId(req), subId, isAdmin);
if (!ok) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
res.status(204).end();
} catch (err) {
@@ -475,10 +488,10 @@ router.post('/subscriptions/:id/sync', async (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
+ const isAdmin = isAdminUser(req);
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
- if (!isAdmin && sub.created_by !== req.session.userId)
+ if (!isAdmin && sub.created_by !== getUserId(req))
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
await icsSubscription.sync(subId);
const updated = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
@@ -526,6 +539,17 @@ router.get('/:id', (req, res) => {
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
+ const userId = getUserId(req);
+ if (!userId) {
+ log.warn('Rejecting calendar create without resolved authenticated user id', {
+ authMethod: req.authMethod || null,
+ authUserId: req.authUserId || null,
+ reqUserId: req.user?.id || null,
+ sessionUserId: req.session?.userId || null,
+ });
+ return res.status(401).json({ error: 'Not authenticated.', code: 401 });
+ }
+
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vDesc = str(req.body.description, 'Beschreibung', { max: MAX_TEXT, required: false });
const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
@@ -553,7 +577,7 @@ router.post('/', (req, res) => {
vStart.value, vEnd.value,
all_day ? 1 : 0, vLoc.value,
vColor.value, assigned_to || null,
- req.session.userId, vRrule.value
+ userId, vRrule.value
);
const event = db.get().prepare(`
@@ -669,8 +693,8 @@ router.post('/:id/reset', (req, res) => {
if (event.external_source !== 'ics')
return res.status(400).json({ error: 'Nur ICS-Events können zurückgesetzt werden.', code: 400 });
- const userId = req.session.userId;
- const isAdmin = req.session.isAdmin;
+ const userId = getUserId(req);
+ const isAdmin = isAdminUser(req);
if (!isAdmin && event.created_by !== userId && event.sub_created_by !== userId)
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js
index 9bb2f32..4e85d39 100644
--- a/server/routes/dashboard.js
+++ b/server/routes/dashboard.js
@@ -7,6 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
+import { hydrateBirthday } from '../services/birthdays.js';
const log = createLogger('Dashboard');
@@ -30,10 +31,12 @@ router.get('/', (req, res) => {
try {
const d = db.get();
const result = {};
+ const userId = req.authUserId || req.session.userId;
// Heute und +48h als ISO-Strings
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
+ const currentMonth = todayStr.slice(0, 7);
const deadline48h = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
// Anstehende Termine (nächste 5, ab jetzt)
@@ -170,6 +173,63 @@ router.get('/', (req, res) => {
result.users = [];
}
+ try {
+ const rows = d.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId);
+ result.birthdays = rows
+ .map((row) => hydrateBirthday(row))
+ .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
+ .slice(0, 3);
+ result.birthdayCount = rows.length;
+ } catch (err) {
+ log.error('birthdays error:', err.message);
+ result.birthdays = [];
+ result.birthdayCount = 0;
+ }
+
+ try {
+ const from = `${currentMonth}-01`;
+ const to = `${currentMonth}-31`;
+ const totals = d.prepare(`
+ SELECT
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
+ SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
+ SUM(amount) AS balance,
+ COUNT(*) AS entry_count
+ FROM budget_entries
+ WHERE date BETWEEN ? AND ?
+ `).get(from, to);
+
+ const topExpense = d.prepare(`
+ SELECT category, SUM(amount) AS amount
+ FROM budget_entries
+ WHERE amount < 0 AND date BETWEEN ? AND ?
+ GROUP BY category
+ ORDER BY ABS(SUM(amount)) DESC
+ LIMIT 1
+ `).get(from, to);
+
+ result.budget = {
+ month: currentMonth,
+ income: totals?.income || 0,
+ expenses: Math.abs(totals?.expenses || 0),
+ balance: totals?.balance || 0,
+ entryCount: totals?.entry_count || 0,
+ topExpenseCategory: topExpense?.category || null,
+ topExpenseAmount: Math.abs(topExpense?.amount || 0),
+ };
+ } catch (err) {
+ log.error('budget error:', err.message);
+ result.budget = {
+ month: currentMonth,
+ income: 0,
+ expenses: 0,
+ balance: 0,
+ entryCount: 0,
+ topExpenseCategory: null,
+ topExpenseAmount: 0,
+ };
+ }
+
res.json(result);
} catch (err) {
log.error('Critical error:', err.message);
diff --git a/server/routes/preferences.js b/server/routes/preferences.js
index 0cd9c71..d02888d 100644
--- a/server/routes/preferences.js
+++ b/server/routes/preferences.js
@@ -7,6 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
+import { str, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Preferences');
@@ -17,8 +18,12 @@ const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
const DEFAULT_CURRENCY = 'EUR';
+const DEFAULT_APP_NAME = 'Oikos';
-const VALID_WIDGET_IDS = ['weather', 'tasks', 'calendar', 'shopping', 'meals', 'notes'];
+const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd'];
+const DEFAULT_DATE_FORMAT = 'mdy';
+
+const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
// --------------------------------------------------------
@@ -39,6 +44,10 @@ function cfgSet(key, value) {
`).run(key, value);
}
+function cfgDelete(key) {
+ db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
+}
+
// --------------------------------------------------------
// Widget-Hilfsfunktionen
// --------------------------------------------------------
@@ -78,12 +87,16 @@ router.get('/', (req, res) => {
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
+ const dateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
+ const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
res.json({
data: {
visible_meal_types: visibleMealTypes,
currency,
+ date_format: dateFormat,
+ app_name: appName,
dashboard_widgets: dashboardWidgets,
},
});
@@ -102,7 +115,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => {
try {
- const { visible_meal_types, currency, dashboard_widgets } = req.body;
+ const { visible_meal_types, currency, date_format, app_name, dashboard_widgets } = req.body;
if (visible_meal_types !== undefined) {
if (!Array.isArray(visible_meal_types)) {
@@ -122,6 +135,20 @@ router.put('/', (req, res) => {
cfgSet('currency', currency);
}
+ if (date_format !== undefined) {
+ if (!VALID_DATE_FORMATS.includes(date_format)) {
+ return res.status(400).json({ error: `Ungültiges Datumsformat. Erlaubt: ${VALID_DATE_FORMATS.join(', ')}`, code: 400 });
+ }
+ cfgSet('date_format', date_format);
+ }
+
+ if (app_name !== undefined) {
+ const vAppName = str(app_name, 'Application name', { max: MAX_SHORT, required: false });
+ if (vAppName.error) return res.status(400).json({ error: vAppName.error, code: 400 });
+ if (vAppName.value) cfgSet('app_name', vAppName.value);
+ else cfgDelete('app_name');
+ }
+
if (dashboard_widgets !== undefined) {
if (!Array.isArray(dashboard_widgets)) {
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
@@ -133,12 +160,16 @@ router.put('/', (req, res) => {
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
+ const savedDateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
+ const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
res.json({
data: {
visible_meal_types: savedMealTypes,
currency: savedCurrency,
+ date_format: savedDateFormat,
+ app_name: savedAppName,
dashboard_widgets: savedWidgets,
},
});
diff --git a/test-dashboard.js b/test-dashboard.js
index f028191..b7b2480 100644
--- a/test-dashboard.js
+++ b/test-dashboard.js
@@ -6,6 +6,7 @@
import { DatabaseSync } from 'node:sqlite';
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
+import { hydrateBirthday } from './server/services/birthdays.js';
let passed = 0;
let failed = 0;
@@ -49,6 +50,7 @@ const uid2 = u2.lastInsertRowid;
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
+const currentMonth = today.slice(0, 7);
const inOneHour = new Date(Date.now() + 3600000).toISOString();
const in30h = new Date(Date.now() + 30 * 3600000).toISOString().slice(0, 10);
const in72h = new Date(Date.now() + 72 * 3600000).toISOString().slice(0, 10);
@@ -83,6 +85,22 @@ db.prepare(`INSERT INTO notes (content, title, pinned, color, created_by)
db.prepare(`INSERT INTO notes (content, pinned, color, created_by)
VALUES ('Nicht angepinnt', 0, '#E3F2FF', ?)`).run(uid1);
+// Geburtstage
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Heute Geburtstag', ?, ?)`).run(`2012-${today.slice(5)}`, uid1);
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Morgen Geburtstag', ?, ?)`).run(`2010-${tomorrow.slice(5)}`, uid1);
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Anderer Nutzer', ?, ?)`).run(`2011-${today.slice(5)}`, uid2);
+
+// Budget
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Salary', 3000, 'Erwerbseinkommen', '', ?, ?)`).run(`${currentMonth}-05`, uid1);
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Rent', -1200, 'housing', 'rent_mortgage', ?, ?)`).run(`${currentMonth}-06`, uid1);
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Groceries', -450, 'food', 'supermarket', ?, ?)`).run(`${currentMonth}-07`, uid1);
+
console.log('\n[Dashboard-Test] API-Abfragen\n');
// --------------------------------------------------------
@@ -191,6 +209,53 @@ test('Angepinnte Notizen: nicht angepinnte werden ausgeschlossen', () => {
assert(!unpinned, 'Nicht angepinnte Notiz sollte gefiltert sein');
});
+// --------------------------------------------------------
+// Tests: Geburtstage
+// --------------------------------------------------------
+test('Geburtstage: nur aktueller Nutzer, sortiert nach nächstem Geburtstag', () => {
+ const rows = db.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(uid1);
+ const birthdays = rows
+ .map((row) => hydrateBirthday(row, new Date(`${today}T12:00:00Z`)))
+ .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
+ .slice(0, 3);
+
+ assert(rows.length === 2, `Erwartet 2 Geburtstage, erhalten ${rows.length}`);
+ assert(birthdays[0].name === 'Heute Geburtstag', 'Heutiger Geburtstag zuerst');
+ assert(birthdays[0].days_until === 0, 'Heutiger Geburtstag hat 0 Tage Rest');
+});
+
+// --------------------------------------------------------
+// Tests: Budget
+// --------------------------------------------------------
+test('Budget: Monatswerte für Einnahmen, Ausgaben, Saldo und Top-Ausgabe', () => {
+ const from = `${currentMonth}-01`;
+ const to = `${currentMonth}-31`;
+ const totals = db.prepare(`
+ SELECT
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
+ SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
+ SUM(amount) AS balance,
+ COUNT(*) AS entry_count
+ FROM budget_entries
+ WHERE date BETWEEN ? AND ?
+ `).get(from, to);
+
+ const topExpense = db.prepare(`
+ SELECT category, SUM(amount) AS amount
+ FROM budget_entries
+ WHERE amount < 0 AND date BETWEEN ? AND ?
+ GROUP BY category
+ ORDER BY ABS(SUM(amount)) DESC
+ LIMIT 1
+ `).get(from, to);
+
+ assert(totals.income === 3000, `Einnahmen sollten 3000 sein, erhalten ${totals.income}`);
+ assert(Math.abs(totals.expenses) === 1650, `Ausgaben sollten 1650 sein, erhalten ${totals.expenses}`);
+ assert(totals.balance === 1350, `Saldo sollte 1350 sein, erhalten ${totals.balance}`);
+ assert(totals.entry_count === 3, `Erwartet 3 Einträge, erhalten ${totals.entry_count}`);
+ assert(topExpense.category === 'housing', 'Wohnen sollte Top-Ausgabenkategorie sein');
+});
+
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------