Skip to content
Open
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
130 changes: 32 additions & 98 deletions hindsight-control-plane/src/components/data-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -1097,103 +1107,28 @@ export function TimelineView({
onMemoryClick: (id: string) => void;
}) {
const t = useTranslations("dataView");
const [granularity, setGranularity] = useState<Granularity>("month");
const [granularity, setGranularity] = useState<TimelineGranularity>("month");
const [currentIndex, setCurrentIndex] = useState(0);
const timelineRef = useRef<HTMLDivElement>(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
Expand All @@ -1205,15 +1140,15 @@ 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]);
}
};

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]);
Expand All @@ -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",
Expand All @@ -1248,7 +1182,7 @@ export function TimelineView({
return { date: dateFormatted, time: timeFormatted };
};

const granularityLabels: Record<Granularity, string> = {
const granularityLabels: Record<TimelineGranularity, string> = {
year: t("granularityYear"),
month: t("granularityMonth"),
week: t("granularityWeek"),
Expand Down Expand Up @@ -1373,7 +1307,7 @@ export function TimelineView({

{/* Items in this month */}
<div className="space-y-1">
{group.items.map((item: any, idx: number) => (
{group.items.map(({ row: item, date: itemDate }, idx: number) => (
<div
key={item.id || idx}
onClick={() => onMemoryClick(item.id)}
Expand All @@ -1382,10 +1316,10 @@ export function TimelineView({
{/* Date & Time */}
<div className="w-[60px] text-right pr-3 pt-1 flex-shrink-0">
<div className="text-[10px] text-muted-foreground">
{formatDateTime(item.occurred_start).date}
{formatDateTime(itemDate).date}
</div>
<div className="text-[9px] text-muted-foreground/70">
{formatDateTime(item.occurred_start).time}
{formatDateTime(itemDate).time}
</div>
</div>

Expand Down
7 changes: 4 additions & 3 deletions hindsight-control-plane/src/components/entities-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ export function EntitiesView() {
const [selectedEntity, setSelectedEntity] = useState<EntityDetail | null>(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<MemoryRow[]>([]);
const [loadingMemories, setLoadingMemories] = useState(false);
const [selectedMemoryId, setSelectedMemoryId] = useState<string | null>(null);
Expand Down
182 changes: 182 additions & 0 deletions hindsight-control-plane/src/lib/effective-date.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<T extends object>(
rows: T[]
): {
sortedItems: EffectiveDateEntry<T>[];
itemsWithoutDates: T[];
} {
const withDates: EffectiveDateEntry<T>[] = [];
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<T> {
key: string;
label: string;
date: Date;
items: EffectiveDateEntry<T>[];
}

/**
* Group date-bearing entries by granularity bucket, ordered ascending by
* bucket date. Input must already be sorted ascending by effective date.
*/
export function groupTimelineItems<T>(
sortedItems: EffectiveDateEntry<T>[],
granularity: TimelineGranularity
): TimelineGroup<T>[] {
if (sortedItems.length === 0) return [];

const groups = new Map<string, { items: EffectiveDateEntry<T>[]; 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,
}));
}
Loading