Skip to content

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.

PieceExampleNotes
day nodeday:2026-10-22, label dayone per day that has activity
week nodeweek:2026-W43, label weekISO week (isocalendar() in Python)
backbone edgeweek --includes_day--> daydeterministic id week:…:day:…
record nodedoc:abc123, label = collection nameproperties.ref back to the record
activity edgedoc --on_day--> day, --in_week--> weekid <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 weight_delta accumulation, and stats.edgeWeightSums totals it per label for free. Everything else (status, tags, capacity) lives in properties.

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 (list / view) 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 create and update (plus delete if records can be deleted):
vski/default/graphs.yaml
version: 1
database: default
graphs:
calendar:
rules:
create: "*" # if clients (not just admins) query the tree, open reads too:
update: "*"
delete: "*"
list: "*"
view: "*"

The plugin instance itself is declarative as well:

vski/default/plugins.yaml
version: 1
database: default
plugins:
cal-notes:
plugin: calendar-index
enabled: true
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:

Terminal window
vski plugins instances invoke cal-notes backfill
vski plugins instances invoke cal-notes week_of --params '{"date": "2026-10-22"}'
graph = client.graph("calendar")
# What happened on a date? One hop in.
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 day nodes:
week = await graph.subgraph(
["week:2026-W43"], hops=1, direction="out", node_labels=["day"]
)
activity = await graph.get_edges(
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 edges, restricted to the week's days.
x = await graph.get_edges(
source="user:alice",
label="on_day",
prop=["hours>0"], # only real activity
)
# How much total? Per-label weight sums:
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.

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

await graph.create_nodes([
{"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.create_edges([
{"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).