From 3c2f5a2766eedcf6bd19c6931da88bcde0252150 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sat, 11 Apr 2026 19:19:28 +0300 Subject: [PATCH] feat: checklists in sidebar --- src/composables/useChecklist.test.ts | 92 ++++++++++++++++++++++++++-- src/composables/useChecklist.ts | 57 +++++++++++++---- src/views/SideNavigation.vue | 46 +++++++++++++- 3 files changed, 176 insertions(+), 19 deletions(-) diff --git a/src/composables/useChecklist.test.ts b/src/composables/useChecklist.test.ts index 4e06c19..693bd2b 100644 --- a/src/composables/useChecklist.test.ts +++ b/src/composables/useChecklist.test.ts @@ -58,9 +58,15 @@ function makeItem(overrides: Partial = {}): ChecklistItem { } } +// Each test uses a unique houseId so module-level shared state doesn't leak +// between tests. That is also what the production sharing guarantees — same +// houseId → same state, different houseId → independent state. +let houseCounter = 100 + describe('useChecklists', () => { beforeEach(() => { vi.resetAllMocks() + houseCounter++ }) describe('load', () => { @@ -68,7 +74,7 @@ describe('useChecklists', () => { const lists = [makeList({ id: 1 }), makeList({ id: 2 })] mockApi.listLists.mockResolvedValue(lists) - const c = useChecklists(1) + const c = useChecklists(houseCounter) await c.load() expect(c.lists.value).toEqual(lists) @@ -79,11 +85,21 @@ describe('useChecklists', () => { it('sets error on failure', async () => { mockApi.listLists.mockRejectedValue(new Error('fail')) - const c = useChecklists(1) + const c = useChecklists(houseCounter) await c.load() expect(c.error.value).toBe('fail') }) + + it('deduplicates concurrent calls for the same house', async () => { + mockApi.listLists.mockResolvedValue([makeList({ id: 1 })]) + + const c = useChecklists(houseCounter) + const [a, b] = await Promise.all([c.load(), c.load()]) + + expect(a).toEqual(b) + expect(mockApi.listLists).toHaveBeenCalledTimes(1) + }) }) describe('create', () => { @@ -92,22 +108,37 @@ describe('useChecklists', () => { const newList = makeList({ id: 10 }) mockApi.createList.mockResolvedValue(newList) - const c = useChecklists(1) + const c = useChecklists(houseCounter) await c.load() const result = await c.create('New', 'desc', 'cart') - expect(mockApi.createList).toHaveBeenCalledWith(1, 'New', 'desc', 'cart') + expect(mockApi.createList).toHaveBeenCalledWith(houseCounter, 'New', 'desc', 'cart') expect(result).toEqual(newList) expect(c.lists.value).toHaveLength(1) }) }) + describe('update', () => { + it('replaces the updated list in state', async () => { + const original = makeList({ id: 1, name: 'Old' }) + const updated = makeList({ id: 1, name: 'New' }) + mockApi.listLists.mockResolvedValue([original]) + mockApi.updateList.mockResolvedValue(updated) + + const c = useChecklists(houseCounter) + await c.load() + await c.update(1, { name: 'New' }) + + expect(c.lists.value[0].name).toBe('New') + }) + }) + describe('remove', () => { it('removes list from state', async () => { mockApi.listLists.mockResolvedValue([makeList({ id: 1 }), makeList({ id: 2 })]) mockApi.deleteList.mockResolvedValue(undefined) - const c = useChecklists(1) + const c = useChecklists(houseCounter) await c.load() await c.remove(1) @@ -115,6 +146,57 @@ describe('useChecklists', () => { expect(c.lists.value[0].id).toBe(2) }) }) + + describe('shared state', () => { + it('two callers for the same house share the same lists ref', async () => { + const lists = [makeList({ id: 1 }), makeList({ id: 2 })] + mockApi.listLists.mockResolvedValue(lists) + + const houseId = houseCounter + const a = useChecklists(houseId) + const b = useChecklists(houseId) + + await a.load() + + // Both consumers see the loaded lists even though only `a` triggered load. + expect(a.lists.value).toEqual(lists) + expect(b.lists.value).toEqual(lists) + // And they reference the exact same ref instance. + expect(a.lists).toBe(b.lists) + }) + + it('propagates create across consumers for the same house', async () => { + mockApi.listLists.mockResolvedValue([]) + const newList = makeList({ id: 10, name: 'Shared' }) + mockApi.createList.mockResolvedValue(newList) + + const houseId = houseCounter + const a = useChecklists(houseId) + const b = useChecklists(houseId) + + await a.load() + await a.create('Shared') + + expect(b.lists.value).toHaveLength(1) + expect(b.lists.value[0].id).toBe(10) + }) + + it('different house ids have independent state', async () => { + mockApi.listLists.mockImplementation((id: number) => + Promise.resolve([makeList({ id: id * 100 })]), + ) + + const a = useChecklists(houseCounter) + houseCounter++ + const b = useChecklists(houseCounter) + + await a.load() + await b.load() + + expect(a.lists.value).not.toEqual(b.lists.value) + expect(a.lists.value[0].id).not.toBe(b.lists.value[0].id) + }) + }) }) describe('useChecklistItems', () => { diff --git a/src/composables/useChecklist.ts b/src/composables/useChecklist.ts index b13843e..e2089c0 100644 --- a/src/composables/useChecklist.ts +++ b/src/composables/useChecklist.ts @@ -1,23 +1,54 @@ -import { ref } from 'vue' +import { ref, type Ref } from 'vue' import * as api from '@/api/lists' import type { Checklist, ChecklistItem } from '@/api/types' import type { ChecklistItemSort } from '@/api/prefs' -export function useChecklists(houseId: number) { - const lists = ref([]) - const loading = ref(false) - const error = ref(null) +// Per-house state shared across all callers so sidebar and views stay in sync. +interface HouseChecklistState { + lists: Ref + loading: Ref + error: Ref + inflight: Promise | null +} +const houseStates = new Map() - async function load(): Promise { +function getState(houseId: number): HouseChecklistState { + let s = houseStates.get(houseId) + if (!s) { + s = { + lists: ref([]), + loading: ref(false), + error: ref(null), + inflight: null, + } + houseStates.set(houseId, s) + } + return s +} + +export function useChecklists(houseId: number) { + const state = getState(houseId) + const { lists, loading, error } = state + + function load(force = false): Promise { + if (state.inflight && !force) return state.inflight loading.value = true error.value = null - try { - lists.value = await api.listLists(houseId) - } catch (e) { - error.value = (e as Error).message - } finally { - loading.value = false - } + state.inflight = api + .listLists(houseId) + .then((result) => { + lists.value = result + return result + }) + .catch((e) => { + error.value = (e as Error).message + return lists.value + }) + .finally(() => { + loading.value = false + state.inflight = null + }) + return state.inflight } async function create( diff --git a/src/views/SideNavigation.vue b/src/views/SideNavigation.vue index 0232b43..3190a5d 100644 --- a/src/views/SideNavigation.vue +++ b/src/views/SideNavigation.vue @@ -9,11 +9,28 @@ + + + (() => { return Number.isFinite(id) ? id : null }) +const currentListId = computed(() => { + const raw = route.params.listId + if (!raw) return null + const id = Number(Array.isArray(raw) ? raw[0] : raw) + return Number.isFinite(id) ? id : null +}) + const house = computed(() => currentHouseId.value !== null ? findById(currentHouseId.value) : undefined, ) + +// Checklists for the sidebar sub-items. The composable shares per-house +// state, so creates/updates/deletes from other views are reflected here. +const listsExpanded = ref(true) +const checklists = computed(() => { + const id = currentHouseId.value + if (id === null) return [] + return useChecklists(id).lists.value +}) + +watch( + currentHouseId, + (id) => { + if (id === null) return + void useChecklists(id).load() + }, + { immediate: true }, +) /** * Prefix-based route matcher for sidebar items. An item is active when the * current route name equals any of the given prefixes, or starts with one of