feat: birthday tracking, dashboard KPIs, and app name customization (#88)
- Add Birthdays module: CRUD with calendar/reminder auto-sync, photo upload, age notes - Add DB migration 18 (birthdays table with calendar_event_id, trigger, indexes) - Add dashboard widgets: birthdays, family participants, budget overview - Add Settings > General: admins can set a custom app name (reflected in title/sidebar/login) - Improve service worker: network-first caching for mutable JS/CSS assets - Add translations for 16 locales (birthday keys) Fixes applied during integration: - innerHTML replaced with insertAdjacentHTML/replaceChildren throughout birthdays.js and dashboard.js - docker-compose.yml personal dev changes reverted Co-authored-by: Rafael Foster <rafaelgfoster@gmail.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+82
-2
@@ -19,6 +19,7 @@ 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' },
|
||||
@@ -116,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;
|
||||
@@ -124,11 +126,14 @@ let _pendingLoginRedirect = false;
|
||||
// Router
|
||||
// --------------------------------------------------------
|
||||
|
||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
|
||||
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);
|
||||
@@ -136,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
|
||||
@@ -151,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;
|
||||
@@ -168,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
|
||||
@@ -198,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
|
||||
@@ -210,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
|
||||
@@ -352,7 +425,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';
|
||||
@@ -455,6 +528,7 @@ function renderAppShell(container) {
|
||||
routeAnnouncer.setAttribute('aria-atomic', 'true');
|
||||
|
||||
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer, routeAnnouncer);
|
||||
updateBranding(currentPath || '/');
|
||||
|
||||
// Klick-Handler für alle Nav-Links
|
||||
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||
@@ -658,6 +732,7 @@ 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' },
|
||||
@@ -900,6 +975,11 @@ window.addEventListener('locale-changed', () => {
|
||||
});
|
||||
|
||||
updateNav(currentPath);
|
||||
updateBranding(currentPath || '/');
|
||||
});
|
||||
|
||||
window.addEventListener('app-name-changed', () => {
|
||||
updateBranding(currentPath || '/');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user