From 2ad0629975513cb514d52a31678bbac85e9a4599 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 21 Jul 2026 23:03:21 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=A0=20=EC=9D=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=9E=85=EB=A0=A5=C2=B7=EC=82=AD=EC=A0=9C=C2=B7?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 할 일 기능 전반을 재검토해 사용자가 바로 겪는 문제를 고친다. - 이름 수정 시 입력에 포커스를 주지 않아 타이핑도 blur 확정도 되지 않던 문제 - 한글 조합 중 Enter 가 미완성 문자열을 저장하던 문제(추가·수정 양쪽) - 되돌릴 수 없는 할 일 삭제에 확인 다이얼로그 추가 - 추가 실패 시 입력이 사라지던 문제 — 성공했을 때만 비운다 - 수정 입력에 빠져 있던 maxlength 추가 - 체크 토글을 낙관적으로 반영하고 실패 시 되돌린다 - 같은 항목의 중복 요청을 막아 응답 역순 도착으로 인한 덮어쓰기 방지 - 편집 중인 행이 목록에서 사라지면 편집 상태를 정리 - 처리되지 않은 Promise 거부 제거 - 삭제 버튼이 키보드 포커스 시에도 보이도록 수정 - 업무에서 만든 라벨에 원본 업무 링크 표시(직접 만든 라벨은 표시하지 않음) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/todo/TodoLabelCard.vue | 117 ++++++++++++++++-- frontend/src/pages/relay/TodoPage.vue | 30 +++-- frontend/src/stores/todo.store.ts | 52 +++++++- 3 files changed, 175 insertions(+), 24 deletions(-) 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 {