diff --git a/client/src/components/Packing/packingListModel.test.ts b/client/src/components/Packing/packingListModel.test.ts new file mode 100644 index 000000000..2388109ca --- /dev/null +++ b/client/src/components/Packing/packingListModel.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPackingItem } from '../../../tests/helpers/factories'; +import { + formatWeight, + groupPackingItems, + isLastCustomItemInCategory, + isPackingPlaceholder, + packingCategoryOrder, + packingItemWeight, + packingProgress, + packingStatusFiltered, + packingViewItems, +} from './packingListModel'; +import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; + +// FE-PACK-MODEL-001 to FE-PACK-MODEL-008 + +const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 }); +const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 }); +const stove = buildPackingItem({ id: 3, name: 'Stove', category: 'Camp' }); +const loose = buildPackingItem({ id: 4, name: 'Map', category: null }); + +describe('packingListModel', () => { + it('FE-PACK-MODEL-001: the common view holds the group pool, the personal one the private items', () => { + const items = [shared, mine, stove, loose]; + expect(packingViewItems(items, 'common').map((i) => i.id)).toEqual([1, 3, 4]); + expect(packingViewItems(items, 'personal').map((i) => i.id)).toEqual([2]); + }); + + it('FE-PACK-MODEL-002: categories come in first-appearance order, the uncategorised under the default name', () => { + expect(packingCategoryOrder([shared, loose, stove], 'Other')).toEqual(['Camp', 'Other']); + expect(packingCategoryOrder([], 'Other')).toEqual([]); + }); + + it('FE-PACK-MODEL-003: the status filter keeps open or done items, and all of them otherwise', () => { + const items = [shared, stove]; + expect(packingStatusFiltered(items, 'open').map((i) => i.id)).toEqual([3]); + expect(packingStatusFiltered(items, 'done').map((i) => i.id)).toEqual([1]); + expect(packingStatusFiltered(items, 'all')).toBe(items); + }); + + it('FE-PACK-MODEL-004: groups follow the first item of each category, after the status filter', () => { + const groups = groupPackingItems([loose, shared, stove], 'all', 'Other'); + expect(groups.map((g) => [g.category, g.items.map((i) => i.id)])).toEqual([ + ['Other', [4]], + ['Camp', [1, 3]], + ]); + expect(groupPackingItems([loose, shared, stove], 'done', 'Other').map((g) => g.category)).toEqual(['Camp']); + }); + + it('FE-PACK-MODEL-005: progress counts checked items and rounds the share', () => { + expect(packingProgress([shared, stove, loose])).toEqual({ checked: 1, total: 3, pct: 33 }); + expect(packingProgress([])).toEqual({ checked: 0, total: 0, pct: 0 }); + }); + + it('FE-PACK-MODEL-006: weights read in grams below a kilo and in kilos from there', () => { + expect(formatWeight(232.4)).toBe('232 g'); + expect(formatWeight(1000)).toBe('1.0 kg'); + expect(formatWeight(1250)).toBe('1.3 kg'); + expect(packingItemWeight({ weight_grams: 200, quantity: 3 })).toBe(600); + expect(packingItemWeight({ weight_grams: null, quantity: 2 })).toBe(0); + expect(packingItemWeight({ weight_grams: 150, quantity: 0 })).toBe(150); + }); + + it('FE-PACK-MODEL-007: only the last real item of a named category is reset instead of deleted', () => { + const only = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes' }); + expect(isLastCustomItemInCategory(only, [only, shared])).toBe(true); + expect(isLastCustomItemInCategory(stove, [shared, stove])).toBe(false); + expect(isLastCustomItemInCategory(loose, [loose])).toBe(false); + const placeholder = buildPackingItem({ id: 6, name: PACKING_PLACEHOLDER_NAME, category: 'Empty' }); + expect(isLastCustomItemInCategory(placeholder, [placeholder])).toBe(false); + }); + + it('FE-PACK-MODEL-008: the placeholder row is recognised by its name', () => { + expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true); + expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false); + }); +}); diff --git a/client/src/components/Packing/packingListModel.ts b/client/src/components/Packing/packingListModel.ts new file mode 100644 index 000000000..39b195958 --- /dev/null +++ b/client/src/components/Packing/packingListModel.ts @@ -0,0 +1,98 @@ +import type { PackingItem } from '../../types'; +import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; + +/** + * Pure view-model helpers for the packing list, shared by the desktop panel and + * the phone tab. No React, no side effects: grouping, filtering and counting + * only, so switching a filter on either surface produces the same buckets. + */ + +export type PackingView = 'common' | 'personal'; +export type PackingStatusFilter = 'all' | 'open' | 'done'; + +/** Three-tier sharing split (#858): Common = group pool, Personal = mine + shared-to-me. */ +export function packingViewItems(items: PackingItem[], view: PackingView): PackingItem[] { + return items.filter((i) => (view === 'common' ? !i.is_private : !!i.is_private)); +} + +/** Category display order = first-appearance order within the active view (stable colours). */ +export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory: string): string[] { + const seen: string[] = []; + for (const item of viewItems) { + const cat = item.category || defaultCategory; + if (!seen.includes(cat)) seen.push(cat); + } + return seen; +} + +export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] { + if (status === 'open') return items.filter((i) => !i.checked); + if (status === 'done') return items.filter((i) => !!i.checked); + return items; +} + +export interface PackingCategoryGroup { + category: string; + items: PackingItem[]; +} + +/** Groups the view+status filtered items by category, in first-encounter order. */ +export function groupPackingItems( + viewItems: PackingItem[], + status: PackingStatusFilter, + defaultCategory: string +): PackingCategoryGroup[] { + const filtered = packingStatusFiltered(viewItems, status); + const order: string[] = []; + const byCategory = new Map(); + for (const item of filtered) { + const cat = item.category || defaultCategory; + let bucket = byCategory.get(cat); + if (!bucket) { + bucket = []; + byCategory.set(cat, bucket); + order.push(cat); + } + bucket.push(item); + } + return order.map((category) => ({ category, items: byCategory.get(category) as PackingItem[] })); +} + +export interface PackingProgress { + checked: number; + total: number; + pct: number; +} + +export function packingProgress(items: PackingItem[]): PackingProgress { + const checked = items.filter((i) => i.checked).length; + const total = items.length; + return { checked, total, pct: total > 0 ? Math.round((checked / total) * 100) : 0 }; +} + +/** "232 g" under 1000g, "1.2 kg" at/above, the phone's and the printout's weight label. */ +export function formatWeight(grams: number): string { + return grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${Math.round(grams)} g`; +} + +/** Weight an item contributes to a bag/total: unit weight times quantity. */ +export function packingItemWeight(item: Pick): number { + return (item.weight_grams || 0) * (item.quantity || 1); +} + +/** + * True when deleting `item` would empty its (custom) category: the row is then + * reset to the `...` placeholder instead of removed, so the category keeps its + * position and colour (#1289). + */ +export function isLastCustomItemInCategory(item: PackingItem, allItems: PackingItem[]): boolean { + return ( + !!item.category && + item.name !== PACKING_PLACEHOLDER_NAME && + !allItems.some((i) => i.id !== item.id && i.category === item.category) + ); +} + +export function isPackingPlaceholder(item: Pick): boolean { + return item.name === PACKING_PLACEHOLDER_NAME; +} diff --git a/client/src/components/Packing/packingPrintDocument.ts b/client/src/components/Packing/packingPrintDocument.ts index 1268fd14a..5df84c71b 100644 --- a/client/src/components/Packing/packingPrintDocument.ts +++ b/client/src/components/Packing/packingPrintDocument.ts @@ -1,6 +1,6 @@ import { escapeHtml } from '@trek/shared' import type { PackingItem } from '../../types' -import { formatWeight, packingItemWeight } from '../../mobile/screens/trip/tabs/listsModel' +import { formatWeight, packingItemWeight } from './packingListModel' import { katColor } from './packingListPanel.helpers' import { groupByCategory, type PackingBagInfo, type PackingBagLookup } from './packingExport' diff --git a/client/src/components/Packing/usePackingBags.test.ts b/client/src/components/Packing/usePackingBags.test.ts new file mode 100644 index 000000000..ad8eaa0e0 --- /dev/null +++ b/client/src/components/Packing/usePackingBags.test.ts @@ -0,0 +1,183 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { packingApi } from '../../api/client'; +import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode'; +import type { PackingBag } from '../../types'; +import { BAG_COLORS } from './packingListPanel.constants'; +import { useBagTotalsPing } from './useBagTotalsPing'; +import { usePackingBags } from './usePackingBags'; + +vi.mock('./useBagTotalsPing', () => ({ useBagTotalsPing: vi.fn() })); + +// FE-PACK-BAGS-001 to FE-PACK-BAGS-010 + +const t = (key: string) => key; +const bag = (over: Partial): PackingBag => ({ + id: 1, + trip_id: 7, + name: 'Backpack', + color: BAG_COLORS[0], + sort_order: 0, + ...over, +}); +const BACKPACK = bag({ id: 1 }); +const DUFFEL = bag({ id: 2, name: 'Duffel', color: BAG_COLORS[1] }); + +function setup(bagTrackingEnabled = true) { + const toast = { error: vi.fn() }; + const view = renderHook( + (props: { enabled: boolean }) => usePackingBags({ tripId: 7, bagTrackingEnabled: props.enabled, t, toast }), + { + initialProps: { enabled: bagTrackingEnabled }, + } + ); + return { ...view, toast }; +} + +beforeEach(() => { + _resetNetworkMode(); + vi.mocked(useBagTotalsPing).mockClear(); +}); + +afterEach(() => { + setForcedOffline(false); + vi.restoreAllMocks(); +}); + +describe('usePackingBags', () => { + it('FE-PACK-BAGS-001: loads the bags and the weight in no bag while bag tracking is on', async () => { + const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK], unassigned_weight_grams: 450 }); + const { result } = setup(); + await waitFor(() => expect(result.current.bags).toEqual([BACKPACK])); + expect(list).toHaveBeenCalledWith(7); + expect(result.current.unassignedWeightGrams).toBe(450); + }); + + it('FE-PACK-BAGS-002: reads nothing while bag tracking is off, and starts once it is on', async () => { + const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] }); + const { result, rerender } = setup(false); + await act(async () => {}); + expect(list).not.toHaveBeenCalled(); + expect(result.current.bags).toEqual([]); + expect(result.current.unassignedWeightGrams).toBeNull(); + rerender({ enabled: true }); + await waitFor(() => expect(result.current.bags).toEqual([BACKPACK])); + // A response without the server sum leaves it unknown. + expect(result.current.unassignedWeightGrams).toBeNull(); + }); + + it('FE-PACK-BAGS-003: a failed read keeps what was there and says nothing', async () => { + vi.spyOn(packingApi, 'listBags').mockRejectedValue(new Error('offline')); + const { result, toast } = setup(); + await act(async () => {}); + expect(result.current.bags).toEqual([]); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PACK-BAGS-004: re-reads the totals on the room ping, and the server totals count only online', async () => { + const list = vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] }); + const { result } = setup(); + await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); + expect(useBagTotalsPing).toHaveBeenLastCalledWith(true, result.current.reloadBags); + expect(result.current.serverWeightsFresh).toBe(true); + act(() => setForcedOffline(true)); + expect(result.current.serverWeightsFresh).toBe(false); + }); + + it('FE-PACK-BAGS-005: a new bag takes the next colour and joins the list', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] }); + const create = vi.spyOn(packingApi, 'createBag').mockResolvedValue({ bag: DUFFEL }); + const { result } = setup(); + await waitFor(() => expect(result.current.bags).toHaveLength(1)); + let created: PackingBag | undefined; + await act(async () => { + created = await result.current.createBag('Duffel'); + }); + expect(create).toHaveBeenCalledWith(7, { name: 'Duffel', color: BAG_COLORS[1] }); + expect(created).toEqual(DUFFEL); + expect(result.current.bags).toEqual([BACKPACK, DUFFEL]); + }); + + it('FE-PACK-BAGS-006: a failed new bag is reported and comes back empty', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] }); + vi.spyOn(packingApi, 'createBag').mockRejectedValue(new Error('x')); + const { result, toast } = setup(); + let created: PackingBag | undefined = BACKPACK; + await act(async () => { + created = await result.current.createBag('Duffel'); + }); + expect(created).toBeUndefined(); + expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError'); + expect(result.current.bags).toEqual([]); + }); + + it('FE-PACK-BAGS-007: an edit merges what the server returns into that bag only', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK, DUFFEL] }); + const update = vi + .spyOn(packingApi, 'updateBag') + .mockResolvedValue({ bag: { ...BACKPACK, name: 'Daypack', weight_limit_grams: 7000 } }); + const { result } = setup(); + await waitFor(() => expect(result.current.bags).toHaveLength(2)); + await act(async () => { + await result.current.updateBag(1, { name: 'Daypack', weight_limit_grams: 7000 }); + }); + expect(update).toHaveBeenCalledWith(7, 1, { name: 'Daypack', weight_limit_grams: 7000 }); + expect(result.current.bags).toEqual([{ ...BACKPACK, name: 'Daypack', weight_limit_grams: 7000 }, DUFFEL]); + }); + + it('FE-PACK-BAGS-008: deleting and setting members change the list, and each failure has its message', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK, DUFFEL] }); + vi.spyOn(packingApi, 'setBagMembers').mockResolvedValue({ members: [{ user_id: 3, username: 'ada' }] }); + vi.spyOn(packingApi, 'deleteBag').mockResolvedValue({ ok: true }); + const { result } = setup(); + await waitFor(() => expect(result.current.bags).toHaveLength(2)); + await act(async () => { + await result.current.setBagMembers(2, [3]); + }); + expect(result.current.bags[1].members).toEqual([{ user_id: 3, username: 'ada' }]); + await act(async () => { + await result.current.deleteBag(1); + }); + expect(result.current.bags.map((b) => b.id)).toEqual([2]); + }); + + it('FE-PACK-BAGS-009: failed edits, deletes and member changes leave the bags and say why', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [BACKPACK] }); + vi.spyOn(packingApi, 'updateBag').mockRejectedValue(new Error('x')); + vi.spyOn(packingApi, 'deleteBag').mockRejectedValue(new Error('x')); + vi.spyOn(packingApi, 'setBagMembers').mockRejectedValue(new Error('x')); + const { result, toast } = setup(); + await waitFor(() => expect(result.current.bags).toHaveLength(1)); + await act(async () => { + await result.current.updateBag(1, { name: 'Daypack' }); + await result.current.deleteBag(1); + await result.current.setBagMembers(1, [3]); + }); + expect(toast.error.mock.calls.map((c) => c[0])).toEqual([ + 'common.error', + 'packing.toast.deleteError', + 'common.error', + ]); + expect(result.current.bags).toEqual([BACKPACK]); + }); + + it('FE-PACK-BAGS-010: tryCreateBag tells a write that went through from a failed one', async () => { + vi.spyOn(packingApi, 'listBags').mockResolvedValue({ bags: [] }); + const create = vi.spyOn(packingApi, 'createBag').mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('x')); + const { result, toast } = setup(); + let first: unknown = 'unset'; + let second: unknown = 'unset'; + await act(async () => { + first = await result.current.tryCreateBag('Duffel'); + }); + expect(first).toEqual({ bag: undefined }); + expect(toast.error).not.toHaveBeenCalled(); + await act(async () => { + second = await result.current.tryCreateBag('Tote'); + }); + expect(second).toBeNull(); + expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError'); + expect(create).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Packing/usePackingBags.ts b/client/src/components/Packing/usePackingBags.ts new file mode 100644 index 000000000..caf8f54be --- /dev/null +++ b/client/src/components/Packing/usePackingBags.ts @@ -0,0 +1,123 @@ +import type { PackingUpdateBagRequest } from '@trek/shared'; +import { useCallback, useEffect, useState } from 'react'; + +import { packingApi } from '../../api/client'; +import { useNetworkMode } from '../../hooks/useNetworkMode'; +import type { PackingBag } from '../../types'; +import { BAG_COLORS } from './packingListPanel.constants'; +import { useBagTotalsPing } from './useBagTotalsPing'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + error: (message: string) => void; +} + +/** + * The trip's bags behind both packing lists: the bags with their server-summed + * weights, kept fresh by the room's totals ping, and the bag writes. The desktop + * panel and the phone tab draw their own bag sidebar, modal or sheet over it. + */ +export function usePackingBags({ + tripId, + bagTrackingEnabled, + t, + toast, +}: { + tripId: number; + bagTrackingEnabled: boolean; + t: Translate; + toast: Toaster; +}) { + const [bags, setBags] = useState([]); + /** Server-summed weight of everything in no bag (#2191); null until the first load. */ + const [unassignedWeightGrams, setUnassignedWeightGrams] = useState(null); + + const reloadBags = useCallback(async () => { + if (!bagTrackingEnabled) return; + try { + const r = await packingApi.listBags(tripId); + setBags(r.bags || []); + setUnassignedWeightGrams(r.unassigned_weight_grams ?? null); + } catch { + // Offline or a failed read: the surfaces fall back to the local sum + // (see `serverWeightsFresh` below), so there is nothing to roll back. + } + }, [tripId, bagTrackingEnabled]); + + useEffect(() => { + void reloadBags(); + }, [reloadBags]); + + // Bag weights are summed server-side across every member (#2191), so an item + // this viewer may not even see still moves them. The item events cannot carry + // that (a private item is delivered only to its owner, which is the very rule + // that made the totals wrong), so the server pings the room content-free and + // we re-read the numbers. + useBagTotalsPing(bagTrackingEnabled, reloadBags); + + // Bags are not part of the offline cache (no repo, no Dexie table), so while + // offline the server totals are frozen at the last online read and cannot see + // the optimistic item writes the mutation queue is holding. A stale absolute + // number measured against an airline limit is worse than an honest partial + // one, so offline the surfaces sum what they can see instead (#2191). + const { offline } = useNetworkMode(); + const serverWeightsFresh = !offline; + + /** + * Creates a bag in the next colour. Resolves to the server's answer once the + * write went through, even when that answer carries no bag, or to null once + * the failure is reported. + */ + const tryCreateBag = async (name: string): Promise<{ bag: PackingBag } | null> => { + try { + const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] }); + setBags((prev) => [...prev, data.bag]); + return { bag: data.bag }; + } catch { + toast.error(t('packing.toast.saveError')); + return null; + } + }; + + /** Creates a bag in the next colour; the new bag, or undefined once the failure is reported. */ + const createBag = async (name: string): Promise => (await tryCreateBag(name))?.bag; + + const updateBag = async (bagId: number, data: PackingUpdateBagRequest) => { + try { + const result = await packingApi.updateBag(tripId, bagId, data); + setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, ...result.bag } : b))); + } catch { + toast.error(t('common.error')); + } + }; + + const deleteBag = async (bagId: number) => { + try { + await packingApi.deleteBag(tripId, bagId); + setBags((prev) => prev.filter((b) => b.id !== bagId)); + } catch { + toast.error(t('packing.toast.deleteError')); + } + }; + + const setBagMembers = async (bagId: number, userIds: number[]) => { + try { + const result = await packingApi.setBagMembers(tripId, bagId, userIds); + setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, members: result.members } : b))); + } catch { + toast.error(t('common.error')); + } + }; + + return { + bags, + unassignedWeightGrams, + serverWeightsFresh, + reloadBags, + tryCreateBag, + createBag, + updateBag, + deleteBag, + setBagMembers, + }; +} diff --git a/client/src/components/Packing/usePackingCategoryAssignees.test.ts b/client/src/components/Packing/usePackingCategoryAssignees.test.ts new file mode 100644 index 000000000..134f84ed9 --- /dev/null +++ b/client/src/components/Packing/usePackingCategoryAssignees.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { packingApi } from '../../api/client'; +import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'; + +// FE-PACK-ASSIGN-001 to FE-PACK-ASSIGN-004 + +const t = (key: string) => key; +const ANNA = { user_id: 3, username: 'anna' }; +const BEN = { user_id: 4, username: 'ben' }; + +function setup() { + const toast = { error: vi.fn() }; + return { ...renderHook(() => usePackingCategoryAssignees({ tripId: 7, t, toast })), toast }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('usePackingCategoryAssignees', () => { + it('FE-PACK-ASSIGN-001: loads who looks after each category of the trip', async () => { + const get = vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA] } }); + const { result } = setup(); + await waitFor(() => expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA] })); + expect(get).toHaveBeenCalledWith(7); + }); + + it('FE-PACK-ASSIGN-002: a failed or empty load leaves every category unassigned, without a message', async () => { + vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({}); + const empty = setup(); + await act(async () => {}); + expect(empty.result.current.categoryAssignees).toEqual({}); + + vi.spyOn(packingApi, 'getCategoryAssignees').mockRejectedValue(new Error('offline')); + const failed = setup(); + await act(async () => {}); + expect(failed.result.current.categoryAssignees).toEqual({}); + expect(failed.toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PACK-ASSIGN-003: setting a category keeps the others and takes what the server answers', async () => { + vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA], Food: [BEN] } }); + const put = vi.spyOn(packingApi, 'setCategoryAssignees').mockResolvedValue({ assignees: [ANNA, BEN] }); + const { result } = setup(); + await waitFor(() => expect(result.current.categoryAssignees.Food).toEqual([BEN])); + await act(async () => { + await result.current.setAssignees('Camp', [3, 4]); + }); + expect(put).toHaveBeenCalledWith(7, 'Camp', [3, 4]); + expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA, BEN], Food: [BEN] }); + + put.mockResolvedValueOnce({}); + await act(async () => { + await result.current.setAssignees('Food', []); + }); + expect(result.current.categoryAssignees.Food).toEqual([]); + }); + + it('FE-PACK-ASSIGN-004: a failed change is reported and changes nothing', async () => { + vi.spyOn(packingApi, 'getCategoryAssignees').mockResolvedValue({ assignees: { Camp: [ANNA] } }); + vi.spyOn(packingApi, 'setCategoryAssignees').mockRejectedValue(new Error('x')); + const { result, toast } = setup(); + await waitFor(() => expect(result.current.categoryAssignees.Camp).toEqual([ANNA])); + await act(async () => { + await result.current.setAssignees('Camp', []); + }); + expect(toast.error).toHaveBeenCalledWith('packing.toast.saveError'); + expect(result.current.categoryAssignees).toEqual({ Camp: [ANNA] }); + }); +}); diff --git a/client/src/components/Packing/usePackingCategoryAssignees.ts b/client/src/components/Packing/usePackingCategoryAssignees.ts new file mode 100644 index 000000000..2159562ef --- /dev/null +++ b/client/src/components/Packing/usePackingCategoryAssignees.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +import { packingApi } from '../../api/client'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + error: (message: string) => void; +} + +/** Who looks after a packing category, as the server lists them. */ +export interface CategoryAssignee { + user_id: number; + username: string; + avatar?: string | null; + is_guest?: boolean; +} + +/** + * Who looks after each packing category, for both packing lists: loaded per + * trip, and replaced category by category. A failed load leaves nobody assigned. + */ +export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: number; t: Translate; toast: Toaster }) { + const [categoryAssignees, setCategoryAssignees] = useState>({}); + + useEffect(() => { + packingApi + .getCategoryAssignees(tripId) + .then((data) => setCategoryAssignees(data.assignees || {})) + .catch(() => { + // A failed read leaves every category unassigned; there is nothing to undo. + }); + }, [tripId]); + + const setAssignees = async (category: string, userIds: number[]) => { + try { + const data = await packingApi.setCategoryAssignees(tripId, category, userIds); + setCategoryAssignees((prev) => ({ ...prev, [category]: data.assignees || [] })); + } catch { + toast.error(t('packing.toast.saveError')); + } + }; + + return { categoryAssignees, setAssignees }; +} diff --git a/client/src/components/Packing/usePackingExport.ts b/client/src/components/Packing/usePackingExport.ts index ba6b27a19..4a1b10d76 100644 --- a/client/src/components/Packing/usePackingExport.ts +++ b/client/src/components/Packing/usePackingExport.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { packingApi } from '../../api/client' import { useTranslation } from '../../i18n' -import { packingViewItems, type PackingView } from '../../mobile/screens/trip/tabs/listsModel' +import { packingViewItems, type PackingView } from './packingListModel' import { useAddonStore } from '../../store/addonStore' import { useTripStore } from '../../store/tripStore' import { isEffectivelyOffline } from '../../sync/networkMode' diff --git a/client/src/components/Packing/usePackingImport.test.ts b/client/src/components/Packing/usePackingImport.test.ts new file mode 100644 index 000000000..b4a03aac9 --- /dev/null +++ b/client/src/components/Packing/usePackingImport.test.ts @@ -0,0 +1,159 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ChangeEvent } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildPackingItem } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { packingApi } from '../../api/client'; +import { useTripStore } from '../../store/tripStore'; +import { parseImportLines } from './packingListPanel.helpers'; +import { usePackingImport } from './usePackingImport'; + +// FE-PACK-IMPORT-001 to FE-PACK-IMPORT-008 + +const t = (key: string, params?: Record) => + params ? `${key}:${Object.values(params).join(',')}` : key; +const EXISTING = buildPackingItem({ id: 1, name: 'Tent' }); +const LIST = 'Gear, Rope\nClothes, Socks'; + +function setup(options: { reportEmpty?: boolean; oneAtATime?: boolean } = {}) { + const toast = { success: vi.fn(), error: vi.fn() }; + const onImported = vi.fn(); + const rendered = renderHook(() => usePackingImport({ tripId: 7, t, toast, onImported, ...options })); + return { ...rendered, toast, onImported }; +} + +const fileEvent = (target: { files: File[] | null; value: string }) => + ({ target }) as unknown as ChangeEvent; + +beforeEach(() => { + resetAllStores(); + seedStore(useTripStore, { packingItems: [EXISTING] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('usePackingImport', () => { + it('FE-PACK-IMPORT-001: parses the text as it is typed', () => { + const { result } = setup(); + expect(result.current.parsed).toEqual([]); + act(() => result.current.setText(LIST)); + expect(result.current.parsed).toEqual(parseImportLines(LIST)); + expect(result.current.parsed).toHaveLength(2); + }); + + it('FE-PACK-IMPORT-002: a picked file fills the text box and frees the picker for the same file', async () => { + const { result } = setup(); + const target = { files: [new File(['Gear, Rope'], 'list.csv', { type: 'text/plain' })], value: 'list.csv' }; + act(() => result.current.readFile(fileEvent(target))); + await waitFor(() => expect(result.current.text).toBe('Gear, Rope')); + expect(target.value).toBe(''); + }); + + it('FE-PACK-IMPORT-003: no file picked, or a result that is not text, leaves the box alone', async () => { + class BinaryReader { + result: unknown = new ArrayBuffer(4); + onload: (() => void) | null = null; + readAsText() { + this.onload?.(); + } + } + const { result } = setup(); + const none = { files: null, value: 'x' }; + act(() => result.current.readFile(fileEvent(none))); + expect(none.value).toBe('x'); + vi.stubGlobal('FileReader', BinaryReader); + act(() => result.current.readFile(fileEvent({ files: [new File(['x'], 'list.csv')], value: 'list.csv' }))); + expect(result.current.text).toBe(''); + }); + + it('FE-PACK-IMPORT-004: an import appends the items to the store, reports the count, clears and closes', async () => { + const added = buildPackingItem({ id: 2, name: 'Rope', category: 'Gear' }); + const bulk = vi.spyOn(packingApi, 'bulkImport').mockResolvedValue({ items: [added], count: 2 }); + const { result, toast, onImported } = setup({ oneAtATime: true }); + act(() => result.current.setText(LIST)); + await act(async () => { + await result.current.runImport(); + }); + expect(bulk).toHaveBeenCalledWith(7, parseImportLines(LIST)); + expect(useTripStore.getState().packingItems).toEqual([EXISTING, added]); + expect(toast.success).toHaveBeenCalledWith('packing.importSuccess:2'); + expect(result.current.text).toBe(''); + expect(onImported).toHaveBeenCalledTimes(1); + expect(result.current.importing).toBe(false); + }); + + it('FE-PACK-IMPORT-005: a failed import is reported, keeps the text and stays open', async () => { + vi.spyOn(packingApi, 'bulkImport').mockRejectedValue(new Error('x')); + const { result, toast, onImported } = setup({ reportEmpty: true }); + act(() => result.current.setText(LIST)); + await act(async () => { + await result.current.runImport(); + }); + expect(toast.error).toHaveBeenCalledWith('packing.importError'); + expect(result.current.text).toBe(LIST); + expect(onImported).not.toHaveBeenCalled(); + expect(useTripStore.getState().packingItems).toEqual([EXISTING]); + }); + + it('FE-PACK-IMPORT-006: nothing to import: the desktop says so, the phone stays quiet, neither sends', async () => { + const bulk = vi.spyOn(packingApi, 'bulkImport'); + const desktop = setup({ reportEmpty: true }); + act(() => desktop.result.current.setText(',')); + await act(async () => { + await desktop.result.current.runImport(); + }); + expect(desktop.toast.error).toHaveBeenCalledWith('packing.importEmpty'); + + const phone = setup({ oneAtATime: true }); + act(() => phone.result.current.setText(',')); + await act(async () => { + await phone.result.current.runImport(); + }); + expect(phone.toast.error).not.toHaveBeenCalled(); + expect(bulk).not.toHaveBeenCalled(); + }); + + it('FE-PACK-IMPORT-007: one import at a time on the phone: a second tap while it runs is ignored', async () => { + let finish: () => void = () => {}; + const bulk = vi.spyOn(packingApi, 'bulkImport').mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve({ items: [], count: 2 }); + }) + ); + const { result } = setup({ oneAtATime: true }); + act(() => result.current.setText(LIST)); + let first: Promise = Promise.resolve(); + act(() => { + first = result.current.runImport(); + }); + expect(result.current.importing).toBe(true); + await act(async () => { + await result.current.runImport(); + }); + expect(bulk).toHaveBeenCalledTimes(1); + await act(async () => { + finish(); + await first; + }); + expect(result.current.importing).toBe(false); + }); + + it('FE-PACK-IMPORT-008: the desktop sends a second import while one runs, as it always has', async () => { + const bulk = vi.spyOn(packingApi, 'bulkImport').mockImplementation(() => new Promise(() => {})); + const { result } = setup({ reportEmpty: true }); + act(() => result.current.setText(LIST)); + act(() => { + void result.current.runImport(); + }); + act(() => { + void result.current.runImport(); + }); + expect(bulk).toHaveBeenCalledTimes(2); + expect(result.current.importing).toBe(false); + }); +}); diff --git a/client/src/components/Packing/usePackingImport.ts b/client/src/components/Packing/usePackingImport.ts new file mode 100644 index 000000000..fddc30e68 --- /dev/null +++ b/client/src/components/Packing/usePackingImport.ts @@ -0,0 +1,76 @@ +import { useMemo, useState, type ChangeEvent } from 'react'; + +import { packingApi } from '../../api/client'; +import { useTripStore } from '../../store/tripStore'; +import { parseImportLines } from './packingListPanel.helpers'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + success: (message: string) => void; + error: (message: string) => void; +} + +/** + * The bulk packing import behind the desktop dialog and the phone sheet: the + * pasted or loaded text, one item per line, and sending it in one request whose + * items land straight in the trip store. `onImported` closes the surface. + * + * The desktop dialog says so when no line parses (`reportEmpty`); the phone sheet + * keeps its button disabled then, and lets one import run at a time (`oneAtATime`). + */ +export function usePackingImport({ + tripId, + t, + toast, + onImported, + reportEmpty = false, + oneAtATime = false, +}: { + tripId: number; + t: Translate; + toast: Toaster; + onImported: () => void; + reportEmpty?: boolean; + oneAtATime?: boolean; +}) { + const [text, setText] = useState(''); + const [importing, setImporting] = useState(false); + const parsed = useMemo(() => parseImportLines(text), [text]); + + /** Loads a picked CSV, text or Markdown file into the text box. */ + const readFile = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') setText(reader.result); + }; + reader.readAsText(file); + }; + + const runImport = async () => { + const lines = parseImportLines(text); + if (lines.length === 0) { + if (reportEmpty) toast.error(t('packing.importEmpty')); + return; + } + if (oneAtATime) { + if (importing) return; + setImporting(true); + } + try { + const result = await packingApi.bulkImport(tripId, lines); + useTripStore.setState((s) => ({ packingItems: [...s.packingItems, ...(result.items || [])] })); + toast.success(t('packing.importSuccess', { count: result.count })); + setText(''); + onImported(); + } catch { + toast.error(t('packing.importError')); + } finally { + if (oneAtATime) setImporting(false); + } + }; + + return { text, setText, parsed, importing, readFile, runImport }; +} diff --git a/client/src/components/Packing/usePackingItemActions.test.ts b/client/src/components/Packing/usePackingItemActions.test.ts new file mode 100644 index 000000000..9f6ae73c0 --- /dev/null +++ b/client/src/components/Packing/usePackingItemActions.test.ts @@ -0,0 +1,258 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildPackingItem } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useTripStore, type TripStoreState } from '../../store/tripStore'; +import type { PackingItem } from '../../types'; +import type { PackingView } from './packingListModel'; +import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; +import { usePackingItemActions } from './usePackingItemActions'; + +// FE-PACK-ITEMACT-001 to FE-PACK-ITEMACT-012 + +const t = (key: string) => key; +const ZWSP = '​'; + +function actions() { + return { + addPackingItem: vi.fn(async () => buildPackingItem()), + updatePackingItem: vi.fn(async () => buildPackingItem()), + deletePackingItem: vi.fn(async () => undefined), + togglePackingItem: vi.fn(async () => undefined), + }; +} + +function setup({ + items = [] as PackingItem[], + view = 'common' as PackingView, + categories = [] as string[], + placeholderFrom = 'items' as 'store' | 'items', + currentUserId = 9 as number | null | undefined, +} = {}) { + const acts = actions(); + const toast = { error: vi.fn() }; + const rendered = renderHook(() => + usePackingItemActions({ + tripId: 7, + items, + view, + currentUserId, + categories, + defaultCategory: 'Other', + actions: acts, + t, + toast, + placeholderFrom, + }) + ); + return { ...rendered, acts, toast }; +} + +beforeEach(() => resetAllStores()); + +describe('usePackingItemActions', () => { + it('FE-PACK-ITEMACT-001: an item added to a category with a placeholder takes over that row', async () => { + const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' }); + const { result, acts } = setup({ items: [placeholder] }); + await act(async () => { + await result.current.addItemToCategory('Camp', 'Tent'); + }); + expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' }); + expect(acts.addPackingItem).not.toHaveBeenCalled(); + }); + + it('FE-PACK-ITEMACT-002: the desktop looks the placeholder up in the store, the phone in the items it shows', async () => { + const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' }); + seedStore(useTripStore, { packingItems: [placeholder] }); + + const desktop = setup({ items: [], placeholderFrom: 'store' }); + await act(async () => { + await desktop.result.current.addItemToCategory('Camp', 'Tent'); + }); + expect(desktop.acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' }); + + const phone = setup({ items: [], placeholderFrom: 'items' }); + await act(async () => { + await phone.result.current.addItemToCategory('Camp', 'Tent'); + }); + expect(phone.acts.updatePackingItem).not.toHaveBeenCalled(); + expect(phone.acts.addPackingItem).toHaveBeenCalledWith(7, { name: 'Tent', category: 'Camp', visibility: 'common' }); + }); + + it('FE-PACK-ITEMACT-003: in my list a new item is shared the way the category already is (#2241)', async () => { + const own = buildPackingItem({ + id: 5, + name: 'Socks', + category: 'Clothes', + is_private: 1, + owner_id: 9, + recipients: [{ user_id: 3, username: 'ada' }], + }); + const { result, acts } = setup({ items: [own], view: 'personal' }); + await act(async () => { + await result.current.addItemToCategory('Clothes', 'Shirt'); + }); + expect(acts.addPackingItem).toHaveBeenCalledWith(7, { + name: 'Shirt', + category: 'Clothes', + visibility: 'shared', + recipient_ids: [3], + }); + }); + + it('FE-PACK-ITEMACT-004: a failed add is reported', async () => { + const { result, acts, toast } = setup(); + acts.addPackingItem.mockRejectedValueOnce(new Error('x')); + await act(async () => { + await result.current.addItemToCategory('Camp', 'Tent'); + }); + expect(toast.error).toHaveBeenCalledWith('packing.toast.addError'); + }); + + it('FE-PACK-ITEMACT-005: the last real item of a category turns back into its placeholder, unchecked first', async () => { + const last = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes', checked: 1 }); + const { result, acts } = setup({ items: [last] }); + await act(async () => { + await result.current.deleteItem(last); + }); + expect(acts.togglePackingItem).toHaveBeenCalledWith(7, 5, false); + expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 5, { + name: PACKING_PLACEHOLDER_NAME, + weight_grams: null, + bag_id: null, + quantity: 1, + }); + expect(acts.deletePackingItem).not.toHaveBeenCalled(); + expect(acts.togglePackingItem.mock.invocationCallOrder[0]).toBeLessThan( + acts.updatePackingItem.mock.invocationCallOrder[0] + ); + }); + + it('FE-PACK-ITEMACT-006: any other item is deleted, and a failure is reported', async () => { + const rope = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes' }); + const knot = buildPackingItem({ id: 6, name: 'Knot', category: 'Ropes' }); + const { result, acts, toast } = setup({ items: [rope, knot] }); + await act(async () => { + await result.current.deleteItem(rope); + }); + expect(acts.deletePackingItem).toHaveBeenCalledWith(7, 5); + expect(acts.togglePackingItem).not.toHaveBeenCalled(); + acts.deletePackingItem.mockRejectedValueOnce(new Error('x')); + await act(async () => { + await result.current.deleteItem(rope); + }); + expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError'); + }); + + it('FE-PACK-ITEMACT-007: renaming moves every item of the category, the uncategorised under the default name', async () => { + const a = buildPackingItem({ id: 1, category: 'Camp' }); + const b = buildPackingItem({ id: 2, category: null }); + const c = buildPackingItem({ id: 3, category: 'Camp' }); + const { result, acts } = setup({ items: [a, b, c] }); + await act(async () => { + await result.current.renameCategory('Camp', 'Outdoor'); + }); + expect(acts.updatePackingItem.mock.calls).toEqual([ + [7, 1, { category: 'Outdoor' }], + [7, 3, { category: 'Outdoor' }], + ]); + acts.updatePackingItem.mockClear(); + await act(async () => { + await result.current.renameCategory('Other', 'Misc'); + }); + expect(acts.updatePackingItem.mock.calls).toEqual([[7, 2, { category: 'Misc' }]]); + }); + + it('FE-PACK-ITEMACT-008: a failed rename stops: the desktop gets the error, the phone a message', async () => { + const a = buildPackingItem({ id: 1, category: 'Camp' }); + const c = buildPackingItem({ id: 3, category: 'Camp' }); + const { result, acts, toast } = setup({ items: [a, c] }); + acts.updatePackingItem.mockRejectedValueOnce(new Error('x')); + await expect(result.current.renameCategory('Camp', 'Outdoor')).rejects.toThrow('x'); + expect(acts.updatePackingItem).toHaveBeenCalledTimes(1); + expect(toast.error).not.toHaveBeenCalled(); + + acts.updatePackingItem.mockClear(); + acts.updatePackingItem.mockRejectedValueOnce(new Error('x')); + await act(async () => { + await result.current.renameCategoryReporting('Camp', 'Outdoor'); + }); + expect(acts.updatePackingItem).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledWith('packing.toast.renameError'); + }); + + it('FE-PACK-ITEMACT-009: emptying a category tries every item and reports a failure once', async () => { + const items = [1, 2, 3].map((id) => buildPackingItem({ id, category: 'Camp' })); + const { result, acts, toast } = setup({ items }); + acts.deletePackingItem.mockRejectedValueOnce(new Error('x')).mockRejectedValueOnce(new Error('y')); + await act(async () => { + await result.current.deleteCategoryItems(items); + }); + expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 2, 3]); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError'); + }); + + it('FE-PACK-ITEMACT-010: clearing removes every checked item of the trip, in both views', async () => { + const items = [ + buildPackingItem({ id: 1, checked: 1 }), + buildPackingItem({ id: 2, checked: 0 }), + buildPackingItem({ id: 3, checked: 1, is_private: 1 }), + ]; + const { result, acts, toast } = setup({ items }); + await act(async () => { + await result.current.clearChecked(); + }); + expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 3]); + expect(toast.error).not.toHaveBeenCalled(); + acts.deletePackingItem.mockRejectedValueOnce(new Error('x')); + await act(async () => { + await result.current.clearChecked(); + }); + expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError'); + }); + + it('FE-PACK-ITEMACT-011: a new category needs a name, and a taken one gets an invisible suffix', async () => { + const { result, acts } = setup({ categories: ['Camp', `Camp${ZWSP}`], view: 'personal' }); + await act(async () => { + await result.current.addNewCategory(); + }); + expect(acts.addPackingItem).not.toHaveBeenCalled(); + + act(() => { + result.current.setAddingCategory(true); + result.current.setNewCategoryName(' Camp '); + }); + await act(async () => { + await result.current.addNewCategory(); + }); + expect(acts.addPackingItem).toHaveBeenCalledWith(7, { + name: PACKING_PLACEHOLDER_NAME, + category: `Camp${ZWSP}${ZWSP}`, + visibility: 'personal', + }); + expect(result.current.newCategoryName).toBe(''); + expect(result.current.addingCategory).toBe(false); + }); + + it('FE-PACK-ITEMACT-012: a failed new category is reported and keeps the field open', async () => { + const { result, acts, toast } = setup(); + acts.addPackingItem.mockRejectedValueOnce(new Error('x')); + act(() => { + result.current.setAddingCategory(true); + result.current.setNewCategoryName('Camp'); + }); + await act(async () => { + await result.current.addNewCategory(); + }); + expect(acts.addPackingItem).toHaveBeenCalledWith(7, { + name: PACKING_PLACEHOLDER_NAME, + category: 'Camp', + visibility: 'common', + }); + expect(toast.error).toHaveBeenCalledWith('packing.toast.addError'); + expect(result.current.newCategoryName).toBe('Camp'); + expect(result.current.addingCategory).toBe(true); + }); +}); diff --git a/client/src/components/Packing/usePackingItemActions.ts b/client/src/components/Packing/usePackingItemActions.ts new file mode 100644 index 000000000..9ff4a9779 --- /dev/null +++ b/client/src/components/Packing/usePackingItemActions.ts @@ -0,0 +1,177 @@ +import { useState } from 'react'; + +import { useTripStore, type TripStoreState } from '../../store/tripStore'; +import type { PackingItem } from '../../types'; +import { isLastCustomItemInCategory, isPackingPlaceholder, type PackingView } from './packingListModel'; +import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; +import { newItemSharing } from './packingListPanel.helpers'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + error: (message: string) => void; +} +type PackingActions = Pick< + TripStoreState, + 'addPackingItem' | 'updatePackingItem' | 'deletePackingItem' | 'togglePackingItem' +>; + +/** + * The item and category writes behind both packing lists: adding an item to a + * category, deleting one, renaming, emptying and adding categories, and removing + * every checked item. Each surface asks for confirmation its own way first. + * + * `placeholderFrom` says where a category's `...` placeholder is looked up when + * an item is added: the desktop reads the store as it is at that moment, the + * phone the items it rendered. + */ +export function usePackingItemActions({ + tripId, + items, + view, + currentUserId, + categories, + defaultCategory, + actions, + t, + toast, + placeholderFrom, +}: { + tripId: number; + items: PackingItem[]; + view: PackingView; + currentUserId: number | null | undefined; + /** The categories of the active view, in display order. */ + categories: string[]; + defaultCategory: string; + actions: PackingActions; + t: Translate; + toast: Toaster; + placeholderFrom: 'store' | 'items'; +}) { + const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = actions; + const [addingCategory, setAddingCategory] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + + const addItemToCategory = async (category: string, name: string) => { + try { + // Reuse the '...' placeholder slot when the category already has one, so a + // freshly-emptied category keeps its position (and therefore its colour) + // instead of the new item being appended to the end of the list. + const pool = placeholderFrom === 'store' ? useTripStore.getState().packingItems : items; + const placeholder = pool.find((i) => i.category === category && isPackingPlaceholder(i)); + if (placeholder) { + await updatePackingItem(tripId, placeholder.id, { name }); + } else { + // New items inherit the active view's tier, and in "my list" the sharing the + // category's own items agree on (#2241). + await addPackingItem(tripId, { + name, + category, + ...newItemSharing(items, category, view, currentUserId), + } as Parameters[1]); + } + } catch { + toast.error(t('packing.toast.addError')); + } + }; + + // Deleting an item from a row. When it is the last item of a user-created + // category, turn that row back into the '...' placeholder in place rather than + // deleting it (#1289). Updating the row keeps its id, list position and colour, + // so the category neither disappears nor jumps to the end. The default + // (uncategorized) group and the placeholder row itself are deleted normally: + // removing the placeholder is how an empty category is dismissed. + const deleteItem = async (item: PackingItem) => { + try { + if (isLastCustomItemInCategory(item, items)) { + if (item.checked) await togglePackingItem(tripId, item.id, false); + await updatePackingItem(tripId, item.id, { + name: PACKING_PLACEHOLDER_NAME, + weight_grams: null, + bag_id: null, + quantity: 1, + }); + } else { + await deletePackingItem(tripId, item.id); + } + } catch { + toast.error(t('packing.toast.deleteError')); + } + }; + + /** Moves every item of a category to the new name, one write each; a failure stops it and is thrown. */ + const renameCategory = async (oldName: string, newName: string) => { + const toUpdate = items.filter((i) => (i.category || defaultCategory) === oldName); + for (const item of toUpdate) { + await updatePackingItem(tripId, item.id, { category: newName }); + } + }; + + /** The same, with a failure reported here rather than thrown to the caller. */ + const renameCategoryReporting = async (oldName: string, newName: string) => { + try { + await renameCategory(oldName, newName); + } catch { + toast.error(t('packing.toast.renameError')); + } + }; + + const deleteCategoryItems = async (catItems: PackingItem[]) => { + let failed = false; + for (const item of catItems) { + try { + await deletePackingItem(tripId, item.id); + } catch { + failed = true; + } + } + if (failed) toast.error(t('packing.toast.deleteError')); + }; + + /** Removes every checked item of the trip, not only those of the active view. */ + const clearChecked = async () => { + let failed = false; + for (const item of items.filter((i) => i.checked)) { + try { + await deletePackingItem(tripId, item.id); + } catch { + failed = true; + } + } + if (failed) toast.error(t('packing.toast.deleteError')); + }; + + const addNewCategory = async () => { + if (!newCategoryName.trim()) return; + let catName = newCategoryName.trim(); + // Allow duplicate display names: append invisible zero-width spaces to make them unique internally. + while (categories.includes(catName)) { + catName += '​'; + } + try { + await addPackingItem(tripId, { + name: PACKING_PLACEHOLDER_NAME, + category: catName, + visibility: view === 'personal' ? 'personal' : 'common', + } as Parameters[1]); + setNewCategoryName(''); + setAddingCategory(false); + } catch { + toast.error(t('packing.toast.addError')); + } + }; + + return { + addingCategory, + setAddingCategory, + newCategoryName, + setNewCategoryName, + addItemToCategory, + deleteItem, + renameCategory, + renameCategoryReporting, + deleteCategoryItems, + clearChecked, + addNewCategory, + }; +} diff --git a/client/src/components/Packing/usePackingListPanel.test.tsx b/client/src/components/Packing/usePackingListPanel.test.tsx index 643c82ad9..8fa10c9a9 100644 --- a/client/src/components/Packing/usePackingListPanel.test.tsx +++ b/client/src/components/Packing/usePackingListPanel.test.tsx @@ -495,6 +495,20 @@ describe('usePackingList — bags', () => { expect(result.current.showAddBag).toBe(false) }) + it('FE-W5HOOK-059: a create that answers without a bag still clears the form', async () => { + server.use(http.post('/api/trips/1/packing/bags', () => HttpResponse.json({}))) + const { result } = renderPanel() + await settled() + + act(() => { result.current.setNewBagName('Duffel'); result.current.setShowAddBag(true) }) + await act(async () => { await result.current.handleCreateBag() }) + + expect(result.current.bags).toEqual([undefined]) + expect(result.current.newBagName).toBe('') + expect(result.current.showAddBag).toBe(false) + expect(toastSpy).not.toHaveBeenCalled() + }) + it('FE-W5HOOK-031: a failing bag create surfaces a save error', async () => { server.use(http.post('/api/trips/1/packing/bags', () => new HttpResponse(null, { status: 500 }))) const { result } = renderPanel() diff --git a/client/src/components/Packing/usePackingListPanel.ts b/client/src/components/Packing/usePackingListPanel.ts index df58a3365..579784529 100644 --- a/client/src/components/Packing/usePackingListPanel.ts +++ b/client/src/components/Packing/usePackingListPanel.ts @@ -1,17 +1,21 @@ -import { useState, useMemo, useRef, useEffect, useCallback } from 'react' -import type { ChangeEvent } from 'react' +import { useState, useMemo, useRef, useEffect } from 'react' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' -import { packingApi, tripsApi } from '../../api/client' +import { tripsApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' -import { useNetworkMode } from '../../hooks/useNetworkMode' -import { useBagTotalsPing } from './useBagTotalsPing' -import type { PackingItem, PackingBag } from '../../types' -import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants' -import { newItemSharing, parseImportLines, sortItemsByName } from './packingListPanel.helpers' +import type { PackingItem } from '../../types' +import { parseImportLines, sortItemsByName } from './packingListPanel.helpers' +import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel' +import { usePackingBags } from './usePackingBags' +import { usePackingCategoryAssignees } from './usePackingCategoryAssignees' +import { usePackingImport } from './usePackingImport' +import { usePackingItemActions } from './usePackingItemActions' +import { usePackingTemplates } from './usePackingTemplates' + +export type { CategoryAssignee } from './usePackingCategoryAssignees' const PACKING_SORT_KEY = 'trek:packing-sort' @@ -25,13 +29,6 @@ export interface TripMember { is_guest?: boolean } -export interface CategoryAssignee { - user_id: number - username: string - avatar?: string | null - is_guest?: boolean -} - export interface PackingListPanelProps { tripId: number items: PackingItem[] @@ -68,8 +65,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor const [ownView, setOwnView] = useState<'common' | 'personal'>('common') const view = viewProp ?? ownView const setView = onViewChange ?? setOwnView - const [addingCategory, setAddingCategory] = useState(false) - const [newCatName, setNewCatName] = useState('') const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems, setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore() const can = useCanDo() @@ -82,7 +77,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor // Trip members & category assignees const [tripMembers, setTripMembers] = useState([]) - const [categoryAssignees, setCategoryAssignees] = useState>({}) useEffect(() => { tripsApi.getMembers(tripId).then(data => { @@ -91,136 +85,39 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest }))) setTripMembers(all) }).catch(() => {}) - packingApi.getCategoryAssignees(tripId).then(data => { - setCategoryAssignees(data.assignees || {}) - }).catch(() => {}) }, [tripId]) - const handleSetAssignees = async (category: string, userIds: number[]) => { - try { - const data = await packingApi.setCategoryAssignees(tripId, category, userIds) - setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] })) - } catch { - toast.error(t('packing.toast.saveError')) - } - } + const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast }) // Split by the active view (#858): Common = group pool (is_private 0), Personal = // my own + shared-to-me (is_private 1, already filtered to me by the server). - const viewItems = useMemo( - () => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)), - [items, view], - ) + const viewItems = useMemo(() => packingViewItems(items, view), [items, view]) - const allCategories = useMemo(() => { - const seen: string[] = [] - for (const item of viewItems) { - const cat = item.category || t('packing.defaultCategory') - if (!seen.includes(cat)) seen.push(cat) - } - return seen - }, [viewItems, t]) + const allCategories = useMemo(() => packingCategoryOrder(viewItems, t('packing.defaultCategory')), [viewItems, t]) const gruppiert = useMemo(() => { - const filtered = viewItems.filter(i => { - if (filter === 'offen') return !i.checked - if (filter === 'erledigt') return i.checked - return true - }) + const status = filter === 'offen' ? 'open' : filter === 'erledigt' ? 'done' : 'all' const groups: Record = {} - for (const item of filtered) { - const kat = item.category || t('packing.defaultCategory') - if (!groups[kat]) groups[kat] = [] - groups[kat].push(item) - } - if (sort === 'name') { - for (const kat of Object.keys(groups)) groups[kat] = sortItemsByName(groups[kat], locale) + for (const group of groupPackingItems(viewItems, status, t('packing.defaultCategory'))) { + groups[group.category] = sort === 'name' ? sortItemsByName(group.items, locale) : group.items } return groups }, [viewItems, filter, sort, locale, t]) - const abgehakt = viewItems.filter(i => i.checked).length - const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0 + const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems) - const handleAddItemToCategory = async (category: string, name: string) => { - try { - // Reuse the '...' placeholder slot when the category already has one, so a - // freshly-emptied category keeps its position (and therefore its colour) - // instead of the new item being appended to the end of the list. - const placeholder = useTripStore.getState().packingItems.find( - i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME - ) - if (placeholder) { - await updatePackingItem(tripId, placeholder.id, { name }) - } else { - // New items inherit the active view's tier, and in "my list" the sharing the - // category's own items agree on (#2241). - await addPackingItem(tripId, { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters[1]) - } - } catch { toast.error(t('packing.toast.addError')) } - } - - // Deleting an item from a row. When it is the last item of a user-created - // category, turn that row back into the '...' placeholder in place rather than - // deleting it (#1289). Updating the row keeps its id, list position and colour, - // so the category neither disappears nor jumps to the end. The default - // (uncategorized) group and the placeholder row itself are deleted normally — - // removing the placeholder is how an empty category is dismissed. - const handleDeleteItem = async (item: PackingItem) => { - const category = item.category - const isLastInCategory = !!category - && item.name !== PACKING_PLACEHOLDER_NAME - && !items.some(i => i.id !== item.id && i.category === category) - try { - if (isLastInCategory) { - if (item.checked) await togglePackingItem(tripId, item.id, false) - await updatePackingItem(tripId, item.id, { - name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1, - }) - } else { - await deletePackingItem(tripId, item.id) - } - } catch { - toast.error(t('packing.toast.deleteError')) - } - } - - const handleAddNewCategory = async () => { - if (!newCatName.trim()) return - let catName = newCatName.trim() - // Allow duplicate display names — append invisible zero-width spaces to make unique internally - while (allCategories.includes(catName)) { - catName += '​' - } - try { - await addPackingItem(tripId, { name: '...', category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters[1]) - setNewCatName('') - setAddingCategory(false) - } catch { toast.error(t('packing.toast.addError')) } - } - - const handleRenameCategory = async (oldName: string, newName: string) => { - const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName) - for (const item of toUpdate) { - await updatePackingItem(tripId, item.id, { category: newName }) - } - } - - const handleDeleteCategory = async (catItems: PackingItem[]) => { - let failed = false - for (const item of catItems) { - try { await deletePackingItem(tripId, item.id) } catch { failed = true } - } - if (failed) toast.error(t('packing.toast.deleteError')) - } + const { + addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName, + addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory, + renameCategory: handleRenameCategory, deleteCategoryItems: handleDeleteCategory, clearChecked, + } = usePackingItemActions({ + tripId, items, view, currentUserId, categories: allCategories, defaultCategory: t('packing.defaultCategory'), + actions: { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem }, t, toast, placeholderFrom: 'store', + }) const handleClearChecked = async () => { if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return - let failed = false - for (const item of items.filter(i => i.checked)) { - try { await deletePackingItem(tripId, item.id) } catch { failed = true } - } - if (failed) toast.error(t('packing.toast.deleteError')) + await clearChecked() } // Bag tracking — the global toggle is a packing sub-flag surfaced to every @@ -229,9 +126,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor const bagTrackingEnabled = useAddonStore(s => s.bagTracking) const addonsLoaded = useAddonStore(s => s.loaded) const loadAddons = useAddonStore(s => s.loadAddons) - const [bags, setBags] = useState([]) - /** Server-summed weight of everything in no bag (#2191); null until the first load. */ - const [unassignedWeightGrams, setUnassignedWeightGrams] = useState(null) const [newBagName, setNewBagName] = useState('') const [showAddBag, setShowAddBag] = useState(false) const [showBagModal, setShowBagModal] = useState(false) @@ -240,81 +134,22 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor if (!addonsLoaded) loadAddons() }, [addonsLoaded, loadAddons]) - const reloadBags = useCallback(async () => { - if (!bagTrackingEnabled) return - try { - const r = await packingApi.listBags(tripId) - setBags(r.bags || []) - setUnassignedWeightGrams(r.unassigned_weight_grams ?? null) - } catch { - // Offline or a failed read: the surfaces fall back to the local sum - // (see `serverWeightsFresh` below), so there is nothing to roll back. - } - }, [tripId, bagTrackingEnabled]) - - useEffect(() => { void reloadBags() }, [reloadBags]) - - // Bag weights are summed server-side across every member (#2191), so an item - // this viewer may not even see still moves them. The item events cannot carry - // that — a private item is delivered only to its owner, which is the very rule - // that made the totals wrong — so the server pings the room content-free and - // we re-read the numbers. - useBagTotalsPing(bagTrackingEnabled, reloadBags) - - // Bags are not part of the offline cache (no repo, no Dexie table), so while - // offline the server totals are frozen at the last online read and cannot see - // the optimistic item writes the mutation queue is holding. A stale absolute - // number measured against an airline limit is worse than an honest partial - // one, so offline the surfaces sum what they can see instead (#2191). - const { offline } = useNetworkMode() - const serverWeightsFresh = !offline + const { + bags, unassignedWeightGrams, serverWeightsFresh, tryCreateBag, createBag: handleCreateBagByName, + deleteBag: handleDeleteBag, updateBag: handleUpdateBag, setBagMembers: handleSetBagMembers, + } = usePackingBags({ tripId, bagTrackingEnabled, t, toast }) const handleCreateBag = async () => { if (!newBagName.trim()) return - try { - const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] }) - setBags(prev => [...prev, data.bag]) + if (await tryCreateBag(newBagName.trim())) { setNewBagName(''); setShowAddBag(false) - } catch { toast.error(t('packing.toast.saveError')) } - } - - const handleCreateBagByName = async (name: string): Promise => { - try { - const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] }) - setBags(prev => [...prev, data.bag]) - return data.bag - } catch { toast.error(t('packing.toast.saveError')); return undefined } - } - - const handleDeleteBag = async (bagId: number) => { - try { - await packingApi.deleteBag(tripId, bagId) - setBags(prev => prev.filter(b => b.id !== bagId)) - } catch { toast.error(t('packing.toast.deleteError')) } - } - - const handleUpdateBag = async (bagId: number, data: Record) => { - try { - const result = await packingApi.updateBag(tripId, bagId, data) - setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b)) - } catch { toast.error(t('common.error')) } - } - - const handleSetBagMembers = async (bagId: number, userIds: number[]) => { - try { - const result = await packingApi.setBagMembers(tripId, bagId, userIds) - setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b)) - } catch { toast.error(t('common.error')) } + } } // Templates - const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) - const [applyingTemplate, setApplyingTemplate] = useState(false) const [showSaveTemplate, setShowSaveTemplate] = useState(false) - const [saveTemplateName, setSaveTemplateName] = useState('') const [showImportModal, setShowImportModal] = useState(false) - const [importText, setImportText] = useState('') const lastHandledImportSignal = useRef(openImportSignal) const lastHandledAddCategorySignal = useRef(addCategorySignal) const lastHandledSaveSignal = useRef(saveTemplateSignal) @@ -331,7 +166,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor setAddingCategory(true) } lastHandledAddCategorySignal.current = addCategorySignal - }, [addCategorySignal]) + }, [addCategorySignal, setAddingCategory]) useEffect(() => { if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) { @@ -342,9 +177,14 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor const csvInputRef = useRef(null) const templateDropdownRef = useRef(null) - useEffect(() => { - packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) - }, [tripId]) + const { + templates: availableTemplates, applyingTemplate, applyTemplate: handleApplyTemplate, + saveTemplateName, setSaveTemplateName, saveAsTemplate: handleSaveAsTemplate, + } = usePackingTemplates({ + tripId, view, t, toast, + onApplied: () => setShowTemplateDropdown(false), + onSaved: () => setShowSaveTemplate(false), + }) useEffect(() => { if (!showTemplateDropdown) return @@ -355,53 +195,9 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor return () => document.removeEventListener('mousedown', handler) }, [showTemplateDropdown]) - const handleApplyTemplate = async (templateId: number) => { - setApplyingTemplate(true) - try { - const data = await packingApi.applyTemplate(tripId, templateId, view) - useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] })) - toast.success(t('packing.templateApplied', { count: data.count })) - setShowTemplateDropdown(false) - } catch { - toast.error(t('packing.templateError')) - } finally { - setApplyingTemplate(false) - } - } - - const handleSaveAsTemplate = async () => { - if (!saveTemplateName.trim()) return - try { - await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()) - toast.success(t('packing.templateSaved')) - setShowSaveTemplate(false) - setSaveTemplateName('') - packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) - } catch { - toast.error(t('common.error')) - } - } - - const handleBulkImport = async () => { - const parsed = parseImportLines(importText) - if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } - try { - const result = await packingApi.bulkImport(tripId, parsed) - useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] })) - toast.success(t('packing.importSuccess', { count: result.count })) - setImportText('') - setShowImportModal(false) - } catch { toast.error(t('packing.importError')) } - } - - const handleCsvFile = (e: ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - e.target.value = '' - const reader = new FileReader() - reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) } - reader.readAsText(file) - } + const { + text: importText, setText: setImportText, readFile: handleCsvFile, runImport: handleBulkImport, + } = usePackingImport({ tripId, t, toast, onImported: () => setShowImportModal(false), reportEmpty: true }) const font = { fontFamily: "var(--font-system)" } diff --git a/client/src/components/Packing/usePackingTemplates.test.ts b/client/src/components/Packing/usePackingTemplates.test.ts new file mode 100644 index 000000000..2e879155a --- /dev/null +++ b/client/src/components/Packing/usePackingTemplates.test.ts @@ -0,0 +1,148 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildPackingItem } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { packingApi } from '../../api/client'; +import { useTripStore } from '../../store/tripStore'; +import type { PackingView } from './packingListModel'; +import { usePackingTemplates } from './usePackingTemplates'; + +// FE-PACK-TEMPL-001 to FE-PACK-TEMPL-007 + +const t = (key: string, params?: Record) => + params ? `${key}:${Object.values(params).join(',')}` : key; +const BEACH = { id: 4, name: 'Beach', item_count: 12 }; +const SKI = { id: 5, name: 'Ski', item_count: 30 }; +const EXISTING = buildPackingItem({ id: 1, name: 'Tent' }); + +function setup(view: PackingView = 'common') { + const toast = { success: vi.fn(), error: vi.fn() }; + const onApplied = vi.fn(); + const onSaved = vi.fn(); + const rendered = renderHook(() => usePackingTemplates({ tripId: 7, view, t, toast, onApplied, onSaved })); + return { ...rendered, toast, onApplied, onSaved }; +} + +beforeEach(() => { + resetAllStores(); + seedStore(useTripStore, { packingItems: [EXISTING] }); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('usePackingTemplates', () => { + it('FE-PACK-TEMPL-001: lists the templates on hand for the trip, and nothing when that fails', async () => { + const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] }); + const { result } = setup(); + await waitFor(() => expect(result.current.templates).toEqual([BEACH])); + expect(list).toHaveBeenCalledWith(7); + + list.mockRejectedValue(new Error('offline')); + const failed = setup(); + await act(async () => {}); + expect(failed.result.current.templates).toEqual([]); + }); + + it('FE-PACK-TEMPL-002: applying lands the items in the list being looked at and reports how many', async () => { + vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] }); + const added = buildPackingItem({ id: 2, name: 'Towel', is_private: 1 }); + let finish: () => void = () => {}; + const apply = vi.spyOn(packingApi, 'applyTemplate').mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve({ items: [added], count: 1 }); + }) + ); + const { result, toast, onApplied } = setup('personal'); + let done: Promise = Promise.resolve(); + act(() => { + done = result.current.applyTemplate(4); + }); + expect(result.current.applyingTemplate).toBe(true); + await act(async () => { + finish(); + await done; + }); + expect(apply).toHaveBeenCalledWith(7, 4, 'personal'); + expect(useTripStore.getState().packingItems).toEqual([EXISTING, added]); + expect(toast.success).toHaveBeenCalledWith('packing.templateApplied:1'); + expect(onApplied).toHaveBeenCalledTimes(1); + expect(result.current.applyingTemplate).toBe(false); + }); + + it('FE-PACK-TEMPL-003: a template without items still reports success and keeps the list', async () => { + vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] }); + vi.spyOn(packingApi, 'applyTemplate').mockResolvedValue({ count: 0 }); + const { result, toast } = setup(); + await act(async () => { + await result.current.applyTemplate(4); + }); + expect(useTripStore.getState().packingItems).toEqual([EXISTING]); + expect(toast.success).toHaveBeenCalledWith('packing.templateApplied:0'); + }); + + it('FE-PACK-TEMPL-004: a failed apply is reported and leaves the surface open', async () => { + vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] }); + vi.spyOn(packingApi, 'applyTemplate').mockRejectedValue(new Error('x')); + const { result, toast, onApplied } = setup(); + await act(async () => { + await result.current.applyTemplate(4); + }); + expect(toast.error).toHaveBeenCalledWith('packing.templateError'); + expect(onApplied).not.toHaveBeenCalled(); + expect(result.current.applyingTemplate).toBe(false); + }); + + it('FE-PACK-TEMPL-005: saving needs a name, then saves it trimmed, clears it and reloads the list', async () => { + const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] }); + const save = vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true }); + const { result, toast, onSaved } = setup(); + await waitFor(() => expect(result.current.templates).toEqual([BEACH])); + + act(() => result.current.setSaveTemplateName(' ')); + await act(async () => { + await result.current.saveAsTemplate(); + }); + expect(save).not.toHaveBeenCalled(); + + list.mockResolvedValue({ templates: [BEACH, SKI] }); + act(() => result.current.setSaveTemplateName(' Ski ')); + await act(async () => { + await result.current.saveAsTemplate(); + }); + expect(save).toHaveBeenCalledWith(7, 'Ski'); + expect(toast.success).toHaveBeenCalledWith('packing.templateSaved'); + expect(onSaved).toHaveBeenCalledTimes(1); + expect(result.current.saveTemplateName).toBe(''); + await waitFor(() => expect(result.current.templates).toEqual([BEACH, SKI])); + }); + + it('FE-PACK-TEMPL-006: a failed save is reported and keeps the name typed', async () => { + vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [] }); + vi.spyOn(packingApi, 'saveAsTemplate').mockRejectedValue(new Error('x')); + const { result, toast, onSaved } = setup(); + act(() => result.current.setSaveTemplateName('Ski')); + await act(async () => { + await result.current.saveAsTemplate(); + }); + expect(toast.error).toHaveBeenCalledWith('common.error'); + expect(onSaved).not.toHaveBeenCalled(); + expect(result.current.saveTemplateName).toBe('Ski'); + }); + + it('FE-PACK-TEMPL-007: a list that fails to reload after a save keeps the templates it had', async () => { + const list = vi.spyOn(packingApi, 'listTemplates').mockResolvedValue({ templates: [BEACH] }); + vi.spyOn(packingApi, 'saveAsTemplate').mockResolvedValue({ ok: true }); + const { result, toast } = setup(); + await waitFor(() => expect(result.current.templates).toEqual([BEACH])); + list.mockRejectedValue(new Error('offline')); + act(() => result.current.setSaveTemplateName('Ski')); + await act(async () => { + await result.current.saveAsTemplate(); + }); + await waitFor(() => expect(list).toHaveBeenCalledTimes(2)); + expect(result.current.templates).toEqual([BEACH]); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Packing/usePackingTemplates.ts b/client/src/components/Packing/usePackingTemplates.ts new file mode 100644 index 000000000..39012fa81 --- /dev/null +++ b/client/src/components/Packing/usePackingTemplates.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; + +import { packingApi } from '../../api/client'; +import { useTripStore } from '../../store/tripStore'; +import type { PackingView } from './packingListModel'; + +type Translate = (key: string, params?: Record) => string; +interface Toaster { + success: (message: string) => void; + error: (message: string) => void; +} + +export interface PackingTemplate { + id: number; + name: string; + item_count: number; +} + +/** + * Packing templates for both packing lists: the trip's template list, applying + * one into the list being looked at, and saving the trip's list as a new one. + * Each surface closes its own menu, dropdown or field through `onApplied` and + * `onSaved`, which run where the surface used to close it. + */ +export function usePackingTemplates({ + tripId, + view, + t, + toast, + onApplied, + onSaved, +}: { + tripId: number; + view: PackingView; + t: Translate; + toast: Toaster; + onApplied: () => void; + onSaved: () => void; +}) { + const [templates, setTemplates] = useState([]); + const [applyingTemplate, setApplyingTemplate] = useState(false); + const [saveTemplateName, setSaveTemplateName] = useState(''); + + useEffect(() => { + packingApi + .listTemplates(tripId) + .then((d) => setTemplates(d.templates || [])) + .catch(() => { + // Without the list there is simply nothing to apply. + }); + }, [tripId]); + + const applyTemplate = async (templateId: number) => { + setApplyingTemplate(true); + try { + // Land the items in the list the user is looking at: without the + // visibility the API defaults to 'common' and they vanish from My list. + const data = await packingApi.applyTemplate(tripId, templateId, view); + useTripStore.setState((s) => ({ packingItems: [...s.packingItems, ...(data.items || [])] })); + toast.success(t('packing.templateApplied', { count: data.count })); + onApplied(); + } catch { + toast.error(t('packing.templateError')); + } finally { + setApplyingTemplate(false); + } + }; + + const saveAsTemplate = async () => { + if (!saveTemplateName.trim()) return; + try { + await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()); + toast.success(t('packing.templateSaved')); + setSaveTemplateName(''); + onSaved(); + packingApi + .listTemplates(tripId) + .then((d) => setTemplates(d.templates || [])) + .catch(() => { + // The template is saved; the list catches up on the next load. + }); + } catch { + toast.error(t('common.error')); + } + }; + + return { templates, applyingTemplate, applyTemplate, saveTemplateName, setSaveTemplateName, saveAsTemplate }; +} diff --git a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx index 05cd8343a..b3c49b8cb 100644 --- a/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MBagsSheet.tsx @@ -6,7 +6,7 @@ import { FIELD_CLS, FormSheetHeader } from '../sheets/PlSheetChrome' import { avatarSrc } from '../../../../utils/avatarSrc' import type { PackingBag, PackingItem, TripMember } from '../../../../types' import type { TripPlanner } from '../MTripShell' -import { formatWeight } from './listsModel' +import { formatWeight } from '../../../../components/Packing/packingListModel' import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from '../../../../components/Packing/packingListPanel.helpers' export interface MBagsSheetProps { diff --git a/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx b/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx index 57cb4861d..10854da16 100644 --- a/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx @@ -5,7 +5,7 @@ import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/ import { avatarSrc } from '../../../../utils/avatarSrc' import type { PackingItem, TripMember } from '../../../../types' import type { TripPlanner } from '../MTripShell' -import { isPackingPlaceholder } from './listsModel' +import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel' export interface MPackItemSheetProps { planner: TripPlanner diff --git a/client/src/mobile/screens/trip/tabs/MPackingImportSheet.tsx b/client/src/mobile/screens/trip/tabs/MPackingImportSheet.tsx index cfea28a6c..a3375f7be 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingImportSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingImportSheet.tsx @@ -1,10 +1,8 @@ -import { useRef, useState } from 'react' +import { useRef } from 'react' import { FileDown } from 'lucide-react' import MSheet from '../../../components/MSheet' import { Eyebrow, FIELD_AREA_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome' -import { packingApi } from '../../../../api/client' -import { useTripStore } from '../../../../store/tripStore' -import { parseImportLines } from '../../../../components/Packing/packingListPanel.helpers' +import { usePackingImport } from '../../../../components/Packing/usePackingImport' import { PACKING_IMPORT_ACCEPT } from '../../../../components/Packing/packingListPanel.constants' import type { TripPlanner } from '../MTripShell' @@ -16,43 +14,15 @@ export interface MPackingImportSheetProps { /** * Bulk packing import (spec 03 §4.2 action-menu "Import"): one item per line, - * `Category, Name, Weight(g), Bag, checked` — same parser + endpoint as the - * desktop bulk-import modal (`packingListPanel.helpers.parseImportLines` + - * `packingApi.bulkImport`), appended straight into the trip store so both - * surfaces stay consistent. + * `Category, Name, Weight(g), Bag, checked`, run by the same `usePackingImport` + * as the desktop bulk-import dialog, which appends the result straight into the + * trip store so both surfaces stay consistent. */ export default function MPackingImportSheet({ planner, open, onClose }: MPackingImportSheetProps) { const { t, toast, tripId } = planner - const [text, setText] = useState('') - const [importing, setImporting] = useState(false) const fileInputRef = useRef(null) - - const parsed = parseImportLines(text) - - const handleFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - e.target.value = '' - const reader = new FileReader() - reader.onload = () => { if (typeof reader.result === 'string') setText(reader.result) } - reader.readAsText(file) - } - - const handleImport = async () => { - if (parsed.length === 0 || importing) return - setImporting(true) - try { - const result = await packingApi.bulkImport(tripId, parsed) - useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] })) - toast.success(t('packing.importSuccess', { count: result.count })) - setText('') - onClose() - } catch { - toast.error(t('packing.importError')) - } finally { - setImporting(false) - } - } + const { text, setText, parsed, importing, readFile: handleFile, runImport: handleImport } = + usePackingImport({ tripId, t, toast, onImported: onClose, oneAtATime: true }) return ( diff --git a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx index eebef79dc..309323b75 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useCallback } from 'react' +import { useMemo, useState } from 'react' import type { LucideIcon } from 'lucide-react' import { Briefcase, Check, CheckCheck, ChevronDown, ChevronUp, HandHelping, Minus, @@ -8,23 +8,21 @@ import { import MDancingTrek from '../../../components/MDancingTrek' import { useAuthStore } from '../../../../store/authStore' import { useAddonStore } from '../../../../store/addonStore' -import { useTripStore } from '../../../../store/tripStore' -import { packingApi } from '../../../../api/client' -import { useNetworkMode } from '../../../../hooks/useNetworkMode' -import { useBagTotalsPing } from '../../../../components/Packing/useBagTotalsPing' -import type { PackingUpdateBagRequest } from '@trek/shared' import type { PackingBag, PackingItem, TripMember } from '../../../../types' import type { TripPlanner } from '../MTripShell' import MConfirmSheet from '../../settings/MConfirmSheet' import { FIELD_CLS } from '../sheets/PlSheetChrome' import { TabScroller } from './tabChrome' -import { katColor, newItemSharing, packedOf } from '../../../../components/Packing/packingListPanel.helpers' -import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from '../../../../components/Packing/packingListPanel.constants' +import { katColor, packedOf } from '../../../../components/Packing/packingListPanel.helpers' import { - formatWeight, groupPackingItems, isLastCustomItemInCategory, isPackingPlaceholder, + formatWeight, groupPackingItems, isPackingPlaceholder, packingCategoryOrder, packingProgress, packingViewItems, type PackingCategoryGroup, type PackingStatusFilter, type PackingView, -} from './listsModel' +} from '../../../../components/Packing/packingListModel' +import { usePackingBags } from '../../../../components/Packing/usePackingBags' +import { usePackingTemplates } from '../../../../components/Packing/usePackingTemplates' +import { usePackingCategoryAssignees, type CategoryAssignee } from '../../../../components/Packing/usePackingCategoryAssignees' +import { usePackingItemActions } from '../../../../components/Packing/usePackingItemActions' import MBagsSheet from './MBagsSheet' import MPackItemSheet from './MPackItemSheet' import MPackingImportSheet from './MPackingImportSheet' @@ -32,8 +30,6 @@ import PackingPrintPreview from '../../../../components/Packing/PackingPrintPrev import { usePackingExport } from '../../../../components/Packing/usePackingExport' type ActionView = 'menu' | 'apply' | 'save' -interface CategoryAssignee { user_id: number; username: string } -interface PackingTemplate { id: number; name: string; item_count: number } /** * Packing sub-tab (spec 03 §4.1-4.4): progress card, action menu (remove @@ -59,50 +55,22 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { const [editMode, setEditMode] = useState(false) const [actionsOpen, setActionsOpen] = useState(false) const [actionView, setActionView] = useState('menu') - const [saveTemplateName, setSaveTemplateName] = useState('') const [collapsed, setCollapsed] = useState>({}) - const [addingCategory, setAddingCategory] = useState(false) - const [newCategoryName, setNewCategoryName] = useState('') const [editingItemId, setEditingItemId] = useState(null) const [showBagsSheet, setShowBagsSheet] = useState(false) const [showImportSheet, setShowImportSheet] = useState(false) const [confirmClear, setConfirmClear] = useState(false) const [deleteCategoryTarget, setDeleteCategoryTarget] = useState<{ name: string; items: PackingItem[] } | null>(null) - const [bags, setBags] = useState([]) - /** Server-summed weight of everything in no bag (#2191); null until first load. */ - const [unassignedWeightGrams, setUnassignedWeightGrams] = useState(null) - const [templates, setTemplates] = useState([]) - const [categoryAssignees, setCategoryAssignees] = useState>({}) - - const reloadBags = useCallback(async () => { - if (!bagTrackingEnabled) return - try { - const r = await packingApi.listBags(tripId) - setBags(r.bags || []) - setUnassignedWeightGrams(r.unassigned_weight_grams ?? null) - } catch { - // Offline or a failed read: the sheet falls back to the local sum. - } - }, [tripId, bagTrackingEnabled]) - - useEffect(() => { void reloadBags() }, [reloadBags]) - - // Bag weights are server-summed across every member (#2191), so an item this - // viewer cannot see still moves them; the server pings the room content-free. - useBagTotalsPing(bagTrackingEnabled, reloadBags) - - // Bags have no offline cache, so their totals go stale the moment the device - // does; offline the sheet sums what it can see instead (#2191). - const { offline } = useNetworkMode() - - useEffect(() => { - packingApi.listTemplates(tripId).then(r => setTemplates(r.templates || [])).catch(() => {}) - }, [tripId]) - - useEffect(() => { - packingApi.getCategoryAssignees(tripId).then(r => setCategoryAssignees(r.assignees || {})).catch(() => {}) - }, [tripId]) + const closeActions = () => { + setActionsOpen(false) + setActionView('menu') + } + const { bags, unassignedWeightGrams, serverWeightsFresh, createBag, updateBag, deleteBag, setBagMembers } = + usePackingBags({ tripId, bagTrackingEnabled, t, toast }) + const { templates, applyTemplate, saveTemplateName, setSaveTemplateName, saveAsTemplate } = + usePackingTemplates({ tripId, view, t, toast, onApplied: closeActions, onSaved: closeActions }) + const { categoryAssignees, setAssignees: setCategoryAssigneesFor } = usePackingCategoryAssignees({ tripId, t, toast }) const defaultCategory = t('packing.defaultCategory') const viewItems = useMemo(() => packingViewItems(items, view), [items, view]) @@ -111,57 +79,16 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { const progress = packingProgress(viewItems) const checkedCount = viewItems.filter(i => i.checked).length + const { + addingCategory, setAddingCategory, newCategoryName, setNewCategoryName, addItemToCategory, deleteItem, + renameCategoryReporting: renameCategory, deleteCategoryItems, clearChecked, addNewCategory, + } = usePackingItemActions({ + tripId, items, view, currentUserId, categories: categoryOrder, defaultCategory, + actions: tripActions, t, toast, placeholderFrom: 'items', + }) + const toggleCategory = (cat: string) => setCollapsed(c => ({ ...c, [cat]: !c[cat] })) - const addItemToCategory = async (category: string, name: string) => { - try { - const placeholder = items.find(i => i.category === category && isPackingPlaceholder(i)) - if (placeholder) { - await tripActions.updatePackingItem(tripId, placeholder.id, { name }) - } else { - await tripActions.addPackingItem( - tripId, - // In "my list", shared the way the category's own items all are (#2241). - { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters[1], - ) - } - } catch { - toast.error(t('packing.toast.addError')) - } - } - - const deleteItem = async (item: PackingItem) => { - try { - if (isLastCustomItemInCategory(item, items)) { - if (item.checked) await tripActions.togglePackingItem(tripId, item.id, false) - await tripActions.updatePackingItem(tripId, item.id, { - name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1, - }) - } else { - await tripActions.deletePackingItem(tripId, item.id) - } - } catch { - toast.error(t('packing.toast.deleteError')) - } - } - - const renameCategory = async (oldName: string, newName: string) => { - const toUpdate = items.filter(i => (i.category || defaultCategory) === oldName) - try { - for (const item of toUpdate) await tripActions.updatePackingItem(tripId, item.id, { category: newName }) - } catch { - toast.error(t('packing.toast.renameError')) - } - } - - const deleteCategoryItems = async (catItems: PackingItem[]) => { - let failed = false - for (const item of catItems) { - try { await tripActions.deletePackingItem(tripId, item.id) } catch { failed = true } - } - if (failed) toast.error(t('packing.toast.deleteError')) - } - const checkAllInCategory = async (catItems: PackingItem[]) => { try { for (const item of catItems) if (!item.checked) await tripActions.togglePackingItem(tripId, item.id, true) @@ -177,103 +104,6 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { } } - const addNewCategory = async () => { - const trimmed = newCategoryName.trim() - if (!trimmed) return - let catName = trimmed - while (categoryOrder.includes(catName)) catName += '​' - try { - await tripActions.addPackingItem( - tripId, - { name: PACKING_PLACEHOLDER_NAME, category: catName, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters[1], - ) - setNewCategoryName('') - setAddingCategory(false) - } catch { - toast.error(t('packing.toast.addError')) - } - } - - const clearChecked = async () => { - let failed = false - for (const item of items.filter(i => i.checked)) { - try { await tripActions.deletePackingItem(tripId, item.id) } catch { failed = true } - } - if (failed) toast.error(t('packing.toast.deleteError')) - } - - const createBag = async (name: string): Promise => { - try { - const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] }) - setBags(prev => [...prev, data.bag]) - return data.bag - } catch { - toast.error(t('packing.toast.saveError')) - return undefined - } - } - const updateBag = async (bagId: number, data: PackingUpdateBagRequest) => { - try { - const result = await packingApi.updateBag(tripId, bagId, data) - setBags(prev => prev.map(b => (b.id === bagId ? { ...b, ...result.bag } : b))) - } catch { - toast.error(t('common.error')) - } - } - const deleteBag = async (bagId: number) => { - try { - await packingApi.deleteBag(tripId, bagId) - setBags(prev => prev.filter(b => b.id !== bagId)) - } catch { - toast.error(t('packing.toast.deleteError')) - } - } - const setBagMembers = async (bagId: number, userIds: number[]) => { - try { - const result = await packingApi.setBagMembers(tripId, bagId, userIds) - setBags(prev => prev.map(b => (b.id === bagId ? { ...b, members: result.members } : b))) - } catch { - toast.error(t('common.error')) - } - } - - const setCategoryAssigneesFor = async (category: string, userIds: number[]) => { - try { - const data = await packingApi.setCategoryAssignees(tripId, category, userIds) - setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] })) - } catch { - toast.error(t('packing.toast.saveError')) - } - } - - const applyTemplate = async (templateId: number) => { - try { - // Land the items in the list the user is looking at — without the - // visibility the API defaults to 'common' and they vanish from My list. - const data = await packingApi.applyTemplate(tripId, templateId, view) - useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] })) - toast.success(t('packing.templateApplied', { count: data.count })) - setActionsOpen(false) - setActionView('menu') - } catch { - toast.error(t('packing.templateError')) - } - } - - const saveAsTemplate = async () => { - if (!saveTemplateName.trim()) return - try { - await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()) - toast.success(t('packing.templateSaved')) - setSaveTemplateName('') - setActionsOpen(false) - setActionView('menu') - packingApi.listTemplates(tripId).then(r => setTemplates(r.templates || [])).catch(() => {}) - } catch { - toast.error(t('common.error')) - } - } - const filterPillCls = (active: boolean) => `flex-1 whitespace-nowrap rounded-full px-2 py-[6px] text-center text-[0.71875rem] font-semibold ${ active ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-ink' @@ -507,7 +337,7 @@ export default function MPackingListTab({ planner }: { planner: TripPlanner }) { bags={bags} items={items} unassignedWeightGrams={unassignedWeightGrams} - serverWeightsFresh={!offline} + serverWeightsFresh={serverWeightsFresh} tripMembers={tripMembers} canEdit={canEdit} currentUserId={currentUserId} diff --git a/client/src/mobile/screens/trip/tabs/listsModel.ts b/client/src/mobile/screens/trip/tabs/listsModel.ts index f25f66508..cd13489f8 100644 --- a/client/src/mobile/screens/trip/tabs/listsModel.ts +++ b/client/src/mobile/screens/trip/tabs/listsModel.ts @@ -1,110 +1,12 @@ -import type { PackingItem } from '../../../../types' -import { PACKING_PLACEHOLDER_NAME } from '../../../../components/Packing/packingListPanel.constants' import { STATUS_COLOR } from './tabModel' /** - * Pure view-model helpers for the Listen tab (Packing + To-do), the real-data - * counterpart to the demo's `PACK`/`TODOS` state (spec 03 §4). No React, no - * side effects — grouping/filtering/sorting only, mirroring the desktop hooks - * (`usePackingListPanel.ts`, `useTodoList.ts`) closely enough that switching a - * filter here and there produces the same buckets. + * The phone's priority tokens for the Listen tab's to-do list (spec 03 §4.7). + * The grouping, filtering and counting behind both lists is shared with the + * desktop panels: `components/Packing/packingListModel.ts` and + * `components/Todo/todoListModel.ts`. */ -// ── Packing ────────────────────────────────────────────────────────────── - -export type PackingView = 'common' | 'personal' -export type PackingStatusFilter = 'all' | 'open' | 'done' - -/** Three-tier sharing split (#858): Common = group pool, Personal = mine + shared-to-me. */ -export function packingViewItems(items: PackingItem[], view: PackingView): PackingItem[] { - return items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)) -} - -/** Category display order = first-appearance order within the active view (stable colours). */ -export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory: string): string[] { - const seen: string[] = [] - for (const item of viewItems) { - const cat = item.category || defaultCategory - if (!seen.includes(cat)) seen.push(cat) - } - return seen -} - -export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] { - if (status === 'open') return items.filter(i => !i.checked) - if (status === 'done') return items.filter(i => !!i.checked) - return items -} - -export interface PackingCategoryGroup { - category: string - items: PackingItem[] -} - -/** Groups the view+status filtered items by category, in first-encounter order. */ -export function groupPackingItems( - viewItems: PackingItem[], - status: PackingStatusFilter, - defaultCategory: string, -): PackingCategoryGroup[] { - const filtered = packingStatusFiltered(viewItems, status) - const order: string[] = [] - const byCategory = new Map() - for (const item of filtered) { - const cat = item.category || defaultCategory - let bucket = byCategory.get(cat) - if (!bucket) { - bucket = [] - byCategory.set(cat, bucket) - order.push(cat) - } - bucket.push(item) - } - return order.map(category => ({ category, items: byCategory.get(category) as PackingItem[] })) -} - -export interface PackingProgress { - checked: number - total: number - pct: number -} - -export function packingProgress(items: PackingItem[]): PackingProgress { - const checked = items.filter(i => i.checked).length - const total = items.length - return { checked, total, pct: total > 0 ? Math.round((checked / total) * 100) : 0 } -} - -/** "232 g" under 1000g, "1.2 kg" at/above — matches the demo's `gFmt` (spec 03 §4.4). */ -export function formatWeight(grams: number): string { - return grams >= 1000 ? `${(grams / 1000).toFixed(1)} kg` : `${Math.round(grams)} g` -} - -/** Weight an item contributes to a bag/total: unit weight times quantity. */ -export function packingItemWeight(item: Pick): number { - return (item.weight_grams || 0) * (item.quantity || 1) -} - -/** - * True when deleting `item` would empty its (custom) category — the row - * should be reset to the `...` placeholder instead of removed so the - * category keeps its position/colour (mirrors usePackingListPanel's - * handleDeleteItem, #1289). - */ -export function isLastCustomItemInCategory(item: PackingItem, allItems: PackingItem[]): boolean { - return ( - !!item.category && - item.name !== PACKING_PLACEHOLDER_NAME && - !allItems.some(i => i.id !== item.id && i.category === item.category) - ) -} - -export function isPackingPlaceholder(item: Pick): boolean { - return item.name === PACKING_PLACEHOLDER_NAME -} - -// ── To-do ──────────────────────────────────────────────────────────────── - /** P1/P2/P3 colours (spec 03 §4.7) reuse the shared status-dot tokens so priority and status stay one palette. */ export const PRIORITY_COLOR: Record = { 1: STATUS_COLOR.danger, diff --git a/client/tests/unit/mobile/trip/listsModel.test.ts b/client/tests/unit/mobile/trip/listsModel.test.ts index 26374a003..814f299a2 100644 --- a/client/tests/unit/mobile/trip/listsModel.test.ts +++ b/client/tests/unit/mobile/trip/listsModel.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect } from 'vitest'; +import { PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_LEVELS } from '../../../../src/mobile/screens/trip/tabs/listsModel'; import { - PRIORITY_COLOR, - PRIORITY_LABEL, - PRIORITY_LEVELS, formatWeight, groupPackingItems, isLastCustomItemInCategory, @@ -12,7 +10,7 @@ import { packingProgress, packingStatusFiltered, packingViewItems, -} from '../../../../src/mobile/screens/trip/tabs/listsModel'; +} from '../../../../src/components/Packing/packingListModel'; import { filterTodoItems, isTodoOverdue,