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",