Files
collective-lists/docs/history/fase-12-section-visibility.md
Oier Bravo Urtasun 99096f9f4a docs(fase-12): history doc + CLAUDE.md status + new-section convention
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>
2026-05-18 04:45:36 +02:00

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 '{}'::jsonb
  • public.collectives.feature_flags jsonb NOT NULL DEFAULT '{}'::jsonb
  • public.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) → boolean signature, 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():

  • currentUserFeatures writable, loaded by root +layout.svelte after sign-in.
  • enabledSections derived Record<SectionKey, boolean> applying the same precedence (collective > user > default true). Used as $enabledSections.tasks in components.
  • enabledSectionList derived SectionKey[] — 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, throws forbidden when 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.id changes (subscribed via currentCollective.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 SectionKey so 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; BottomTabBar adds data-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 to setUserFeature.
  • 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 to setCollectiveFeature.
  • 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:

  1. Migration that updates public.known_sections().
  2. SectionKey + SECTION_KEYS entry in packages/types/src/domain.ts.
  3. Paraglide section_label_<key> message + route prefix in (app)/+layout.svelte's SECTION_BY_PREFIX.
  4. 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: false for himself; Ana then sets tasks: true at 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 from server_settings.default_sections at 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 enabledSections derived store reads from two sources today (currentUserFeatures + currentCollective). Fase 13 will add a third (serverSettings), and the derived shape stays Record<SectionKey, boolean>. No consumer changes.
  • setCollectiveFeature throws 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.sql
  • supabase/tests/015_section_visibility.sql
  • packages/test-utils/tests/section-visibility.test.ts
  • apps/web/src/lib/stores/features.ts
  • apps/web/tests/e2e/section-visibility.test.ts
  • docs/history/fase-12-section-visibility.md

Modified:

  • packages/types/src/domain.tsSectionKey, SECTION_KEYS, FeatureFlags
  • packages/types/src/database.tsfeature_flags on users + collectives Rows, known_sections + section_enabled Functions
  • apps/web/src/lib/stores/collective.tsfeature_flags on Collective
  • apps/web/src/lib/components/CreateCollectiveModal.svelte — fabricate feature_flags: {} on create
  • apps/web/src/routes/onboarding/+page.svelte — same
  • apps/web/src/routes/(app)/collective/manage/+page.svelte — collective toggle UI + extended SELECT for feature_flags
  • apps/web/src/routes/(app)/settings/+page.svelte — user toggle UI
  • apps/web/src/routes/(app)/+layout.svelte — route guard + toast
  • apps/web/src/routes/+layout.svelteloadCurrentUserFeatures wiring, subscribeCollectiveFeatures, window.__sb dev hook
  • apps/web/src/lib/components/layout/DesktopSidebar.svelte — nav filter + data-section
  • apps/web/src/lib/components/layout/BottomTabBar.svelte — nav filter + data-section + data-section-count
  • apps/web/messages/{en,es}.json — 11 keys
  • CLAUDE.md — Domain Model "new section requires" rule