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:
@@ -0,0 +1,397 @@
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
import { t, formatDate } from '/i18n.js';
|
||||
import { esc } from '/utils/html.js';
|
||||
|
||||
let state = {
|
||||
birthdays: [],
|
||||
upcoming: [],
|
||||
query: '',
|
||||
};
|
||||
let _container = null;
|
||||
|
||||
function initials(name) {
|
||||
return String(name || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('') || '?';
|
||||
}
|
||||
|
||||
function ageNote(birthday) {
|
||||
if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age });
|
||||
if (birthday.days_until === 1) return t('birthdays.ageNoteTomorrow', { age: birthday.next_age });
|
||||
return t('birthdays.ageNoteDays', { age: birthday.next_age, days: birthday.days_until });
|
||||
}
|
||||
|
||||
function photoAvatar(birthday, extraClass = '') {
|
||||
if (birthday.photo_data) {
|
||||
return `<img class="birthday-avatar ${extraClass}" src="${birthday.photo_data}" alt="${esc(birthday.name)}">`;
|
||||
}
|
||||
return `<span class="birthday-avatar birthday-avatar--fallback ${extraClass}">${esc(initials(birthday.name))}</span>`;
|
||||
}
|
||||
|
||||
function filteredBirthdays() {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
const list = !q ? state.birthdays : state.birthdays.filter((birthday) =>
|
||||
birthday.name.toLowerCase().includes(q) ||
|
||||
(birthday.notes || '').toLowerCase().includes(q)
|
||||
);
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function suggestions() {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return state.birthdays
|
||||
.filter((birthday) => birthday.name.toLowerCase().includes(q))
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const [allRes, upcomingRes] = await Promise.all([
|
||||
api.get('/birthdays'),
|
||||
api.get('/birthdays/upcoming?limit=4'),
|
||||
]);
|
||||
state.birthdays = allRes.data ?? [];
|
||||
state.upcoming = upcomingRes.data ?? [];
|
||||
}
|
||||
|
||||
function renderSuggestions() {
|
||||
const dropdown = _container.querySelector('#birthdays-autocomplete');
|
||||
if (!dropdown) return;
|
||||
const items = suggestions();
|
||||
if (!items.length) {
|
||||
dropdown.hidden = true;
|
||||
dropdown.replaceChildren();
|
||||
return;
|
||||
}
|
||||
dropdown.hidden = false;
|
||||
dropdown.replaceChildren();
|
||||
dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => `
|
||||
<button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}">
|
||||
${photoAvatar(birthday, 'birthday-avatar--xs')}
|
||||
<span>
|
||||
<strong>${esc(birthday.name)}</strong>
|
||||
<small>${esc(ageNote(birthday))}</small>
|
||||
</span>
|
||||
</button>
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderUpcoming() {
|
||||
const host = _container.querySelector('#birthdays-upcoming');
|
||||
if (!host) return;
|
||||
if (!state.upcoming.length) {
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', `<div class="empty-state empty-state--compact">
|
||||
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
|
||||
<article class="birthday-card">
|
||||
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
|
||||
<div class="birthday-card__body">
|
||||
<div class="birthday-card__top">
|
||||
<div>
|
||||
<div class="birthday-card__name">${esc(birthday.name)}</div>
|
||||
<div class="birthday-card__date">${esc(formatDate(birthday.next_birthday))}</div>
|
||||
</div>
|
||||
<div class="birthday-card__pill">
|
||||
${birthday.days_until === 0 ? esc(t('common.today')) : birthday.days_until === 1 ? esc(t('common.tomorrow')) : esc(`${birthday.days_until}d`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="birthday-card__note">${esc(ageNote(birthday))}</div>
|
||||
</div>
|
||||
</article>
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const host = _container.querySelector('#birthdays-list');
|
||||
if (!host) return;
|
||||
const list = filteredBirthdays();
|
||||
if (!list.length) {
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', `<div class="empty-state">
|
||||
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
host.replaceChildren();
|
||||
host.insertAdjacentHTML('beforeend', list.map((birthday) => `
|
||||
<article class="birthday-item" data-id="${birthday.id}">
|
||||
<div class="birthday-item__media">${photoAvatar(birthday)}</div>
|
||||
<div class="birthday-item__body">
|
||||
<div class="birthday-item__row">
|
||||
<strong class="birthday-item__name">${esc(birthday.name)}</strong>
|
||||
<span class="birthday-item__next">${esc(formatDate(birthday.next_birthday))}</span>
|
||||
</div>
|
||||
<div class="birthday-item__meta">${esc(formatDate(birthday.birth_date))}</div>
|
||||
<div class="birthday-item__note">${esc(ageNote(birthday))}</div>
|
||||
${birthday.notes ? `<div class="birthday-item__notes">${esc(birthday.notes)}</div>` : ''}
|
||||
</div>
|
||||
<div class="birthday-item__actions">
|
||||
<button class="contact-action-btn" type="button" data-action="edit" data-id="${birthday.id}" aria-label="${t('common.edit')}">
|
||||
<i data-lucide="pencil" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="contact-action-btn" type="button" data-action="delete" data-id="${birthday.id}" aria-label="${t('common.delete')}">
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
`).join(''));
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(host.querySelectorAll('.birthday-item'));
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
_container.replaceChildren();
|
||||
_container.insertAdjacentHTML('beforeend', `
|
||||
<div class="birthdays-page">
|
||||
<h1 class="sr-only">${t('birthdays.title')}</h1>
|
||||
<div class="birthdays-toolbar">
|
||||
<div class="birthdays-toolbar__title">
|
||||
<i data-lucide="cake" class="birthdays-toolbar__title-icon" aria-hidden="true"></i>
|
||||
<span>${t('birthdays.title')}</span>
|
||||
</div>
|
||||
<button class="btn btn--primary birthdays-header__action" id="birthdays-add-btn">
|
||||
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||
${t('birthdays.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
<p class="birthdays-toolbar__subtitle">${t('birthdays.calendarHint')}</p>
|
||||
|
||||
<div class="birthdays-grid">
|
||||
<aside class="birthdays-panel birthdays-panel--upcoming">
|
||||
<div class="birthdays-section__header">
|
||||
<h3>${t('birthdays.upcomingTitle')}</h3>
|
||||
<p>${t('birthdays.upcomingHint')}</p>
|
||||
</div>
|
||||
<div class="birthday-cards" id="birthdays-upcoming"></div>
|
||||
</aside>
|
||||
|
||||
<section class="birthdays-panel birthdays-panel--list">
|
||||
<div class="birthdays-toolbar birthdays-toolbar--embedded">
|
||||
<div class="birthdays-toolbar__search">
|
||||
<i data-lucide="search" class="birthdays-toolbar__search-icon" aria-hidden="true"></i>
|
||||
<input type="search" class="birthdays-toolbar__search-input" id="birthdays-search"
|
||||
placeholder="${t('birthdays.searchPlaceholder')}" autocomplete="off" value="${esc(state.query)}">
|
||||
<div class="autocomplete-dropdown birthdays-autocomplete" id="birthdays-autocomplete" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="birthdays-section__header birthdays-section__header--spaced">
|
||||
<h3>${t('birthdays.peopleTitle')}</h3>
|
||||
<p>${t('birthdays.peopleHint')}</p>
|
||||
</div>
|
||||
<div class="birthdays-list" id="birthdays-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button class="page-fab" id="fab-new-birthday" aria-label="${t('birthdays.addButton')}">
|
||||
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
renderUpcoming();
|
||||
renderList();
|
||||
renderSuggestions();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const openCreate = () => openBirthdayModal({ mode: 'create' });
|
||||
_container.querySelector('#birthdays-add-btn').addEventListener('click', openCreate);
|
||||
_container.querySelector('#fab-new-birthday').addEventListener('click', openCreate);
|
||||
|
||||
const search = _container.querySelector('#birthdays-search');
|
||||
search.addEventListener('input', (e) => {
|
||||
state.query = e.target.value;
|
||||
renderSuggestions();
|
||||
renderList();
|
||||
});
|
||||
search.addEventListener('focus', renderSuggestions);
|
||||
search.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
const dropdown = _container.querySelector('#birthdays-autocomplete');
|
||||
if (dropdown) dropdown.hidden = true;
|
||||
}, 100);
|
||||
});
|
||||
|
||||
_container.querySelector('#birthdays-autocomplete').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.birthday-suggestion');
|
||||
if (!btn) return;
|
||||
state.query = btn.dataset.name;
|
||||
search.value = state.query;
|
||||
renderList();
|
||||
renderSuggestions();
|
||||
});
|
||||
|
||||
_container.querySelector('#birthdays-list').addEventListener('click', async (e) => {
|
||||
const action = e.target.closest('[data-action]');
|
||||
if (!action) return;
|
||||
const id = Number(action.dataset.id);
|
||||
const birthday = state.birthdays.find((item) => item.id === id);
|
||||
if (!birthday) return;
|
||||
if (action.dataset.action === 'edit') {
|
||||
openBirthdayModal({ mode: 'edit', birthday });
|
||||
return;
|
||||
}
|
||||
if (action.dataset.action === 'delete') {
|
||||
await deleteBirthday(id, birthday.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(new Error('Failed to read image.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function birthdayPreviewHtml(name, photoData) {
|
||||
if (photoData) return `<img class="birthday-preview__image" src="${photoData}" alt="${esc(name || '')}">`;
|
||||
return `<span class="birthday-preview__fallback">${esc(initials(name))}</span>`;
|
||||
}
|
||||
|
||||
function openBirthdayModal({ mode, birthday = null }) {
|
||||
const isEdit = mode === 'edit';
|
||||
let photoData = birthday?.photo_data || null;
|
||||
|
||||
openSharedModal({
|
||||
title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'),
|
||||
content: `
|
||||
<div class="birthday-modal">
|
||||
<div class="birthday-preview" id="birthday-preview">${birthdayPreviewHtml(birthday?.name || '', photoData)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bd-name">${t('birthdays.nameLabel')}</label>
|
||||
<input class="form-input" id="bd-name" type="text" value="${esc(birthday?.name || '')}" autocomplete="name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label>
|
||||
<input class="form-input" id="bd-birth-date" type="date" value="${esc(birthday?.birth_date || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label>
|
||||
<input class="form-input" id="bd-photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
|
||||
<div class="form-help">${t('birthdays.photoOptional')}</div>
|
||||
<div class="birthday-modal__photo-actions">
|
||||
<button type="button" class="btn btn--secondary" id="bd-remove-photo">${t('birthdays.removePhoto')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bd-notes">${t('birthdays.notesLabel')}</label>
|
||||
<textarea class="form-input" id="bd-notes" rows="3" placeholder="${t('birthdays.notesPlaceholder')}">${esc(birthday?.notes || '')}</textarea>
|
||||
</div>
|
||||
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
|
||||
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||
${isEdit ? `<button class="btn btn--danger" id="bd-delete">${t('common.delete')}</button>` : '<div></div>'}
|
||||
<div style="display:flex;gap:var(--space-3);">
|
||||
<button class="btn btn--secondary" type="button" id="bd-cancel">${t('common.cancel')}</button>
|
||||
<button class="btn btn--primary" type="button" id="bd-save">${isEdit ? t('common.save') : t('common.create')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
size: 'md',
|
||||
onSave(panel) {
|
||||
const nameInput = panel.querySelector('#bd-name');
|
||||
const preview = panel.querySelector('#birthday-preview');
|
||||
const renderPreview = () => {
|
||||
preview.replaceChildren();
|
||||
preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData));
|
||||
};
|
||||
nameInput.addEventListener('input', renderPreview);
|
||||
panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
photoData = await readFileAsDataUrl(file);
|
||||
renderPreview();
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'danger');
|
||||
}
|
||||
});
|
||||
panel.querySelector('#bd-remove-photo').addEventListener('click', () => {
|
||||
photoData = null;
|
||||
panel.querySelector('#bd-photo').value = '';
|
||||
renderPreview();
|
||||
});
|
||||
panel.querySelector('#bd-cancel').addEventListener('click', closeModal);
|
||||
panel.querySelector('#bd-delete')?.addEventListener('click', async () => {
|
||||
closeModal();
|
||||
await deleteBirthday(birthday.id, birthday.name);
|
||||
});
|
||||
panel.querySelector('#bd-save').addEventListener('click', async () => {
|
||||
const saveBtn = panel.querySelector('#bd-save');
|
||||
const body = {
|
||||
name: panel.querySelector('#bd-name').value.trim(),
|
||||
birth_date: panel.querySelector('#bd-birth-date').value,
|
||||
notes: panel.querySelector('#bd-notes').value.trim(),
|
||||
photo_data: photoData,
|
||||
};
|
||||
|
||||
if (!body.name || !body.birth_date) {
|
||||
window.oikos?.showToast(t('birthdays.requiredFields'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
if (isEdit) {
|
||||
const res = await api.put(`/birthdays/${birthday.id}`, body);
|
||||
const idx = state.birthdays.findIndex((item) => item.id === birthday.id);
|
||||
if (idx !== -1) state.birthdays[idx] = res.data;
|
||||
window.oikos?.showToast(t('birthdays.updatedToast'), 'success');
|
||||
} else {
|
||||
const res = await api.post('/birthdays', body);
|
||||
state.birthdays.push(res.data);
|
||||
window.oikos?.showToast(t('birthdays.createdToast'), 'success');
|
||||
}
|
||||
state.birthdays.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const upcomingRes = await api.get('/birthdays/upcoming?limit=4');
|
||||
state.upcoming = upcomingRes.data ?? [];
|
||||
renderUpcoming();
|
||||
renderSuggestions();
|
||||
renderList();
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message, 'danger');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBirthday(id, name) {
|
||||
if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||
await api.delete(`/birthdays/${id}`);
|
||||
state.birthdays = state.birthdays
|
||||
.filter((birthday) => birthday.id !== id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id);
|
||||
renderUpcoming();
|
||||
renderSuggestions();
|
||||
renderList();
|
||||
window.oikos?.showToast(t('birthdays.deletedToast'), 'success');
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
await loadData();
|
||||
renderPage();
|
||||
bindEvents();
|
||||
}
|
||||
+378
-104
@@ -110,7 +110,7 @@ function showOnboarding(appContainer) {
|
||||
// Widget-Definitionen (Reihenfolge = Standard-Layout)
|
||||
// --------------------------------------------------------
|
||||
|
||||
const WIDGET_IDS = ['weather', 'tasks', 'calendar', 'shopping', 'meals', 'notes'];
|
||||
const WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
|
||||
|
||||
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
|
||||
|
||||
@@ -122,15 +122,34 @@ function widgetLabel(id) {
|
||||
meals: () => t('nav.meals'),
|
||||
notes: () => t('nav.notes'),
|
||||
weather: () => t('dashboard.weather'),
|
||||
birthdays: () => t('nav.birthdays'),
|
||||
budget: () => t('nav.budget'),
|
||||
family: () => t('dashboard.familyMembers'),
|
||||
};
|
||||
return (map[id] ?? (() => id))();
|
||||
}
|
||||
|
||||
function widgetIcon(id) {
|
||||
const map = { tasks: 'check-square', calendar: 'calendar', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
|
||||
const map = { tasks: 'check-square', calendar: 'calendar', birthdays: 'cake', budget: 'wallet', family: 'users', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
|
||||
return map[id] ?? 'layout-dashboard';
|
||||
}
|
||||
|
||||
const BUDGET_CATEGORY_LABEL_KEYS = {
|
||||
housing: 'catHousing',
|
||||
food: 'catFood',
|
||||
transport: 'catTransport',
|
||||
personal_health: 'catPersonalHealth',
|
||||
leisure: 'catLeisure',
|
||||
shopping_clothing: 'catShoppingClothing',
|
||||
education: 'catEducation',
|
||||
financial_other: 'catFinancialOther',
|
||||
'Erwerbseinkommen': 'catEarnedIncome',
|
||||
'Kapitalerträge': 'catInvestmentIncome',
|
||||
'Geschenke & Transfers': 'catTransferGiftIncome',
|
||||
'Sozialleistungen': 'catGovernmentBenefits',
|
||||
'Sonstiges Einkommen': 'catOtherIncome',
|
||||
};
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// --------------------------------------------------------
|
||||
@@ -225,6 +244,19 @@ function initials(name = '') {
|
||||
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function budgetCategoryLabel(category) {
|
||||
const key = BUDGET_CATEGORY_LABEL_KEYS[category];
|
||||
return key ? t(`budget.${key}`) : (category || '-');
|
||||
}
|
||||
|
||||
function formatCurrency(amount, currency = 'EUR') {
|
||||
return new Intl.NumberFormat(getLocale(), {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: Math.abs(amount) >= 1000 ? 0 : 2,
|
||||
}).format(amount || 0);
|
||||
}
|
||||
|
||||
function widgetHeader(icon, title, count, linkHref, linkLabel) {
|
||||
linkLabel = linkLabel ?? t('dashboard.allLink');
|
||||
const badge = count != null
|
||||
@@ -264,51 +296,6 @@ function skeletonWidget(lines = 3) {
|
||||
// Widget-Renderer
|
||||
// --------------------------------------------------------
|
||||
|
||||
function renderGreeting(user, stats = {}) {
|
||||
const { overdueCount = 0, dueSoonCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
|
||||
|
||||
const statChips = [];
|
||||
if (overdueCount > 0)
|
||||
statChips.push(`<span class="greeting-chip greeting-chip--warn">
|
||||
<i data-lucide="alert-circle" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||
${overdueCount > 1 ? t('dashboard.overdueTasksChipPlural', { count: overdueCount }) : t('dashboard.overdueTasksChip', { count: overdueCount })}
|
||||
</span>`);
|
||||
if (dueSoonCount > 0)
|
||||
statChips.push(`<span class="greeting-chip greeting-chip--due">
|
||||
<i data-lucide="clock" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||
${dueSoonCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: dueSoonCount }) : t('dashboard.urgentTasksChip', { count: dueSoonCount })}
|
||||
</span>`);
|
||||
if (todayEventCount > 0)
|
||||
statChips.push(`<span class="greeting-chip">
|
||||
<i data-lucide="calendar" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
|
||||
</span>`);
|
||||
if (todayMealTitle)
|
||||
statChips.push(`<span class="greeting-chip">
|
||||
<i data-lucide="utensils" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
|
||||
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
|
||||
</span>`);
|
||||
|
||||
const time = formatTime(new Date());
|
||||
const hour = new Date().getHours();
|
||||
const timeVariant = hour < 11 ? 'morning' : hour < 18 ? 'day' : 'evening';
|
||||
|
||||
return `
|
||||
<div class="widget-greeting" data-time-variant="${timeVariant}">
|
||||
<div class="widget-greeting__inner">
|
||||
<div class="widget-greeting__content">
|
||||
<div class="widget-greeting__title">${formatDate(new Date())} - ${time}</div>
|
||||
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
|
||||
</div>
|
||||
<button class="widget-customize-btn" id="dashboard-customize-btn"
|
||||
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
||||
<i data-lucide="settings-2" class="icon-base" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderUrgentTasks(tasks) {
|
||||
if (!tasks.length) {
|
||||
return `<div class="widget widget--tasks">
|
||||
@@ -382,6 +369,43 @@ function renderUpcomingEvents(events) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUpcomingBirthdays(birthdays) {
|
||||
if (!birthdays.length) {
|
||||
return `<div class="widget widget--birthdays">
|
||||
${widgetHeader('cake', t('nav.birthdays'), 0, '/birthdays')}
|
||||
<div class="widget__empty">
|
||||
<i data-lucide="cake" class="empty-state__icon" aria-hidden="true"></i>
|
||||
<div>${t('dashboard.noBirthdays')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const items = birthdays.map((b) => {
|
||||
const daysLabel = b.days_until === 0
|
||||
? t('common.today')
|
||||
: b.days_until === 1
|
||||
? t('common.tomorrow')
|
||||
: t('dashboard.daysLeft', { count: b.days_until });
|
||||
return `
|
||||
<div class="birthday-widget-item" data-route="/birthdays" role="button" tabindex="0">
|
||||
<div class="birthday-widget-item__avatar">
|
||||
${b.photo_data ? `<img src="${esc(b.photo_data)}" alt="" loading="lazy">` : `<span>${esc(initials(b.name))}</span>`}
|
||||
</div>
|
||||
<div class="birthday-widget-item__body">
|
||||
<div class="birthday-widget-item__name">${esc(b.name)}</div>
|
||||
<div class="birthday-widget-item__meta">${formatDate(b.next_birthday)} · ${daysLabel}</div>
|
||||
</div>
|
||||
<div class="birthday-widget-item__age">${esc(String(b.next_age ?? ''))}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="widget widget--birthdays">
|
||||
${widgetHeader('cake', t('nav.birthdays'), birthdays.length, '/birthdays')}
|
||||
<div class="widget__body">${items}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTodayMeals(meals) {
|
||||
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
|
||||
@@ -428,6 +452,283 @@ function renderPinnedNotes(notes) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFamilyWidget(users) {
|
||||
const visible = users.slice(0, 6);
|
||||
const avatars = visible.map((u) => `
|
||||
<span class="family-widget-avatar" style="background:${esc(u.avatar_color || '#64748b')}" title="${esc(u.display_name)}">
|
||||
${esc(initials(u.display_name))}
|
||||
</span>
|
||||
`).join('');
|
||||
|
||||
return `<div class="widget widget--family">
|
||||
${widgetHeader('users', t('dashboard.familyMembers'), users.length, '/settings')}
|
||||
<div class="family-widget">
|
||||
<div class="family-widget__count">${users.length}</div>
|
||||
<div class="family-widget__meta">${t('dashboard.participantsAdded')}</div>
|
||||
<div class="family-widget__avatars">${avatars}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderBudgetWidget(budget, currency) {
|
||||
const income = budget?.income || 0;
|
||||
const expenses = budget?.expenses || 0;
|
||||
const balance = budget?.balance || 0;
|
||||
const savingsRate = income > 0 ? Math.round((balance / income) * 100) : 0;
|
||||
const balanceTone = balance >= 0 ? 'positive' : 'negative';
|
||||
const hasData = (budget?.entryCount || 0) > 0;
|
||||
|
||||
return `<div class="widget widget--budget">
|
||||
${widgetHeader('wallet', t('dashboard.budgetOverview'), null, '/budget')}
|
||||
<div class="budget-widget">
|
||||
<div class="budget-widget__headline">
|
||||
<span>${t('dashboard.monthlyBalance')}</span>
|
||||
<strong class="budget-widget__balance budget-widget__balance--${balanceTone}">${formatCurrency(balance, currency)}</strong>
|
||||
</div>
|
||||
<div class="budget-widget__grid">
|
||||
<div class="budget-widget-metric budget-widget-metric--income">
|
||||
<span>${t('dashboard.monthlyIncome')}</span>
|
||||
<strong>${formatCurrency(income, currency)}</strong>
|
||||
</div>
|
||||
<div class="budget-widget-metric budget-widget-metric--expense">
|
||||
<span>${t('dashboard.monthlyExpenses')}</span>
|
||||
<strong>${formatCurrency(expenses, currency)}</strong>
|
||||
</div>
|
||||
<div class="budget-widget-metric">
|
||||
<span>${t('dashboard.savingsRate')}</span>
|
||||
<strong>${income > 0 ? `${savingsRate}%` : '-'}</strong>
|
||||
</div>
|
||||
<div class="budget-widget-metric">
|
||||
<span>${t('dashboard.budgetEntries')}</span>
|
||||
<strong>${budget?.entryCount || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-widget__footer">
|
||||
${hasData && budget?.topExpenseCategory
|
||||
? `${t('dashboard.topExpense')}: <strong>${esc(budgetCategoryLabel(budget.topExpenseCategory))}</strong> · ${formatCurrency(budget.topExpenseAmount, currency)}`
|
||||
: t('dashboard.noBudgetData')}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderQuickAction({ route, label, icon, tone = '' }) {
|
||||
return `
|
||||
<button type="button" class="dashboard-action ${tone ? `dashboard-action--${tone}` : ''}" data-route="${route}">
|
||||
<span class="dashboard-action__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
|
||||
<span class="dashboard-action__label">${label}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderKpiTile({ title, value, meta, icon, route, tone = '' }) {
|
||||
return `
|
||||
<button type="button" class="dashboard-kpi ${tone ? `dashboard-kpi--${tone}` : ''}" data-route="${route}">
|
||||
<span class="dashboard-kpi__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
|
||||
<span class="dashboard-kpi__body">
|
||||
<span class="dashboard-kpi__label">${title}</span>
|
||||
<span class="dashboard-kpi__value">${value}</span>
|
||||
<span class="dashboard-kpi__meta">${meta}</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardOverview(user, stats = null, weather = null) {
|
||||
const dateLabel = formatDate(new Date());
|
||||
const weatherLabel = weather
|
||||
? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}`
|
||||
: t('dashboard.weather');
|
||||
|
||||
const actions = [
|
||||
{ route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' },
|
||||
{ route: '/calendar', label: t('nav.calendar'), icon: 'calendar', tone: 'violet' },
|
||||
{ route: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', tone: 'green' },
|
||||
{ route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' },
|
||||
].map(renderQuickAction).join('');
|
||||
|
||||
const kpis = stats ? [
|
||||
renderKpiTile({
|
||||
title: t('tasks.title'),
|
||||
value: String(stats.overdueCount ?? 0),
|
||||
meta: t('dashboard.overdue'),
|
||||
icon: 'alert-circle',
|
||||
route: '/tasks',
|
||||
tone: 'danger',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.calendar'),
|
||||
value: String(stats.todayEventCount ?? 0),
|
||||
meta: t('common.today'),
|
||||
icon: 'calendar-days',
|
||||
route: '/calendar',
|
||||
tone: 'calendar',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.meals'),
|
||||
value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-',
|
||||
meta: t('dashboard.todayMeals'),
|
||||
icon: 'utensils',
|
||||
route: '/meals',
|
||||
tone: 'meals',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('dashboard.weather'),
|
||||
value: weatherLabel,
|
||||
meta: t('dashboard.weatherRefreshTitle'),
|
||||
icon: 'cloud-sun',
|
||||
route: '/',
|
||||
tone: 'weather',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('nav.birthdays'),
|
||||
value: String(stats.birthdayCount ?? 0),
|
||||
meta: t('dashboard.upcomingBirthdays'),
|
||||
icon: 'cake',
|
||||
route: '/birthdays',
|
||||
tone: 'birthdays',
|
||||
}),
|
||||
renderKpiTile({
|
||||
title: t('dashboard.familyMembers'),
|
||||
value: String(stats.familyCount ?? 0),
|
||||
meta: t('dashboard.participantsAdded'),
|
||||
icon: 'users',
|
||||
route: '/settings',
|
||||
tone: 'family',
|
||||
}),
|
||||
].join('') : `
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<section class="dashboard-overview">
|
||||
<div class="dashboard-overview__header">
|
||||
<div class="dashboard-overview__heading">
|
||||
<span class="dashboard-overview__date">${dateLabel}</span>
|
||||
<h1 class="dashboard-overview__title">${greeting(user.display_name)}</h1>
|
||||
</div>
|
||||
<div class="dashboard-overview__tools">
|
||||
<div class="dashboard-overview__actions">${actions}</div>
|
||||
<button class="dashboard-icon-btn" id="dashboard-customize-btn"
|
||||
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
|
||||
<i data-lucide="settings-2" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi-grid">
|
||||
${kpis}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function widgetRegion(id) {
|
||||
return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main';
|
||||
}
|
||||
|
||||
function widgetTileClass(id) {
|
||||
const map = {
|
||||
tasks: 'dashboard-tile--wide',
|
||||
calendar: 'dashboard-tile--compact',
|
||||
birthdays: 'dashboard-tile--compact',
|
||||
budget: 'dashboard-tile--wide',
|
||||
family: 'dashboard-tile--compact',
|
||||
meals: 'dashboard-tile--compact',
|
||||
notes: 'dashboard-tile--wide',
|
||||
shopping: 'dashboard-tile--compact',
|
||||
weather: 'dashboard-tile--wide',
|
||||
};
|
||||
return map[id] || 'dashboard-tile--compact';
|
||||
}
|
||||
|
||||
function renderDashboardTile(id, html) {
|
||||
if (!html) return '';
|
||||
return `<section class="dashboard-tile dashboard-tile--${id} ${widgetTileClass(id)}">${html}</section>`;
|
||||
}
|
||||
|
||||
function renderDashboardLayout(cfg, data, weather, currency) {
|
||||
const widgetById = {
|
||||
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
|
||||
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
|
||||
birthdays: () => renderUpcomingBirthdays(data.birthdays ?? []),
|
||||
budget: () => renderBudgetWidget(data.budget ?? {}, currency),
|
||||
family: () => renderFamilyWidget(data.users ?? []),
|
||||
meals: () => renderTodayMeals(data.todayMeals ?? []),
|
||||
notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
|
||||
shopping: () => renderShoppingLists(data.shoppingLists ?? []),
|
||||
weather: () => (weather ? renderWeatherWidget(weather) : ''),
|
||||
};
|
||||
|
||||
const visible = cfg.filter((w) => w.visible && widgetById[w.id]);
|
||||
const mainTiles = visible
|
||||
.filter((w) => widgetRegion(w.id) === 'main')
|
||||
.map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
|
||||
.join('');
|
||||
|
||||
const sideTiles = visible
|
||||
.filter((w) => widgetRegion(w.id) === 'side')
|
||||
.map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="dashboard-workspace">
|
||||
<div class="dashboard-workspace__main">
|
||||
<div class="dashboard-widget-grid">
|
||||
${mainTiles}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-workspace__side">
|
||||
<div class="dashboard-side-stack">
|
||||
${sideTiles}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDashboardSkeleton() {
|
||||
return `
|
||||
<section class="dashboard-overview">
|
||||
<div class="dashboard-overview__header">
|
||||
<div class="dashboard-overview__heading">
|
||||
<div class="skeleton skeleton-line skeleton-line--short"></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--medium"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi-grid">
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-workspace">
|
||||
<div class="dashboard-workspace__main">
|
||||
<div class="dashboard-widget-grid">
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
${skeletonWidget(3)}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="dashboard-workspace__side">
|
||||
<div class="dashboard-side-stack">
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Shopping-Widget
|
||||
// --------------------------------------------------------
|
||||
@@ -609,25 +910,6 @@ function initFab(container, signal) {
|
||||
document.addEventListener('click', () => { if (open) toggleFab(false); }, { signal });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Widget-Rendering nach Konfiguration
|
||||
// --------------------------------------------------------
|
||||
|
||||
function renderWidgets(cfg, data, weather) {
|
||||
const renderers = {
|
||||
tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
|
||||
calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
|
||||
shopping: () => renderShoppingLists(data.shoppingLists ?? []),
|
||||
meals: () => renderTodayMeals(data.todayMeals ?? []),
|
||||
notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
|
||||
weather: () => (weather ? renderWeatherWidget(weather) : ''),
|
||||
};
|
||||
return cfg
|
||||
.filter((w) => w.visible)
|
||||
.map((w) => (renderers[w.id] ? renderers[w.id]() : ''))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Customize-Modal
|
||||
// --------------------------------------------------------
|
||||
@@ -822,20 +1104,17 @@ export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="dashboard">
|
||||
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||
<div class="dashboard__grid">
|
||||
${renderGreeting(user, {})}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(3)}
|
||||
${skeletonWidget(2)}
|
||||
${skeletonWidget(3)}
|
||||
<div class="dashboard-shell" id="dashboard-shell">
|
||||
${renderDashboardSkeleton()}
|
||||
</div>
|
||||
</div>
|
||||
${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'),
|
||||
@@ -845,6 +1124,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');
|
||||
@@ -866,52 +1146,46 @@ 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.replaceChildren();
|
||||
shell.insertAdjacentHTML('beforeend', `
|
||||
${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);
|
||||
@@ -923,7 +1197,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 () => {
|
||||
@@ -936,7 +1210,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 */ }
|
||||
|
||||
+25
-3
@@ -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 = `
|
||||
<main class="login-page" id="main-content">
|
||||
<div class="login-hero">
|
||||
<h1 class="login-hero__title">Oikos</h1>
|
||||
<h1 class="login-hero__title">${storedAppName}</h1>
|
||||
<p class="login-hero__tagline">${t('login.tagline')}</p>
|
||||
</div>
|
||||
<div class="login-card card card--padded">
|
||||
@@ -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) => {
|
||||
|
||||
+104
-1
@@ -12,6 +12,8 @@ import '/components/oikos-locale-picker.js';
|
||||
|
||||
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
||||
const SETTINGS_TAB_KEY = 'oikos:settings:tab';
|
||||
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
|
||||
const DEFAULT_APP_NAME = 'Oikos';
|
||||
|
||||
const CATEGORY_I18N = {
|
||||
'Obst & Gemüse': 'shopping.catFruitVeg',
|
||||
@@ -56,7 +58,7 @@ export async function render(container, { user }) {
|
||||
let users = [];
|
||||
let googleStatus = { configured: false, connected: false, lastSync: null };
|
||||
let appleStatus = { configured: false, lastSync: null };
|
||||
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
|
||||
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME };
|
||||
let categories = [];
|
||||
let icsSubscriptions = [];
|
||||
let apiTokens = [];
|
||||
@@ -80,6 +82,13 @@ export async function render(container, { user }) {
|
||||
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
|
||||
} catch (_) { /* non-critical */ }
|
||||
|
||||
if (prefs.date_format) {
|
||||
try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
|
||||
}
|
||||
if (prefs.app_name) {
|
||||
try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
|
||||
}
|
||||
|
||||
const googleStatusText = googleStatus.connected
|
||||
? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
|
||||
: googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
|
||||
@@ -139,6 +148,48 @@ export async function render(container, { user }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${user?.role === 'admin' ? `
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionAppName')}</h2>
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.appNameTitle')}</h3>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.appNameHint')}</p>
|
||||
<form class="settings-form settings-form--compact" id="app-name-form" novalidate autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="app-name-input">${t('settings.appNameLabel')}</label>
|
||||
<input
|
||||
class="form-input"
|
||||
type="text"
|
||||
id="app-name-input"
|
||||
maxlength="60"
|
||||
placeholder="${t('settings.appNamePlaceholder')}"
|
||||
value="${esc(prefs.app_name || DEFAULT_APP_NAME)}"
|
||||
/>
|
||||
</div>
|
||||
<div id="app-name-error" class="form-error" hidden></div>
|
||||
<div class="settings-form-actions">
|
||||
<button type="submit" class="btn btn--primary">${t('common.save')}</button>
|
||||
<button type="button" class="btn btn--secondary" id="app-name-reset-btn">${t('common.reset')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
` : ''}
|
||||
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.sectionDate')}</h2>
|
||||
<div class="settings-card">
|
||||
<h3 class="settings-card__title">${t('settings.dateFormatTitle')}</h3>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.dateFormatHint')}</p>
|
||||
<label class="form-label" for="date-format-select">${t('settings.dateFormatLabel')}</label>
|
||||
<select class="form-input" id="date-format-select">
|
||||
<option value="mdy"${prefs.date_format === 'mdy' ? ' selected' : ''}>MM/DD/YYYY</option>
|
||||
<option value="dmy"${prefs.date_format === 'dmy' ? ' selected' : ''}>DD/MM/YYYY</option>
|
||||
<option value="ymd"${prefs.date_format === 'ymd' ? ' selected' : ''}>YYYY-MM-DD</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2 class="settings-section__title">${t('settings.languageTitle')}</h2>
|
||||
<div class="settings-card">
|
||||
@@ -514,6 +565,58 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
|
||||
});
|
||||
}
|
||||
|
||||
const dateFormatSelect = container.querySelector('#date-format-select');
|
||||
if (dateFormatSelect) {
|
||||
dateFormatSelect.addEventListener('change', async () => {
|
||||
try {
|
||||
await api.put('/preferences', { date_format: dateFormatSelect.value });
|
||||
try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } }));
|
||||
window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const appNameForm = container.querySelector('#app-name-form');
|
||||
if (appNameForm) {
|
||||
appNameForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorEl = container.querySelector('#app-name-error');
|
||||
const input = container.querySelector('#app-name-input');
|
||||
errorEl.hidden = true;
|
||||
const value = input.value.trim();
|
||||
try {
|
||||
await api.put('/preferences', { app_name: value });
|
||||
try {
|
||||
if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value);
|
||||
else localStorage.removeItem(APP_NAME_STORAGE_KEY);
|
||||
} catch (_) {}
|
||||
input.value = value || DEFAULT_APP_NAME;
|
||||
window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } }));
|
||||
window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message ?? t('common.errorGeneric'));
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => {
|
||||
const errorEl = container.querySelector('#app-name-error');
|
||||
const input = container.querySelector('#app-name-input');
|
||||
errorEl.hidden = true;
|
||||
input.value = DEFAULT_APP_NAME;
|
||||
try {
|
||||
await api.put('/preferences', { app_name: '' });
|
||||
try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {}
|
||||
window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } }));
|
||||
window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message ?? t('common.errorGeneric'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Passwort ändern
|
||||
const passwordForm = container.querySelector('#password-form');
|
||||
if (passwordForm) {
|
||||
|
||||
Reference in New Issue
Block a user