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 atomicweightDeltaaccumulation, andstats.edgeWeightSumstotals it per label for free. Everything else (status, tags, capacity) lives inproperties.
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'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
createRuleandupdateRule(plusdeleteRuleif 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).
- Getting Started
- JS/TS SDK
- Authentication
- Databases
- Collections
- Graphs
- Rules and Access Control
- Granular Access Control
- Triggers
- Plugins
- Realtime
- Full Text Search
- Vector Embeddings
- Workflows
- Pipelines
- Cron
- Webhooks
- Migrations
- Replication
- File Sync
- Backup
- Distributed Realtime
- Tutorial: Embeddings
- Tutorial: Graphs
- Recipe: Calendar Timetree
- Tutorial: Plugins