diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index 6aa386210c..28c307d6d5 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -4,6 +4,11 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useTranslations } from "next-intl"; import { client } from "@/lib/api"; import { useBank } from "@/lib/bank-context"; +import { + groupTimelineItems, + partitionByEffectiveDate, + type TimelineGranularity, +} from "@/lib/effective-date"; import { EntityChip, TagChip } from "@/components/ui/facet-chip"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -1083,7 +1088,12 @@ export function DataView({ // Exported for reuse (e.g. the per-entity timeline in entities-view). It renders // purely from `filteredRows`; `data`/`bankId` are accepted for backward-compat // with the memories view but unused here. -type Granularity = "year" | "month" | "week" | "day"; +// +// Items are plotted by their effective date (occurred_start, then mentioned_at, +// then occurred_end, then created_at, then the legacy event_date) - the same +// coalescing order the backend uses for a unit's effective time, extended with +// the system fields which are always populated so memories without dates extracted +// by LLMs still appear on the timeline. export function TimelineView({ data, @@ -1097,103 +1107,28 @@ export function TimelineView({ onMemoryClick: (id: string) => void; }) { const t = useTranslations("dataView"); - const [granularity, setGranularity] = useState("month"); + const [granularity, setGranularity] = useState("month"); const [currentIndex, setCurrentIndex] = useState(0); const timelineRef = useRef(null); - // Filter and sort items that have occurred_start dates (using filtered data) - const { sortedItems, itemsWithoutDates } = useMemo(() => { - if (!filteredRows || filteredRows.length === 0) - return { sortedItems: [], itemsWithoutDates: [] }; - - const withDates = filteredRows - .filter((row: any) => row.occurred_start) - .sort((a: any, b: any) => { - const dateA = new Date(a.occurred_start).getTime(); - const dateB = new Date(b.occurred_start).getTime(); - return dateA - dateB; - }); - - const withoutDates = filteredRows.filter((row: any) => !row.occurred_start); - - return { sortedItems: withDates, itemsWithoutDates: withoutDates }; - }, [filteredRows]); - - // Group items by granularity - const timelineGroups = useMemo(() => { - if (sortedItems.length === 0) return []; - - const getGroupKey = (date: Date): string => { - const year = date.getFullYear(); - const month = date.getMonth(); - const day = date.getDate(); - - switch (granularity) { - case "year": - return `${year}`; - case "month": - return `${year}-${String(month + 1).padStart(2, "0")}`; - case "week": - const startOfWeek = new Date(date); - startOfWeek.setDate(day - date.getDay()); - return `${startOfWeek.getFullYear()}-W${String(Math.ceil(startOfWeek.getDate() / 7)).padStart(2, "0")}-${String(startOfWeek.getMonth() + 1).padStart(2, "0")}-${String(startOfWeek.getDate()).padStart(2, "0")}`; - case "day": - return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; - } - }; - - const getGroupLabel = (key: string, date: Date): string => { - switch (granularity) { - case "year": - return key; - case "month": - return date.toLocaleDateString("en-US", { year: "numeric", month: "short" }); - case "week": - const endOfWeek = new Date(date); - endOfWeek.setDate(date.getDate() + 6); - return `${date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${endOfWeek.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; - case "day": - return date.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", - }); - } - }; - - const groups: { [key: string]: { items: any[]; date: Date } } = {}; - sortedItems.forEach((row: any) => { - const date = new Date(row.occurred_start); - const key = getGroupKey(date); - if (!groups[key]) { - // For week, parse the start date from key - let groupDate = date; - if (granularity === "week") { - const parts = key.split("-"); - groupDate = new Date(parseInt(parts[0]), parseInt(parts[2]) - 1, parseInt(parts[3])); - } - groups[key] = { items: [], date: groupDate }; - } - groups[key].items.push(row); - }); + // Split rows by effective date (see partitionByEffectiveDate): date-bearing + // rows come back sorted ascending; rows without any usable date are kept + // aside for the "without dates" counter. + const { sortedItems, itemsWithoutDates } = useMemo( + () => partitionByEffectiveDate(filteredRows ?? []), + [filteredRows] + ); - return Object.entries(groups) - .sort(([, a], [, b]) => a.date.getTime() - b.date.getTime()) - .map(([key, { items, date }]) => ({ - key, - label: getGroupLabel(key, date), - items, - date, - })); - }, [sortedItems, granularity]); + // Group the date-bearing rows by granularity bucket. + const timelineGroups = useMemo( + () => groupTimelineItems(sortedItems, granularity), + [sortedItems, granularity] + ); // Get date range info const dateRange = useMemo(() => { if (sortedItems.length === 0) return null; - const first = new Date(sortedItems[0].occurred_start); - const last = new Date(sortedItems[sortedItems.length - 1].occurred_start); - return { first, last }; + return { first: sortedItems[0].date, last: sortedItems[sortedItems.length - 1].date }; }, [sortedItems]); // Navigation @@ -1205,7 +1140,7 @@ export function TimelineView({ }; const zoomIn = () => { - const levels: Granularity[] = ["year", "month", "week", "day"]; + const levels: TimelineGranularity[] = ["year", "month", "week", "day"]; const currentIdx = levels.indexOf(granularity); if (currentIdx < levels.length - 1) { setGranularity(levels[currentIdx + 1]); @@ -1213,7 +1148,7 @@ export function TimelineView({ }; const zoomOut = () => { - const levels: Granularity[] = ["year", "month", "week", "day"]; + const levels: TimelineGranularity[] = ["year", "month", "week", "day"]; const currentIdx = levels.indexOf(granularity); if (currentIdx > 0) { setGranularity(levels[currentIdx - 1]); @@ -1237,8 +1172,7 @@ export function TimelineView({ ); } - const formatDateTime = (dateStr: string) => { - const date = new Date(dateStr); + const formatDateTime = (date: Date) => { const dateFormatted = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const timeFormatted = date.toLocaleTimeString("en-US", { hour: "2-digit", @@ -1248,7 +1182,7 @@ export function TimelineView({ return { date: dateFormatted, time: timeFormatted }; }; - const granularityLabels: Record = { + const granularityLabels: Record = { year: t("granularityYear"), month: t("granularityMonth"), week: t("granularityWeek"), @@ -1373,7 +1307,7 @@ export function TimelineView({ {/* Items in this month */}
- {group.items.map((item: any, idx: number) => ( + {group.items.map(({ row: item, date: itemDate }, idx: number) => (
onMemoryClick(item.id)} @@ -1382,10 +1316,10 @@ export function TimelineView({ {/* Date & Time */}
- {formatDateTime(item.occurred_start).date} + {formatDateTime(itemDate).date}
- {formatDateTime(item.occurred_start).time} + {formatDateTime(itemDate).time}
diff --git a/hindsight-control-plane/src/components/entities-view.tsx b/hindsight-control-plane/src/components/entities-view.tsx index c975c6bad1..b7090ef7cc 100644 --- a/hindsight-control-plane/src/components/entities-view.tsx +++ b/hindsight-control-plane/src/components/entities-view.tsx @@ -55,9 +55,10 @@ export function EntitiesView() { const [selectedEntity, setSelectedEntity] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); // Per-entity timeline: every memory linked to the entity (reverse lookup via - // the entity_id filter). We intentionally do NOT filter to observations — - // entity links live on the source world/experience facts (which carry the - // occurred dates the timeline plots); derived observations aren't entity-linked. + // the entity_id filter). We intentionally do NOT filter to observations - + // entity links live on the source world/experience facts; derived + // observations aren't entity-linked. TimelineView plots each row by its + // effective date, so facts without LLM-extracted occurred dates still show. const [entityMemories, setEntityMemories] = useState([]); const [loadingMemories, setLoadingMemories] = useState(false); const [selectedMemoryId, setSelectedMemoryId] = useState(null); diff --git a/hindsight-control-plane/src/lib/effective-date.ts b/hindsight-control-plane/src/lib/effective-date.ts new file mode 100644 index 0000000000..8409557744 --- /dev/null +++ b/hindsight-control-plane/src/lib/effective-date.ts @@ -0,0 +1,182 @@ +/** + * Timeline date utilities for memory rows, replicating the backend's temporal + * coalescing policy. + * + * The backend derives a unit's effective time via + * `COALESCE(occurred_start, mentioned_at, occurred_end)` + * (see `_coalesce_date` in hindsight-api-slim/hindsight_api/engine/search/ + * retrieval.py, and the same order for recency in engine/search/reranking.py). + * `occurred_*` dates are content dates extracted by the LLM and are missing + * on most rows, while `mentioned_at` is the system-set mention time and + * `created_at` the ingest time. `effectiveDate` extends that chain with the + * always-populated system fields (mirroring the per-row `COALESCE` fallback + * to `created_at` in `get_memories_timeseries`) plus the legacy + * `date`/`event_date` column, so rows without any content date still plot + * instead of being dropped from the timeline. + * + * The partition and grouping helpers below are the pure date logic behind the + * TimelineView, extracted so it can be unit-tested without rendering. + */ + +// The graph endpoint serializes its legacy `date` column as +// "YYYY-MM-DD HH:MM" - no `T`, no zone. That form is outside ECMA-262's date +// grammar (browsers parse it inconsistently), so it is matched explicitly and +// interpreted as UTC, matching the UTC timestamps the backend emits. +const SPACE_FORMAT = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/; + +function parseCandidate(raw: string): Date | null { + const spaceMatch = SPACE_FORMAT.exec(raw.trim()); + if (spaceMatch) { + const [, year, month, day, hour, minute] = spaceMatch; + return new Date( + Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute)) + ); + } + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function effectiveDate(row: { + occurred_start?: string | null; + mentioned_at?: string | null; + occurred_end?: string | null; + created_at?: string | null; + date?: string | null; +}): Date | null { + for (const raw of [ + row.occurred_start, + row.mentioned_at, + row.occurred_end, + row.created_at, + row.date, + ]) { + if (!raw) continue; + const parsed = parseCandidate(raw); + if (parsed) return parsed; + } + return null; +} + +export interface EffectiveDateEntry { + row: T; + date: Date; +} + +/** + * Split rows into date-bearing entries (sorted ascending by effective date; + * ties keep input order, as `Array.sort` is stable) and rows with no usable + * date at all. + */ +export function partitionByEffectiveDate( + rows: T[] +): { + sortedItems: EffectiveDateEntry[]; + itemsWithoutDates: T[]; +} { + const withDates: EffectiveDateEntry[] = []; + const withoutDates: T[] = []; + for (const row of rows) { + const date = effectiveDate(row); + if (date) withDates.push({ row, date }); + else withoutDates.push(row); + } + withDates.sort((a, b) => a.date.getTime() - b.date.getTime()); + return { sortedItems: withDates, itemsWithoutDates: withoutDates }; +} + +export type TimelineGranularity = "year" | "month" | "week" | "day"; + +/** + * Bucket key for a date at the given granularity. Week buckets start on + * Sunday (`getDay() === 0`); the `Wxx` component is a week index within the + * month (ceil of the start day / 7) and only needs to be unique per bucket, + * mirroring the timeline's historical key format. + */ +export function getTimelineGroupKey(date: Date, granularity: TimelineGranularity): string { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + + switch (granularity) { + case "year": + return `${year}`; + case "month": + return `${year}-${String(month + 1).padStart(2, "0")}`; + case "week": { + const startOfWeek = new Date(date); + startOfWeek.setDate(day - date.getDay()); + return `${startOfWeek.getFullYear()}-W${String(Math.ceil(startOfWeek.getDate() / 7)).padStart(2, "0")}-${String(startOfWeek.getMonth() + 1).padStart(2, "0")}-${String(startOfWeek.getDate()).padStart(2, "0")}`; + } + case "day": + return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + } +} + +export function getTimelineGroupLabel( + key: string, + date: Date, + granularity: TimelineGranularity +): string { + switch (granularity) { + case "year": + return key; + case "month": + return date.toLocaleDateString("en-US", { year: "numeric", month: "short" }); + case "week": { + const endOfWeek = new Date(date); + endOfWeek.setDate(date.getDate() + 6); + return `${date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${endOfWeek.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; + } + case "day": + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); + } +} + +export interface TimelineGroup { + key: string; + label: string; + date: Date; + items: EffectiveDateEntry[]; +} + +/** + * Group date-bearing entries by granularity bucket, ordered ascending by + * bucket date. Input must already be sorted ascending by effective date. + */ +export function groupTimelineItems( + sortedItems: EffectiveDateEntry[], + granularity: TimelineGranularity +): TimelineGroup[] { + if (sortedItems.length === 0) return []; + + const groups = new Map[]; date: Date }>(); + for (const entry of sortedItems) { + const key = getTimelineGroupKey(entry.date, granularity); + let group = groups.get(key); + if (!group) { + // Week keys embed the week-start date; recover it for labels/ordering. + let groupDate = entry.date; + if (granularity === "week") { + const parts = key.split("-"); + groupDate = new Date(Number(parts[0]), Number(parts[2]) - 1, Number(parts[3])); + } + group = { items: [], date: groupDate }; + groups.set(key, group); + } + group.items.push(entry); + } + + return [...groups.entries()] + .sort(([, a], [, b]) => a.date.getTime() - b.date.getTime()) + .map(([key, { items, date }]) => ({ + key, + label: getTimelineGroupLabel(key, date, granularity), + items, + date, + })); +} diff --git a/hindsight-control-plane/src/messages/de.json b/hindsight-control-plane/src/messages/de.json index a5643017d9..508e1926e8 100644 --- a/hindsight-control-plane/src/messages/de.json +++ b/hindsight-control-plane/src/messages/de.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "Keine Erinnerungen entsprechen Ihrem Filter", "noMemoriesFound": "Keine Erinnerungen gefunden", "noTimelineData": "Keine Zeitreihendaten", - "noTimelineDataDescription": "Keine Erinnerungen haben occurred_at-Datumsangaben.", + "noTimelineDataDescription": "Keine Erinnerungen haben Datumsangaben.", "memoriesWithoutDatesInTable": "{count} Erinnerungen ohne Datum in der Tabellenansicht.", "timelineMemoriesCount": "{count} Erinnerungen", "timelineWithoutDates": "· {count} ohne Datum", diff --git a/hindsight-control-plane/src/messages/en.json b/hindsight-control-plane/src/messages/en.json index e5fe45f553..5bcbca70e6 100644 --- a/hindsight-control-plane/src/messages/en.json +++ b/hindsight-control-plane/src/messages/en.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "No memories match your filter", "noMemoriesFound": "No memories found", "noTimelineData": "No Timeline Data", - "noTimelineDataDescription": "No memories have occurred_at dates.", + "noTimelineDataDescription": "No memories have date information.", "memoriesWithoutDatesInTable": "{count} memories without dates in Table View.", "timelineMemoriesCount": "{count} memories", "timelineWithoutDates": "· {count} without dates", diff --git a/hindsight-control-plane/src/messages/es.json b/hindsight-control-plane/src/messages/es.json index 091afa0993..21c1617b9e 100644 --- a/hindsight-control-plane/src/messages/es.json +++ b/hindsight-control-plane/src/messages/es.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "Ninguna memoria coincide con tu filtro", "noMemoriesFound": "No se encontraron memorias", "noTimelineData": "Sin datos de línea de tiempo", - "noTimelineDataDescription": "Ninguna memoria tiene fechas occurred_at.", + "noTimelineDataDescription": "Ninguna memoria tiene información de fecha.", "memoriesWithoutDatesInTable": "{count} memorias sin fechas en la vista de tabla.", "timelineMemoriesCount": "{count} memorias", "timelineWithoutDates": "· {count} sin fechas", diff --git a/hindsight-control-plane/src/messages/fr.json b/hindsight-control-plane/src/messages/fr.json index 3fd7e74a3e..56aec8fff0 100644 --- a/hindsight-control-plane/src/messages/fr.json +++ b/hindsight-control-plane/src/messages/fr.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "Aucun souvenir ne correspond à votre filtre", "noMemoriesFound": "Aucun souvenir trouvé", "noTimelineData": "Aucune donnée chronologique", - "noTimelineDataDescription": "Aucun souvenir ne possède de date occurred_at.", + "noTimelineDataDescription": "Aucun souvenir ne possède d’informations de date.", "memoriesWithoutDatesInTable": "{count} souvenirs sans dates dans la vue tableau.", "timelineMemoriesCount": "{count} souvenirs", "timelineWithoutDates": "· {count} sans dates", diff --git a/hindsight-control-plane/src/messages/ja.json b/hindsight-control-plane/src/messages/ja.json index c62d3ac20c..8c427f4cc4 100644 --- a/hindsight-control-plane/src/messages/ja.json +++ b/hindsight-control-plane/src/messages/ja.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "フィルターに一致するメモリがありません", "noMemoriesFound": "メモリが見つかりません", "noTimelineData": "タイムラインデータなし", - "noTimelineDataDescription": "発生日時が設定されたメモリがありません。", + "noTimelineDataDescription": "日付情報を持つメモリがありません。", "memoriesWithoutDatesInTable": "テーブルビューに日付のないメモリが{count}件あります。", "timelineMemoriesCount": "{count}件のメモリ", "timelineWithoutDates": "・日付なし{count}件", diff --git a/hindsight-control-plane/src/messages/ko.json b/hindsight-control-plane/src/messages/ko.json index 9e21778832..85ce2fef71 100644 --- a/hindsight-control-plane/src/messages/ko.json +++ b/hindsight-control-plane/src/messages/ko.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "필터와 일치하는 메모리 없음", "noMemoriesFound": "메모리를 찾을 수 없습니다", "noTimelineData": "타임라인 데이터 없음", - "noTimelineDataDescription": "occurred_at 날짜가 있는 메모리가 없습니다.", + "noTimelineDataDescription": "날짜 정보가 있는 메모리가 없습니다.", "memoriesWithoutDatesInTable": "표 뷰에 날짜 없는 메모리 {count}개.", "timelineMemoriesCount": "메모리 {count}개", "timelineWithoutDates": "· 날짜 없음 {count}개", diff --git a/hindsight-control-plane/src/messages/pt.json b/hindsight-control-plane/src/messages/pt.json index 5f01c083ef..e519823b56 100644 --- a/hindsight-control-plane/src/messages/pt.json +++ b/hindsight-control-plane/src/messages/pt.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "Nenhuma memória corresponde ao seu filtro", "noMemoriesFound": "Nenhuma memória encontrada", "noTimelineData": "Sem Dados de Linha do Tempo", - "noTimelineDataDescription": "Nenhuma memória possui datas de occurred_at.", + "noTimelineDataDescription": "Nenhuma memória possui informações de data.", "memoriesWithoutDatesInTable": "{count} memórias sem datas na Visualização de Tabela.", "timelineMemoriesCount": "{count} memórias", "timelineWithoutDates": "· {count} sem datas", diff --git a/hindsight-control-plane/src/messages/yue-Hant.json b/hindsight-control-plane/src/messages/yue-Hant.json index 05c38fd420..75087399da 100644 --- a/hindsight-control-plane/src/messages/yue-Hant.json +++ b/hindsight-control-plane/src/messages/yue-Hant.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "沒有符合篩選條件的記憶", "noMemoriesFound": "找不到記憶", "noTimelineData": "未有時間軸資料", - "noTimelineDataDescription": "沒有帶 occurred_at 日期的記憶。", + "noTimelineDataDescription": "沒有記憶帶有日期資訊。", "memoriesWithoutDatesInTable": "{count} 條沒有日期記憶已在表格視圖中顯示。", "timelineMemoriesCount": "{count} 條記憶", "timelineWithoutDates": "· {count} 條沒有日期", diff --git a/hindsight-control-plane/src/messages/zh-CN.json b/hindsight-control-plane/src/messages/zh-CN.json index 1533477258..c932744d60 100644 --- a/hindsight-control-plane/src/messages/zh-CN.json +++ b/hindsight-control-plane/src/messages/zh-CN.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "无记忆匹配您的筛选条件", "noMemoriesFound": "未找到记忆", "noTimelineData": "没有时间线数据", - "noTimelineDataDescription": "没有带 occurred_at 日期的记忆。", + "noTimelineDataDescription": "没有记忆带有日期信息。", "memoriesWithoutDatesInTable": "{count} 条无日期记忆已在表格视图中显示。", "timelineMemoriesCount": "{count} 条记忆", "timelineWithoutDates": "· {count} 条无日期", diff --git a/hindsight-control-plane/src/messages/zh-TW.json b/hindsight-control-plane/src/messages/zh-TW.json index 416b99d55f..61628d0df2 100644 --- a/hindsight-control-plane/src/messages/zh-TW.json +++ b/hindsight-control-plane/src/messages/zh-TW.json @@ -594,7 +594,7 @@ "noMemoriesMatchFilter": "沒有符合篩選條件的記憶", "noMemoriesFound": "找不到記憶", "noTimelineData": "沒有時間軸資料", - "noTimelineDataDescription": "沒有帶 occurred_at 日期的記憶。", + "noTimelineDataDescription": "沒有記憶帶有日期資訊。", "memoriesWithoutDatesInTable": "{count} 條無日期記憶已在表格檢視中顯示。", "timelineMemoriesCount": "{count} 條記憶", "timelineWithoutDates": "· {count} 條無日期", diff --git a/hindsight-control-plane/tests/lib/effective-date.test.ts b/hindsight-control-plane/tests/lib/effective-date.test.ts new file mode 100644 index 0000000000..170c4feebf --- /dev/null +++ b/hindsight-control-plane/tests/lib/effective-date.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; + +import { + effectiveDate, + getTimelineGroupKey, + groupTimelineItems, + partitionByEffectiveDate, +} from "@/lib/effective-date"; + +const ISO = { + occurred: "2024-01-15T10:30:00Z", + mentioned: "2024-03-20T08:00:00Z", + occurredEnd: "2024-02-10T12:00:00Z", + created: "2024-04-01T00:00:00Z", + legacyDate: "2024-05-05T00:00:00Z", +}; + +describe("effectiveDate", () => { + it("prefers occurred_start over every later field (backend COALESCE order)", () => { + const d = effectiveDate({ + occurred_start: ISO.occurred, + mentioned_at: ISO.mentioned, + occurred_end: ISO.occurredEnd, + created_at: ISO.created, + date: ISO.legacyDate, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.occurred)); + }); + + it("falls back to mentioned_at when occurred_start is missing", () => { + const d = effectiveDate({ + occurred_start: null, + mentioned_at: ISO.mentioned, + occurred_end: ISO.occurredEnd, + created_at: ISO.created, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.mentioned)); + }); + + it("falls back to occurred_end when occurred_start and mentioned_at are missing", () => { + const d = effectiveDate({ + occurred_start: null, + mentioned_at: null, + occurred_end: ISO.occurredEnd, + created_at: ISO.created, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.occurredEnd)); + }); + + it("falls back to created_at when all occurred/mentioned fields are missing", () => { + const d = effectiveDate({ + occurred_start: null, + mentioned_at: null, + occurred_end: null, + created_at: ISO.created, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.created)); + }); + + it("falls back to the legacy event_date field (list endpoint `date`)", () => { + const d = effectiveDate({ + occurred_start: null, + mentioned_at: null, + occurred_end: null, + created_at: null, + date: ISO.legacyDate, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.legacyDate)); + }); + + it("returns null when no field carries a date", () => { + expect(effectiveDate({})).toBeNull(); + expect( + effectiveDate({ + occurred_start: null, + mentioned_at: null, + occurred_end: null, + created_at: null, + date: null, + }) + ).toBeNull(); + }); + + it("skips empty strings and the graph endpoint's 'N/A' placeholder", () => { + const d = effectiveDate({ + occurred_start: "", + mentioned_at: "N/A", + occurred_end: undefined, + created_at: ISO.created, + date: "", + }); + expect(d?.getTime()).toBe(Date.parse(ISO.created)); + }); + + it("skips invalid strings and keeps walking the chain", () => { + const d = effectiveDate({ + occurred_start: "not-a-date", + mentioned_at: ISO.mentioned, + }); + expect(d?.getTime()).toBe(Date.parse(ISO.mentioned)); + }); + + it("parses the graph endpoint's space-separated `date` format as UTC", () => { + const d = effectiveDate({ date: "2024-01-15 10:30" }); + // The space form is outside ECMA-262's grammar, so it must not depend on + // `new Date` local-time behavior; interpret it as UTC deterministically. + expect(d?.getTime()).toBe(Date.UTC(2024, 0, 15, 10, 30)); + }); +}); + +interface TestRow { + id: number; + occurred_start?: string | null; + mentioned_at?: string | null; + occurred_end?: string | null; + created_at?: string | null; + date?: string | null; +} + +describe("partitionByEffectiveDate", () => { + it("includes rows that only carry mentioned_at and sorts by effective date", () => { + const rows: TestRow[] = [ + { id: 1, mentioned_at: "2024-03-20T08:00:00Z" }, + { id: 2, occurred_start: "2024-01-15T10:30:00Z" }, + { id: 3, mentioned_at: "2024-02-10T12:00:00Z" }, + ]; + const { sortedItems, itemsWithoutDates } = partitionByEffectiveDate(rows); + + expect(itemsWithoutDates).toEqual([]); + expect(sortedItems.map((e) => e.row.id)).toEqual([2, 3, 1]); + expect(sortedItems[0].date.getTime()).toBe(Date.parse("2024-01-15T10:30:00Z")); + }); + + it("falls through an invalid preferred date instead of dropping the row", () => { + const rows: TestRow[] = [ + { id: 1, occurred_start: "not-a-date", mentioned_at: "2024-03-20T08:00:00Z" }, + ]; + const { sortedItems, itemsWithoutDates } = partitionByEffectiveDate(rows); + + expect(itemsWithoutDates).toEqual([]); + expect(sortedItems[0].row.id).toBe(1); + expect(sortedItems[0].date.getTime()).toBe(Date.parse("2024-03-20T08:00:00Z")); + }); + + it("puts rows with no usable date aside, preserving their identity", () => { + const rows: TestRow[] = [ + { id: 1, mentioned_at: "2024-03-20T08:00:00Z" }, + { id: 2, occurred_start: null, mentioned_at: null, occurred_end: null, date: "" }, + { id: 3 }, + ]; + const { sortedItems, itemsWithoutDates } = partitionByEffectiveDate(rows); + + expect(sortedItems.map((e) => e.row.id)).toEqual([1]); + expect(itemsWithoutDates.map((r) => r.id)).toEqual([2, 3]); + }); + + it("keeps input order for equal effective dates (stable sort)", () => { + const rows: TestRow[] = [ + { id: 1, mentioned_at: "2024-03-20T08:00:00Z" }, + { id: 2, occurred_start: "2024-03-20T08:00:00Z" }, + { id: 3, mentioned_at: "2024-03-20T08:00:00Z" }, + ]; + const { sortedItems } = partitionByEffectiveDate(rows); + expect(sortedItems.map((e) => e.row.id)).toEqual([1, 2, 3]); + }); + + it("handles empty input", () => { + expect(partitionByEffectiveDate([])).toEqual({ sortedItems: [], itemsWithoutDates: [] }); + }); +}); + +describe("groupTimelineItems", () => { + // Local-time constructors so the local date components below match the + // intended values on any machine timezone. + const entries = (dates: Date[]) => + dates.map((date, i) => ({ row: { id: i }, date })); + + it("buckets by month and orders groups ascending", () => { + const sorted = entries([ + new Date(2024, 0, 15, 10, 30), + new Date(2024, 0, 31, 10, 30), + new Date(2024, 2, 5, 10, 30), + ]); + const groups = groupTimelineItems(sorted, "month"); + + expect(groups.map((g) => g.key)).toEqual(["2024-01", "2024-03"]); + expect(groups[0].items.map((e) => e.row.id)).toEqual([0, 1]); + expect(groups[1].items.map((e) => e.row.id)).toEqual([2]); + expect(groups[0].label).toBe(new Date(2024, 0, 15).toLocaleDateString("en-US", { year: "numeric", month: "short" })); + }); + + it("groups week buckets by Sunday-aligned start", () => { + // 2024-01-10 is a Wednesday; its week starts Sunday 2024-01-07. + const sorted = entries([ + new Date(2024, 0, 10, 10, 0), + new Date(2024, 0, 12, 10, 0), + new Date(2024, 0, 15, 10, 0), // next week (Monday, week start 2024-01-14) + ]); + const groups = groupTimelineItems(sorted, "week"); + + expect(groups.map((g) => g.key)).toEqual(["2024-W01-01-07", "2024-W02-01-14"]); + expect(groups[0].items.map((e) => e.row.id)).toEqual([0, 1]); + expect(groups[1].items.map((e) => e.row.id)).toEqual([2]); + }); + + it("buckets by day at day granularity", () => { + const sorted = entries([new Date(2024, 0, 15, 10, 0), new Date(2024, 0, 16, 10, 0)]); + const groups = groupTimelineItems(sorted, "day"); + expect(groups.map((g) => g.key)).toEqual(["2024-01-15", "2024-01-16"]); + }); + + it("returns the full year as the key at year granularity", () => { + const sorted = entries([new Date(2024, 5, 1), new Date(2023, 11, 31)]); + const groups = groupTimelineItems(sorted, "year"); + expect(groups.map((g) => g.key)).toEqual(["2023", "2024"]); + }); + + it("returns no groups for empty input", () => { + expect(groupTimelineItems([], "month")).toEqual([]); + }); + + it("derives deterministic keys for every granularity", () => { + const date = new Date(2024, 0, 15, 10, 30); + expect(getTimelineGroupKey(date, "year")).toBe("2024"); + expect(getTimelineGroupKey(date, "month")).toBe("2024-01"); + expect(getTimelineGroupKey(date, "day")).toBe("2024-01-15"); + expect(getTimelineGroupKey(date, "week")).toBe("2024-W02-01-14"); + }); +});