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