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 {