5 Commits

Author SHA1 Message Date
Alexander c99442b54b fix(cospend): avoid localStorage at module init on list page
CI / update (push) Successful in 3m56s
The store-picker read localStorage at component init, which crashed
SSR on full-page loads of /cospend/list with 'localStorage.getItem is
not a function'. Deferred the read to onMount and wrapped writes in
try/catch.
2026-04-21 16:50:54 +02:00
Alexander 5b35c9e63b feat(cospend): edit name and amount in list edit modal
Long-press modal on /cospend/list now lets you change the item's name
and quantity (e.g. "500g", "3x") alongside category and icon. The
quantity is re-prepended to the name so the existing parser keeps
picking it up.
2026-04-21 16:41:21 +02:00
Alexander b66c458a4d fix(cospend): redirect to dash after adding payment
Server action redirected to /cospend which routes to /list. Now
redirects to the dashboard in the current locale's root.
2026-04-21 16:36:29 +02:00
Alexander 2f2fcc2f51 feat(faith): rename rite URL slugs to vetus/novus
Replace /1962 and /1969 with /vetus and /novus — matches how Catholics
actually refer to the missals (Vetus Ordo / Novus Ordo), reads the same
across de/en/la, and sidesteps the value-laden old-vs-new framing.
Rite pill labels flip to "Vetus" / "Novus"; the year stays visible in
the subtitle. Legacy year-slug URLs 307-redirect to keep bookmarks alive.
2026-04-21 16:32:48 +02:00
Alexander 693db06128 fix(faith): detail page rolls over to next LY past Advent I
Romcal's liturgical scope emits LY N with a stale post-Pentecost tail
~3 weeks into December; dates from Advent I onward belong to LY N+1.
Month/ring views already shift — port the same rollover to the detail
page so Dec 1–20 stop showing "After Pentecost" data and Dec 21–31 stop
404'ing.
2026-04-21 15:54:08 +02:00
11 changed files with 144 additions and 44 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.41.3",
"version": "1.43.1",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -178,6 +178,9 @@ const translations: Translations = {
search_icon: { en: 'Search icon...', de: 'Icon suchen...' },
save: { en: 'Save', de: 'Speichern' },
saving: { en: 'Saving...', de: 'Speichern...' },
edit_name: { en: 'Name', de: 'Name' },
edit_qty: { en: 'Amount', de: 'Menge' },
edit_qty_ph: { en: 'e.g. 3x, 500g, 1L', de: 'z.B. 3x, 500g, 1L' },
// EnhancedBalance
your_balance: { en: 'Your Balance', de: 'Dein Saldo' },
+6
View File
@@ -219,6 +219,11 @@ export function createShoppingSync() {
debouncedPush();
}
function updateItem(id: string, patch: Partial<Omit<ShoppingItem, 'id'>>) {
items = items.map(item => item.id === id ? { ...item, ...patch } : item);
debouncedPush();
}
return {
get items() { return items; },
get status() { return status; },
@@ -232,6 +237,7 @@ export function createShoppingSync() {
removeItem,
clearChecked,
updateItemCategory,
updateItem,
disconnect
};
}
+3 -1
View File
@@ -1,5 +1,7 @@
import type { ParamMatcher } from '@sveltejs/kit';
// Accepts the new Latin slugs (`vetus` / `novus`) plus the legacy year slugs
// so old bookmarks land on a load handler that 307s them to the new shape.
export const match: ParamMatcher = (param) => {
return param === '1962' || param === '1969';
return param === 'vetus' || param === 'novus' || param === '1962' || param === '1969';
};
@@ -61,14 +61,12 @@
};
const STORE_NAMES = Object.keys(STORE_PRESETS);
let selectedStore = $state(
(typeof localStorage !== 'undefined' && localStorage.getItem('shopping-store')) || STORE_NAMES[0]
);
let selectedStore = $state(STORE_NAMES[0]);
let categoryOrder = $derived(STORE_PRESETS[selectedStore] || STORE_PRESETS[STORE_NAMES[0]]);
function setStore(name) {
selectedStore = name;
localStorage.setItem('shopping-store', name);
try { localStorage.setItem('shopping-store', name); } catch { /* ignore */ }
}
let newItemName = $state('');
@@ -149,6 +147,11 @@
let totalCount = $derived(sync.items.length);
onMount(() => {
try {
const saved = localStorage.getItem('shopping-store');
if (saved && STORE_PRESETS[saved]) selectedStore = saved;
} catch { /* ignore */ }
if (data.initialList) {
sync.connect(shareToken);
} else {
@@ -205,6 +208,8 @@
let longPressTimer = $state(null);
/** @type {import('$lib/js/shoppingSync.svelte').ShoppingItem | null} */
let editingItem = $state(null);
let editName = $state('');
let editQty = $state('');
let editCategory = $state('');
let editIcon = $state('');
let iconSearch = $state('');
@@ -238,6 +243,9 @@
function startLongPress(item) {
longPressTimer = window.setTimeout(() => {
editingItem = item;
const parsed = parseQuantity(item.name);
editName = parsed.name;
editQty = parsed.qty || '';
editCategory = item.category;
editIcon = item.icon || '';
iconSearch = '';
@@ -355,14 +363,21 @@
async function saveEdit() {
if (!editingItem) return;
editSaving = true;
const cleanName = parseQuantity(editingItem.name).name;
const trimmedName = editName.trim();
const trimmedQty = editQty.trim();
if (!trimmedName) { editSaving = false; return; }
const newRawName = trimmedQty ? `${trimmedQty} ${trimmedName}` : trimmedName;
try {
await fetch(sync.apiUrl('/api/cospend/list/categorize/override'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: cleanName, category: editCategory, icon: editIcon || null })
body: JSON.stringify({ name: trimmedName, category: editCategory, icon: editIcon || null })
});
sync.updateItem(editingItem.id, {
name: newRawName,
category: editCategory,
icon: editIcon || null
});
sync.updateItemCategory(editingItem.id, editCategory, editIcon || null);
closeEdit();
} catch (err) {
console.error('[shopping] Save override error:', err);
@@ -482,6 +497,19 @@
<div class="edit-modal" onclick={(e) => e.stopPropagation()}>
<h3>{parseQuantity(editingItem.name).name}</h3>
<div class="name-qty-row">
<div class="field name-field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_name', lang)}</label>
<input class="edit-input" type="text" bind:value={editName} />
</div>
<div class="field qty-field">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('edit_qty', lang)}</label>
<input class="edit-input" type="text" bind:value={editQty} placeholder={t('edit_qty_ph', lang)} />
</div>
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">{t('kategorie', lang)}</label>
<div class="category-picker">
@@ -934,6 +962,29 @@
margin-bottom: 0.5rem;
}
.name-qty-row {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
}
.field { display: flex; flex-direction: column; }
.name-field { flex: 2; }
.qty-field { flex: 1; }
.edit-input {
padding: 0.5rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
}
.edit-input:focus {
outline: none;
border-color: var(--nord10);
}
.category-picker {
display: flex;
flex-wrap: wrap;
@@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ locals }) => {
};
export const actions: Actions = {
default: async ({ request, locals, fetch }) => {
default: async ({ request, locals, fetch, params }) => {
const session = await locals.auth();
if (!session || !session.user?.nickname) {
@@ -217,7 +217,7 @@ export const actions: Actions = {
}
// Success - redirect to dashboard
throw redirect(303, '/cospend');
throw redirect(303, `/${params.cospendRoot}/dash`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'status' in error && (error as { status: number }).status === 303) throw error; // Re-throw redirect
@@ -10,5 +10,5 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
throw redirect(307, `/${params.faithLang}/${slug}`);
}
const search = url.search ?? '';
throw redirect(307, `/${params.faithLang}/${params.calendar}/1962${search}`);
throw redirect(307, `/${params.faithLang}/${params.calendar}/vetus${search}`);
};
@@ -40,7 +40,18 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const lang: CalendarLang =
params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
const rite: Rite = params.rite === '1969' ? '1969' : '1962';
// Legacy year-slug links (/.../1962/... or /.../1969/...) forward to the new
// Latin slugs so old bookmarks stay alive.
if (params.rite === '1962' || params.rite === '1969') {
const tail = url.pathname.split('/').slice(4).join('/');
const suffix = tail ? `/${tail}` : '';
throw redirect(
307,
`/${params.faithLang}/${params.calendar}/${params.rite === '1962' ? 'vetus' : 'novus'}${suffix}${url.search}`
);
}
const rite: Rite = params.rite === 'novus' ? 'novus' : 'vetus';
const dioceseParam = url.searchParams.get('diocese');
const diocese1962: Diocese1962 = isDiocese1962(dioceseParam)
@@ -57,7 +68,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
}
const today = new Date();
const minYear = rite === '1962' ? 1900 : 1969;
const minYear = rite === 'vetus' ? 1900 : 1969;
const yParam = params.yyyy ? Number(params.yyyy) : NaN;
const mParam = params.mm ? Number(params.mm) - 1 : NaN;
@@ -65,7 +76,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const month = Number.isFinite(mParam) && mParam >= 0 && mParam <= 11 ? mParam : today.getMonth();
const fetchLy = async (y: number) =>
rite === '1962'
rite === 'vetus'
? await getYear1962(lang, diocese1962, y)
: await getYear(lang, diocese1969, y);
@@ -135,7 +146,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const todayIso = today.toISOString().slice(0, 10);
const todayYearMap =
rite === '1962'
rite === 'vetus'
? await getYear1962(lang, diocese1962, today.getFullYear())
: await getYear(lang, diocese1969, today.getFullYear());
const todayEntry = todayYearMap.get(todayIso) ?? null;
@@ -154,7 +165,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
? yearMap
: selectedYear === today.getFullYear()
? todayYearMap
: rite === '1962'
: rite === 'vetus'
? await getYear1962(lang, diocese1962, selectedYear)
: await getYear(lang, diocese1969, selectedYear);
const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0];
@@ -241,7 +252,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
// are unresolved i18n paths like 'lent.season', so always go through
// `season1962Label`. 1969: prefer the engine-resolved localized name.
const name =
rite === '1962'
rite === 'vetus'
? (key ? season1962Label(key, lang) : '')
: key && key !== d.seasonKey
? key
@@ -264,7 +275,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
return {
rite,
diocese: rite === '1962' ? diocese1962 : diocese1969,
diocese: rite === 'vetus' ? diocese1962 : diocese1969,
wip: false,
year,
liturgicalYear,
@@ -65,7 +65,7 @@
const rite = $derived(data.rite);
const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === '1962' ? 'rite1962Long' : 'rite1969Long', lang));
const riteSubtitle = $derived(t(rite === 'vetus' ? 'rite1962Long' : 'rite1969Long', lang));
function pad(n: number) {
return String(n).padStart(2, '0');
@@ -94,11 +94,11 @@
// Only append ?diocese= when non-default, keeps default URLs clean.
const dioceseQuery = $derived.by(() => {
const def = rite === '1962' ? DEFAULT_DIOCESE_1962 : DEFAULT_DIOCESE_1969;
const def = rite === 'vetus' ? DEFAULT_DIOCESE_1962 : DEFAULT_DIOCESE_1969;
return diocese && diocese !== def ? `?diocese=${diocese}` : '';
});
const dioceseOptions = $derived(rite === '1962' ? DIOCESES_1962 : DIOCESES_1969);
const dioceseOptions = $derived(rite === 'vetus' ? DIOCESES_1962 : DIOCESES_1969);
// URL: /{faithLang}/{calendar}/{rite}/{yyyy}/{mm}/{dd} — rite is a required
// path segment so day/month nav stays inside the active rite.
@@ -133,14 +133,14 @@
// When switching rites we drop ?diocese because the ID spaces differ (1962 has
// diocesan calendars, 1969 only "general" or "switzerland"). The server
// re-applies each rite's default if none is given.
function riteHref(r: '1969' | '1962') {
function riteHref(r: 'novus' | 'vetus') {
const dd = selectedIso.slice(8, 10);
return `${calendarBase}/${r}/${year}/${pad(month + 1)}/${dd}`;
}
function onDioceseChange(e: Event) {
const next = (e.currentTarget as HTMLSelectElement).value;
const def = rite === '1962' ? DEFAULT_DIOCESE_1962 : DEFAULT_DIOCESE_1969;
const def = rite === 'vetus' ? DEFAULT_DIOCESE_1962 : DEFAULT_DIOCESE_1969;
const dd = selectedIso.slice(8, 10);
const path = `${riteBase}/${year}/${pad(month + 1)}/${dd}`;
goto(next === def ? path : `${path}?diocese=${next}`, { noScroll: true });
@@ -159,21 +159,21 @@
<div class="rite-toggle" role="tablist" aria-label="Rite">
<a
role="tab"
aria-selected={rite === '1969'}
aria-selected={rite === 'novus'}
class="rite-pill"
class:active={rite === '1969'}
href={riteHref('1969')}
class:active={rite === 'novus'}
href={riteHref('novus')}
>
1969
Novus
</a>
<a
role="tab"
aria-selected={rite === '1962'}
aria-selected={rite === 'vetus'}
class="rite-pill"
class:active={rite === '1962'}
href={riteHref('1962')}
class:active={rite === 'vetus'}
href={riteHref('vetus')}
>
1962
Vetus
</a>
</div>
<label class="diocese-picker">
@@ -184,7 +184,7 @@
{/each}
</select>
</label>
{#if rite === '1969'}
{#if rite === 'novus'}
<p class="diocese-note">{t('rite1969SwissNote', lang)}</p>
{/if}
</header>
@@ -196,7 +196,7 @@
<p>{t('wipBody', lang)}</p>
</section>
{:else}
{#if rite === '1962'}
{#if rite === 'vetus'}
<aside class="disclaimer" role="note">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
<div>
@@ -210,7 +210,7 @@
day={hero}
{lang}
{todayIso}
href={rite === '1962' ? detailHref(hero.iso) : undefined}
href={rite === 'vetus' ? detailHref(hero.iso) : undefined}
/>
{/if}
@@ -24,8 +24,18 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const lang: CalendarLang =
params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
const rite: Rite = params.rite === '1969' ? '1969' : '1962';
if (rite !== '1962') await errorWithVerse(fetch, url.pathname, 404, 'Not found');
// Legacy year-slug links forward to the Latin slugs.
if (params.rite === '1962' || params.rite === '1969') {
const tail = url.pathname.split('/').slice(4).join('/');
const suffix = tail ? `/${tail}` : '';
throw redirect(
307,
`/${params.faithLang}/${params.calendar}/${params.rite === '1962' ? 'vetus' : 'novus'}${suffix}${url.search}`
);
}
const rite: Rite = params.rite === 'novus' ? 'novus' : 'vetus';
if (rite !== 'vetus') await errorWithVerse(fetch, url.pathname, 404, 'Not found');
const dioceseParam = url.searchParams.get('diocese');
const diocese1962: Diocese1962 = isDiocese1962(dioceseParam)
@@ -35,7 +45,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
? dioceseParam
: DEFAULT_DIOCESE_1969;
const minYear = rite === '1962' ? 1900 : 1969;
const minYear = rite === 'vetus' ? 1900 : 1969;
const year = Number(params.yyyy);
const month = Number(params.mm) - 1;
const day = Number(params.dd);
@@ -46,10 +56,27 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
if (!Number.isFinite(day) || day < 1 || day > daysInMonth) await errorWithVerse(fetch, url.pathname, 404, 'Not found');
const iso = isoFor(year, month, day);
// Romcal (scope: liturgical) emits LY N past Saturday-before-Advent-I of
// civil year N with a stale tail (still on the previous post-Pentecost
// cycle) before going missing. Dates from Advent I of civil year N onward
// belong to LY N+1, so fetch both and pick the correct one — mirrors the
// rollover logic in the month page.
const fetchLy = async (y: number) =>
rite === 'vetus'
? await getYear1962(lang, diocese1962, y)
: await getYear(lang, diocese1969, y);
const yearMapN = await fetchLy(year);
const yearMapNext = await fetchLy(year + 1);
let adventIOfUrlYear: string | null = null;
for (const [i, d] of yearMapNext) {
if (d.id === 'advent_1_sunday' || d.id === 'first_sunday_of_advent') {
adventIOfUrlYear = i;
break;
}
}
const yearMap =
rite === '1962'
? await getYear1962(lang, diocese1962, year)
: await getYear(lang, diocese1969, year);
adventIOfUrlYear != null && iso >= adventIOfUrlYear ? yearMapNext : yearMapN;
const entry = yearMap.get(iso);
if (!entry) await errorWithVerse(fetch, url.pathname, 404, 'Not found');
@@ -59,7 +86,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
return {
lang,
rite,
diocese: rite === '1962' ? diocese1962 : diocese1969,
diocese: rite === 'vetus' ? diocese1962 : diocese1969,
year,
month,
day,
@@ -145,9 +145,9 @@ export function t(key: keyof typeof ui, lang: CalendarLang): string {
return ui[key][lang] ?? ui[key].en;
}
export type Rite = '1969' | '1962';
export type Rite = 'novus' | 'vetus';
export function isValidRite(v: string | null): v is Rite {
return v === '1969' || v === '1962';
return v === 'novus' || v === 'vetus';
}
// --- Diocese selection ---