diff --git a/frontend/src/components/todo/TodoLabelCard.vue b/frontend/src/components/todo/TodoLabelCard.vue index 055445b..3caf8c5 100644 --- a/frontend/src/components/todo/TodoLabelCard.vue +++ b/frontend/src/components/todo/TodoLabelCard.vue @@ -1,13 +1,18 @@ @@ -131,7 +145,7 @@ async function onRemoveItem(item: ApiTodoItem): Promise { v-for="label in store.displayLabels" :key="label.id" :label="label" - @add="onAddItem(label.id, $event)" + :add-item="(title: string) => onAddItem(label.id, title)" @toggle="onToggleItem" @rename="onRenameItem" @remove="onRemoveItem" diff --git a/frontend/src/stores/todo.store.ts b/frontend/src/stores/todo.store.ts index 97b8769..fa68cbe 100644 --- a/frontend/src/stores/todo.store.ts +++ b/frontend/src/stores/todo.store.ts @@ -1,7 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { useTodo } from '@/composables/useTodo' -import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo' +import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo' // 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD. // 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다. @@ -35,11 +35,31 @@ export const useTodoStore = defineStore('todo', () => { labels.value.reduce((sum, l) => sum + l.totalCount, 0), ) + // 진행 중인 항목 변경 — 같은 항목에 중복 요청이 나가지 않도록 막는다. + // (응답이 올 때까지 화면 상태가 그대로라, 막지 않으면 같은 값을 두 번 보내고 + // 두 응답이 역순으로 도착하면 나중 응답이 이겨 버린다) + const pendingItemIds = ref>(new Set()) + function markPending(id: string, on: boolean): void { + const next = new Set(pendingItemIds.value) + if (on) next.add(id) + else next.delete(id) + pendingItemIds.value = next + } + // 변경된 라벨 한 건을 목록에 반영 function replaceLabel(next: ApiTodoLabel): void { labels.value = labels.value.map((l) => (l.id === next.id ? next : l)) } + // 항목 한 건을 화면에서 먼저 바꾼다(낙관적 갱신). 진행 집계도 함께 맞춘다. + function patchItemLocally(id: string, patch: Partial): void { + labels.value = labels.value.map((l) => { + if (!l.items.some((i) => i.id === id)) return l + const items = l.items.map((i) => (i.id === id ? { ...i, ...patch } : i)) + return { ...l, items, doneCount: items.filter((i) => i.done).length } + }) + } + async function load(): Promise { loading.value = true try { @@ -69,14 +89,38 @@ export const useTodoStore = defineStore('todo', () => { async function addItem(labelId: string, title: string): Promise { replaceLabel(await api.createItem(labelId, title)) } + // 체크 토글 — 응답을 기다리지 않고 먼저 반영하고, 실패하면 되돌린다 async function toggleItem(id: string, done: boolean): Promise { - replaceLabel(await api.updateItem(id, { done })) + if (pendingItemIds.value.has(id)) return + const snapshot = labels.value + markPending(id, true) + patchItemLocally(id, { done }) + try { + replaceLabel(await api.updateItem(id, { done })) + } catch (e) { + labels.value = snapshot + throw e + } finally { + markPending(id, false) + } } async function renameItem(id: string, title: string): Promise { - replaceLabel(await api.updateItem(id, { title })) + if (pendingItemIds.value.has(id)) return + markPending(id, true) + try { + replaceLabel(await api.updateItem(id, { title })) + } finally { + markPending(id, false) + } } async function removeItem(id: string): Promise { - replaceLabel(await api.deleteItem(id)) + if (pendingItemIds.value.has(id)) return + markPending(id, true) + try { + replaceLabel(await api.deleteItem(id)) + } finally { + markPending(id, false) + } } return {