feat(fase-15): /collective/manage common items UI + e2e (15.3)

New subsection on /collective/manage:
  - Table with rows of (name, uses, last used) + 3-way Hide/Normal/Boost
    segmented control writing weight=-50/0/50, + Remove action that opens
    a confirmation modal explaining "items already in lists are not
    touched".
  - Add common-item modal (admin only) — input + same 3-way control,
    defaulting to Boost; useful for seeding the catalogue before lists
    actually mention an item.
  - Empty / filtered-empty states + client-side substring search.
  - Members see the entire table with all buttons disabled and a tooltip
    pointing to "only admins can manage common items". Guests don't see
    the section at all.

Implementation notes:
  - The data-weight-state attribute on each row gives the test suite a
    stable handle for the current visual state (boost / normal / hide).
  - The segmented control is a plain `inline-flex` of three buttons —
    different visual language to the existing section-visibility toggles
    (those are checkboxes), so there's no ambiguity at a glance.
  - HIDE_WEIGHT / BOOST_WEIGHT constants make the magic numbers explicit;
    the column itself has no check constraint so future tiers can land
    without a schema change.

Playwright covers CI-01 (Ana boosts yogurt → it leads the dropdown ahead
of higher-use-count milk), CI-03 (Borja sees the table, all actions
disabled, "Add common item" hidden), CI-04 (Ana purges apples → row gone
from the table). CI-02 (exclude-on-list) lives in the same file but
exercises the /lists/[id] page and ships in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 11:27:03 +02:00
parent 3f96e6cdb3
commit 834034bada
4 changed files with 607 additions and 4 deletions

View File

