feat(fase-5.10.0): drop shopping_items.unit column

The unit field had no edit path under the redesign (only name + qty
remain in the row; the double-tap overlay is name-only). Users can encode
the unit inside the name when it matters — e.g. "Milk 1L", "Eggs 1 doz".

Changes
  supabase/migrations/011_drop_shopping_items_unit.sql — DROP COLUMN
  supabase/seed.sql — unit values folded into names where relevant
  supabase/tests/002_item_frequency_trigger.sql — remove unit from INSERT
  packages/types — remove unit from ShoppingItem Row/Insert/Update
  apps/web/src/lib/stores/lists.ts — addItem/updateItem signatures
  apps/web/src/routes/(app)/lists/[id]/+page.svelte — purge newUnit /
    editUnit state + inputs + display
  apps/web/src/routes/(app)/lists/[id]/session/+page.svelte — simplify
    quantity-only render
  apps/web/messages/{en,es}.json — remove list_unit_label

Verification
  just test-db  → 34 pgTAP green
  just test-integration → 140 + 2 skipped
  just test-unit → 11 green
  just test-e2e → 39 + 2 skipped

The generated search tsvector on shopping_items.search already indexes
only `name`, so full-text search coverage is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:00:08 +02:00
parent 5a63e5fc4a
commit d6cea5146f
11 changed files with 363 additions and 58 deletions

View File

@@ -91,7 +91,6 @@
"list_actions": "List actions",
"list_item_placeholder": "Add item (e.g. Yogurt 500g)",
"list_qty_label": "Qty",
"list_unit_label": "Unit",
"list_add_item": "Add item…",
"list_to_buy": "To buy",
"list_checked": "Checked",

View File

@@ -91,7 +91,6 @@
"list_actions": "Acciones de lista",
"list_item_placeholder": "Añadir producto…",
"list_qty_label": "Cant.",
"list_unit_label": "Unidad",
"list_add_item": "Añadir producto…",
"list_to_buy": "Por comprar",
"list_checked": "Marcado",

View File

