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',
});
// ─── 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 ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.

View file

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

View file

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

View file

@ -3,3 +3,4 @@ export * from './collections';
export * from './queries';
export { eventsStore } from './stores/events.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 type { LocalSocialEvent, EventStatus } from '../types';
import { eventsApi } from '../api';
import { recordTombstone } from '../tombstones';
let error = $state<string | null>(null);
@ -141,7 +142,11 @@ export const eventsStore = {
try {
await eventsApi.unpublish(id);
} 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, {
@ -197,12 +202,16 @@ export const eventsStore = {
async unpublishEvent(id: string) {
error = null;
try {
// Best-effort delete on the server. If the network fails we still
// flip the local flag — host clearly intended to unpublish.
// Capture the token before we wipe it locally so the tombstone
// queue (if needed) has something to retry against.
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
const tokenForRetry = event?.publicToken ?? null;
try {
await eventsApi.unpublish(id);
} 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, {
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 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 titleDraft = $state('');
let descDraft = $state('');

View file

@ -12,6 +12,20 @@ const EVENTS_URL =
process.env.PUBLIC_MANA_EVENTS_URL ||
'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 {
token: string;
title: string;
@ -33,22 +47,26 @@ interface RsvpSummary {
totalAttending: number;
}
export const load: PageServerLoad = async ({ params, fetch }) => {
export const load: PageServerLoad = async ({ params, fetch, request }) => {
const token = params.token;
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 {
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
if (res.status === 404) throw error(404, 'Event nicht gefunden');
if (!res.ok) throw error(500, 'Konnte Event nicht laden');
if (res.status === 404) throw error(404, notFoundMsg);
if (!res.ok) throw error(500, errorMsg);
const data = (await res.json()) as {
event: EventSnapshot;
summary: RsvpSummary | null;
cancelled?: boolean;
};
return { token, ...data, eventsUrl: EVENTS_URL };
return { token, ...data, eventsUrl: EVENTS_URL, lang };
} catch (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">
import type { PageData } from './$types';
import { getManaEventsUrl } from '$lib/api/config';
import { getStrings } from './strings';
let { data }: { data: PageData } = $props();
const t = $derived(getStrings(data.lang));
let name = $state('');
let email = $state('');
@ -15,7 +17,7 @@
const startDate = $derived(new Date(data.event.startAt));
const dateLabel = $derived(
startDate.toLocaleDateString('de-DE', {
startDate.toLocaleDateString(t.dateLocale, {
weekday: 'long',
day: '2-digit',
month: 'long',
@ -24,8 +26,8 @@
);
const timeLabel = $derived(
data.event.allDay
? 'Ganztägig'
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
? t.allDay
: startDate.toLocaleTimeString(t.dateLocale, { hour: '2-digit', minute: '2-digit' })
);
async function handleSubmit(e: SubmitEvent) {
@ -51,7 +53,7 @@
}
submitted = true;
} catch (e) {
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden';
errorMessage = e instanceof Error ? e.message : t.genericError;
} finally {
submitting = false;
}
@ -59,7 +61,7 @@
</script>
<svelte:head>
<title>{data.event.title} — RSVP</title>
<title>{data.event.title} {t.pageTitleSuffix}</title>
</svelte:head>
<div class="rsvp-page">
@ -92,7 +94,8 @@
<div class="meta-row">
<span class="icon">👥</span>
<div>
<strong>{data.summary.totalAttending}</strong> Personen kommen
<strong>{data.summary.totalAttending}</strong>
{t.peopleAttending}
{#if data.event.capacity}
<span class="muted">/ {data.event.capacity}</span>
{/if}
@ -106,16 +109,20 @@
{/if}
{#if data.cancelled}
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div>
<div class="cancelled">{t.cancelledNotice}</div>
{:else if submitted}
<div class="success">
<h2>Danke für deine Antwort!</h2>
<h2>{t.successHeading}</h2>
<p>
Du hast mit
{t.successYou}
<strong>
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'}
{status === 'yes'
? t.successComing
: status === 'no'
? t.successNotComing
: t.successMaybe}
</strong>
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.
{t.successHint}
</p>
<button
class="action-btn"
@ -123,31 +130,31 @@
submitted = false;
}}
>
Antwort ändern
{t.changeAnswer}
</button>
</div>
{:else}
<form class="rsvp-form" onsubmit={handleSubmit}>
<h2>Sag bitte zu</h2>
<h2>{t.formHeading}</h2>
<label class="field">
<span class="label">Dein Name</span>
<span class="label">{t.yourName}</span>
<input
type="text"
bind:value={name}
placeholder="z. B. Anna Schmidt"
placeholder={t.yourNamePlaceholder}
required
maxlength="100"
/>
</label>
<label class="field">
<span class="label">E-Mail (optional)</span>
<input type="email" bind:value={email} placeholder="anna@example.com" maxlength="200" />
<span class="label">{t.emailLabel}</span>
<input type="email" bind:value={email} placeholder={t.emailPlaceholder} maxlength="200" />
</label>
<div class="field">
<span class="label">Kommst du?</span>
<span class="label">{t.areYouComing}</span>
<div class="status-pills">
<button
type="button"
@ -155,7 +162,7 @@
class:active={status === 'yes'}
onclick={() => (status = 'yes')}
>
✓ Ja, komme
{t.yesComing}
</button>
<button
type="button"
@ -163,7 +170,7 @@
class:active={status === 'maybe'}
onclick={() => (status = 'maybe')}
>
? Vielleicht
{t.maybe}
</button>
<button
type="button"
@ -171,25 +178,21 @@
class:active={status === 'no'}
onclick={() => (status = 'no')}
>
✕ Nein
{t.no}
</button>
</div>
</div>
{#if status === 'yes'}
<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} />
</label>
{/if}
<label class="field">
<span class="label">Notiz (optional)</span>
<textarea
bind:value={note}
placeholder="z. B. „Komme erst um 20 Uhr“"
rows="2"
maxlength="1000"
<span class="label">{t.noteLabel}</span>
<textarea bind:value={note} placeholder={t.notePlaceholder} rows="2" maxlength="1000"
></textarea>
</label>
@ -198,14 +201,14 @@
{/if}
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
{submitting ? 'Sende...' : 'Antwort senden'}
{submitting ? t.sending : t.send}
</button>
</form>
{/if}
</div>
<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>
</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;
}