@@ -5,9 +5,15 @@
import { currentUser } from '$lib/stores/auth';
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
import Avatar from '$lib/components/Avatar.svelte';
import type { FeatureFlags, SectionKey } from '@colectivo/types';
import type { FeatureFlags, SectionKey, ItemFrequency } from '@colectivo/types';
import { SECTION_KEYS } from '@colectivo/types';
import { setCollectiveFeature } from '$lib/stores/features';
import {
commonItems,
loadCommonItems,
setWeight as setCommonItemWeight,
purge as purgeCommonItem
} from '$lib/stores/commonItems';
import * as m from '$lib/paraglide/messages';
type Member = {
@@ -30,6 +36,8 @@
const myRole = $derived(members.find((m) => m.user_id === $currentUser?.id)?.role);
const isAdmin = $derived(myRole === 'admin');
// Members + admins see the common-items table; guests never do.
const canSeeCommonItems = $derived(myRole === 'admin' || myRole === 'member');
onMount(() => {
// Re-run loadMembers() whenever the active collective resolves or
@@ -39,7 +47,10 @@
// empty list because the first fetch was short-circuited by
// `$currentCollective == null`.
const unsub = currentCollective.subscribe((c) => {
if (c) void loadMembers();
if (c) {
void loadMembers();
void loadCommonItems(c.id);
}
});
return unsub;
});
@@ -317,6 +328,106 @@
dissolveError = null;
}
// ── Fase 15: common items management ────────────────────────────────────
const HIDE_WEIGHT = -50;
const NORMAL_WEIGHT = 0;
const BOOST_WEIGHT = 50;
type WeightState = 'hide' | 'normal' | 'boost';
function weightState(weight: number): WeightState {
if (weight <= -1) return 'hide';
if (weight >= 1) return 'boost';
return 'normal';
}
let commonItemsSearch = $state('');
let commonItemsError = $state<string | null>(null);
const filteredCommonItems = $derived.by(() => {
const term = commonItemsSearch.trim().toLowerCase();
const list = $commonItems;
if (!term) return list;
return list.filter((row: ItemFrequency) => row.name.includes(term));
});
async function setCommonItemState(name: string, state: WeightState) {
if (!$currentCollective || !isAdmin) return;
const weight =
state === 'boost' ? BOOST_WEIGHT : state === 'hide' ? HIDE_WEIGHT : NORMAL_WEIGHT;
commonItemsError = null;
const err = await setCommonItemWeight($currentCollective.id, name, weight);
if (err) commonItemsError = err.message;
}
// Purge modal
let purgeTarget = $state<string | null>(null);
function openPurge(name: string) {
if (!isAdmin) return;
purgeTarget = name;
}
function closePurge() {
purgeTarget = null;
}
async function confirmPurge() {
if (!$currentCollective || !purgeTarget || !isAdmin) return;
commonItemsError = null;
const err = await purgeCommonItem($currentCollective.id, purgeTarget);
purgeTarget = null;
if (err) commonItemsError = err.message;
}
// Add common-item modal
let addOpen = $state(false);
let addName = $state('');
let addWeightState = $state<WeightState>('boost');
let addSaving = $state(false);
function openAdd() {
if (!isAdmin) return;
addName = '';
addWeightState = 'boost';
addOpen = true;
}
function closeAdd() {
addOpen = false;
}
async function confirmAdd() {
if (!$currentCollective || !isAdmin) return;
const trimmed = addName.trim();
if (!trimmed) return;
addSaving = true;
commonItemsError = null;
const weight =
addWeightState === 'boost'
? BOOST_WEIGHT
: addWeightState === 'hide'
? HIDE_WEIGHT
: NORMAL_WEIGHT;
const err = await setCommonItemWeight($currentCollective.id, trimmed, weight);
addSaving = false;
if (err) {
commonItemsError = err.message;
return;
}
addOpen = false;
}
function formatLastUsed(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const now = Date.now();
const diffMs = now - d.getTime();
const day = 86_400_000;
if (diffMs < day) return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString();
}
async function confirmDissolve() {
if (!$currentCollective || !dissolveConfirmReady || dissolveInFlight) return;
const target = $currentCollective;
@@ -492,6 +603,145 @@
{/if}
</section>
<!-- Common items (Fase 15) — admin curates; member sees read-only; guest hidden -->
{#if canSeeCommonItems}
<section data-testid="common-items-section" class="mb-8">
<div class="mb-2 flex items-center justify-between gap-3">
<h2 class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.common_items_title()}
</h2>
{#if isAdmin}
<button
type="button"
data-testid="common-item-add-button"
onclick={openAdd}
class="rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white
hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.common_items_add_button()}
</button>
{/if}
</div>
<p class="mb-3 text-xs text-text-secondary">{m.common_items_blurb()}</p>
{#if !isAdmin}
<p class="mb-3 text-xs text-text-secondary" data-testid="common-items-readonly-banner">
{m.common_items_state_locked_tooltip()}
</p>
{/if}
{#if commonItemsError}
<p class="mb-2 text-sm text-destructive" data-testid="common-items-error">{commonItemsError}</p>
{/if}
<input
type="search"
bind:value={commonItemsSearch}
placeholder={m.common_items_search_placeholder()}
data-testid="common-items-search"
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
/>
{#if $commonItems.length === 0}
<p class="text-sm text-text-secondary" data-testid="common-items-empty">
{m.common_items_empty()}
</p>
{:else if filteredCommonItems.length === 0}
<p class="text-sm text-text-secondary" data-testid="common-items-empty-filtered">
{m.common_items_empty_filtered()}
</p>
{:else}
<ul class="space-y-1" data-testid="common-items-list">
{#each filteredCommonItems as row (row.name)}
{@const state = weightState(row.weight)}
<li
data-testid="common-item-row-{row.name}"
data-weight-state={state}
class="flex flex-col gap-2 rounded-md bg-surface-raised px-3 py-2 md:flex-row md:items-center md:gap-3"
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-900 dark:text-slate-50">
{row.name}
</p>
<p class="text-[11px] text-text-secondary">
{m.common_items_col_uses()}: {row.use_count} · {m.common_items_col_last_used()}: {formatLastUsed(row.last_used_at)}
</p>
</div>
<!-- 3-way segmented control: Hide / Normal / Boost -->
<div
role="group"
aria-label={m.common_items_col_weight()}
class="inline-flex shrink-0 overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-600"
>
<button
type="button"
data-testid="common-item-hide-{row.name}"
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
aria-pressed={state === 'hide'}
disabled={!isAdmin}
onclick={() => void setCommonItemState(row.name, 'hide')}
class="px-2 py-1 transition-colors
{state === 'hide'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
{m.common_items_state_hide()}
</button>
<button
type="button"
data-testid="common-item-normal-{row.name}"
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
aria-pressed={state === 'normal'}
disabled={!isAdmin}
onclick={() => void setCommonItemState(row.name, 'normal')}
class="border-x border-slate-300 px-2 py-1 transition-colors dark:border-slate-600
{state === 'normal'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
{m.common_items_state_normal()}
</button>
<button
type="button"
data-testid="common-item-boost-{row.name}"
title={!isAdmin ? m.common_items_state_locked_tooltip() : ''}
aria-pressed={state === 'boost'}
disabled={!isAdmin}
onclick={() => void setCommonItemState(row.name, 'boost')}
class="px-2 py-1 transition-colors
{state === 'boost'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
{m.common_items_state_boost()}
</button>
</div>
<button
type="button"
data-testid="common-item-purge-{row.name}"
title={!isAdmin ? m.common_items_state_locked_tooltip() : m.common_items_purge_button()}
aria-label={m.common_items_purge_button()}
disabled={!isAdmin}
onclick={() => openPurge(row.name)}
class="shrink-0 rounded-md p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-slate-400
dark:hover:bg-red-900/20"
>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<!-- Invite link generator — background shift instead of border -->
{#if isAdmin}
<section class="rounded-lg bg-surface-container-low p-4 dark:bg-surface-raised">
@@ -633,3 +883,147 @@
</div>
</div>
{/if}
<!-- Purge common-item modal (Fase 15) -->
{#if purgeTarget}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
data-testid="common-item-purge-modal"
role="dialog"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) closePurge();
}}
onkeydown={(e) => e.key === 'Escape' && closePurge()}
tabindex="-1"
>
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-2 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.common_items_purge_modal_title({ name: purgeTarget })}
</h2>
<p class="mb-4 text-sm text-text-secondary">{m.common_items_purge_modal_body()}</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closePurge}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="common-item-purge-confirm"
onclick={() => void confirmPurge()}
class="rounded-md bg-destructive px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90"
>
{m.common_items_purge_confirm()}
</button>
</div>
</div>
</div>
{/if}
<!-- Add common-item modal (Fase 15) -->
{#if addOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
data-testid="common-item-add-modal"
role="dialog"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) closeAdd();
}}
onkeydown={(e) => e.key === 'Escape' && closeAdd()}
tabindex="-1"
>
<div class="w-full max-w-md rounded-lg bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.2)] dark:bg-surface-raised">
<h2 class="mb-3 text-base font-semibold text-slate-900 dark:text-slate-50">
{m.common_items_add_modal_title()}
</h2>
<label for="common-item-add-name" class="mb-1 block text-xs font-medium text-text-secondary">
{m.common_items_add_name_label()}
</label>
<!-- svelte-ignore a11y_autofocus -->
<input
id="common-item-add-name"
data-testid="common-item-add-name"
type="text"
bind:value={addName}
autofocus
placeholder={m.common_items_add_name_placeholder()}
class="mb-3 w-full rounded-lg border border-slate-300 bg-surface px-3 py-2 text-sm
focus:border-slate-500 focus:outline-none dark:border-slate-600 dark:bg-slate-800"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void confirmAdd();
}
}}
/>
<p class="mb-1 text-xs font-medium text-text-secondary">{m.common_items_add_visibility_label()}</p>
<div
role="group"
aria-label={m.common_items_add_visibility_label()}
class="mb-4 inline-flex overflow-hidden rounded-md border border-slate-300 text-xs dark:border-slate-600"
>
<button
type="button"
data-testid="common-item-add-hide"
aria-pressed={addWeightState === 'hide'}
onclick={() => (addWeightState = 'hide')}
class="px-3 py-1 transition-colors
{addWeightState === 'hide'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{m.common_items_state_hide()}
</button>
<button
type="button"
data-testid="common-item-add-normal"
aria-pressed={addWeightState === 'normal'}
onclick={() => (addWeightState = 'normal')}
class="border-x border-slate-300 px-3 py-1 transition-colors dark:border-slate-600
{addWeightState === 'normal'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{m.common_items_state_normal()}
</button>
<button
type="button"
data-testid="common-item-add-boost"
aria-pressed={addWeightState === 'boost'}
onclick={() => (addWeightState = 'boost')}
class="px-3 py-1 transition-colors
{addWeightState === 'boost'
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{m.common_items_state_boost()}
</button>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={closeAdd}
class="rounded-md px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.common_cancel()}
</button>
<button
type="button"
data-testid="common-item-add-save"
disabled={!addName.trim() || addSaving}
onclick={() => void confirmAdd()}
class="rounded-md bg-slate-900 px-4 py-1.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50
dark:bg-slate-50 dark:text-slate-900"
>
{addSaving ? m.loading() : m.common_items_add_save()}
</button>
</div>
</div>
</div>
{/if}