fix(events): tech debt — self-heal snapshots, tombstones, polling cleanup, RSVP i18n

- PublicRsvpList: collapse onMount/onDestroy/$effect into a single
  $effect with proper cleanup; eliminates the redundant interval init
  paths and the dead else-branch
- DetailView: re-push the snapshot to mana-events when a published
  event is opened, so any earlier fire-and-forget that lost a write
  silently self-heals
- New _eventsTombstones queue (db version 8): when unpublish/delete
  fails to remove the server snapshot, queue (eventId, token) for
  retry; ListView drains the queue on mount with capped attempts
- Public /rsvp/[token]: detect Accept-Language in +page.server.ts,
  pass lang to the page, and use a small inline DE/EN dict in
  strings.ts — no svelte-i18n on the public route
This commit is contained in:
Till JS 2026-04-07 14:36:11 +02:00
parent fbab96c74b
commit 836c9692c5
10 changed files with 283 additions and 59 deletions

View file

@ -433,6 +433,15 @@ db.version(7).stores({
cycleSymptoms: 'id, name, category, count, updatedAt', cycleSymptoms: 'id, name, category, count, updatedAt',
}); });
// ─── Version 8: Events tombstones (orphaned snapshot cleanup) ─
// Local-only retry queue. When the events store fails to DELETE a
// server snapshot during unpublish/delete, the (eventId, token) is
// pushed here so a later drain attempt can clean it up. NOT synced.
db.version(8).stores({
_eventsTombstones: 'id, token, attempts, createdAt',
});
// ─── Sync App Map ────────────────────────────────────────── // ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing. // Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}. // The SyncEngine uses this to group pending changes and push to /sync/{appId}.

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries'; import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
import { eventsStore } from './stores/events.svelte'; import { eventsStore } from './stores/events.svelte';
import { drainTombstones } from './tombstones';
import EventCard from './components/EventCard.svelte'; import EventCard from './components/EventCard.svelte';
import type { SocialEvent } from './types'; import type { SocialEvent } from './types';
@ -14,6 +16,11 @@
const past = usePastEvents(); const past = usePastEvents();
const guestsByEvent = useGuestsByEvent(); const guestsByEvent = useGuestsByEvent();
// Retry any orphaned server snapshots from previous failed deletes.
onMount(() => {
void drainTombstones();
});
let showCreate = $state(false); let showCreate = $state(false);
let newTitle = $state(''); let newTitle = $state('');
let newDate = $state(''); let newDate = $state('');

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { eventsApi, type PublicRsvpRecord } from '../api'; import { eventsApi, type PublicRsvpRecord } from '../api';
import { eventGuestsStore } from '../stores/guests.svelte'; import { eventGuestsStore } from '../stores/guests.svelte';
@ -14,7 +13,6 @@
let loading = $state(false); let loading = $state(false);
let lastError = $state<string | null>(null); let lastError = $state<string | null>(null);
let lastFetchedAt = $state<Date | null>(null); let lastFetchedAt = $state<Date | null>(null);
let pollHandle: ReturnType<typeof setInterval> | null = null;
async function fetchRsvps() { async function fetchRsvps() {
if (!isPublished) return; if (!isPublished) return;
@ -31,27 +29,16 @@
} }
} }
onMount(() => { // Single source of truth: poll only while published. Cleanup tears down
if (isPublished) { // the interval automatically on unmount or when isPublished flips false.
void fetchRsvps();
pollHandle = setInterval(fetchRsvps, 30_000);
}
});
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
});
// Re-poll when isPublished flips
$effect(() => { $effect(() => {
if (isPublished && !pollHandle) { if (!isPublished) {
void fetchRsvps();
pollHandle = setInterval(fetchRsvps, 30_000);
} else if (!isPublished && pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
rsvps = []; rsvps = [];
return;
} }
void fetchRsvps();
const id = setInterval(fetchRsvps, 30_000);
return () => clearInterval(id);
}); });
async function importToGuestList(r: PublicRsvpRecord) { async function importToGuestList(r: PublicRsvpRecord) {

View file

@ -3,3 +3,4 @@ export * from './collections';
export * from './queries'; export * from './queries';
export { eventsStore } from './stores/events.svelte'; export { eventsStore } from './stores/events.svelte';
export { eventGuestsStore } from './stores/guests.svelte'; export { eventGuestsStore } from './stores/guests.svelte';
export { drainTombstones, recordTombstone } from './tombstones';

View file

@ -10,6 +10,7 @@ import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/ser
import { timeBlockTable } from '$lib/data/time-blocks/collections'; import { timeBlockTable } from '$lib/data/time-blocks/collections';
import type { LocalSocialEvent, EventStatus } from '../types'; import type { LocalSocialEvent, EventStatus } from '../types';
import { eventsApi } from '../api'; import { eventsApi } from '../api';
import { recordTombstone } from '../tombstones';
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -141,7 +142,11 @@ export const eventsStore = {
try { try {
await eventsApi.unpublish(id); await eventsApi.unpublish(id);
} catch (e) { } catch (e) {
console.warn('Failed to delete server snapshot during deleteEvent:', e); console.warn(
'Failed to delete server snapshot during deleteEvent, queuing tombstone:',
e
);
if (event.publicToken) await recordTombstone(id, event.publicToken);
} }
} }
await db.table('socialEvents').update(id, { await db.table('socialEvents').update(id, {
@ -197,12 +202,16 @@ export const eventsStore = {
async unpublishEvent(id: string) { async unpublishEvent(id: string) {
error = null; error = null;
try { try {
// Best-effort delete on the server. If the network fails we still // Capture the token before we wipe it locally so the tombstone
// flip the local flag — host clearly intended to unpublish. // queue (if needed) has something to retry against.
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
const tokenForRetry = event?.publicToken ?? null;
try { try {
await eventsApi.unpublish(id); await eventsApi.unpublish(id);
} catch (e) { } catch (e) {
console.warn('Failed to delete server snapshot during unpublish:', e); console.warn('Failed to delete server snapshot during unpublish, queuing tombstone:', e);
if (tokenForRetry) await recordTombstone(id, tokenForRetry);
} }
await db.table('socialEvents').update(id, { await db.table('socialEvents').update(id, {
isPublished: false, isPublished: false,

View file

@ -0,0 +1,71 @@
/**
* Tombstone queue for orphaned mana-events snapshots.
*
* When the client fails to DELETE a published snapshot from the server
* (network error, server down, etc.), the local event is still removed
* but the server keeps a stale copy. We push the (eventId, token) here
* so a later drain pass can retry the delete.
*/
import { db } from '$lib/data/database';
import { eventsApi } from './api';
interface Tombstone {
id: string;
token: string;
eventId: string;
attempts: number;
createdAt: string;
}
const MAX_ATTEMPTS = 10;
export async function recordTombstone(eventId: string, token: string): Promise<void> {
try {
await db.table<Tombstone>('_eventsTombstones').put({
id: token, // token is unique per snapshot
token,
eventId,
attempts: 0,
createdAt: new Date().toISOString(),
});
} catch (e) {
console.warn('Failed to record events tombstone:', e);
}
}
let draining = false;
/**
* Try to delete every queued snapshot. Idempotent safe to call repeatedly.
* Tombstones are removed on success or after MAX_ATTEMPTS gives up.
* Skips work if a drain is already in progress.
*/
export async function drainTombstones(): Promise<{ deleted: number; failed: number }> {
if (draining) return { deleted: 0, failed: 0 };
draining = true;
let deleted = 0;
let failed = 0;
try {
const all = await db.table<Tombstone>('_eventsTombstones').toArray();
for (const t of all) {
try {
await eventsApi.unpublish(t.eventId);
await db.table('_eventsTombstones').delete(t.id);
deleted++;
} catch {
const next = t.attempts + 1;
if (next >= MAX_ATTEMPTS) {
console.warn(`Giving up on events tombstone ${t.token} after ${MAX_ATTEMPTS} attempts`);
await db.table('_eventsTombstones').delete(t.id);
} else {
await db.table('_eventsTombstones').update(t.id, { attempts: next });
}
failed++;
}
}
} finally {
draining = false;
}
return { deleted, failed };
}

View file

@ -17,6 +17,17 @@
const summary = $derived(summarizeRsvps(guests.value ?? [])); const summary = $derived(summarizeRsvps(guests.value ?? []));
const event = $derived(eventQuery.value); const event = $derived(eventQuery.value);
// Self-heal: if a previous edit failed to push its snapshot to the
// server (fire-and-forget can lose writes), opening the detail view
// re-pushes the current state. Idempotent and cheap.
let lastHealedId: string | null = null;
$effect(() => {
if (event?.isPublished && event.id !== lastHealedId) {
lastHealedId = event.id;
void eventsStore.syncSnapshotIfPublished(event.id);
}
});
let editing = $state(false); let editing = $state(false);
let titleDraft = $state(''); let titleDraft = $state('');
let descDraft = $state(''); let descDraft = $state('');

View file

@ -12,6 +12,20 @@ const EVENTS_URL =
process.env.PUBLIC_MANA_EVENTS_URL || process.env.PUBLIC_MANA_EVENTS_URL ||
'http://localhost:3065'; 'http://localhost:3065';
type Lang = 'de' | 'en';
/** Pick the best supported language from an Accept-Language header. */
function pickLang(header: string | null): Lang {
if (!header) return 'de';
// Header looks like "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
const parts = header.split(',').map((p) => p.trim().split(';')[0].toLowerCase().slice(0, 2));
for (const p of parts) {
if (p === 'de') return 'de';
if (p === 'en') return 'en';
}
return 'de';
}
interface EventSnapshot { interface EventSnapshot {
token: string; token: string;
title: string; title: string;
@ -33,22 +47,26 @@ interface RsvpSummary {
totalAttending: number; totalAttending: number;
} }
export const load: PageServerLoad = async ({ params, fetch }) => { export const load: PageServerLoad = async ({ params, fetch, request }) => {
const token = params.token; const token = params.token;
if (!token) throw error(404, 'Not found'); if (!token) throw error(404, 'Not found');
const lang = pickLang(request.headers.get('accept-language'));
const notFoundMsg = lang === 'de' ? 'Event nicht gefunden' : 'Event not found';
const errorMsg = lang === 'de' ? 'Konnte Event nicht laden' : 'Could not load event';
try { try {
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`); const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
if (res.status === 404) throw error(404, 'Event nicht gefunden'); if (res.status === 404) throw error(404, notFoundMsg);
if (!res.ok) throw error(500, 'Konnte Event nicht laden'); if (!res.ok) throw error(500, errorMsg);
const data = (await res.json()) as { const data = (await res.json()) as {
event: EventSnapshot; event: EventSnapshot;
summary: RsvpSummary | null; summary: RsvpSummary | null;
cancelled?: boolean; cancelled?: boolean;
}; };
return { token, ...data, eventsUrl: EVENTS_URL }; return { token, ...data, eventsUrl: EVENTS_URL, lang };
} catch (e) { } catch (e) {
if (e && typeof e === 'object' && 'status' in e) throw e; if (e && typeof e === 'object' && 'status' in e) throw e;
throw error(500, 'Konnte Event nicht laden'); throw error(500, errorMsg);
} }
}; };

View file

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { getManaEventsUrl } from '$lib/api/config'; import { getManaEventsUrl } from '$lib/api/config';
import { getStrings } from './strings';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const t = $derived(getStrings(data.lang));
let name = $state(''); let name = $state('');
let email = $state(''); let email = $state('');
@ -15,7 +17,7 @@
const startDate = $derived(new Date(data.event.startAt)); const startDate = $derived(new Date(data.event.startAt));
const dateLabel = $derived( const dateLabel = $derived(
startDate.toLocaleDateString('de-DE', { startDate.toLocaleDateString(t.dateLocale, {
weekday: 'long', weekday: 'long',
day: '2-digit', day: '2-digit',
month: 'long', month: 'long',
@ -24,8 +26,8 @@
); );
const timeLabel = $derived( const timeLabel = $derived(
data.event.allDay data.event.allDay
? 'Ganztägig' ? t.allDay
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : startDate.toLocaleTimeString(t.dateLocale, { hour: '2-digit', minute: '2-digit' })
); );
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
@ -51,7 +53,7 @@
} }
submitted = true; submitted = true;
} catch (e) { } catch (e) {
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden'; errorMessage = e instanceof Error ? e.message : t.genericError;
} finally { } finally {
submitting = false; submitting = false;
} }
@ -59,7 +61,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.event.title} — RSVP</title> <title>{data.event.title} {t.pageTitleSuffix}</title>
</svelte:head> </svelte:head>
<div class="rsvp-page"> <div class="rsvp-page">
@ -92,7 +94,8 @@
<div class="meta-row"> <div class="meta-row">
<span class="icon">👥</span> <span class="icon">👥</span>
<div> <div>
<strong>{data.summary.totalAttending}</strong> Personen kommen <strong>{data.summary.totalAttending}</strong>
{t.peopleAttending}
{#if data.event.capacity} {#if data.event.capacity}
<span class="muted">/ {data.event.capacity}</span> <span class="muted">/ {data.event.capacity}</span>
{/if} {/if}
@ -106,16 +109,20 @@
{/if} {/if}
{#if data.cancelled} {#if data.cancelled}
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div> <div class="cancelled">{t.cancelledNotice}</div>
{:else if submitted} {:else if submitted}
<div class="success"> <div class="success">
<h2>Danke für deine Antwort!</h2> <h2>{t.successHeading}</h2>
<p> <p>
Du hast mit {t.successYou}
<strong> <strong>
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'} {status === 'yes'
? t.successComing
: status === 'no'
? t.successNotComing
: t.successMaybe}
</strong> </strong>
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern. {t.successHint}
</p> </p>
<button <button
class="action-btn" class="action-btn"
@ -123,31 +130,31 @@
submitted = false; submitted = false;
}} }}
> >
Antwort ändern {t.changeAnswer}
</button> </button>
</div> </div>
{:else} {:else}
<form class="rsvp-form" onsubmit={handleSubmit}> <form class="rsvp-form" onsubmit={handleSubmit}>
<h2>Sag bitte zu</h2> <h2>{t.formHeading}</h2>
<label class="field"> <label class="field">
<span class="label">Dein Name</span> <span class="label">{t.yourName}</span>
<input <input
type="text" type="text"
bind:value={name} bind:value={name}
placeholder="z. B. Anna Schmidt" placeholder={t.yourNamePlaceholder}
required required
maxlength="100" maxlength="100"
/> />
</label> </label>
<label class="field"> <label class="field">
<span class="label">E-Mail (optional)</span> <span class="label">{t.emailLabel}</span>
<input type="email" bind:value={email} placeholder="anna@example.com" maxlength="200" /> <input type="email" bind:value={email} placeholder={t.emailPlaceholder} maxlength="200" />
</label> </label>
<div class="field"> <div class="field">
<span class="label">Kommst du?</span> <span class="label">{t.areYouComing}</span>
<div class="status-pills"> <div class="status-pills">
<button <button
type="button" type="button"
@ -155,7 +162,7 @@
class:active={status === 'yes'} class:active={status === 'yes'}
onclick={() => (status = 'yes')} onclick={() => (status = 'yes')}
> >
✓ Ja, komme {t.yesComing}
</button> </button>
<button <button
type="button" type="button"
@ -163,7 +170,7 @@
class:active={status === 'maybe'} class:active={status === 'maybe'}
onclick={() => (status = 'maybe')} onclick={() => (status = 'maybe')}
> >
? Vielleicht {t.maybe}
</button> </button>
<button <button
type="button" type="button"
@ -171,25 +178,21 @@
class:active={status === 'no'} class:active={status === 'no'}
onclick={() => (status = 'no')} onclick={() => (status = 'no')}
> >
✕ Nein {t.no}
</button> </button>
</div> </div>
</div> </div>
{#if status === 'yes'} {#if status === 'yes'}
<label class="field"> <label class="field">
<span class="label">Bringst du jemanden mit? ({plusOnes})</span> <span class="label">{t.bringingPeople(plusOnes)}</span>
<input type="range" min="0" max="10" bind:value={plusOnes} /> <input type="range" min="0" max="10" bind:value={plusOnes} />
</label> </label>
{/if} {/if}
<label class="field"> <label class="field">
<span class="label">Notiz (optional)</span> <span class="label">{t.noteLabel}</span>
<textarea <textarea bind:value={note} placeholder={t.notePlaceholder} rows="2" maxlength="1000"
bind:value={note}
placeholder="z. B. „Komme erst um 20 Uhr“"
rows="2"
maxlength="1000"
></textarea> ></textarea>
</label> </label>
@ -198,14 +201,14 @@
{/if} {/if}
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}> <button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
{submitting ? 'Sende...' : 'Antwort senden'} {submitting ? t.sending : t.send}
</button> </button>
</form> </form>
{/if} {/if}
</div> </div>
<footer class="footer"> <footer class="footer">
Powered by <a href="https://mana.how" target="_blank" rel="noopener">Mana</a> {t.poweredBy} <a href="https://mana.how" target="_blank" rel="noopener">Mana</a>
</footer> </footer>
</div> </div>

View file

@ -0,0 +1,108 @@
/**
* Inline i18n dictionary for the public RSVP page.
* Kept tiny and local this page is the only public consumer.
*/
export type Lang = 'de' | 'en';
interface Strings {
rsvpTitle: string;
pageTitleSuffix: string;
allDay: string;
peopleAttending: string;
cancelledNotice: string;
successHeading: string;
successYou: string;
successComing: string;
successNotComing: string;
successMaybe: string;
successHint: string;
changeAnswer: string;
formHeading: string;
yourName: string;
yourNamePlaceholder: string;
emailLabel: string;
emailPlaceholder: string;
areYouComing: string;
yesComing: string;
maybe: string;
no: string;
bringingPeople: (count: number) => string;
noteLabel: string;
notePlaceholder: string;
send: string;
sending: string;
genericError: string;
poweredBy: string;
dateLocale: string;
}
const DICTS: Record<Lang, Strings> = {
de: {
rsvpTitle: 'Sag bitte zu',
pageTitleSuffix: '— RSVP',
allDay: 'Ganztägig',
peopleAttending: 'Personen kommen',
cancelledNotice: '⚠️ Dieses Event wurde abgesagt.',
successHeading: 'Danke für deine Antwort!',
successYou: 'Du hast mit',
successComing: '„Ja, komme“',
successNotComing: '„Nein“',
successMaybe: '„Vielleicht“',
successHint:
'geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.',
changeAnswer: 'Antwort ändern',
formHeading: 'Sag bitte zu',
yourName: 'Dein Name',
yourNamePlaceholder: 'z. B. Anna Schmidt',
emailLabel: 'E-Mail (optional)',
emailPlaceholder: 'anna@example.com',
areYouComing: 'Kommst du?',
yesComing: '✓ Ja, komme',
maybe: '? Vielleicht',
no: '✕ Nein',
bringingPeople: (count) => `Bringst du jemanden mit? (${count})`,
noteLabel: 'Notiz (optional)',
notePlaceholder: 'z. B. „Komme erst um 20 Uhr“',
send: 'Antwort senden',
sending: 'Sende...',
genericError: 'Konnte nicht senden',
poweredBy: 'Powered by',
dateLocale: 'de-DE',
},
en: {
rsvpTitle: 'Please RSVP',
pageTitleSuffix: '— RSVP',
allDay: 'All day',
peopleAttending: 'people attending',
cancelledNotice: '⚠️ This event has been cancelled.',
successHeading: 'Thanks for your reply!',
successYou: 'You answered',
successComing: '"Yes, coming"',
successNotComing: '"No"',
successMaybe: '"Maybe"',
successHint: '. You can reopen this page anytime to change your answer.',
changeAnswer: 'Change answer',
formHeading: 'Please RSVP',
yourName: 'Your name',
yourNamePlaceholder: 'e.g. Anna Smith',
emailLabel: 'Email (optional)',
emailPlaceholder: 'anna@example.com',
areYouComing: 'Are you coming?',
yesComing: '✓ Yes, coming',
maybe: '? Maybe',
no: '✕ No',
bringingPeople: (count) => `Bringing anyone? (${count})`,
noteLabel: 'Note (optional)',
notePlaceholder: 'e.g. "Will arrive at 8pm"',
send: 'Send reply',
sending: 'Sending...',
genericError: 'Could not send',
poweredBy: 'Powered by',
dateLocale: 'en-US',
},
};
export function getStrings(lang: Lang): Strings {
return DICTS[lang] ?? DICTS.de;
}