diff --git a/WORKPLAN.md b/WORKPLAN.md index e3e7b2f..b00936f 100644 --- a/WORKPLAN.md +++ b/WORKPLAN.md @@ -632,7 +632,7 @@ UI: | P1-26 | 오프라인 큐 (IndexedDB, **첨부 Blob 포함**) | 비행기 모드 5건 + 사진 → 복귀 시 자동 전송. 4xx는 재전송 않고 알림 | | P1-27 | 타임라인 무한 스크롤 + 삭제/복구 + 편집 | `@@index([petId, occurredAt(sort: Desc)])`를 타는 쿼리 | | P1-27a | **시작 3개 + "더보기"** (G-1) | **완료** (#7) | -| P1-27b | **빈 화면 예시 카드 + 첫 기록 안내 + 3일 안내** (§3.8) | 타임라인 0건에서 다음 행동이 보인다 (K-11) | +| P1-27b | **빈 화면 예시 카드 + 첫 기록 안내 + 3일 안내** (§3.8) | **완료** — 흐릿한 예시 타임라인·`journalStats` 기반 안내 문구 | | P1-27c | **대변 스코어 1~7 선택 UI** | 그림에서 1탭. 건너뛰어도 저장된다 (G-3) | | P1-28 | **i18n ko + en 양쪽 사전** | 컴포넌트 하드코딩 0건. **en 미번역 키 0건** (K-9) | diff --git a/apps/api/src/lib/journalStats.test.ts b/apps/api/src/lib/journalStats.test.ts new file mode 100644 index 0000000..8b86266 --- /dev/null +++ b/apps/api/src/lib/journalStats.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { journalStatsForPet } from "./journalStats.js"; + +describe("journalStatsForPet", () => { + it("returns distinct KST day count without scanning all events", async () => { + const db = { + event: { count: async () => 3000 }, + $queryRaw: async () => [ + { kst_day: new Date("2026-09-03T00:00:00.000Z") }, + { kst_day: new Date("2026-09-02T00:00:00.000Z") }, + ], + }; + + const stats = await journalStatsForPet(db as never, "hh", "pet1"); + expect(stats.totalEventCount).toBe(3000); + expect(stats.distinctDayCount).toBe(2); + }); + + it("caps distinct days at probe limit (four or more)", async () => { + const db = { + event: { count: async () => 500 }, + $queryRaw: async () => [ + { kst_day: new Date("2026-09-04T00:00:00.000Z") }, + { kst_day: new Date("2026-09-03T00:00:00.000Z") }, + { kst_day: new Date("2026-09-02T00:00:00.000Z") }, + { kst_day: new Date("2026-09-01T00:00:00.000Z") }, + ], + }; + + const stats = await journalStatsForPet(db as never, "hh", "pet1"); + expect(stats.distinctDayCount).toBe(4); + }); + + it("returns exactly three for third-day milestone boundary", async () => { + const db = { + event: { count: async () => 12 }, + $queryRaw: async () => [ + { kst_day: new Date("2026-09-03T00:00:00.000Z") }, + { kst_day: new Date("2026-09-02T00:00:00.000Z") }, + { kst_day: new Date("2026-09-01T00:00:00.000Z") }, + ], + }; + + const stats = await journalStatsForPet(db as never, "hh", "pet1"); + expect(stats.distinctDayCount).toBe(3); + }); +}); diff --git a/apps/api/src/lib/journalStats.ts b/apps/api/src/lib/journalStats.ts new file mode 100644 index 0000000..d287861 --- /dev/null +++ b/apps/api/src/lib/journalStats.ts @@ -0,0 +1,33 @@ +import type { PrismaClient } from "@prisma/client"; +import type { JournalStats } from "@kibble/shared"; +import { householdWhere } from "./householdScope.js"; + +/** §3.8 copy needs at most 3 distinct days; 4 rows ⇒ four or more. */ +const DISTINCT_DAY_PROBE_LIMIT = 4; + +export async function journalStatsForPet( + db: Pick, + householdId: string, + petId: string, +): Promise { + const where = { + ...householdWhere(householdId), + petId, + deletedAt: null, + }; + + const [totalEventCount, dayRows] = await Promise.all([ + db.event.count({ where }), + db.$queryRaw<{ kst_day: Date }[]>` + SELECT DISTINCT ((e."occurredAt" AT TIME ZONE 'UTC') + interval '9 hours')::date AS kst_day + FROM "Event" e + WHERE e."householdId" = ${householdId} + AND e."petId" = ${petId} + AND e."deletedAt" IS NULL + ORDER BY kst_day DESC + LIMIT ${DISTINCT_DAY_PROBE_LIMIT} + `, + ]); + + return { totalEventCount, distinctDayCount: dayRows.length }; +} diff --git a/apps/api/src/lib/kstClock.ts b/apps/api/src/lib/kstClock.ts index a9deae1..e3c219d 100644 --- a/apps/api/src/lib/kstClock.ts +++ b/apps/api/src/lib/kstClock.ts @@ -1,39 +1,7 @@ -/** Phase 1 일 경계·파싱 시각 — WORKPLAN §7.11. KST(UTC+9) 고정. */ -export const PHASE1_TODAY_UTC_OFFSET_MINUTES = 9 * 60; - -export function startOfTodayBoundary( - now = new Date(), - offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES, -): Date { - const shifted = new Date(now.getTime() + offsetMinutes * 60_000); - const y = shifted.getUTCFullYear(); - const m = shifted.getUTCMonth(); - const d = shifted.getUTCDate(); - return new Date(Date.UTC(y, m, d) - offsetMinutes * 60_000); -} - -/** KST 달력 날짜(base) + dayOffset일의 hour:minute → UTC instant */ -export function kstDateTime( - base: Date, - hour: number, - minute: number, - dayOffset = 0, - offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES, -): Date { - const shifted = new Date(base.getTime() + offsetMinutes * 60_000); - const anchor = new Date( - Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()), - ); - const kstMidnightUtc = anchor.getTime() - offsetMinutes * 60_000; - const dayMs = dayOffset * 86_400_000; - return new Date(kstMidnightUtc + dayMs + hour * 3_600_000 + minute * 60_000); -} - -export function kstCalendarParts(base: Date, offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES) { - const shifted = new Date(base.getTime() + offsetMinutes * 60_000); - return { - year: shifted.getUTCFullYear(), - month: shifted.getUTCMonth(), - date: shifted.getUTCDate(), - }; -} +export { + PHASE1_TODAY_UTC_OFFSET_MINUTES, + startOfTodayBoundary, + kstDateTime, + kstCalendarParts, + kstDayKey, +} from "@kibble/shared"; diff --git a/apps/api/src/routes/home.ts b/apps/api/src/routes/home.ts index 1684176..0d074db 100644 --- a/apps/api/src/routes/home.ts +++ b/apps/api/src/routes/home.ts @@ -3,6 +3,7 @@ import { prisma } from "../lib/prisma.js"; import { householdWhere, requireHouseholdId } from "../lib/householdScope.js"; import { t } from "../lib/i18n.js"; import { todaySummaryForPet } from "../lib/todaySummary.js"; +import { journalStatsForPet } from "../lib/journalStats.js"; const recentEventSelect = { id: true, @@ -41,7 +42,14 @@ export async function homeRoutes(app: FastifyInstance) { } if (!activePet) { - return { pets, activePet: null, presets: [], todaySummary: [], recentEvents: [] }; + return { + pets, + activePet: null, + presets: [], + todaySummary: [], + recentEvents: [], + journalStats: { totalEventCount: 0, distinctDayCount: 0 }, + }; } const petScope = { @@ -49,7 +57,7 @@ export async function homeRoutes(app: FastifyInstance) { petId: activePet.id, }; - const [presets, todaySummary, recentEvents] = await Promise.all([ + const [presets, todaySummary, recentEvents, journalStats] = await Promise.all([ prisma.preset.findMany({ where: { ...householdWhere(householdId), @@ -67,8 +75,9 @@ export async function homeRoutes(app: FastifyInstance) { take: 30, select: recentEventSelect, }), + journalStatsForPet(prisma, householdId, activePet.id), ]); - return { pets, activePet, presets, todaySummary, recentEvents }; + return { pets, activePet, presets, todaySummary, recentEvents, journalStats }; }); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 1d7199e..ed1d09f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -133,10 +133,42 @@ a { } .timeline-empty { - margin-top: 24px; + margin-top: 8px; + margin-bottom: 12px; text-align: center; } +.timeline-empty-state { + margin-top: 16px; +} + +.timeline-list-example { + pointer-events: none; + user-select: none; +} + +.timeline-item-example { + opacity: 0.45; + border-bottom-style: dashed; +} + +.timeline-example-badge { + margin-left: 6px; + font-size: 0.72rem; + font-weight: 500; + color: var(--color-text-muted); + border: 1px dashed var(--color-border); + border-radius: 4px; + padding: 0 4px; + vertical-align: middle; +} + +.home-journal-insight { + margin: 0 0 8px; + font-size: 0.9rem; + color: var(--color-text-secondary); +} + .timeline-list { list-style: none; margin: 0; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7bbb0e2..978562c 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -15,6 +15,8 @@ import type { ParseSuggestion, ParseEntryResponse, } from "../lib/types"; +import type { JournalStats } from "@kibble/shared"; +import { bumpJournalStats, journalInsightMessage } from "@kibble/shared"; interface HomePayload { pets: Pet[]; @@ -22,8 +24,14 @@ interface HomePayload { presets: Preset[]; todaySummary: TodaySummaryRow[]; recentEvents: TimelineEvent[]; + journalStats: JournalStats; } +const TIMELINE_EXAMPLES = [ + { label: "eventType.meal", time: "08:00", detail: "40g" }, + { label: "eventType.water", time: "14:00", detail: null }, +] as const; + function newDedupeKey(petId: string, presetId: string): string { const uuid = globalThis.crypto?.randomUUID?.(); const suffix = uuid ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -101,6 +109,10 @@ export default function HomePage() { const [presets, setPresets] = useState([]); const [todaySummary, setTodaySummary] = useState([]); const [recentEvents, setRecentEvents] = useState([]); + const [journalStats, setJournalStats] = useState({ + totalEventCount: 0, + distinctDayCount: 0, + }); const [dataLoading, setDataLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [moreOpen, setMoreOpen] = useState(false); @@ -108,6 +120,11 @@ export default function HomePage() { const [parseBatch, setParseBatch] = useState(null); const [parseBatchRetryable, setParseBatchRetryable] = useState(false); const loadSeq = useRef(0); + const recentEventsRef = useRef([]); + + useEffect(() => { + recentEventsRef.current = recentEvents; + }, [recentEvents]); useEffect(() => { if (!loading && !user) router.push("/login"); @@ -123,6 +140,7 @@ export default function HomePage() { setPresets(data.presets); setTodaySummary(data.todaySummary); setRecentEvents(data.recentEvents); + setJournalStats(data.journalStats); }, []); const loadHome = useCallback( @@ -196,13 +214,21 @@ export default function HomePage() { return `${t("homeToday")} · ${parts.join(" · ")}`; }, [todaySummary, t]); + const journalInsight = useMemo( + () => journalInsightMessage(journalStats, t), + [journalStats, t], + ); + const tabPanelId = activePet ? `home-pet-panel-${activePet.id}` : undefined; const [recording, setRecording] = useState(false); const inFlightDedupeKey = useRef(null); function applyCreatedEvent(event: CreatedEvent) { - setRecentEvents((prev) => [createdEventToTimeline(event), ...prev]); + const timelineEntry = createdEventToTimeline(event); + const latestOccurredAt = recentEventsRef.current[0]?.occurredAt ?? null; + setJournalStats((stats) => bumpJournalStats(stats, event.occurredAt, latestOccurredAt)); + setRecentEvents((prev) => [timelineEntry, ...prev]); setTodaySummary((prev) => bumpSummary(prev, event.eventType.key, event.eventType.label), ); @@ -336,11 +362,7 @@ export default function HomePage() { }), }); - const timelineEntry = createdEventToTimeline(event); - const typeKey = event.eventType.key; - - setRecentEvents((prev) => [timelineEntry, ...prev]); - setTodaySummary((prev) => bumpSummary(prev, typeKey, event.eventType.label)); + applyCreatedEvent(event); show(t("recordSaved", { label }), "success", { label: t("undo"), @@ -349,7 +371,11 @@ export default function HomePage() { try { await apiJson(`/api/events/${event.id}`, { method: "DELETE" }); setRecentEvents((prev) => prev.filter((e) => e.id !== event.id)); - setTodaySummary((prev) => decrementSummary(prev, typeKey)); + setTodaySummary((prev) => decrementSummary(prev, event.eventType.key)); + setJournalStats((prev) => ({ + totalEventCount: Math.max(0, prev.totalEventCount - 1), + distinctDayCount: prev.distinctDayCount, + })); show(t("recordUndone"), "info"); } catch { show(t("recordError"), "error"); @@ -405,6 +431,7 @@ export default function HomePage() { ) : ( <> {summaryLine &&

{summaryLine}

} + {journalInsight &&

{journalInsight}

}
{recentEvents.length === 0 ? ( -

{t("homeTimelineEmpty")}

+
+

{t("homeTimelineEmpty")}

+ +
) : (
    {recentEvents.map((event) => { diff --git a/apps/web/lib/i18n/translations.ts b/apps/web/lib/i18n/translations.ts index 233c182..320312d 100644 --- a/apps/web/lib/i18n/translations.ts +++ b/apps/web/lib/i18n/translations.ts @@ -76,8 +76,21 @@ const dict = { }, homeToday: { ko: "오늘", en: "Today" }, homeTimelineEmpty: { - ko: "아직 기록이 없습니다. 아래 칩을 눌러 시작하세요.", - en: "No entries yet. Tap a chip below to start.", + ko: "아래 칩이나 입력으로 첫 기록을 남겨 보세요.", + en: "Tap a chip below or type to log your first entry.", + }, + homeExampleLabel: { ko: "예시", en: "Example" }, + homeJournalInsightFirst: { + ko: "기록 1건. 3일 모이면 패턴이 보여요.", + en: "One entry logged. Patterns emerge after 3 days.", + }, + homeJournalInsightProgress: { + ko: "{days}일째 기록 중. 3일 모이면 패턴이 보여요.", + en: "Day {days} of logging. Patterns emerge after 3 days.", + }, + homeJournalInsightTrends: { + ko: "3일치가 모였어요. 이제 추세를 볼 수 있어요.", + en: "Three days logged. You can start spotting trends.", }, homeInputPlaceholder: { ko: "기록 입력…", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 4682b39..782cd3d 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -45,6 +45,8 @@ export interface TodaySummaryRow { count: number; } +export type { JournalStats } from "@kibble/shared"; + export interface TimelineEvent { id: string; occurredAt: string; diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index aebb02a..b12f1af 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -305,3 +305,24 @@ - `parse.ts`: `hiddenAt: null` 프리셋만, `quantityOffered`·`lineIndex` 응답 **다음**: PR #10 push·재리뷰, P1-24 상세 시트(칩 탭 → 수정) + +### 2026-09-01 — PR #10 머지 + P1-27b 빈 화면·저널 안내 + +**한 일** + +- PR #10 스쿼시 머지 (`0d478ba`) +- P1-27b: `journalStats` API, 흐릿한 예시 타임라인, 1건/3일 안내 문구 + +**다음**: P1-11 펫 CRUD, P1-24 상세 시트 + +### 2026-09-01 — P1-27b 리뷰 반영 (journalStats) + +**한 일** + +- distinct day: `$queryRaw` + `LIMIT 4` (전체 이벤트 스캔 제거) +- `kstClock`·`journalInsight` → `@kibble/shared` (웹 `kstDay.ts` 삭제) +- 낙관적 갱신: 최신 KST 날짜가 바뀔 때만 +1; StrictMode 안전(ref + 분리 setState) +- 3일 마일스톤: `distinctDayCount === 3`일 때만 +- `journalInsight.test.ts` 8케이스 + +**다음**: P1-27b PR push, P1-11 펫 CRUD diff --git a/package-lock.json b/package-lock.json index b754bd8..cacd554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2554,13 +2554,6 @@ "empathic": "2.0.0" } }, - "node_modules/@prisma/debug": { - "version": "7.9.1", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz", - "integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==", - "devOptional": true, - "license": "Apache-2.0" - }, "node_modules/@prisma/dev": { "version": "0.24.17", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", @@ -11978,6 +11971,9 @@ "version": "0.7.4", "dependencies": { "zod": "^3.23.8" + }, + "devDependencies": { + "vitest": "^4.1.11" } } } diff --git a/package.json b/package.json index 6553744..39debc4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "prisma:generate": "npm run prisma:generate -w apps/api", "prisma:migrate": "npm run prisma:migrate -w apps/api", "seed": "npm run seed -w apps/api", - "test": "npm run build:shared && npm run test -w apps/api && npm run test -w apps/web", + "test": "npm run build:shared && npm run test -w packages/shared && npm run test -w apps/api && npm run test -w apps/web", "lint": "npm run lint -w apps/api && npm run lint -w apps/web", "measure:zxing": "node scripts/measure-zxing-chunks.cjs" }, diff --git a/packages/shared/package.json b/packages/shared/package.json index ea809da..dca310b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,9 +6,13 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -p tsconfig.json", + "test": "vitest run" }, "dependencies": { "zod": "^3.23.8" + }, + "devDependencies": { + "vitest": "^4.1.11" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1773887..ea62e5b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,5 @@ export * from "./schemas/event.js"; export * from "./schemas/apiToken.js"; export * from "./schemas/parse.js"; export * from "./i18n/index.js"; +export * from "./kstClock.js"; +export * from "./journalInsight.js"; diff --git a/packages/shared/src/journalInsight.test.ts b/packages/shared/src/journalInsight.test.ts new file mode 100644 index 0000000..65f6d36 --- /dev/null +++ b/packages/shared/src/journalInsight.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { bumpJournalStats, journalInsightMessage, type JournalStats } from "./journalInsight.js"; + +const t = (key: string, vars?: Record) => + vars ? `${key}:${vars.days ?? ""}` : key; + +describe("journalInsightMessage", () => { + it("returns null when empty", () => { + expect(journalInsightMessage({ totalEventCount: 0, distinctDayCount: 0 }, t)).toBeNull(); + }); + + it("shows first-entry copy once", () => { + expect(journalInsightMessage({ totalEventCount: 1, distinctDayCount: 1 }, t)).toBe( + "homeJournalInsightFirst", + ); + }); + + it("shows progress for day 2", () => { + expect(journalInsightMessage({ totalEventCount: 5, distinctDayCount: 2 }, t)).toBe( + "homeJournalInsightProgress:2", + ); + }); + + it("shows milestone only on exactly day 3", () => { + expect(journalInsightMessage({ totalEventCount: 10, distinctDayCount: 3 }, t)).toBe( + "homeJournalInsightTrends", + ); + expect(journalInsightMessage({ totalEventCount: 100, distinctDayCount: 4 }, t)).toBeNull(); + }); +}); + +describe("bumpJournalStats", () => { + const base: JournalStats = { totalEventCount: 50, distinctDayCount: 4 }; + + it("does not change distinct days when already capped", () => { + expect( + bumpJournalStats(base, "2026-09-02T00:00:00.000Z", "2026-09-01T00:00:00.000Z"), + ).toEqual({ totalEventCount: 51, distinctDayCount: 4 }); + }); + + it("does not inflate distinct days from recent-30 recalculation", () => { + const prev: JournalStats = { totalEventCount: 200, distinctDayCount: 3 }; + expect( + bumpJournalStats(prev, "2026-09-03T01:00:00.000Z", "2026-09-03T00:00:00.000Z"), + ).toEqual({ totalEventCount: 201, distinctDayCount: 3 }); + }); + + it("increments distinct days only when KST day is new", () => { + const prev: JournalStats = { totalEventCount: 2, distinctDayCount: 2 }; + expect( + bumpJournalStats(prev, "2026-09-02T15:00:00.000Z", "2026-09-01T10:00:00.000Z"), + ).toEqual({ totalEventCount: 3, distinctDayCount: 3 }); + }); + + it("starts at one day on first event", () => { + expect(bumpJournalStats({ totalEventCount: 0, distinctDayCount: 0 }, "2026-09-01T00:00:00.000Z", null)).toEqual({ + totalEventCount: 1, + distinctDayCount: 1, + }); + }); +}); diff --git a/packages/shared/src/journalInsight.ts b/packages/shared/src/journalInsight.ts new file mode 100644 index 0000000..6930822 --- /dev/null +++ b/packages/shared/src/journalInsight.ts @@ -0,0 +1,42 @@ +import { kstDayKey } from "./kstClock.js"; + +export type JournalStats = { + totalEventCount: number; + /** KST distinct days; 4 = four or more (§3.8 copy only needs 1–3). */ + distinctDayCount: number; +}; + +export function journalInsightMessage( + stats: JournalStats, + t: (key: string, vars?: Record) => string, +): string | null { + if (stats.totalEventCount === 0) return null; + if (stats.distinctDayCount === 3) return t("homeJournalInsightTrends"); + if (stats.distinctDayCount >= 4) return null; + if (stats.totalEventCount === 1) return t("homeJournalInsightFirst"); + return t("homeJournalInsightProgress", { days: String(stats.distinctDayCount) }); +} + +/** 낙관적 갱신 — 최신 이벤트 시각만 알 때; 전체 일수는 서버 journalStats가 진실. */ +export function bumpJournalStats( + prev: JournalStats, + occurredAt: string, + latestOccurredAt: string | null, +): JournalStats { + const totalEventCount = prev.totalEventCount + 1; + if (prev.distinctDayCount >= 4) { + return { totalEventCount, distinctDayCount: prev.distinctDayCount }; + } + const newDay = kstDayKey(new Date(occurredAt)); + if (latestOccurredAt == null) { + return { totalEventCount, distinctDayCount: 1 }; + } + const latestDay = kstDayKey(new Date(latestOccurredAt)); + if (newDay === latestDay) { + return { totalEventCount, distinctDayCount: prev.distinctDayCount }; + } + return { + totalEventCount, + distinctDayCount: Math.min(prev.distinctDayCount + 1, 4), + }; +} diff --git a/packages/shared/src/kstClock.ts b/packages/shared/src/kstClock.ts new file mode 100644 index 0000000..1d9e40f --- /dev/null +++ b/packages/shared/src/kstClock.ts @@ -0,0 +1,47 @@ +/** Phase 1 일 경계·파싱 시각 — WORKPLAN §7.11. KST(UTC+9) 고정. */ +export const PHASE1_TODAY_UTC_OFFSET_MINUTES = 9 * 60; + +export function startOfTodayBoundary( + now = new Date(), + offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES, +): Date { + const shifted = new Date(now.getTime() + offsetMinutes * 60_000); + const y = shifted.getUTCFullYear(); + const m = shifted.getUTCMonth(); + const d = shifted.getUTCDate(); + return new Date(Date.UTC(y, m, d) - offsetMinutes * 60_000); +} + +/** KST 달력 날짜(base) + dayOffset일의 hour:minute → UTC instant */ +export function kstDateTime( + base: Date, + hour: number, + minute: number, + dayOffset = 0, + offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES, +): Date { + const shifted = new Date(base.getTime() + offsetMinutes * 60_000); + const anchor = new Date( + Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()), + ); + const kstMidnightUtc = anchor.getTime() - offsetMinutes * 60_000; + const dayMs = dayOffset * 86_400_000; + return new Date(kstMidnightUtc + dayMs + hour * 3_600_000 + minute * 60_000); +} + +export function kstCalendarParts(base: Date, offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES) { + const shifted = new Date(base.getTime() + offsetMinutes * 60_000); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth(), + date: shifted.getUTCDate(), + }; +} + +/** KST 달력 날짜 키 — journal distinct-day 집계·낙관적 갱신용 */ +export function kstDayKey(base: Date, offsetMinutes = PHASE1_TODAY_UTC_OFFSET_MINUTES): string { + const p = kstCalendarParts(base, offsetMinutes); + const month = String(p.month + 1).padStart(2, "0"); + const date = String(p.date).padStart(2, "0"); + return `${p.year}-${month}-${date}`; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 55d9af2..74daf8e 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -10,5 +10,6 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] }