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 WORKPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/lib/journalStats.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions apps/api/src/lib/journalStats.ts
Original file line number Diff line number Diff line change
@@ -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<PrismaClient, "event" | "$queryRaw">,
householdId: string,
petId: string,
): Promise<JournalStats> {
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 };
}
46 changes: 7 additions & 39 deletions apps/api/src/lib/kstClock.ts
Original file line number Diff line number Diff line change
@@ -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";
15 changes: 12 additions & 3 deletions apps/api/src/routes/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,15 +42,22 @@ 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 = {
...householdWhere(householdId),
petId: activePet.id,
};

const [presets, todaySummary, recentEvents] = await Promise.all([
const [presets, todaySummary, recentEvents, journalStats] = await Promise.all([
prisma.preset.findMany({
where: {
...householdWhere(householdId),
Expand All @@ -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 };
});
}
34 changes: 33 additions & 1 deletion apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
61 changes: 53 additions & 8 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,23 @@ import type {
ParseSuggestion,
ParseEntryResponse,
} from "../lib/types";
import type { JournalStats } from "@kibble/shared";
import { bumpJournalStats, journalInsightMessage } from "@kibble/shared";

interface HomePayload {
pets: Pet[];
activePet: Pet | null;
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)}`;
Expand Down Expand Up @@ -101,13 +109,22 @@ export default function HomePage() {
const [presets, setPresets] = useState<Preset[]>([]);
const [todaySummary, setTodaySummary] = useState<TodaySummaryRow[]>([]);
const [recentEvents, setRecentEvents] = useState<TimelineEvent[]>([]);
const [journalStats, setJournalStats] = useState<JournalStats>({
totalEventCount: 0,
distinctDayCount: 0,
});
const [dataLoading, setDataLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [textInput, setTextInput] = useState("");
const [parseBatch, setParseBatch] = useState<ParseEntryResponse | null>(null);
const [parseBatchRetryable, setParseBatchRetryable] = useState(false);
const loadSeq = useRef(0);
const recentEventsRef = useRef<TimelineEvent[]>([]);

useEffect(() => {
recentEventsRef.current = recentEvents;
}, [recentEvents]);

useEffect(() => {
if (!loading && !user) router.push("/login");
Expand All @@ -123,6 +140,7 @@ export default function HomePage() {
setPresets(data.presets);
setTodaySummary(data.todaySummary);
setRecentEvents(data.recentEvents);
setJournalStats(data.journalStats);
}, []);

const loadHome = useCallback(
Expand Down Expand Up @@ -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<string | null>(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),
);
Expand Down Expand Up @@ -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"),
Expand All @@ -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");
Expand Down Expand Up @@ -405,6 +431,7 @@ export default function HomePage() {
) : (
<>
{summaryLine && <p className="home-summary-line">{summaryLine}</p>}
{journalInsight && <p className="home-journal-insight">{journalInsight}</p>}

<section
className="timeline-section"
Expand All @@ -413,7 +440,25 @@ export default function HomePage() {
aria-labelledby={activePet ? `home-pet-tab-${activePet.id}` : undefined}
>
{recentEvents.length === 0 ? (
<p className="meta timeline-empty">{t("homeTimelineEmpty")}</p>
<div className="timeline-empty-state">
<p className="meta timeline-empty">{t("homeTimelineEmpty")}</p>
<ul className="timeline-list timeline-list-example" aria-hidden="true">
{TIMELINE_EXAMPLES.map((example) => (
<li key={example.label} className="timeline-item timeline-item-example">
<time className="timeline-time">{example.time}</time>
<div className="timeline-body">
<span className="timeline-label">
{t(example.label)}
<span className="timeline-example-badge">{t("homeExampleLabel")}</span>
</span>
{example.detail && (
<span className="timeline-detail">{example.detail}</span>
)}
</div>
</li>
))}
</ul>
</div>
) : (
<ul className="timeline-list">
{recentEvents.map((event) => {
Expand Down
17 changes: 15 additions & 2 deletions apps/web/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "기록 입력…",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface TodaySummaryRow {
count: number;
}

export type { JournalStats } from "@kibble/shared";

export interface TimelineEvent {
id: string;
occurredAt: string;
Expand Down
Loading