1 Graphs Timetree
Anton Nesterov edited this page 2026-10-22 16:09:37 +02:00

Recipe: Calendar Timetree

"How do I answer what did X do on a date / last week?" The pattern is a time tree inside a light graph: calendar periods (days, weeks) are ordinary nodes, records link to them with labelled edges, and the question becomes one subgraph or edges query.

This is the classic graph timetree (the Neo4j world calls it the same thing): a week --includes_day--> day backbone plus record --on_day--> day / record --in_week--> week activity edges. Precise timestamps stay on the records themselves - the tree exists so calendar questions are traversals, not scans.

The model

Piece Example Notes
day node day:2026-10-22, label day one per day that has activity
week node week:2026-W43, label week ISO week (isocalendar() in Python)
backbone edge week --includes_day--> day deterministic id week:…:day:…
record node doc:abc123, label = collection name properties.ref back to the record
activity edge doc --on_day--> day, --in_week--> week id <record>:on_day:<day>; weight = magnitude (hours, amount)

Two rules make it robust:

  • Deterministic ids everywhere. Re-ingesting a record upserts the same node/edge ids instead of duplicating them.
  • Magnitudes ride the edge weight. Weight is the only numeric the in-memory index tracks, the only one with atomic weightDelta accumulation, and stats.edgeWeightSums totals it per label for free. Everything else (status, tags, capacity) lives in properties.

The calendar-index plugin

examples/plugins/calendar-index automates all of it: subscribe it to a collection with a date field and it maintains the tree on every create/update/delete (record deletes cascade the activity edges; the calendar backbone stays). It needs vski-sdk >= 0.6.0 (native upsert).

The plugin runs as a regular authenticated client (a service API key), so two rule sets are part of the setup:

  • the source collection needs read rules (listRule / viewRule) or the plugin's backfill silently sees zero records (a rule-less collection lists as empty for non-admins, not as an error);
  • the target graph needs write rules - at minimum createRule and updateRule (plus deleteRule if records can be deleted), e.g.:
await client.settings.graphs.create({
  name: "calendar",
  createRule: "*",
  updateRule: "*",
  deleteRule: "*",
  listRule: "*",   // if clients (not just admins) query the tree
  viewRule: "*",
});
{
  "name": "cal-notes",
  "plugin": "calendar-index",
  "dbName": "default",
  "config": {
    "collection": "notes",
    "graph": "calendar",
    "dateField": "created",
    "labelTemplate": "",
    "weightField": "hours"
  }
}

Run its backfill function once for existing records, and week_of with {"date": "2026-10-22"} to resolve a date to its day/week ids and the week's seven days.

Answering the questions

const graph = client.graph("calendar");

// What happened on a date? One hop in.
const day = await graph.subgraph(["day:2026-10-22"], { hops: 1, direction: "in" });

// What did everyone do last week? Subgraph around the week node (or pass
// the seven day ids as seeds), keeping only record nodes:
const week = await graph.subgraph(["week:2026-W43"], {
  hops: 1,
  direction: "out",
  nodeLabels: ["day"],
});
const activity = await graph.getEdges({
  target: ["day:2026-10-19", "day:2026-10-20", "day:2026-10-21",
           "day:2026-10-22", "day:2026-10-23", "day:2026-10-24",
           "day:2026-10-25"],
  label: "on_day",
});

// What did *X* do last week? Her neighbors, restricted to the week's days.
const x = await graph.getEdges({
  source: "user:alice",
  label: "on_day",
  prop: ["hours>0"], // only real activity
});

// How much total? Per-label weight sums:
const stats = await graph.stats();
stats.edgeWeightSums["on_day"]; // all recorded hours across the graph

"Last week" itself is client-side date math (resolve the ISO week, take its Monday) - the week_of helper shows the three lines it takes. The server stays calendar-agnostic on purpose: any bucketing (months, sprints, fiscal quarters) is just more backbone nodes with the same shape.

Rolling it yourself

Without the plugin, the ingest loop is two upserts and two edges per record:

await graph.createNodes([
  { id: "day:2026-10-22", label: "day", properties: { date: "2026-10-22" } },
  { id: "week:2026-W43", label: "week", properties: { weekStart: "2026-10-19" } },
  { id: "doc:abc", label: "document",
    properties: { ref: { collection: "documents", recordId: "abc" } } },
], { upsert: true });

await graph.createEdges([
  { id: "week:2026-W43:day:2026-10-22", source: "week:2026-W43",
    target: "day:2026-10-22", label: "includes_day" },
  { id: "doc:abc:on_day:day:2026-10-22", source: "doc:abc",
    target: "day:2026-10-22", label: "on_day", weight: 2 },
], { upsert: true });

Deleting a record node cascades its activity edges automatically; day and week nodes are cheap to leave behind (a year of daily nodes is ~365 rows).