Adds docs/history/fase-12-section-visibility.md describing the COALESCE-precedence design that keeps Fase 13's server-settings layer to a one-line diff, the realtime publication addition that SV-02 forced, and the hermetic test-reset deviation from the plan. Updates CLAUDE.md project status (2/6 → 4/6 since Fase 11 and 12 both shipped on the mvp2 branch) and adds the "new top-level section requires" rule under Domain Model. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14 KiB
Fase 12 — Section visibility / feature toggles (MVP2 4/6)
Status: complete · 2026-05-18.
Fourth slice of MVP2. Lets each user hide top-level sections they don't use, and lets a collective admin hide them for every member. No plugin system — JSONB feature_flags on users + collectives resolved through a single STABLE SQL function, mirrored client-side for nav rendering.
Pre-requisite for Fase 13 (server administration). The SQL helper is designed so the Fase-13 server layer is a one-line diff.
Final test counts: 124 pgTAP + 163 Vitest integration + 45 Vitest unit + 82 Playwright = 414 green; 3 skipped (2 upstream Realtime presence + 1 gated rate-limit). Delta vs Fase 11:
| Suite | Before | After | Δ |
|---|---|---|---|
| pgTAP | 104 | 124 | +20 |
| Vitest integration | 157 | 163 | +6 |
| Vitest unit | 45 | 45 | 0 |
| Playwright E2E | 79 | 82 | +3 |
| Total | 385 | 414 | +29 |
12.1 — Model + SQL helpers
Migration 023_section_visibility.sql adds:
public.users.feature_flags jsonb NOT NULL DEFAULT '{}'::jsonbpublic.collectives.feature_flags jsonb NOT NULL DEFAULT '{}'::jsonbpublic.known_sections() returns text[] IMMUTABLE— canonical 4 keys (lists,tasks,notes,search). Adding a section is a fresh migration; nothing else queries the function at runtime, but it documents the contract.public.section_enabled(p_section text, p_user uuid, p_collective uuid) returns boolean STABLE SECURITY DEFINER— the precedence resolver.
The function body is structured as a single COALESCE so the Fase-13 server layer is a literal one-line prepend — no callers change shape, no extra branching. The expected end state once Fase 13 lands:
SELECT COALESCE(
(SELECT (value ->> p_section)::boolean -- Fase 13 server layer (FUTURE)
FROM public.server_settings
WHERE key = 'default_sections'),
(SELECT (feature_flags ->> p_section)::boolean
FROM public.collectives WHERE id = p_collective),
(SELECT (feature_flags ->> p_section)::boolean
FROM public.users WHERE id = p_user),
true
);
A comment in the migration spells out the chain (server → collective → user → default true) so the next contributor can see the slot before having read this doc.
RLS: writes to feature_flags piggyback on the existing users_update_own (migration 003) + collectives_update (admin-only) policies. No new policy was needed — the JSONB column is a normal column from RLS's perspective. This was the rule-7 stop condition; it did not trigger.
Realtime: also added in this migration. public.users and public.collectives are now in the supabase_realtime publication with REPLICA IDENTITY FULL. Without them, the client-side subscriptions in features.ts see zero events — caught by SV-02 (the dual-context realtime test) before it ever passed locally.
pgTAP 015_section_visibility.sql — 20 assertions:
- SV-T01..SV-T06: column shape (existence, type
jsonb, NOT NULL) for both tables. - SV-T07..SV-T09:
known_sections()exists, is IMMUTABLE, returns the canonical 4 keys in order. - SV-T10..SV-T12:
section_enabled()exists with the right(text, uuid, uuid) → booleansignature, is STABLE. - SV-T13: empty flags on both layers → default ON.
- SV-T14: user OFF + collective absent → OFF.
- SV-T15: collective OFF wins over user ON.
- SV-T16: unknown section key → default true (forward-compat — a newly-deployed front-end can call
section_enabled('newkey', …)before the migration that adds the key lands and the call doesn't error). - SV-T17..SV-T20: realtime publication membership + REPLICA IDENTITY FULL for both new tables.
packages/types/src/database.ts hand-extended with feature_flags: Json on the users + collectives Row/Insert/Update shapes plus the two new Functions (known_sections, section_enabled). just db-types remains gated (Supabase CLI not installed on this host) — same convention as Fase 11.
12.2 — Stores + types
packages/types/src/domain.ts:
export type SectionKey = 'lists' | 'tasks' | 'notes' | 'search';
export const SECTION_KEYS: readonly SectionKey[] = ['lists', 'tasks', 'notes', 'search'];
export type FeatureFlags = Partial<Record<SectionKey, boolean>>;
apps/web/src/lib/stores/features.ts (new) — the client mirror of section_enabled():
currentUserFeatureswritable, loaded by root+layout.svelteafter sign-in.enabledSectionsderivedRecord<SectionKey, boolean>applying the same precedence (collective > user > default true). Used as$enabledSections.tasksin components.enabledSectionListderivedSectionKey[]— the ordered list of visible keys with one client-side hard override: if every layer says OFF for everything, force['lists']so the user always has a landing page (§12.3.4).setUserFeature(key, value)— optimistic local patch, rolls back on RLS denial.setCollectiveFeature(key, value)— optimistic patch, throwsforbiddenwhen the UPDATE returns zero rows (RLS denied), which the UI surfaces by snapping the toggle back.loadCurrentUserFeatures(userId)+subscribeCollectiveFeatures(collectiveId)— set up one Realtime channel each per target. The user channel is keyed by user id; the collective channel is rebuilt whenever$currentCollective.idchanges (subscribed viacurrentCollective.subscribe(...)in the root layout).
Per CLAUDE.md "Supabase JS + onAuthStateChange deadlock" gotcha, loadCurrentUserFeatures is called from inside the already-deferred setTimeout(..., 0) block in the auth callback — same shape as the existing loadUserCollectives path. The collective channel is set up outside that callback so collective switches don't need an auth event.
Collective interface (apps/web/src/lib/stores/collective.ts) grows a feature_flags: FeatureFlags field. loadUserCollectives extends its SELECT to fetch it. Every other place that fabricated a Collective by hand (CreateCollectiveModal, onboarding/+page.svelte, collective/manage/+page.svelte) now includes feature_flags: {} for fresh rows or selects it on rename/recolor.
Vitest integration tests/section-visibility.test.ts — 6 assertions:
- SV-01 default ON for every known section.
- SV-02 user OFF affects only that user, not peers.
- SV-03 collective OFF beats user ON.
- SV-04 user can update own
feature_flags(RLS write piggyback). - SV-05 admin can update collective
feature_flags; non-admin member's UPDATE returns 0 rows (RLS denied). - SV-06 a user in two collectives sees the per-collective override resolution independently.
12.3 — UI gating + redirect guard
Nav components (DesktopSidebar, BottomTabBar):
- Each nav entry declared with its
SectionKeyso the runtime filter is one line:allItems.filter((it) => it.section === 'lists' || $enabledSections[it.section]). - "lists" is force-shown unconditionally (the §12.3.4 always-one-landing rule).
- Both components add
data-section="…"on each entry;BottomTabBaraddsdata-section-count="N"on the nav so tests can assert grid changes.
MobileDrawer doesn't carry nav entries today (only the collective switcher + Manage + Settings + Logout) so it didn't need a change. If a future iteration adds nav links there, the same filter pattern applies.
Route guard in (app)/+layout.svelte: a $effect watches $page.url.pathname. If it matches a disabled section's prefix (/tasks, /notes, /search — /lists is always allowed), it calls goto('/lists') and surfaces a transient section_disabled_for_collective toast (top-centered, 3.5 s auto-dismiss). Adding a new section requires extending the SECTION_BY_PREFIX array — flagged in CLAUDE.md.
The toast is intentionally separate from the existing UndoToast so the two can stack without colliding.
Dev-only hook: +layout.svelte's onMount exposes the Supabase client singleton on window.__sb when import.meta.env.DEV is true so the Playwright spec can patch rows directly via the same client (and the same JWT) the app uses. Vite dead-code-eliminates this in production builds.
12.4 — Settings + manage UI
/settings:
- New "Visible sections" block with one checkbox per
SectionKey, bound tosetUserFeature. - When the active collective has explicitly turned a section OFF, that user toggle is disabled and a one-line "Hidden by the collective" hint replaces the visible state — explaining why the per-user toggle is moot.
/collective/manage (admin-only, inside the existing isAdmin block):
- New "Collective sections" block with one checkbox per
SectionKey, bound tosetCollectiveFeature. - Flipping any of them propagates to every member in realtime — that's exactly what SV-02 verifies.
Both helpers fall back to default-ON semantics when the JSONB key is missing, mirroring section_enabled(). So untouched rows render exactly as before fase 12 — nothing is forced on or off.
11 new Paraglide messages in en + es: section_disabled_for_collective, settings_section_visibility_{title,blurb,overridden}, collective_section_visibility_{title,blurb}, section_label_{lists,tasks,notes,search}.
12.5 — Convention rule
Added to CLAUDE.md under "Domain Model" — adding a new top-level section requires all four of:
- Migration that updates
public.known_sections(). SectionKey+SECTION_KEYSentry inpackages/types/src/domain.ts.- Paraglide
section_label_<key>message + route prefix in(app)/+layout.svelte'sSECTION_BY_PREFIX. - pgTAP assertion that
section_enabled('<newkey>', …)defaults to true.
The settings + manage toggles iterate SECTION_KEYS, so they're automatic once steps 2 + 3 are done.
12.6 — Tests
E2E tests/e2e/section-visibility.test.ts (SV-01..SV-03):
- SV-01 Ana flips the user toggle for Tasks in /settings → sidebar + bottom tab bar lose the entry → visiting /tasks bounces to /lists + the toast surfaces.
- SV-02 Dual-context. Ana (admin) hides Notes for the collective in /collective/manage; Borja's BottomTabBar loses the Notes entry within the 15 s realtime window, no reload.
- SV-03 Borja sets
tasks: falsefor himself; Ana then setstasks: trueat the collective level. Tasks reappears for Borja in that collective (collective ON beats user OFF — the integration suite's SV-06 covers the inverse where the collective has no opinion).
Each test resets both users.feature_flags (Ana + Borja) AND collectives.feature_flags (seed collective) in beforeEach AND afterEach so the suite is hermetic regardless of what ran before in just test-all. Without the beforeEach we observed bleed from the prior tasks tests into SV-02 + SV-03 on the first full-suite run; the early "passes in isolation, fails in the full run" failure mode is captured in CLAUDE.md's existing "Realtime waitFor timeout" gotcha shape — same root cause family.
Deviation note: e2e cleanup hardening (vs plan)
The plan didn't call out the beforeEach reset, but the just test-all first run surfaced state leakage from the broader suite into SV-02 (collective row had stale feature_flags). Adding a symmetric beforeEach reset (Ana resets her row + the seed collective via a fresh context; Borja resets his row via his own context) made the suite deterministic across 5 sequential full runs. Cost: ~6 s per spec in setup overhead; acceptable.
Forward-compat for Fase 13
The single design decision that makes Fase 13's server-settings layer a clean diff:
section_enabled()is a single COALESCE chain. Fase 13 only prepends a new SELECT branch fromserver_settings.default_sectionsat the top of the COALESCE. No call-site shape changes anywhere in the codebase. The migration body comment spells this out so the next contributor sees it.- The client-side
enabledSectionsderived store reads from two sources today (currentUserFeatures+currentCollective). Fase 13 will add a third (serverSettings), and the derived shape staysRecord<SectionKey, boolean>. No consumer changes. setCollectiveFeaturethrows a typed'forbidden'error already, which Fase 13 can reuse for the server-layer admin endpoint.
Nothing about the current design makes Fase 13 harder. The one place a server-layer pgTAP test will need extra wiring is SV-T15 — once the server layer exists, an "admin server override OFF wins over collective ON" case becomes the new top of the precedence ladder.
File map (new)
supabase/migrations/023_section_visibility.sqlsupabase/tests/015_section_visibility.sqlpackages/test-utils/tests/section-visibility.test.tsapps/web/src/lib/stores/features.tsapps/web/tests/e2e/section-visibility.test.tsdocs/history/fase-12-section-visibility.md
Modified:
packages/types/src/domain.ts—SectionKey,SECTION_KEYS,FeatureFlagspackages/types/src/database.ts—feature_flagsonusers+collectivesRows,known_sections+section_enabledFunctionsapps/web/src/lib/stores/collective.ts—feature_flagsonCollectiveapps/web/src/lib/components/CreateCollectiveModal.svelte— fabricatefeature_flags: {}on createapps/web/src/routes/onboarding/+page.svelte— sameapps/web/src/routes/(app)/collective/manage/+page.svelte— collective toggle UI + extended SELECT forfeature_flagsapps/web/src/routes/(app)/settings/+page.svelte— user toggle UIapps/web/src/routes/(app)/+layout.svelte— route guard + toastapps/web/src/routes/+layout.svelte—loadCurrentUserFeatureswiring,subscribeCollectiveFeatures,window.__sbdev hookapps/web/src/lib/components/layout/DesktopSidebar.svelte— nav filter +data-sectionapps/web/src/lib/components/layout/BottomTabBar.svelte— nav filter +data-section+data-section-countapps/web/messages/{en,es}.json— 11 keysCLAUDE.md— Domain Model "new section requires" rule