From e8687bae86f6548d2611824de1ef74e39ae8acff Mon Sep 17 00:00:00 2001 From: eigger Date: Thu, 3 Sep 2026 22:59:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EB=A1=9D=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=97=90=20=EC=9E=91=EC=84=B1=EC=9E=90=C2=B7=EC=B5=9C=EC=A2=85?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=ED=91=9C=EA=B8=B0=20+=20=ED=88=AC?= =?UTF-8?q?=EC=95=BD=20=EC=8B=9C=EA=B0=84=20=EB=B2=84=ED=8A=BC=20=EC=95=88?= =?UTF-8?q?=20=EB=B3=B4=EC=9D=B4=EB=8D=98=20=EB=B2=84=EA=B7=B8=20(v0.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 가지 사용 피드백. 1. 케어 화면의 투약 시간 버튼("오전 8:00" 등)이 흰 배경에 흰 글자였다. .care-dose-slot-btn이 color를 지정하지 않아 전역 button { color: var(--color-primary-text) }(라이트 모드 흰색)를 그대로 물려받는데, 배경은 var(--color-surface)(라이트 모드에서도 흰색)라 텍스트가 안 보였다. "수정" 버튼(.btn-action)은 색을 명시해서 멀쩡했다. 2. 기록 상세 하단에 "{이름} 작성 · {날짜} 수정" 한 줄을 추가했다. Event.createdById·updatedAt은 이미 스키마에 있었다 — API select에서 createdBy 관계를 안 뽑고 있었을 뿐이다. POST/PATCH/GET /api/events, GET /api/events/:id, GET /api/home(recentEvents, /q 화면이 씀) 세 select 모두에 createdBy를 추가했다. eventAuditParts()(eventDisplay.ts)로 로직을 분리했다: 작성자를 모르면 (API 토큰으로 생성된 기록 등) 그 줄만 건너뛰고, 생성과 동시(2초 이내)면 "수정" 표시를 하지 않는다 — 이 문턱이 없으면 createdAt·updatedAt이 사실상 같은 순간인 모든 기록에 "방금 수정됨"이 붙어 정보가 아니라 소음이 된다. 알아낸 것: button { color: ... } 기본값을 물려받는 커스텀 버튼 클래스는 배경만 바꾸고 글자색을 빠뜨리기 쉽다. /q(홈 빠른 기록) 화면의 최근 기록 목록은 GET /api/events가 아니라 GET /api/home의 별도 select를 쓴다 — 관계 필드를 추가할 때 이 select도 함께 챙겨야 같은 상세 시트인데 여는 경로에 따라 필드가 있다 없다 하는 일이 없다. eventAuditParts는 순수 함수로 분리해 문턱값·작성자 누락 케이스를 테스트로 고정했다. 버튼 색은 브라우저에서 라이트·다크 모드 모두 실측했다. Co-Authored-By: Claude Sonnet 5 --- apps/api/package.json | 2 +- apps/api/src/routes/events.ts | 1 + apps/api/src/routes/home.ts | 3 ++ apps/api/src/services/createEvent.ts | 1 + apps/web/app/globals.css | 9 ++++ apps/web/app/history/page.tsx | 3 ++ apps/web/app/q/page.tsx | 3 ++ apps/web/components/EventDetailSheet.tsx | 10 ++++ apps/web/lib/eventDisplay.test.ts | 67 ++++++++++++++++++++++++ apps/web/lib/eventDisplay.ts | 36 +++++++++++++ apps/web/lib/i18n/translations.ts | 2 + apps/web/lib/types.ts | 6 +++ apps/web/package.json | 2 +- docs/WORKLOG.md | 24 +++++++++ package-lock.json | 10 ++-- package.json | 2 +- packages/shared/package.json | 2 +- 17 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 apps/web/lib/eventDisplay.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 2bbd7cb..022eb26 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@kibble/api", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 0c41e66..0645dff 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -309,6 +309,7 @@ export async function eventRoutes(app: FastifyInstance) { }, }, course: { select: { id: true, name: true } }, + createdBy: { select: { id: true, name: true } }, attachments: { select: { id: true, path: true, mime: true, size: true, width: true, height: true }, orderBy: { createdAt: "asc" }, diff --git a/apps/api/src/routes/home.ts b/apps/api/src/routes/home.ts index 4c0d97a..5ee6885 100644 --- a/apps/api/src/routes/home.ts +++ b/apps/api/src/routes/home.ts @@ -13,6 +13,8 @@ import { medicationCoursesWithProgress } from "../lib/medicationCourseProgress.j const recentEventSelect = { id: true, occurredAt: true, + createdAt: true, + updatedAt: true, quantity: true, quantityOffered: true, unit: true, @@ -23,6 +25,7 @@ const recentEventSelect = { contact: { select: { id: true, name: true, address: true } }, course: { select: { id: true, name: true } }, eventType: { select: { key: true, label: true, icon: true, scaleType: true, category: true } }, + createdBy: { select: { id: true, name: true } }, attachments: { select: { id: true, path: true, mime: true, size: true, width: true, height: true }, orderBy: { createdAt: "asc" as const }, diff --git a/apps/api/src/services/createEvent.ts b/apps/api/src/services/createEvent.ts index fc9df40..f50af6d 100644 --- a/apps/api/src/services/createEvent.ts +++ b/apps/api/src/services/createEvent.ts @@ -281,6 +281,7 @@ export const eventWithRelationsSelect = { select: { id: true, name: true, address: true, latitude: true, longitude: true, placeUrl: true }, }, course: { select: { id: true, name: true } }, + createdBy: { select: { id: true, name: true } }, attachments: { select: { id: true, path: true, mime: true, size: true, width: true, height: true }, orderBy: { createdAt: "asc" as const }, diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 4cc3e98..348dd47 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1457,6 +1457,7 @@ button:disabled { border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface); + color: var(--color-text); font-size: 0.8rem; white-space: nowrap; } @@ -2336,6 +2337,14 @@ button:disabled { color: var(--color-text-muted); } +/* 작성자 · 최종 수정 — 기록 내용이 아니라 시스템 메타이므로 살짝 떨어뜨려 둔다. */ +.event-detail-audit { + margin: 4px 0 0; + padding-top: 8px; + border-top: 1px solid var(--color-border); + font-size: 0.75rem; +} + .event-detail-save-error { width: 100%; margin: 0 0 8px; diff --git a/apps/web/app/history/page.tsx b/apps/web/app/history/page.tsx index 5c33e2a..1a231e4 100644 --- a/apps/web/app/history/page.tsx +++ b/apps/web/app/history/page.tsx @@ -222,6 +222,9 @@ export default function HistoryPage() { note: event.note, scaleType: event.eventType.scaleType ?? null, scaleValue: event.scaleValue, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + createdByName: event.createdBy?.name ?? null, }); setDetailOpen(true); } diff --git a/apps/web/app/q/page.tsx b/apps/web/app/q/page.tsx index c92be68..26f6024 100644 --- a/apps/web/app/q/page.tsx +++ b/apps/web/app/q/page.tsx @@ -199,6 +199,9 @@ export default function QuickRecordPage() { note: event.note, scaleType: event.eventType.scaleType ?? null, scaleValue: event.scaleValue, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + createdByName: event.createdBy?.name ?? null, }); setDetailOpen(true); } diff --git a/apps/web/components/EventDetailSheet.tsx b/apps/web/components/EventDetailSheet.tsx index 2d2e184..cf2f881 100644 --- a/apps/web/components/EventDetailSheet.tsx +++ b/apps/web/components/EventDetailSheet.tsx @@ -18,6 +18,7 @@ import { toggleProductNameTag, } from "../lib/eventDetailTags"; import type { EventAttachment } from "../lib/types"; +import { eventAuditParts } from "../lib/eventDisplay"; import { mapsEnabled } from "../lib/maps/types"; import { useMapProviders } from "../lib/maps/useMapProviders"; import { geocodeAddress } from "../lib/maps/geocode"; @@ -62,6 +63,10 @@ export interface EventDetailDraft { medicationCourseId?: string | null; doseSlotIndex?: number | null; needsReview?: boolean; + /** 조회용 메타 — 수정 대상이 아니다. view 모드 하단에 "작성자 · 최종 수정"으로만 쓰인다. */ + createdAt?: string; + updatedAt?: string; + createdByName?: string | null; } interface EventDetailSheetProps { @@ -389,6 +394,7 @@ export function EventDetailSheet({ ? { lat: draft.clinicLatitude, lon: draft.clinicLongitude } : geocodedCoords; const clinicMapName = draft.clinicName?.trim() || ""; + const auditParts = eventAuditParts(draft, t, locale); function renderScale3Field() { if (!fields.scale3) return null; @@ -654,6 +660,10 @@ export function EventDetailSheet({ )} + {auditParts.length > 0 && ( +

{auditParts.join(" · ")}

+ )} +
{draft.eventId && ( diff --git a/apps/web/lib/eventDisplay.test.ts b/apps/web/lib/eventDisplay.test.ts new file mode 100644 index 0000000..8e2060c --- /dev/null +++ b/apps/web/lib/eventDisplay.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { eventAuditParts } from "./eventDisplay"; + +function t(key: string, params?: Record): string { + const dict: Record = { + eventDetailCreatedBy: "{name} 작성", + eventDetailLastModified: "{datetime} 수정", + }; + const template = dict[key] ?? key; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)); +} + +describe("eventAuditParts", () => { + it("작성자·수정 정보가 둘 다 없으면 빈 배열", () => { + expect(eventAuditParts({}, t, "ko")).toEqual([]); + }); + + it("작성자만 있으면 그 한 줄만", () => { + expect( + eventAuditParts({ createdBy: { name: "보람" }, createdAt: "2026-09-01T00:00:00Z" }, t, "ko"), + ).toEqual(["보람 작성"]); + }); + + // API 토큰으로 생성된 기록은 createdBy가 없다 — 그 줄만 조용히 빠진다. + it("작성자를 모르면 그 줄만 건너뛴다", () => { + const parts = eventAuditParts( + { + createdBy: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-02T00:00:00.000Z", + }, + t, + "ko", + ); + expect(parts).toHaveLength(1); + expect(parts[0]).toContain("수정"); + }); + + // 생성 직후 createdAt·updatedAt이 같은 순간(또는 거의 같은 순간)이면 "수정됨"이 아니다. + it("생성과 동시(2초 이내)면 수정 표시를 하지 않는다", () => { + expect( + eventAuditParts( + { + createdBy: { name: "보람" }, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:01.500Z", + }, + t, + "ko", + ), + ).toEqual(["보람 작성"]); + }); + + it("2초를 넘겨 갈라지면 수정 표시를 한다", () => { + const parts = eventAuditParts( + { + createdBy: { name: "보람" }, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:05.000Z", + }, + t, + "ko", + ); + expect(parts).toEqual(["보람 작성", "9월 1일 09:00 수정"]); + }); +}); diff --git a/apps/web/lib/eventDisplay.ts b/apps/web/lib/eventDisplay.ts index 7b5dadf..0de11f0 100644 --- a/apps/web/lib/eventDisplay.ts +++ b/apps/web/lib/eventDisplay.ts @@ -63,6 +63,42 @@ export function formatEventDate(iso: string, locale: string): string { }); } +/** + * Prisma는 생성 시 createdAt·updatedAt을 같은 순간에 찍는다 — 그런데도 미세하게 + * 어긋날 수 있어(같은 트랜잭션 안 서로 다른 now() 호출) 여유를 둔다. 이 문턱을 + * 넘어야 "나중에 실제로 고쳤다"로 본다. + */ +const EDITED_THRESHOLD_MS = 2000; + +/** + * 상세 시트 하단에 붙는 "작성자 · 최종 수정" 메타 줄. 작성자를 모르면(API 토큰으로 + * 생성된 기록 등) 그 줄을 건너뛰고, 생성 이후 실제로 고친 적이 없으면 수정 시각도 + * 건너뛴다 — 모든 기록에 "방금 수정됨"이 붙는 소음을 피한다. + */ +export function eventAuditParts( + event: { + createdBy?: { name: string } | null; + createdAt?: string; + updatedAt?: string; + }, + t: (key: string, params?: Record) => string, + locale: string, +): string[] { + const parts: string[] = []; + if (event.createdBy?.name) { + parts.push(t("eventDetailCreatedBy", { name: event.createdBy.name })); + } + if (event.createdAt && event.updatedAt) { + const createdMs = new Date(event.createdAt).getTime(); + const updatedMs = new Date(event.updatedAt).getTime(); + if (Number.isFinite(createdMs) && Number.isFinite(updatedMs) && updatedMs - createdMs > EDITED_THRESHOLD_MS) { + const when = `${formatEventDate(event.updatedAt, locale)} ${formatEventTime(event.updatedAt, locale)}`; + parts.push(t("eventDetailLastModified", { datetime: when })); + } + } + return parts; +} + export function eventDisplayLabel(event: TimelineEvent, t: (key: string) => string): string { if (event.eventType.key === "medication" && event.course?.name) { return event.course.name; diff --git a/apps/web/lib/i18n/translations.ts b/apps/web/lib/i18n/translations.ts index 85548c1..86e6271 100644 --- a/apps/web/lib/i18n/translations.ts +++ b/apps/web/lib/i18n/translations.ts @@ -359,6 +359,8 @@ const dict = { loadMore: { ko: "더보기", en: "Load more" }, timelineLoadMoreError: { ko: "기록을 더 불러오지 못했습니다.", en: "Could not load more events." }, eventDetailAttachments: { ko: "사진·영상", en: "Photos & videos" }, + eventDetailCreatedBy: { ko: "{name} 작성", en: "Logged by {name}" }, + eventDetailLastModified: { ko: "{datetime} 수정", en: "Edited {datetime}" }, lightboxResetZoom: { ko: "원래 크기", en: "Reset zoom" }, attachPhotos: { ko: "사진·영상", en: "Photos" }, attachFromAlbum: { ko: "앨범", en: "Album" }, diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 999344c..6705394 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -71,12 +71,15 @@ export interface CreatedEvent { petId: string; presetId: string | null; occurredAt: string; + createdAt?: string; + updatedAt?: string; quantity: number | null; quantityOffered: number | null; unit: string | null; scaleValue?: number | null; productName: string | null; note: string | null; + createdBy?: { id: string; name: string } | null; contact?: { id: string; name: string; @@ -151,12 +154,15 @@ export type { JournalStats } from "@kibble/shared"; export interface TimelineEvent { id: string; occurredAt: string; + createdAt?: string; + updatedAt?: string; quantity: number | null; quantityOffered: number | null; unit: string | null; scaleValue: number | null; productName: string | null; note: string | null; + createdBy?: { id: string; name: string } | null; contact?: { id: string; name: string; diff --git a/apps/web/package.json b/apps/web/package.json index f5ce249..13be70e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@kibble/web", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev", diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index b741e17..de9116f 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -82,12 +82,36 @@ | R49 | 카카오 내비를 웹 링크(`map.kakao.com/link/to/…`)로 두기 | **기각** | **실기기에서 경로가 안 잡힌다** — 웹 링크라 폰에서 앱으로 넘어가지 않고 웹 지도만 뜬다. 네이버(`nmap://`)·티맵(`tmap://`)만 되던 이유가 이것. `kakaomap://route`로 바꾼다. **garage에도 같은 버그가 있다** | — | | R50 | 이력 목록 한 줄에 병원 주소까지 표기 | **기각** | 상세 시트가 주소를 이미 보여준다. 목록에서는 줄만 길어지고 병원 이름이 밀린다 (사용 피드백) | — | | R51 | 라이트박스 확대를 브라우저 기본 핀치에 맡기기 | **기각** | 라이트박스는 `position: fixed` 오버레이라 브라우저 페이지 확대가 먹지 않는다(확대해도 오버레이는 그대로). 포인터 이벤트로 직접 구현한다 | — | +| R52 | 모든 기록에 "최종 수정" 시각을 항상 표기 | **기각** | 생성 직후에는 `createdAt`과 `updatedAt`이 사실상 같은 순간이다. 문턱(2초) 없이 그대로 비교하면 **모든 기록에 "방금 수정됨"이 붙어** 정보가 아니라 소음이 된다 → 실제로 갈라졌을 때만 표기 | — | +| R53 | 작성자 미상(API 토큰 생성)일 때 "알 수 없음" 표기 | **기각** | K-11 정신과 같다 — 모를 때 빈 라벨을 억지로 채우면 오히려 눈에 걸린다. 그 줄 자체를 건너뛴다 | — | --- ## 2. 세션 로그 +### 2026-09-03 — 기록 상세에 작성자·최종 수정 표기 + 투약 시간 버튼 안 보이던 버그 + +**한 일** + +- **투약 시간 버튼이 흰 배경에 흰 글자였다.** `.care-dose-slot-btn`(케어 화면의 "오전 8:00" 같은 시간 버튼)이 `color`를 지정하지 않아 전역 `button { color: var(--color-primary-text) }`(라이트 모드 흰색)를 그대로 물려받는데, 배경은 `var(--color-surface)`(라이트 모드에서도 흰색)라 텍스트가 안 보였다. "수정" 버튼(`.btn-action`)은 색을 명시해서 멀쩡했던 것 — 사용자가 "수정 빼고 아무것도 안 보인다"고 정확히 짚었다 +- **기록 상세 하단에 "{이름} 작성 · {날짜} 수정" 한 줄 추가.** `Event.createdById`·`updatedAt`은 이미 스키마에 있었다 — API select에서 `createdBy` 관계를 안 뽑고 있었을 뿐 + - `POST/PATCH/GET /api/events`, `GET /api/events/:id`, `GET /api/home`(recentEvents, `/q` 화면이 씀) 세 select 모두에 `createdBy: { select: { id, name } }` 추가 + - `eventAuditParts()`(`eventDisplay.ts`)로 로직을 분리: 작성자를 모르면(API 토큰 생성) 그 줄만 건너뛰고, 생성과 동시(2초 이내)면 "수정" 표시를 하지 않는다(R52) — 이 문턱이 없으면 모든 기록에 "방금 수정됨"이 붙는다 + +**알아낸 것** + +- `button { color: ... }` 기본값을 물려받는 커스텀 버튼 클래스는 배경만 바꾸고 글자색을 빠뜨리기 쉽다. 이번 세션에서만 이 패턴으로 두 번 걸렸다(카카오 지도 미리보기 버튼은 처음부터 색을 명시해 뒀었다) — 새 버튼 클래스를 추가할 때 `color`를 항상 같이 확인한다 +- `/q`(홈 빠른 기록) 화면의 최근 기록 목록은 `GET /api/events`가 아니라 `GET /api/home`의 별도 select(`recentEventSelect`)를 쓴다. 관계 필드를 추가할 때 이 select도 함께 챙겨야 한다 — 빠뜨리면 같은 상세 시트인데 여는 경로에 따라 필드가 있다 없다 한다 + +**검증** + +`build`·`lint`·`test`(266개) 통과. `eventAuditParts`는 순수 함수로 분리해 문턱값·작성자 누락 케이스를 테스트로 고정. 버튼 색은 브라우저에서 라이트·다크 모드 모두 실측. 감사 줄 배치도 정적 하네스로 렌더 확인. + +**다음** + +- `GET /api/home`의 `contact` select에 좌표·`placeUrl`이 빠져 있다(다른 두 select에는 있음) — 별도 태스크로 분리(`task_26ca9301`) + ### 2026-09-02 — 이력 행 정렬·병원 주소·카카오 내비·라이트박스 확대 (사용 피드백) 실기기에서 쓰다 나온 지적 넷을 처리했다. diff --git a/package-lock.json b/package-lock.json index aef6bf4..e848bfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kibble", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kibble", - "version": "0.5.0", + "version": "0.6.0", "workspaces": [ "apps/*", "packages/*" @@ -17,7 +17,7 @@ }, "apps/api": { "name": "@kibble/api", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", @@ -108,7 +108,7 @@ }, "apps/web": { "name": "@kibble/web", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@kibble/shared": "*", "@zxing/browser": "^0.2.1", @@ -11610,7 +11610,7 @@ }, "packages/shared": { "name": "@kibble/shared", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "zod": "^3.23.8" }, diff --git a/package.json b/package.json index fa70996..1987d24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kibble", "private": true, - "version": "0.5.0", + "version": "0.6.0", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/shared/package.json b/packages/shared/package.json index 416bde6..22c8c98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@kibble/shared", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "main": "dist/index.js",