diff --git a/packages/core/src/clock.ts b/packages/core/src/clock.ts index feac19e..802c030 100644 --- a/packages/core/src/clock.ts +++ b/packages/core/src/clock.ts @@ -17,3 +17,90 @@ export class WorldClock { return new WorldClock(new Date(iso)); } } + +/** + * Naturalizes a standard datetime format into a psychologically realistic subjective duration. + * Resolves quantized time formats into natural language phrases based on the relationship + * between a reference/current time and a past event time. + */ +export function naturalizeTime(now: Date, past: Date): string { + const deltaMs = now.getTime() - past.getTime(); + + // Guard against future dates + if (deltaMs <= 0) { + return "just now"; + } + + const deltaSeconds = Math.floor(deltaMs / 1000); + const deltaMinutes = Math.floor(deltaSeconds / 60); + const deltaHours = Math.floor(deltaMinutes / 60); + const deltaDays = Math.floor(deltaHours / 24); + + // --- Tier 1: Pure Relative (delta < 6 hours) --- + if (deltaHours < 6) { + if (deltaMinutes < 1) return "just now"; + if (deltaMinutes < 3) return "moments ago"; + if (deltaMinutes < 10) return "a few minutes ago"; + if (deltaMinutes < 30) return "several minutes ago"; + if (deltaMinutes < 45) return "about half an hour ago"; + if (deltaHours < 1.5) return "about an hour ago"; + if (deltaHours < 3) return "a couple hours ago"; + return "a few hours ago"; + } + + // --- Tier 3: Coarse (delta >= 48 hours / 2 days) --- + if (deltaDays >= 2) { + if (deltaDays < 3) return "a couple days ago"; + if (deltaDays < 7) return "a few days ago"; + if (deltaDays < 14) return "about a week ago"; + if (deltaDays < 21) return "a couple weeks ago"; + if (deltaDays < 30) return "a few weeks ago"; + if (deltaDays < 60) return "about a month ago"; + if (deltaDays < 90) return "a couple months ago"; + if (deltaDays < 180) return "a few months ago"; + if (deltaDays < 365) return "many months ago"; + if (deltaDays < 730) return "about a year ago"; + return "years ago"; + } + + // --- Tier 2: Period-Anchored (6 hours <= delta < 48 hours) --- + const pastHour = past.getHours(); + + let period: string; + if (pastHour >= 5 && pastHour < 12) { + period = "morning"; + } else if (pastHour >= 12 && pastHour < 17) { + period = "afternoon"; + } else if (pastHour >= 17 && pastHour < 21) { + period = "evening"; + } else if (pastHour >= 21 && pastHour < 24) { + period = "night"; + } else if (pastHour >= 0 && pastHour < 3) { + period = "midnight"; + } else { + period = "late night"; + } + + // Subjective day test: waking hours (05:00 - 20:59) + const nowHour = now.getHours(); + const pastIsWaking = pastHour >= 5 && pastHour < 22; + const nowIsWaking = nowHour >= 5 && nowHour < 22; + + const isSameSubjectiveDay = pastIsWaking && nowIsWaking && deltaHours < 18; + + if (isSameSubjectiveDay) { + return `earlier today, in the ${period}`; + } + + if (period === "night") { + return "last night"; + } + if (period === "midnight") { + return "around midnight"; + } + if (period === "late night") { + return "late last night"; + } + + return `yesterday ${period}`; +} diff --git a/packages/core/tests/clock.test.ts b/packages/core/tests/clock.test.ts new file mode 100644 index 0000000..bca0168 --- /dev/null +++ b/packages/core/tests/clock.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect } from "vitest"; +import { naturalizeTime } from "../src/clock.js"; + +describe("TimeNaturalization Unit Tests (Tier 1)", () => { + test("future or current times return just now", () => { + const now = new Date(2026, 6, 8, 12, 0, 0); + const pastFuture = new Date(2026, 6, 8, 12, 0, 5); + expect(naturalizeTime(now, pastFuture)).toBe("just now"); + expect(naturalizeTime(now, now)).toBe("just now"); + }); + + test("Tier 1: relative time ranges (< 6 hours)", () => { + const now = new Date(2026, 6, 8, 12, 0, 0); + + // < 1 minute + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 59, 30))).toBe("just now"); + // < 3 minutes + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 58, 0))).toBe("moments ago"); + // < 10 minutes + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 52, 0))).toBe("a few minutes ago"); + // < 30 minutes + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 40, 0))).toBe("several minutes ago"); + // < 45 minutes + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 20, 0))).toBe("about half an hour ago"); + // < 1.5 hours (90 minutes) + expect(naturalizeTime(now, new Date(2026, 6, 8, 10, 50, 0))).toBe("about an hour ago"); + // < 3 hours + expect(naturalizeTime(now, new Date(2026, 6, 8, 9, 30, 0))).toBe("a couple hours ago"); + // < 6 hours + expect(naturalizeTime(now, new Date(2026, 6, 8, 7, 0, 0))).toBe("a few hours ago"); + }); + + test("Tier 2: Same subjective day vs yesterday periods (6h <= delta < 48h)", () => { + // 1. Same subjective day (both waking hours, delta < 18h) + // 9am to 3pm (delta 6h) -> morning + const nowWaking = new Date(2026, 6, 8, 15, 0, 0); + const pastMorning = new Date(2026, 6, 8, 9, 0, 0); + expect(naturalizeTime(nowWaking, pastMorning)).toBe("earlier today, in the morning"); + + // 2pm to 9pm (delta 7h) -> afternoon + const nowEvening = new Date(2026, 6, 8, 21, 0, 0); + const pastAfternoon = new Date(2026, 6, 8, 14, 0, 0); + expect(naturalizeTime(nowEvening, pastAfternoon)).toBe("earlier today, in the afternoon"); + + // 2. Previous night (past period: night (21-23)) + // 11pm to 5am (delta 6h) -> last night + const nowNightRun = new Date(2026, 6, 8, 5, 0, 0); + const pastNight = new Date(2026, 6, 7, 23, 0, 0); + expect(naturalizeTime(nowNightRun, pastNight)).toBe("last night"); + + // 3. Around midnight (past period: midnight (0-2)) + // 1am to 7am (delta 6h) -> around midnight + const nowMidnightRun = new Date(2026, 6, 8, 7, 0, 0); + const pastMidnight = new Date(2026, 6, 8, 1, 0, 0); + expect(naturalizeTime(nowMidnightRun, pastMidnight)).toBe("around midnight"); + + // 4. Late night (past period: late night (3-4)) + // 3am to 9am (delta 6h) -> late last night + const nowLateNightRun = new Date(2026, 6, 8, 9, 0, 0); + const pastLateNight = new Date(2026, 6, 8, 3, 0, 0); + expect(naturalizeTime(nowLateNightRun, pastLateNight)).toBe("late last night"); + + // 5. Yesterday waking hours (delta >= 6h, diff day / waking check fails) + // 3pm to 9am next day (delta 18h) -> yesterday afternoon + const nowNextDay = new Date(2026, 6, 9, 9, 0, 0); + const pastYesterdayAfternoon = new Date(2026, 6, 8, 15, 0, 0); + expect(naturalizeTime(nowNextDay, pastYesterdayAfternoon)).toBe("yesterday afternoon"); + }); + + test("Tier 3: Coarse relative time ranges (delta >= 48 hours)", () => { + const now = new Date(2026, 6, 10, 12, 0, 0); + + // 2 days + expect(naturalizeTime(now, new Date(2026, 6, 8, 11, 0, 0))).toBe("a couple days ago"); + // 3-6 days + expect(naturalizeTime(now, new Date(2026, 6, 6, 12, 0, 0))).toBe("a few days ago"); + // 7-13 days + expect(naturalizeTime(now, new Date(2026, 6, 1, 12, 0, 0))).toBe("about a week ago"); + // 14-20 days + expect(naturalizeTime(now, new Date(2026, 5, 25, 12, 0, 0))).toBe("a couple weeks ago"); + // 21-29 days + expect(naturalizeTime(now, new Date(2026, 5, 15, 12, 0, 0))).toBe("a few weeks ago"); + // 30-59 days + expect(naturalizeTime(now, new Date(2026, 5, 1, 12, 0, 0))).toBe("about a month ago"); + // 60-89 days + expect(naturalizeTime(now, new Date(2026, 4, 1, 12, 0, 0))).toBe("a couple months ago"); + // 90-179 days + expect(naturalizeTime(now, new Date(2026, 3, 1, 12, 0, 0))).toBe("a few months ago"); + // 180-364 days + expect(naturalizeTime(now, new Date(2026, 0, 1, 12, 0, 0))).toBe("many months ago"); + // 365-729 days + expect(naturalizeTime(now, new Date(2025, 6, 1, 12, 0, 0))).toBe("about a year ago"); + // >= 730 days + expect(naturalizeTime(now, new Date(2023, 6, 1, 12, 0, 0))).toBe("years ago"); + }); +});