feat: add Calendar blueprint illustration

Transcribe the calendar blueprint from blueprint-ontology (README +
calendar.als): Navigate, Render, Load, Scope functions, its own
Idle/Loading/Refreshing/Error lifecycle, and a bespoke month/week
specimen. Composition follows the formal model (Async + Pullable).
This commit is contained in:
Julien Calixte
2026-07-02 18:55:42 +02:00
parent 8e7a903fe4
commit 6264c9959b
3 changed files with 737 additions and 0 deletions

View File

@@ -6,8 +6,10 @@ import type { Component } from "vue"
import type { Blueprint } from "./blueprint"
import { list } from "./listBlueprint"
import { grid } from "./gridBlueprint"
import { calendar } from "./calendarBlueprint"
import ListSpecimen from "@/specimens/ListSpecimen.vue"
import GridSpecimen from "@/specimens/GridSpecimen.vue"
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
export interface RegistryEntry {
blueprint: Blueprint
@@ -17,6 +19,7 @@ export interface RegistryEntry {
export const REGISTRY: Record<string, RegistryEntry> = {
list: { blueprint: list, specimen: ListSpecimen },
grid: { blueprint: grid, specimen: GridSpecimen },
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
}
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)

View File

@@ -0,0 +1,208 @@
// The Calendar blueprint, as data. Lifted from the blueprint-ontology `calendar`
// README + calendar.als. Calendar has its OWN lifecycle (no LoadingMore; adds
// navigate/changeScope transitions), so it defines its own state machine rather
// than sharing LOADSTATE_MACHINE with List/Grid. `visible` is a viewport
// projection Calendar owns — not Filterable — so only Async + Pullable are
// represented as composed capabilities, matching what the formal model models.
import type { Blueprint, StateMachine } from "./blueprint"
// Idle ⇄ Loading (navigate/changeScope out, succeed in), plus refresh via
// Refreshing and the two failure edges. Node coordinates are hand-placed.
const CALENDAR_MACHINE: StateMachine = {
nodes: [
{ id: "loading", x: 70, y: 150, w: 110, h: 40, label: "Loading" },
{ id: "idle", x: 300, y: 150, w: 96, h: 40, label: "Idle" },
{ id: "refreshing", x: 540, y: 56, w: 130, h: 40, label: "Refreshing" },
{ id: "error", x: 770, y: 150, w: 96, h: 40, label: "Error" },
],
edges: [
{ from: "loading", to: "idle", label: "succeed()", kind: "succeed", off: 24 },
{ from: "loading", to: "error", label: "fail()", kind: "fail", off: 150 },
{ from: "idle", to: "loading", label: "navigate(δ)", kind: "navigate", off: 20 },
{ from: "idle", to: "loading", label: "changeScope(s)", kind: "changeScope", off: 56 },
{ from: "idle", to: "refreshing", label: "refresh()", kind: "refresh", off: -16 },
{ from: "refreshing", to: "idle", label: "succeed()", kind: "succeed", off: -16 },
{ from: "refreshing", to: "error", label: "fail()", kind: "fail", off: 14 },
{ from: "error", to: "refreshing", label: "refresh()", kind: "refresh", off: 70 },
],
}
export const calendar: Blueprint = {
slug: "calendar",
name: "Calendar<Event>",
tagline: "A navigable temporal grid of time-bounded events within a scoped viewport.",
role: "feature",
extendsName: "Set<Event>",
composesCount: 2,
sig: {
header: "Calendar<Event> + Async + Pullable",
fields: [
{
name: "members",
type: "set CalendarEvent",
note: "loaded events for the current viewport",
},
{ name: "scope", type: "Scope", note: "viewport granularity" },
{ name: "focusDate", type: "Date", note: "anchor date of the viewport" },
{ name: "loadState", type: "LoadState", note: "Async · Pullable" },
{
name: "visible",
type: "set CalendarEvent",
note: "derived: events anchored in the viewport",
},
],
types: [
"Scope = Day | Week | Month",
"LoadState = Idle | Loading | Refreshing | Error",
"CalendarEvent extends Item { startAt, endAt : DateTime } -- endAt ≥ startAt",
"viewportStart = startOf(focusDate, scope) viewportEnd = endOf(focusDate, scope)",
"visible = { e ∈ members | e.startAt ∈ [viewportStart, viewportEnd) } (derived)",
],
},
functions: [
{
id: "navigate",
name: "Navigate",
fig: "01",
caps: ["Calendar"],
verb: "Advance or retreat focusDate by one scope unit; trigger a fetch for the new viewport range.",
state: [
["focusDate", "Date"],
["loadState", "LoadState"],
],
behaviors: [
{
sig: "navigate(delta)",
pre: "loadState = Idle",
eff: "focusDate ← focusDate ± 1 scope unit, loadState ← Loading",
},
],
invariants: ["scope ∈ {Day, Week, Month} — viewport always has a defined granularity"],
failures: [
"navigate during load — navigate() called while loadState = Loading; pre-condition fails, the call must be ignored or queued, never double-fetched",
],
perf: [
"new viewport renders within 300 ms — pre-fetch adjacent viewports while loadState = Idle",
],
notes: [
"navigate(+1) advances by one scope unit (e.g. next week when scope = Week); navigate(-1) retreats.",
],
},
{
id: "render",
name: "Render",
fig: "02",
caps: ["Calendar", "Async"],
verb: "Position events in their day / time-slot cells; clip multi-day events at the viewport boundary.",
state: [
["members", "set CalendarEvent"],
["visible", "set CalendarEvent"],
],
behaviors: [],
invariants: [
"#1 visible ⊆ members — visible never exceeds loaded events",
"#2 ∀ e ∈ visible : e.startAt ∈ [viewportStart, viewportEnd) — only events anchored in the viewport are shown",
"#4 ∀ e ∈ members : e.endAt ≥ e.startAt — no negative-duration events",
],
failures: [
"multi-day overflow — event.endAt > viewportEnd; the event must be clipped at viewportEnd in the display, the underlying data not truncated",
"timezone mismatch — server returns UTC, client displays local time; events land in the wrong day or hour, so every DateTime must carry a timezone",
],
perf: [
"60 fps minimum for time-axis scrolling in Day and Week scopes (hour-slot rows must be virtualized)",
],
notes: ["visible is always derived — it is never set directly."],
},
{
id: "load",
name: "Load",
fig: "03",
caps: ["Async", "Pullable"],
verb: "Fetch events for the current viewport range; reload on every navigate and changeScope; support refresh.",
state: [
["loadState", "LoadState"],
["members", "set CalendarEvent"],
],
behaviors: [
{
sig: "refresh()",
pre: "loadState = Idle",
eff: "loadState ← Refreshing; re-fetch events for the current viewport range",
},
{
sig: "succeed(ev)",
pre: "loadState ∈ {Loading, Refreshing}",
eff: "members ← ev, loadState ← Idle",
},
{ sig: "fail()", pre: "loadState ∈ {Loading, Refreshing}", eff: "loadState ← Error" },
],
invariants: [
"#6 members = ∅ is a valid state (empty calendar, not an error)",
"#3 events are identity-unique within members",
],
failures: [
"empty vs error conflation — server returns 0 events with a 4xx status; the empty viewport is treated as an error and the user sees the wrong UI",
"stale events after reload — succeed(ev) does not replace members atomically; old and new events coexist and identity-uniqueness is violated",
],
perf: [
"pre-fetch adjacent viewports while loadState = Idle so navigate() renders within 300 ms",
],
notes: [],
},
{
id: "scope",
name: "Scope",
fig: "04",
caps: ["Calendar"],
verb: "Switch between Day, Week, and Month granularities without losing focusDate.",
state: [
["scope", "Scope"],
["loadState", "LoadState"],
],
behaviors: [
{
sig: "changeScope(s)",
pre: "loadState = Idle, s ≠ scope",
eff: "scope ← s, loadState ← Loading; fetch events for the new [viewportStart, viewportEnd)",
},
],
invariants: [
"#5 scope ∈ {Day, Week, Month} — viewport always has a defined granularity",
"changeScope preserves focusDate",
],
failures: [],
perf: ["layout recalculates within one frame (≤ 16 ms) for already-loaded events"],
notes: [],
},
],
coreInvariants: [
"visible ⊆ members — only loaded events can be shown",
"∀ e ∈ visible : e.startAt ∈ [viewportStart, viewportEnd) — only viewport-anchored events are shown",
"∀ e ∈ members : e.endAt ≥ e.startAt — no negative-duration events",
"events are identity-unique within members",
"members = ∅ is a valid state (empty calendar, not an error)",
],
composition: {
caps: [
{ id: "core", label: "Calendar : Set", sub: "members, scope, focusDate", core: true },
{ id: "async", label: "Async", sub: "loadState" },
{ id: "pullable", label: "Pullable", sub: "refresh" },
],
funcs: ["Navigate", "Render", "Load", "Scope"],
links: [
["core", "Navigate"],
["core", "Render"],
["core", "Scope"],
["async", "Render"],
["async", "Load"],
["pullable", "Load"],
],
},
stateMachine: CALENDAR_MACHINE,
}