feat(fase-12): settings + manage toggles per section

Two new sections, both hooked into the precedence-aware features store:

  * /settings — "Visible sections" block with one toggle per SectionKey
    bound to setUserFeature(). When a collective-level override is
    present the toggle is disabled and a one-line "Hidden by the
    collective" hint replaces the visible state — making it obvious
    why the per-user toggle is moot.
  * /collective/manage — "Collective sections" block (admin-only,
    inside the existing isAdmin guard) with one toggle per SectionKey
    bound to setCollectiveFeature(). Flipping any of them propagates
    to every member in realtime via the publication added in the
    previous commit.

Both helpers fall back to the default-ON semantics of section_enabled
when the JSONB key is missing, so untouched rows render exactly as
before fase 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 04:10:28 +02:00
parent 286f59225f
commit a59e7e15ea
2 changed files with 130 additions and 1 deletions

View File

@@ -5,6 +5,9 @@
import { currentUser } from '$lib/stores/auth'; import { currentUser } from '$lib/stores/auth';
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective'; import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import type { FeatureFlags, SectionKey } from '@colectivo/types';
import { SECTION_KEYS } from '@colectivo/types';
import { setCollectiveFeature } from '$lib/stores/features';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
type Member = { type Member = {
@@ -236,6 +239,31 @@
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it))); userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
} }
// ── Fase 12.4.2 — collective section visibility (admin only) ────────────
function sectionLabel(section: SectionKey): string {
if (section === 'lists') return m.section_label_lists();
if (section === 'tasks') return m.section_label_tasks();
if (section === 'notes') return m.section_label_notes();
return m.section_label_search();
}
function collectiveFlag(section: SectionKey): boolean {
const flags = (($currentCollective?.feature_flags ?? {}) as FeatureFlags);
// Missing key → ON (the collective has "no opinion" and the user layer
// can take over). Equivalent to section_enabled's collective coalesce.
return flags[section] ?? true;
}
async function toggleCollectiveSection(section: SectionKey) {
if (!isAdmin) return;
const next = !collectiveFlag(section);
try {
await setCollectiveFeature(section, next);
} catch {
// optimistic mutation already rolled back inside the helper
}
}
// ── Fase 10.3: dissolve collective (CU-H08) ───────────────────────────── // ── Fase 10.3: dissolve collective (CU-H08) ─────────────────────────────
let dissolveOpen = $state(false); let dissolveOpen = $state(false);
let dissolveConfirmInput = $state(''); let dissolveConfirmInput = $state('');
@@ -511,6 +539,35 @@
{/if} {/if}
</section> </section>
<!-- Section visibility (Fase 12.4.2) — admin only. Collective-level
toggles are the higher precedence layer; turning one off hides
the section for every member. -->
<section data-testid="manage-section-visibility" class="mt-6">
<h2 class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.collective_section_visibility_title()}
</h2>
<p class="mb-3 text-xs text-text-secondary">
{m.collective_section_visibility_blurb()}
</p>
<ul class="space-y-1">
{#each SECTION_KEYS as section (section)}
{@const enabled = collectiveFlag(section)}
<li class="flex items-center justify-between gap-3 rounded-md bg-surface-raised px-3 py-2">
<p class="min-w-0 flex-1 text-sm text-slate-900 dark:text-slate-50">{sectionLabel(section)}</p>
<label class="inline-flex shrink-0 cursor-pointer items-center">
<input
type="checkbox"
data-testid="manage-section-toggle-{section}"
checked={enabled}
onchange={() => void toggleCollectiveSection(section)}
class="h-4 w-4 cursor-pointer accent-slate-900 dark:accent-slate-50"
/>
</label>
</li>
{/each}
</ul>
</section>
<!-- Danger zone (Fase 10.3) — admin only --> <!-- Danger zone (Fase 10.3) — admin only -->
<section class="mt-10 rounded-lg border border-destructive/40 p-4"> <section class="mt-10 rounded-lg border border-destructive/40 p-4">
<h2 class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive"> <h2 class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">

View File

@@ -17,7 +17,12 @@
recolorTag, recolorTag,
deleteTag deleteTag
} from '$lib/stores/tags'; } from '$lib/stores/tags';
import type { ItemTag, ItemTagColor } from '@colectivo/types'; import type { ItemTag, ItemTagColor, FeatureFlags, SectionKey } from '@colectivo/types';
import { SECTION_KEYS } from '@colectivo/types';
import {
currentUserFeatures,
setUserFeature
} from '$lib/stores/features';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime'; import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import type { ThemePreference } from '$lib/theme'; import type { ThemePreference } from '$lib/theme';
@@ -205,6 +210,39 @@
.eq('id', $currentUser.id); .eq('id', $currentUser.id);
} }
// ── Fase 12.4.1 — section visibility toggles ────────────────────────────
// The "effective" map is derived from the user + active-collective flags
// (see $lib/stores/features). The disabled state of each toggle reflects
// whether the collective has explicitly hidden the section — when it has,
// the user toggle is moot and we surface that with a one-line hint.
function sectionLabel(section: SectionKey): string {
if (section === 'lists') return m.section_label_lists();
if (section === 'tasks') return m.section_label_tasks();
if (section === 'notes') return m.section_label_notes();
return m.section_label_search();
}
function userFlag(section: SectionKey): boolean {
const flags = ($currentUserFeatures?.feature_flags ?? {}) as FeatureFlags;
const v = flags[section];
// Default ON when the key is missing or null — matches section_enabled().
return v ?? true;
}
function collectiveHidden(section: SectionKey): boolean {
const flags = ($currentCollective?.feature_flags ?? {}) as FeatureFlags;
return flags[section] === false;
}
async function toggleUserSection(section: SectionKey) {
const next = !userFlag(section);
try {
await setUserFeature(section, next);
} catch {
// optimistic mutation already rolled back inside the helper
}
}
async function switchLanguage(lang: 'en' | 'es') { async function switchLanguage(lang: 'en' | 'es') {
language = lang; language = lang;
setLanguageTag(lang); setLanguageTag(lang);
@@ -420,6 +458,40 @@
</div> </div>
</section> </section>
<!-- Section visibility (Fase 12.4.1). Per-user opt-out of top-level
sections. Collective-level overrides win — when present they
disable the toggle and explain why. -->
<section data-testid="settings-section-visibility">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.settings_section_visibility_title()}
</p>
<p class="mb-3 text-xs text-text-secondary">{m.settings_section_visibility_blurb()}</p>
<ul class="space-y-1">
{#each SECTION_KEYS as section (section)}
{@const overridden = collectiveHidden(section)}
{@const checked = !overridden && userFlag(section)}
<li class="flex items-center justify-between gap-3 rounded-md bg-surface-raised px-3 py-2">
<div class="min-w-0 flex-1">
<p class="text-sm text-slate-900 dark:text-slate-50">{sectionLabel(section)}</p>
{#if overridden}
<p class="text-xs text-text-secondary">{m.settings_section_visibility_overridden()}</p>
{/if}
</div>
<label class="inline-flex shrink-0 cursor-pointer items-center">
<input
type="checkbox"
data-testid="settings-section-toggle-{section}"
checked={checked}
disabled={overridden}
onchange={() => void toggleUserSection(section)}
class="h-4 w-4 cursor-pointer accent-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:accent-slate-50"
/>
</label>
</li>
{/each}
</ul>
</section>
<!-- Fase 9.3.3 — sync conflicts debug panel. Gated on <!-- Fase 9.3.3 — sync conflicts debug panel. Gated on
import.meta.env.DEV inside the component itself. --> import.meta.env.DEV inside the component itself. -->
<SyncConflictsPanel /> <SyncConflictsPanel />