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
Section titled “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 atomicweight_deltaaccumulation, andstats.edgeWeightSumstotals it per label for free. Everything else (status, tags, capacity) lives inproperties.
The calendar-index plugin
Section titled “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 (
list/view) or the plugin’sbackfillsilently 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
createandupdate(plusdeleteif records can be deleted):
version: 1database: 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:
version: 1database: default
plugins: cal-notes: plugin: calendar-index enabled: true config: collection: notes graph: calendar dateField: created labelTemplate: "" weightField: hoursRun 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:
vski plugins instances invoke cal-notes backfillvski plugins instances invoke cal-notes week_of --params '{"date": "2026-10-22"}'Answering the questions
Section titled “Answering the questions”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.
Rolling it yourself
Section titled “Rolling it yourself”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).