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.27.1",
"version": "0.27.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
30 changes: 1 addition & 29 deletions apps/web/app/q/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { apiJson, isApiError } from "../../lib/api";
import { scheduledDoseTime } from "../../lib/eventDetailFields";
import { formatApiErrorMessage } from "../../lib/apiErrorMessage";
import { createEventWithOfflineFallback } from "../../lib/createEventOffline";
import { isGroupedWithPrevious } from "../../lib/timeline";
import { createdEventToTimeline, isGroupedWithPrevious } from "../../lib/timeline";
import { useAuth } from "../../lib/auth-context";
import { useLocale } from "../../lib/i18n/locale-context";
import type { TranslationKey } from "../../lib/i18n/translations";
Expand Down Expand Up @@ -100,34 +100,6 @@ function newEntryId(): string {
return `entry:${randomSuffix()}`;
}

function createdEventToTimeline(event: CreatedEvent): TimelineEvent {
return {
id: event.id,
occurredAt: event.occurredAt,
createdAt: event.createdAt,
updatedAt: event.updatedAt,
quantity: event.quantity,
quantityOffered: event.quantityOffered,
unit: event.unit,
scaleValue: event.scaleValue ?? null,
productName: event.productName ?? null,
entryId: event.entryId ?? null,
costKrw: event.costKrw ?? null,
contact: event.contact ?? null,
course: event.course ?? null,
doseOrdinal: event.doseOrdinal ?? null,
note: event.note,
preset: event.preset,
eventType: {
...event.eventType,
scaleType: event.eventType.scaleType ?? null,
},
attachments: event.attachments,
createdBy: event.createdBy ?? null,
updatedBy: event.updatedBy ?? null,
};
}

export default function QuickRecordPage() {
const router = useRouter();
const pathname = routePath(usePathname());
Expand Down
46 changes: 46 additions & 0 deletions apps/web/lib/createdEventToTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createdEventToTimeline, isGroupedWithPrevious } from "./timeline";
import { eventDetailLine } from "./eventDisplay";
import type { CreatedEvent, ProductSummary } from "./types";

const shampoo: ProductSummary = {
id: "shampoo", name: "샴푸", brand: null, category: "HYGIENE",
photoPath: null, dosage: null, isActive: true,
};
const conditioner: ProductSummary = { ...shampoo, id: "conditioner", name: "린스" };

function created(product?: ProductSummary): CreatedEvent {
return {
id: `event-${product?.id ?? "none"}`, petId: "pet", presetId: null,
productId: product?.id, product, occurredAt: "2026-10-02T00:00:00Z",
quantity: null, quantityOffered: null, unit: null,
productName: product?.id === "shampoo" ? "bath" : null,
entryId: "entry-care", costKrw: null, note: null, preset: null,
eventType: { key: "care", label: "관리", icon: null },
};
}

describe("createdEventToTimeline — 등록 직후 제품 연결", () => {
it("관리 제품 두 건의 이름·연결과 묶음을 보존한다", () => {
const rows = [shampoo, conditioner].map((product) => createdEventToTimeline(created(product)));
expect(rows.map((row) => row.productId)).toEqual(["shampoo", "conditioner"]);
expect(rows.map((row) => row.product)).toEqual([shampoo, conditioner]);
expect(isGroupedWithPrevious(rows, 1)).toBe(true);
expect(rows.map((row) => eventDetailLine(row, (key) => key))).toEqual([
"eventTag.care.bath · 샴푸", "린스",
]);
});

it("저장 직후 다시 열어 수정해도 제품 ID가 null로 바뀌지 않는다", () => {
const event = createdEventToTimeline(created(shampoo));
const draftProductId = event.productId ?? null;
expect(draftProductId).toBe("shampoo");
});

it("제품이 없는 기록은 연결을 만들지 않는다", () => {
const event = createdEventToTimeline(created());
expect(event.productId).toBeNull();
expect(event.product).toBeNull();
expect(eventDetailLine(event, (key) => key)).toBeNull();
});
});
32 changes: 31 additions & 1 deletion apps/web/lib/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { apiJson } from "./api";
import { TIMELINE_PAGE_SIZE } from "@kibble/shared";
import type { TimelineEvent } from "./types";
import type { CreatedEvent, TimelineEvent } from "./types";

export { TIMELINE_PAGE_SIZE };

Expand Down Expand Up @@ -64,3 +64,33 @@ export function isGroupedWithPrevious(
if (!entryId) return false;
return events[index - 1]?.entryId === entryId;
}

export function createdEventToTimeline(event: CreatedEvent): TimelineEvent {
return {
id: event.id,
occurredAt: event.occurredAt,
createdAt: event.createdAt,
updatedAt: event.updatedAt,
quantity: event.quantity,
quantityOffered: event.quantityOffered,
unit: event.unit,
scaleValue: event.scaleValue ?? null,
productId: event.productId ?? null,
product: event.product ?? null,
productName: event.productName ?? null,
entryId: event.entryId ?? null,
costKrw: event.costKrw ?? null,
contact: event.contact ?? null,
course: event.course ?? null,
doseOrdinal: event.doseOrdinal ?? null,
note: event.note,
preset: event.preset,
eventType: {
...event.eventType,
scaleType: event.eventType.scaleType ?? null,
},
attachments: event.attachments,
createdBy: event.createdBy ?? null,
updatedBy: event.updatedBy ?? null,
};
}
2 changes: 1 addition & 1 deletion apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ export interface TimelineEvent {
unit: string | null;
scaleValue: number | null;
productId?: string | null;
product?: Product | null;
product?: ProductSummary | null;
productName: string | null;
/** 한 번에 만든 여러 이벤트를 묶는 id — 텍스트 파싱, 여러 제품 동시 기록 (§7.22) */
entryId?: string | null;
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.27.1",
"version": "0.27.2",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
7 changes: 7 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@

## 2. 세션 로그

### 2026-10-02 — 등록 직후 제품 연결 누락 수정

- `/q`에서 새 기록 응답을 타임라인으로 변환할 때 `productId`·`product`를 빠뜨려, 관리 제품 여러 개를 기록하면 태그만 보이거나 제품만 있는 행이 비어 보였다. 저장 직후 다시 수정하면 누락된 ID가 `null`로 전송되어 실제 제품 연결도 지워질 수 있었다.
- 빠른 기록·루틴이 함께 사용하는 변환 함수에서 제품 연결을 보존한다. 타임라인 제품 타입은 API가 실제 반환하는 `ProductSummary`로 맞췄다.
- 회귀 테스트: 관리 제품 2개의 이름·연결·묶음 유지, 저장 직후 수정용 ID 유지, 제품 없는 기록. 수정 전 실패·수정 후 통과를 확인했다.


### 2026-10-01 — v0.27.1 릴리스

PR #130(제품 중복 저장 방어) 머지 후 태그. 마이그레이션 없음.
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.27.1",
"version": "0.27.2",
"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.27.1",
"version": "0.27.2",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down
Loading