UI side:
* DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
tag each entry with data-section, and BottomTabBar exposes a
data-section-count attribute so the grid is observable from tests.
* "lists" is force-shown unconditionally — the §12.3.4 always-one-
landing rule covers the edge case where every layer says OFF.
* (app)/+layout.svelte adds a $effect that, when the URL matches a
disabled section (/tasks, /notes, /search), goto's /lists and shows
a transient `section_disabled_for_collective` toast.
* Root +layout.svelte exposes the supabase singleton on window.__sb
in dev so the new Playwright spec can patch rows without a parallel
client; dead-code-eliminated in production builds.
DB side:
* Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
public.users + public.collectives + REPLICA IDENTITY FULL on both.
Without this membership the features.ts subscriptions get zero
events and SV-02 (collective toggle → realtime → member's nav
updates) silently fails. Caught while running the spec.
* pgTAP 015 grows from 16 to 20 assertions to cover publication
membership + REPLICA IDENTITY for both new realtime tables.
Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.
Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
SV-01 user toggle → nav reflects → /tasks redirects to /lists
SV-02 admin collective toggle → member sees it disappear in realtime
SV-03 collective ON beats user OFF — per-collective resolution
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
202 lines
7.1 KiB
Svelte
202 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
import { getSupabase } from '$lib/supabase';
|
|
import { currentUser, authLoading } from '$lib/stores/auth';
|
|
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
|
import {
|
|
loadCurrentUserFeatures,
|
|
subscribeCollectiveFeatures,
|
|
teardownFeatureSubscriptions
|
|
} from '$lib/stores/features';
|
|
import type { FeatureFlags } from '@colectivo/types';
|
|
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
|
import { i18n } from '$lib/i18n';
|
|
import { initTheme } from '$lib/theme';
|
|
import { setLanguageTag } from '$lib/paraglide/runtime';
|
|
import { detectLanguage } from '$lib/utils/accept-language';
|
|
|
|
onMount(() => {
|
|
// Fase 9.1: hydrate the theme stores from localStorage and start
|
|
// listening for OS prefers-color-scheme changes. The inline script
|
|
// in app.html has already set <html data-theme> synchronously to
|
|
// avoid FOUC — initTheme just builds the JS-side state on top.
|
|
initTheme();
|
|
|
|
// Dev-only: expose the supabase client on window so Playwright tests
|
|
// can drive PostgREST through it without re-creating a parallel
|
|
// client. Production never sees this (import.meta.env.DEV is
|
|
// dead-code-eliminated by Vite at build time).
|
|
if (import.meta.env.DEV) {
|
|
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
|
|
}
|
|
|
|
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
|
// not auto-register — the virtual module is a no-op unless called.
|
|
void import('virtual:pwa-register').then(({ registerSW }) => {
|
|
registerSW({ immediate: true });
|
|
});
|
|
|
|
const supabase = getSupabase();
|
|
|
|
const {
|
|
data: { subscription }
|
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
|
currentUser.set(session?.user ?? null);
|
|
authLoading.set(false);
|
|
|
|
if (session) {
|
|
// Defer out of the onAuthStateChange callback so the internal auth
|
|
// lock is fully released before we make any PostgREST query.
|
|
// Without this, queries hang on `getAccessToken() -> initializePromise`
|
|
// in some browser environments (e.g. Playwright headless Chromium).
|
|
setTimeout(async () => {
|
|
await loadUserCollectives(session.user.id);
|
|
|
|
// Fase 12.2: load the user's feature_flags + subscribe to
|
|
// realtime updates (own row). Best-effort: a failure here
|
|
// only degrades to default-ON for every section.
|
|
try {
|
|
await loadCurrentUserFeatures(session.user.id);
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
|
|
// Fase 10.8: auto-detect language for genuinely new users.
|
|
// Best-effort, runs after every auth event so newly-seeded
|
|
// (post-Keycloak-self-registration) rows pick up the user's
|
|
// browser preference instead of the seed default 'en'.
|
|
await maybeBootstrapLanguage(session.user.id);
|
|
|
|
if (event === 'SIGNED_IN') {
|
|
// Post-login: send the user where they belong.
|
|
// Only redirect when coming from the callback or the root — not on
|
|
// token refreshes or when the user is already on a real route.
|
|
const path = $page.url.pathname;
|
|
if (path === '/auth/callback' || path === '/') {
|
|
// If the user came in via /invitation/[token] and had to
|
|
// log in first, resume that flow instead of going to
|
|
// /lists or /onboarding.
|
|
const pendingToken = sessionStorage.getItem('pendingInvitationToken');
|
|
if (pendingToken) {
|
|
sessionStorage.removeItem('pendingInvitationToken');
|
|
goto(`/invitation/${pendingToken}`);
|
|
} else if ($userCollectives.length === 0) {
|
|
goto('/onboarding');
|
|
} else {
|
|
goto('/lists');
|
|
}
|
|
}
|
|
}
|
|
}, 0);
|
|
}
|
|
});
|
|
|
|
// Fase 12.2: keep the active-collective realtime subscription in sync.
|
|
// Subscribing here (after onAuthStateChange) rather than at the auth
|
|
// callback level means it follows the user across collective switches
|
|
// without needing a sign-in event. Tear down on layout destroy too.
|
|
const collectiveUnsub = currentCollective.subscribe((c) => {
|
|
if (c) void subscribeCollectiveFeatures(c.id);
|
|
});
|
|
|
|
return () => {
|
|
subscription.unsubscribe();
|
|
collectiveUnsub();
|
|
void teardownFeatureSubscriptions();
|
|
};
|
|
});
|
|
|
|
/**
|
|
* Fase 10.8 — Accept-Language detection for first-time users.
|
|
*
|
|
* The Paraglide stack is compile-time and the OIDC dance lives outside
|
|
* SvelteKit, so we can't sniff the real Accept-Language header server-
|
|
* side here without standing up a hooks.server.ts that the auth flow
|
|
* never traverses. Instead we use `navigator.languages` (the browser's
|
|
* Accept-Language proxy) on the client right after the first SIGNED_IN.
|
|
*
|
|
* Idempotency: we only UPDATE when the row was just created (within
|
|
* 60s of now) AND `language` still equals the seed default ('en'). This
|
|
* means a Spanish-browser user lands in Spanish on first run; an
|
|
* established user who later switched themselves to English stays
|
|
* English; existing users are not retroactively re-detected (plan
|
|
* §10.8.5 — respect their previous explicit choice).
|
|
*/
|
|
async function maybeBootstrapLanguage(userId: string): Promise<void> {
|
|
const supabase = getSupabase();
|
|
const { data } = await supabase
|
|
.from('users')
|
|
.select('language, created_at')
|
|
.eq('id', userId)
|
|
.single();
|
|
if (!data) return;
|
|
|
|
const ageMs = Date.now() - new Date(data.created_at).getTime();
|
|
const isFreshAccount = ageMs < 60_000;
|
|
if (!isFreshAccount) return;
|
|
if (data.language !== 'en') return;
|
|
|
|
const langs =
|
|
(typeof navigator !== 'undefined' &&
|
|
(navigator.languages?.length ? Array.from(navigator.languages) : [navigator.language])) ||
|
|
[];
|
|
const detected = detectLanguage(langs);
|
|
if (detected === 'en') return;
|
|
|
|
await supabase.from('users').update({ language: detected }).eq('id', userId);
|
|
setLanguageTag(detected);
|
|
}
|
|
|
|
async function loadUserCollectives(userId: string): Promise<void> {
|
|
const supabase = getSupabase();
|
|
|
|
const { data } = await supabase
|
|
.from('collective_members')
|
|
.select(
|
|
'collective_id, role, collectives(id, name, emoji, created_at, feature_flags)'
|
|
)
|
|
.eq('user_id', userId);
|
|
|
|
if (!data) return;
|
|
|
|
type CollectiveRow = {
|
|
id: string;
|
|
name: string;
|
|
emoji: string;
|
|
created_at: string;
|
|
feature_flags: FeatureFlags;
|
|
};
|
|
const collectives = data
|
|
.map((row) => {
|
|
const c = row.collectives as
|
|
| { id: string; name: string; emoji: string; created_at: string; feature_flags: FeatureFlags | null }
|
|
| null;
|
|
return c
|
|
? {
|
|
id: c.id,
|
|
name: c.name,
|
|
emoji: c.emoji,
|
|
created_at: c.created_at,
|
|
feature_flags: (c.feature_flags ?? {}) as FeatureFlags
|
|
}
|
|
: null;
|
|
})
|
|
.filter((c): c is CollectiveRow => c !== null);
|
|
|
|
userCollectives.set(collectives);
|
|
|
|
// Restore last active collective from localStorage, or default to first
|
|
const savedId = localStorage.getItem('activeCollectiveId');
|
|
const active = collectives.find((c) => c.id === savedId) ?? collectives[0] ?? null;
|
|
currentCollective.set(active);
|
|
if (active) localStorage.setItem('activeCollectiveId', active.id);
|
|
}
|
|
</script>
|
|
|
|
<ParaglideJS {i18n}>
|
|
<slot />
|
|
</ParaglideJS>
|