fix(mana/web/news): instant onboarding handoff to feed branch

The "Fertig" button needed two clicks before the wizard would
disappear. Cause: the wizard branch is gated on
`prefs.onboardingCompleted` which comes out of a Dexie liveQuery.
liveQuery debounces and emits the post-write value ~50-100ms after
the table.update() returns, so the first click writes the row but
the page re-renders the same wizard step until the next liveQuery
tick. Users instinctively click again before noticing.

Fix: a local `onboardingJustFinished` $state override that flips to
true synchronously inside `finishOnboarding()`. The wizard branch is
now hidden by `!(prefs.onboardingCompleted || onboardingJustFinished)`,
so the feed appears the instant the write resolves. The liveQuery
catches up a moment later but its update is a no-op because the
override and the queried value agree.

Also:
  - `onboardingSubmitting` $state guard so a panicked double-click
    gets ignored, and the button shows "Speichere…" while the write
    is in flight (visual feedback that something is happening)
  - Eagerly call `feedCacheStore.refresh()` from finishOnboarding so
    the feed isn't empty for the moment the layout's $effect needs
    to notice the prefs change. The store's inFlight guard makes the
    redundant layout-effect refresh a no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 19:41:31 +02:00
parent 958819f06a
commit f3effe9390

View file

@ -39,6 +39,14 @@
let pickedLanguages = $state<Language[]>(['de', 'en']);
let pickedBlocked = $state<string[]>([]);
let onboardingStep = $state<1 | 2 | 3>(1);
// Local "just finished" override so the wizard hides immediately on
// click instead of waiting for Dexie's liveQuery to debounce + emit
// the new prefs.onboardingCompleted = true. Without this, the user
// clicks "Fertig", the write goes through, but the UI re-renders
// the same wizard step until the next liveQuery tick (~50-100ms),
// so people instinctively click again before noticing the change.
let onboardingJustFinished = $state(false);
let onboardingSubmitting = $state(false);
function toggleTopic(t: Topic) {
pickedTopics = pickedTopics.includes(t)
@ -57,20 +65,49 @@
}
async function finishOnboarding() {
// $state.snapshot strips the Svelte 5 reactive proxies — without it
// the arrays travel into Dexie hooks as proxies and trip
// DataCloneError on the structured-clone into _pendingChanges.
await preferencesStore.completeOnboarding({
topics: $state.snapshot(pickedTopics) as Topic[],
languages: $state.snapshot(pickedLanguages) as Language[],
blockedSources: $state.snapshot(pickedBlocked) as string[],
});
// The +layout effect will pick up the new prefs and refresh.
if (onboardingSubmitting) return;
onboardingSubmitting = true;
try {
// $state.snapshot strips the Svelte 5 reactive proxies — without it
// the arrays travel into Dexie hooks as proxies and trip
// DataCloneError on the structured-clone into _pendingChanges.
const topicsSnap = $state.snapshot(pickedTopics) as Topic[];
const langsSnap = $state.snapshot(pickedLanguages) as Language[];
const blockedSnap = $state.snapshot(pickedBlocked) as string[];
await preferencesStore.completeOnboarding({
topics: topicsSnap,
languages: langsSnap,
blockedSources: blockedSnap,
});
// Flip to the feed branch immediately. Without this we'd be at
// the mercy of Dexie's liveQuery debounce — the prefs read
// behind `prefs.onboardingCompleted` only updates a few ticks
// after the write, so the wizard would re-render the same
// step for ~50-100ms and the user would click "Fertig" twice.
onboardingJustFinished = true;
// Eagerly trigger the first feed pull instead of waiting for
// the layout's $effect to notice the prefs change. The layout
// effect WILL also fire shortly after, but its refresh is a
// no-op via the store's inFlight guard.
void feedCacheStore.refresh({
topics: topicsSnap,
lang: langsSnap.length === 1 ? langsSnap[0] : 'all',
});
} finally {
onboardingSubmitting = false;
}
}
// ─── Feed branch ──────────────────────────────────────────
const reactedIds = $derived(buildReactedIds(reactions));
const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []);
// Treat the local "just finished" override as fully onboarded so the
// feed renders immediately after the user clicks Fertig, before the
// liveQuery has had a chance to refresh prefs.
const isOnboarded = $derived(prefs.onboardingCompleted || onboardingJustFinished);
const ranked = $derived(isOnboarded ? rankFeed(pool, { prefs, reactedIds }) : []);
async function react(
article: LocalCachedArticle,
@ -107,7 +144,7 @@
</svelte:head>
<div class="news-page">
{#if !prefs.onboardingCompleted}
{#if !isOnboarded}
<!-- ─── Onboarding ───────────────────────────────────── -->
<header class="hero">
<h1>Willkommen beim News Hub</h1>
@ -216,7 +253,14 @@
<button type="button" class="btn-secondary" onclick={() => (onboardingStep = 2)}>
Zurück
</button>
<button type="button" class="btn-primary" onclick={finishOnboarding}> Fertig </button>
<button
type="button"
class="btn-primary"
onclick={finishOnboarding}
disabled={onboardingSubmitting}
>
{onboardingSubmitting ? 'Speichere…' : 'Fertig'}
</button>
</div>
</section>
{/if}