diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9eeaa8d..1a563ea 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 92bd89d..6e5ed4c 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -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", diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts index c257f34..9e1b3af 100644 --- a/apps/web/src/lib/stores/lists.ts +++ b/apps/web/src/lib/stores/lists.ts @@ -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' > > ) { diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte index 97da161..7ffc0c9 100644 --- a/apps/web/src/routes/(app)/lists/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -49,7 +49,6 @@ // Add-item form let newName = $state(''); let newQty = $state(null); - let newUnit = $state(''); let nameInput: HTMLInputElement | undefined = $state(); // Suggestions @@ -60,7 +59,6 @@ let editingId = $state(null); let editName = $state(''); let editQty = $state(null); - let editUnit = $state(''); // Swipe-to-delete (touch only) let swipeOffsets = $state>({}); @@ -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" /> - 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" - /> {:else} @@ -550,9 +528,6 @@ {item.name} - {#if item.unit} - {item.unit} - {/if} @@ -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" - /> -

{item.name}

- {#if item.quantity || item.unit} -

- {item.quantity ?? ''}{item.unit ? ' ' + item.unit : ''} -

+ {#if item.quantity} +

{item.quantity}

{/if}
diff --git a/packages/types/src/database.ts b/packages/types/src/database.ts index 3f22c08..5bc1cfc 100644 --- a/packages/types/src/database.ts +++ b/packages/types/src/database.ts @@ -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; diff --git a/packages/types/src/domain.ts b/packages/types/src/domain.ts index 5edd487..d3080b3 100644 --- a/packages/types/src/domain.ts +++ b/packages/types/src/domain.ts @@ -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; diff --git a/plan/fase-5-mobile-ux.md b/plan/fase-5-mobile-ux.md index 51326f6..7e493dd 100644 --- a/plan/fase-5-mobile-ux.md +++ b/plan/fase-5-mobile-ux.md @@ -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: 1–2 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 `` (`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 ``, idéntico al de `/notes` (fondo `bg-slate-900` en light mode, icono `Plus`): + ```svelte + + ``` +- [ ] `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 + + ``` + - 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** (`
`): + - `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 3–6 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 ``: + ```svelte + {#if !isDetailRoute($page.url.pathname)} + (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**: diff --git a/supabase/migrations/011_drop_shopping_items_unit.sql b/supabase/migrations/011_drop_shopping_items_unit.sql new file mode 100644 index 0000000..43ac078 --- /dev/null +++ b/supabase/migrations/011_drop_shopping_items_unit.sql @@ -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; diff --git a/supabase/seed.sql b/supabase/seed.sql index 1572d3f..b94c3c5 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -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; diff --git a/supabase/tests/002_item_frequency_trigger.sql b/supabase/tests/002_item_frequency_trigger.sql index ba38554..59251db 100644 --- a/supabase/tests/002_item_frequency_trigger.sql +++ b/supabase/tests/002_item_frequency_trigger.sql @@ -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 (