Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kibble/api",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/routes/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/services/createEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
9 changes: 9 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/history/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/q/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/components/EventDetailSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -654,6 +660,10 @@ export function EventDetailSheet({
</section>
)}

{auditParts.length > 0 && (
<p className="event-detail-audit meta">{auditParts.join(" · ")}</p>
)}

<div className="event-detail-actions">
<div className="event-detail-actions-primary">
{draft.eventId && (
Expand Down
67 changes: 67 additions & 0 deletions apps/web/lib/eventDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { eventAuditParts } from "./eventDisplay";

function t(key: string, params?: Record<string, string>): string {
const dict: Record<string, string> = {
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 수정"]);
});
});
36 changes: 36 additions & 0 deletions apps/web/lib/eventDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>) => 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;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kibble/web",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
24 changes: 24 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 이력 행 정렬·병원 주소·카카오 내비·라이트박스 확대 (사용 피드백)

실기기에서 쓰다 나온 지적 넷을 처리했다.
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "kibble",
"private": true,
"version": "0.5.0",
"version": "0.6.0",
"workspaces": [
"apps/*",
"packages/*"
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kibble/shared",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down