@@ -150,7 +150,6 @@ export async function addItem(
listId: string,
name: string,
quantity: number | null,
unit: string | null,
userId: string,
sortOrder: number,
id?: string
@@ -160,7 +159,6 @@ export async function addItem(
list_id: listId,
name,
quantity,
unit,
sort_order: sortOrder,
created_by: userId
};
@@ -180,7 +178,7 @@ export async function updateItem(
patch: Partial<
Pick<
ShoppingItem,
'name' | 'quantity' | 'unit' | 'is_checked' | 'checked_by' | 'checked_at' | 'sort_order'
'name' | 'quantity' | 'is_checked' | 'checked_by' | 'checked_at' | 'sort_order'
>
>
) {

View File

@@ -49,7 +49,6 @@
// Add-item form
let newName = $state('');
let newQty = $state<number | null>(null);
let newUnit = $state('');
let nameInput: HTMLInputElement | undefined = $state();
// Suggestions
@@ -60,7 +59,6 @@
let editingId = $state<string | null>(null);
let editName = $state('');
let editQty = $state<number | null>(null);
let editUnit = $state('');
// Swipe-to-delete (touch only)
let swipeOffsets = $state<Record<string, number>>({});
@@ -169,7 +167,6 @@
list_id: listId,
name,
quantity: newQty,
unit: newUnit.trim() || null,
is_checked: false,
checked_by: null,
checked_at: null,
@@ -188,14 +185,12 @@
items = [...items, optimistic];
newName = '';
newQty = null;
newUnit = '';
nameInput?.focus();
const real = await addItem(
listId,
name,
newQty,
optimistic.unit,
$currentUser.id,
sortOrder,
tempId
@@ -221,7 +216,6 @@
list_id: listId,
name,
quantity: newQty,
unit: optimistic.unit,
sort_order: sortOrder,
created_by: $currentUser.id
}
@@ -268,7 +262,6 @@
editingId = item.id;
editName = item.name;
editQty = item.quantity;
editUnit = item.unit ?? '';
}
async function commitEdit(item: ShoppingItem) {
@@ -276,18 +269,12 @@
editingId = null;
const name = editName.trim();
if (!name) return;
if (name === item.name && editQty === item.quantity && (editUnit || null) === item.unit) return;
if (name === item.name && editQty === item.quantity) return;
items = items.map((i) =>
i.id === item.id
? { ...i, name, quantity: editQty, unit: editUnit.trim() || null }
: i
i.id === item.id ? { ...i, name, quantity: editQty } : i
);
await updateItem(item.id, {
name,
quantity: editQty,
unit: editUnit.trim() || null
});
await updateItem(item.id, { name, quantity: editQty });
}
function handleEditKeydown(e: KeyboardEvent, item: ShoppingItem) {
@@ -531,15 +518,6 @@
class="w-14 bg-transparent text-sm text-text-secondary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5 text-right"
/>
<input
type="text"
bind:value={editUnit}
onblur={() => commitEdit(item)}
onkeydown={(e) => handleEditKeydown(e, item)}
placeholder={m.list_unit_label()}
class="w-14 bg-transparent text-sm text-text-secondary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5"
/>
</div>
{:else}
<!-- Item display -->
@@ -550,9 +528,6 @@
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
{#if item.unit}
<span class="text-[12px] text-text-muted">{item.unit}</span>
{/if}
</button>
<!-- Quantity stepper -->
@@ -662,16 +637,9 @@
type="number"
min="1"
placeholder={m.list_qty_label()}
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
outline-none text-right"
/>
<input
bind:value={newUnit}
type="text"
placeholder={m.list_unit_label()}
onkeydown={handleAddKeydown}
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
outline-none"
outline-none text-right"
/>
</div>
<button

View File

@@ -134,10 +134,8 @@
></button>
<div class="flex-1 min-w-0">
<p class="truncate text-base font-medium">{item.name}</p>
{#if item.quantity || item.unit}
<p class="text-sm text-text-muted">
{item.quantity ?? ''}{item.unit ? ' ' + item.unit : ''}
</p>
{#if item.quantity}
<p class="text-sm text-text-muted">{item.quantity}</p>
{/if}
</div>
</li>

View File

@@ -202,7 +202,6 @@ export interface Database {
list_id: string;
name: string;
quantity: number | null;
unit: string | null;
is_checked: boolean;
checked_by: string | null;
checked_at: string | null;
@@ -215,7 +214,6 @@ export interface Database {
list_id: string;
name: string;
quantity?: number | null;
unit?: string | null;
is_checked?: boolean;
checked_by?: string | null;
checked_at?: string | null;
@@ -228,7 +226,6 @@ export interface Database {
list_id?: string;
name?: string;
quantity?: number | null;
unit?: string | null;
is_checked?: boolean;
checked_by?: string | null;
checked_at?: string | null;

View File

@@ -64,7 +64,6 @@ export interface ShoppingItem {
list_id: string;
name: string;
quantity: number | null;
unit: string | null;
is_checked: boolean;
checked_by: string | null;
checked_at: string | null;

View File

@@ -1,5 +1,5 @@
### Fase 5 — Rediseño UX Mobile ("Monolith Editorial")
**Estado: 🟡 Shell + masthead + undoQueue + swipe-delete shipped; presence avatars en cards + touch-gesture E2E diferidos.**
**Estado: 🟡 Shell + masthead + undoQueue + swipe-delete shipped. Pendientes: 5.8 (big-button create), 5.9 (audit responsive `/lists/[id]`), 5.10 (interacción rediseñada: buttons + swipes + drag handles), 5.11 (selection mode + bulk actions), 5.12 (chrome de detail views). Avatars en cards + touch-gesture E2E aún diferidos.**
**Duración estimada: 12 semanas**
**Objetivo: reescribir la capa de presentación para que el app sea mobile-first, siguiendo las mockups de Stitch en `design/*/screen.png` y la dirección "Monolith Editorial" de `design/slate_collective/DESIGN.md`.**
@@ -102,6 +102,346 @@ Esta fase sigue TDD: **primero los tests, al final la verificación.** Toda la c
- [x] `/notes`: masthead + contador pendiente de meta. Grid responsivo (1 col mobile → 2/3/4 col en md/lg/xl) desde Fase 3.
- [x] `/search`: masthead "Omni-Search / Find Anything.". Badges "{N} MATCHES / MATCH" junto a cada header de grupo ✅
#### 5.8 Crear lista con botón "New list" (alineado con `/notes`)
**Objetivo:** sustituir la barra sticky de creación en `/lists` por el mismo patrón que usa `/notes` — un botón en el masthead que crea directamente el recurso con defaults y navega al detalle, donde el usuario renombra inline.
**Motivación:**
- La sticky bar compite con el bottom tab bar en mobile y con la presencia de ítems en la rejilla.
- `/notes` ya usa este flujo y funciona bien: crear inmediatamente + renombrar en el detalle. Consistencia entre módulos.
- Un botón "big" cumple lo pedido sin ocupar viewport permanente.
**Cambios propuestos:**
- [ ] `/lists` — eliminar el bloque `<!-- Sticky bottom: create new list -->` (`div absolute bottom-0 ... backdrop-blur-[12px]`) y el estado asociado (`newListName`, `creating`, `nameInput`, `handleCreate`, `handleKeydown`).
- [ ] `/lists` — añadir un botón primario `New list` en el `actions` snippet del `<ScreenMasthead>`, idéntico al de `/notes` (fondo `bg-slate-900` en light mode, icono `Plus`):
```svelte
<button
type="button"
onclick={handleCreate}
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
>
<Plus size={14} strokeWidth={2} />
{m.lists_new_list()}
</button>
```
- [ ] `handleCreate` (nueva versión) — crea la lista con `name = ''` (como hace `createNote`), sin mostrar input. Al resolver la promesa, `goto(`/lists/${created.id}`)`.
- [ ] Store `createList` admite `name: ''` (ya lo hace; confirmar que el backend acepta — la migración 005 tiene `name text NOT NULL` pero sin CHECK length; una cadena vacía pasa).
- [ ] `/lists/[id]` — añadir un row de título editable al inicio del área de contenido (encima de las secciones "To buy" / "Checked"), siguiendo la pauta de `/notes/[id]`:
```svelte
<input
bind:value={listName}
oninput={scheduleNameSave}
placeholder={m.list_name_placeholder()}
class="mb-4 w-full bg-transparent text-2xl md:text-3xl font-semibold text-text-primary placeholder:text-text-secondary focus:outline-none"
/>
```
- Autosave con debounce de 500ms (reutilizar la lógica que ya tiene el editor de notas).
- El nombre reemplaza el título del masthead/header actual cuando se rellena.
- [ ] i18n — añadir `lists_new_list` = "New list" / "Nueva lista". El key `list_name_placeholder` ya existe.
- [ ] Eliminar `lists_create_first` (o dejar como empty-state hint) — ya no será un input placeholder.
**Tests:**
- [ ] Playwright `lists.test.ts` — actualizar `createList(page, name)` helper:
```ts
async function createList(page: Page, name: string) {
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
const titleInput = page.getByPlaceholder(/weekly shop|compra semanal/i);
await titleInput.fill(name);
await titleInput.blur();
await page.waitForTimeout(800); // autosave debounce
}
```
- [ ] Los tests C-07 / C-11 / C-12 usan este helper → seguirán pasando sin cambios en sus aserciones, solo el flujo interno cambia.
- [ ] Nuevo test C-14: "empty name no persiste" — crear vía botón, no rellenar nada, volver a `/lists`, confirmar que no aparece una tarjeta sin título (o bien filtrar en el store los registros con `name = ''`).
**Criterio de aceptación:**
- No hay sticky bar en `/lists`.
- El botón "New list" del masthead crea la lista y lleva al detalle.
- El nombre se introduce en `/lists/[id]` y se persiste vía autosave.
- La suite completa sigue verde.
---
#### 5.9 Responsive audit + fix de `/lists/[id]`
**Objetivo:** hacer que la pantalla de detalle de una lista funcione bien en viewports pequeños (iPhone 13 390×844), no solo en desktop.
#### 5.9.a Auditoría — revisión previa antes de tocar código
**No-código; producir una lista corta de puntos problemáticos con recomendación de fix.** Buscar:
- [ ] **Padding horizontal**: ¿hay `px-8` genérico? Debe ser `px-4 md:px-8` o `px-4 md:px-6`.
- [ ] **Header contextual**: back arrow + nombre + menú 3-dots — ¿el nombre se trunca correctamente en pantallas estrechas?
- [ ] **Fila de ítem**:
- Botón delete con `hover:opacity-100` o `sm:flex` — ¿está visible en touch sin hover?
- Stepper de qty — ¿ocupa demasiado ancho cuando el nombre es largo?
- Drag handle — ¿está oculto en mobile? ¿se activa con long-press?
- Swipe-to-delete funciona en touch (ya cubierto en 5.4)
- [ ] **Formulario sticky de añadir ítem** (`<div class="absolute bottom-0 left-56 right-0 ...">`):
- `left-56` asume sidebar presente (desktop). En mobile donde no hay sidebar, debería ser `left-0`/`inset-x-0`.
- Campos qty/unit al lado del nombre — ¿caben en 390px? Si no, colapsar en una segunda fila o esconderlos detrás de un toggle.
- Chips de frecuencia (`ItemSuggestions`) ya son scrollables (5.6).
- [ ] **Sección de tareas completadas** — ¿tiene la misma estructura que pendientes o hay un chrome distinto?
- [ ] **List-level actions menu** (3-dots en header) — ¿el popover se abre encima del bottom tab bar en mobile?
- [ ] **Modo Compra link** — ¿suficientemente visible en mobile?
**Entregable de 5.9.a:** una sección con 36 bullets "problema → fix propuesto" antes de abrir 5.9.b.
#### 5.9.b Implementación de los fixes
La implementación efectiva se hace en **5.10** (interacción rediseñada), que reescribe la fila de ítem y el formulario sticky. Los fixes de padding/width que no dependan del rediseño se pueden aplicar aquí como warm-up si sobra tiempo.
---
#### 5.10 Rediseño de interacción en `/lists/[id]`
**Objetivo:** reemplazar el modelo "checkbox + botones en hover" por un flujo explícito basado en gestos y un overlay de edición. Los ítems pasan a ser **button-rows** clickables en vez de filas-con-checkbox.
##### 5.10.0 Prerequisito — eliminar la columna `unit` del modelo
La decisión de no editar `unit` en el overlay (solo name) deja el campo sin ningún punto de edición en la UI. Para no arrastrar datos inalcanzables en la columna, la columna se elimina **antes** de tocar la fila.
**Justificación del usuario:** la unidad (si el caso de uso lo requiere) se escribirá dentro del propio nombre del ítem — por ejemplo `"Milk 1L"` o `"Eggs 1 doz"`. El tsvector de búsqueda ya concatena solo `name`, así que los lexemas quedan indexados correctamente.
**Cambios:**
- [ ] Migración `supabase/migrations/011_drop_shopping_items_unit.sql`:
```sql
ALTER TABLE public.shopping_items DROP COLUMN unit;
```
Nota: la columna `search` (GENERATED) ya refleja solo `name`, no hay que regenerarla.
- [ ] `packages/types/src/database.ts` + `domain.ts` — retirar `unit: string | null` del type `ShoppingItem` (Row / Insert / Update).
- [ ] `apps/web/src/lib/stores/lists.ts`:
- `addItem` pierde el parámetro `unit`.
- `updateItem` retira `unit` del tipo del patch.
- [ ] `apps/web/src/routes/(app)/lists/[id]/+page.svelte`:
- Remover `newUnit`, `editUnit` y sus inputs.
- Remover el render `{#if item.unit}` debajo del nombre.
- [ ] `apps/web/src/routes/(app)/lists/[id]/session/+page.svelte`:
- Cambiar `{item.quantity ?? ''}{item.unit ? ' ' + item.unit : ''}` → `{item.quantity ?? ''}`.
- [ ] `apps/web/messages/{en,es}.json` — retirar `list_unit_label`.
- [ ] `supabase/seed.sql`: retirar el valor de `unit` de cada `INSERT INTO shopping_items`. (Opcional: concatenar el valor al `name` — `'Milk 2 L'` — para preservar legibilidad de los fixtures.)
- [ ] `apps/web/tests/e2e/items.test.ts` y cualquier otro test que referencie unit: limpiar.
**Criterio de aceptación 5.10.0:**
- `just db-reset` aplica la migración sin error.
- `just test-all` sigue verde (los tests se han ajustado).
- La columna `unit` no existe en `information_schema.columns`.
##### 5.10.a Fila de ítem (nueva anatomía)
```
┌─────────────────────────────────────────────────────────┐
│ ⋮⋮ Name of the item [ 2 +] │
└─────────────────────────────────────────────────────────┘
drag stepper
handle name (primary text) (always visible)
(left)
```
- [ ] Drag handle `GripVertical` **siempre visible** en mobile, **revelado en hover** en desktop (`md:opacity-0 md:group-hover:opacity-100`).
- [ ] Sin checkbox en modo normal.
- [ ] Nombre del ítem como `button` accesible (el elemento clickable de la fila). Si el usuario quiere una unidad, la incluye en el nombre ("Milk 1L"). Ver 5.10.0.
- [ ] Stepper ` N +` a la derecha, siempre visible, tanto mobile como desktop.
- [ ] Estado checked: strikethrough + `text-text-secondary`.
##### 5.10.b Gestos en la fila
| Estado del ítem | Swipe → (right) | Swipe ← (left) |
|-----------------|---------------------|----------------|
| Unchecked | **Mark as checked** | — (no-op) |
| Checked | — (no-op) | **Uncheck** |
Racional: el swipe es una toggle "simétrico" (toda la fila se "voltea" de un lado a otro). **Nunca borra**. El delete vive solo en el overlay de doble-tap y en selection mode.
- [ ] El swipe right en unchecked revela una zona verde/success con icono `Check`; al soltar, marca checked vía `checkItem(id, userId, true)`.
- [ ] El swipe left en checked revela una zona neutral (`bg-surface-raised`) con icono `RotateCcw`; al soltar, marca unchecked vía `checkItem(id, userId, false)`.
- [ ] Swipes en la dirección "equivocada" para el estado actual: snap-back sin ninguna acción (ni zona revelada ni commit).
- [ ] Ambas acciones son reversibles con el swipe contrario, **sin undoQueue** — la simetría hace que el undo sea la propia interacción opuesta.
- [ ] Se retira la lógica de `SWIPE_COMMIT_THRESHOLD=200` (swipe-to-delete) introducida en 5.4. Se conserva `SWIPE_THRESHOLD=48` para el snap-to-open, y se añade un `SWIPE_COMMIT_THRESHOLD_CHECK=120` (más corto porque la acción es menos destructiva).
- [ ] Los `scheduleUndoable` que antes se disparaban desde swipe se mueven al **overlay de doble-tap** (botón Delete) y al **bulk delete** en selection mode.
- [ ] Renombrar `mobile-swipe-delete.test.ts` → `mobile-swipe-toggle.test.ts` y reescribir los casos (los actuales están `.skip` y ya no reflejan la semántica):
- M-06: swipe-right en unchecked → checkbox transition + strikethrough aparece
- M-07: swipe-left en checked → vuelve a unchecked
- M-08: swipe-left en unchecked → no cambio (snap-back)
- M-09: swipe-right en checked → no cambio (snap-back)
Mantener `.skip` hasta que se añada WebKit install a CI.
##### 5.10.c Double-tap → overlay de edición
Doble tap corto (≤ 300 ms entre toques) en una fila (en modo normal) abre un overlay:
```
┌───────────────────────────┐
│ Edit item ×
│ │
│ [ milk ] │
│ │
│ [Delete] [Confirm] │
└───────────────────────────┘
```
- [ ] Overlay como `Dialog` (o bottom sheet en mobile ≤ md; `shadcn-svelte` Sheet / Dialog). Centrado en desktop.
- [ ] Input de **solo nombre** prellenado con el valor actual.
- [ ] Botón Confirm: guarda el nuevo nombre (optimista + UPDATE) y cierra.
- [ ] Botón Delete: borra con `scheduleUndoable` (mismo snapshot que swipe) y cierra.
- [ ] Botón X en la esquina + tap fuera del overlay: cierra sin guardar.
- [ ] Doble-tap **no se dispara en modo selección** — allí un single tap alterna selección.
- [ ] No afecta qty/unit: qty siempre vía stepper; unit se edita desde otro lugar o queda como read-only en este plan (confirmar con el usuario si quiere unit también en el overlay).
##### 5.10.d Drag handle → reorder
- [ ] El handle es el ÚNICO drag target (no la fila entera), para no competir con swipes y double-tap.
- [ ] Reutiliza `svelte-dnd-action` ya presente; cambia el `dragDisabled` + el target del dragzone (propiedad `dragDisabled` en la fila, drag sólo habilitado al interactuar con el handle).
- [ ] Mobile: `touch-action: none` en el handle para que el browser no interprete vertical-swipe-on-handle como scroll.
- [ ] Solo aplica a items unchecked (los checked ya quedan al fondo con flip).
**Criterio de aceptación 5.10:**
- Single tap no hace nada en modo normal.
- Double tap abre el overlay y permite rename/delete.
- Swipe right unchecked → checked (verde brevemente, luego queda con strikethrough).
- Swipe right checked → delete con undo toast.
- Swipe left checked → unchecked.
- Drag handle reordena pendientes, handle sólo visible al tocar/hover.
- Qty stepper intacto, unit visible debajo del nombre si hay.
- Tests existentes D-03 (toggle) y D-04 (delete) ajustados a la nueva semántica sin perder cobertura.
---
#### 5.11 Selection mode + bulk actions
**Objetivo:** permitir acciones masivas (delete / move / mark-all-checked) mediante un modo selección activado por long-press en mobile o botón toggle en desktop.
##### 5.11.a Entrada y salida
- [ ] **Mobile — entrada**: long-press (≥ 500 ms) sobre cualquier fila entra en modo selección con esa fila **preseleccionada**.
- [ ] **Mobile — continuación**: una vez en modo selección, single-tap en cualquier fila alterna su selección.
- [ ] **Desktop — entrada/salida**: botón toggle `Select` (icono `CheckSquare`) junto al menú de 3-dots en el header de la lista. Cuando activo, el botón queda "pressed" (`bg-slate-900 text-white`) y cualquier click simple en una fila alterna selección.
- [ ] **Salida**:
- Tap en "Cancel" del top-mode bar (mobile) o toggle del botón (desktop).
- Navegación fuera de la ruta.
- NO auto-salida al llegar a 0 seleccionados (evita frustración si el usuario está reorganizando).
##### 5.11.b Chrome durante selection mode (mobile)
- [ ] **Top bar contextual** (reemplaza el header `back + title + 3-dots`):
```
[ Cancel ] 3 selected [ ⋯ ]
```
Sticky top-0, fondo `bg-slate-900 text-white` para diferenciar visualmente del modo normal.
- [ ] **Bottom action bar** (reemplaza el `BottomTabBar`, sticky al mismo `bottom` con glassmorphism, misma altura 56 px):
```
[ Trash ] [ Move ] [ Check ]
```
Iconos Lucide: `Trash2`, `FolderInput`, `CheckSquare`. Cada botón con label sr-only + tooltip desktop.
- [ ] Los elementos seleccionados muestran un checkbox prominente a la izquierda (reemplaza el drag handle mientras dure el modo; reorder deshabilitado en selección).
- [ ] El stepper de qty se oculta en selection mode para simplificar la fila.
##### 5.11.c Chrome durante selection mode (desktop)
- [ ] El header de la lista (back + título + 3-dots) se transforma en una barra de selección inline:
```
← Back 3 selected · Trash · Move · Mark checked · Cancel
```
- [ ] Los checkboxes aparecen en el lado izquierdo de cada fila, a la derecha del drag handle oculto.
- [ ] No existe bottom bar en desktop: todas las acciones caben en el header.
##### 5.11.d Bulk actions
- [ ] **Delete**: borra las filas seleccionadas con un único `scheduleUndoable`:
- `label`: "Deleted N items"
- `restore`: reinserta las filas (snapshot del array completo)
- `commit`: DELETE por IDs
- Mantiene la misma UI de `UndoToast`.
- [ ] **Move to other list**: abre un bottom sheet / dialog con:
- Lista de colectivos activos (`status = 'active'`, excluyendo la actual), cargadas via `loadLists` si hace falta.
- Botón "+ Create new list" al final; crea una lista vacía y mueve.
- Al confirmar: `UPDATE shopping_items SET list_id = $new WHERE id IN (...)`. Checked state se preserva.
- Toast de confirmación "Moved N items to {list name}" (sin undo; el usuario puede volver a mover si quiere).
- [ ] **Mark selected as checked**: `UPDATE shopping_items SET is_checked = true, checked_by = auth.uid(), checked_at = now() WHERE id IN (...) AND is_checked = false`. Solo marca; no toggle. Si todos ya estaban checked, no-op silencioso.
##### 5.11.e Store + backend helpers
- [ ] `apps/web/src/lib/stores/lists.ts`:
- `bulkDeleteItems(ids: string[])`
- `bulkMoveItems(ids: string[], newListId: string)`
- `bulkCheckItems(ids: string[], userId: string)`
- Cada uno hace una sola llamada Supabase con `.in('id', ids)`.
- [ ] No se necesita migración: todas las ops usan las mismas RLS existentes.
##### 5.11.f Tests
- [ ] Vitest: unit tests de las tres funciones bulk en una suite nueva `rls-bulk-items.test.ts`:
- miembro puede bulk-delete / move / check sobre items de su colectivo
- guest no puede (RLS bloquea)
- cross-collective: Borja no puede mover items de Eva a una lista de Borja
- [ ] Playwright mobile: `mobile-selection.test.ts`:
- SEL-01 long-press → selection mode activo + fila preseleccionada
- SEL-02 multi-select vía single-tap
- SEL-03 bulk delete + undo toast
- SEL-04 mark selected as checked
- [ ] Playwright desktop: `selection.test.ts`:
- SEL-D-01 toggle del botón selection activa modo
- SEL-D-02 move-to-other-list con dialog de destino
**Criterio de aceptación 5.11:**
- Long-press entra en selection mode, single-tap alterna selección.
- Botón "Select" en desktop (toggle).
- Bulk delete con single undo toast.
- Bulk move con dialog que muestra listas activas + crear.
- Bulk mark-checked solo sobre los seleccionados.
---
#### 5.12 Chrome de detail views (top bar oculto, sticky bars)
**Objetivo:** en las vistas de detalle (`/lists/[id]`, `/tasks/[id]`, `/notes/[id]`, más `/lists/[id]/session` que ya lo hace) el `MobileTopBar` global desaparece y se reemplaza por una barra contextual local con `back + title` pegada al top. El `BottomTabBar` permanece siempre visible.
**Cambios:**
- [ ] `apps/web/src/routes/(app)/+layout.svelte` — detectar si la ruta es una detail view y condicionalmente ocultar `<MobileTopBar>`:
```svelte
{#if !isDetailRoute($page.url.pathname)}
<MobileTopBar onMenu={() => (drawerOpen = true)} />
{/if}
```
con un helper:
```ts
function isDetailRoute(path: string): boolean {
return /^\/lists\/[0-9a-f-]+(\/session)?$/.test(path)
|| /^\/tasks\/[0-9a-f-]+$/.test(path)
|| /^\/notes\/[0-9a-f-]+$/.test(path);
}
```
- [ ] Para cada detail view, su header local (back arrow + título) debe ser `sticky top-0 z-30 bg-surface/80 backdrop-blur-xl` para igualar el estilo del `MobileTopBar` que sustituye.
- `/lists/[id]/+page.svelte` — actualmente el header está dentro del scroll; mover a sticky.
- `/tasks/[id]/+page.svelte` — igual.
- `/notes/[id]/+page.svelte` — igual (ya tiene glassmorphism implícito por el color de fondo).
- [ ] `BottomTabBar` sigue presente en todas las detail views salvo `/lists/[id]/session` (que ya se oculta explícitamente al ser un overlay full-screen con z-50).
- [ ] El título contextual se trunca con ellipsis si es largo (`truncate`).
- [ ] Regla general: cualquier barra que una vista renderice debe ser sticky (o fixed si replace-nav), nunca inline scrolling.
**Scope explícito de 5.12:** solo las rutas de detalle de listas/tareas/notas. `/collective/manage`, `/settings`, `/invitation/[token]`, `/notes/archive`, `/notes/trash` mantienen el chrome actual (fuera de este sprint).
**Tests:**
- [ ] Actualizar `mobile-shell.test.ts`:
- M-01: en una detail view (e.g. `/lists/[id]`), el `MobileTopBar` NO está presente.
- M-02: en la misma ruta, existe un header local con `back + title` sticky.
- M-03: el `BottomTabBar` sigue visible.
**Criterio de aceptación 5.12:**
- En mobile, navegar a `/lists/seed-id`:
- `MobileTopBar` (hamburger + collective + avatar) no aparece.
- Un header local "← Weekly shop" pegado al top, sticky al hacer scroll.
- `BottomTabBar` visible.
- En desktop, sin cambios (los detail headers actuales ya se ven arriba del main).
---
#### 5.Z Verificación final — todos los tests verdes
- [x] `just test-all` → **224 verdes, 4 skipped**:

View File

@@ -0,0 +1,7 @@
-- Migration 011: drop shopping_items.unit
--
-- The UI no longer exposes a unit field. Users encode the unit inside the item
-- name when relevant (e.g. "Milk 1L", "Eggs 1 doz"). The generated `search`
-- tsvector already indexes only `name`, so search coverage is unchanged.
ALTER TABLE public.shopping_items DROP COLUMN unit;

View File

@@ -103,13 +103,13 @@ BEGIN
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Weekly shop', 'active', '11111111-1111-1111-1111-111111111111')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by)
INSERT INTO public.shopping_items (list_id, name, quantity, sort_order, created_by)
VALUES
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk', 2, 'L', 1, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, NULL, 2, '22222222-2222-2222-2222-222222222222'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs', 1, 'doz', 3, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, NULL, 4, '33333333-3333-3333-3333-333333333333'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, NULL, 5, '22222222-2222-2222-2222-222222222222')
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk 1L', 2, 1, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, 2, '22222222-2222-2222-2222-222222222222'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs 1 doz', 1, 3, '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, 4, '33333333-3333-3333-3333-333333333333'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, 5, '22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING;
END IF;

View File

@@ -23,8 +23,8 @@ VALUES (
) ON CONFLICT (id) DO NOTHING;
-- D-13: Inserting an item creates a new frequency row (normalized name)
INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by)
VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', ' Butter ', 1, NULL, 50, '11111111-1111-1111-1111-111111111111');
INSERT INTO public.shopping_items (list_id, name, quantity, sort_order, created_by)
VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', ' Butter ', 1, 50, '11111111-1111-1111-1111-111111111111');
SELECT ok(
EXISTS (