feat: Design and implement time naturalization helper function

This commit is contained in:
2026-07-08 22:05:26 +05:30
parent 74ffb456a7
commit 15abfdd898
2 changed files with 183 additions and 0 deletions

View File

@@ -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}`;
}

View File

@@ -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");
});
});