From 6264c9959b51e85b3f16562e1f53395b529e4967 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 2 Jul 2026 18:55:42 +0200 Subject: [PATCH] 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). --- src/data/blueprints.ts | 3 + src/data/calendarBlueprint.ts | 208 ++++++++++++ src/specimens/CalendarSpecimen.vue | 526 +++++++++++++++++++++++++++++ 3 files changed, 737 insertions(+) create mode 100644 src/data/calendarBlueprint.ts create mode 100644 src/specimens/CalendarSpecimen.vue diff --git a/src/data/blueprints.ts b/src/data/blueprints.ts index 6cde111..b4a8e17 100644 --- a/src/data/blueprints.ts +++ b/src/data/blueprints.ts @@ -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 = { 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) diff --git a/src/data/calendarBlueprint.ts b/src/data/calendarBlueprint.ts new file mode 100644 index 0000000..2330556 --- /dev/null +++ b/src/data/calendarBlueprint.ts @@ -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", + tagline: "A navigable temporal grid of time-bounded events within a scoped viewport.", + role: "feature", + extendsName: "Set", + composesCount: 2, + + sig: { + header: "Calendar + 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, +} diff --git a/src/specimens/CalendarSpecimen.vue b/src/specimens/CalendarSpecimen.vue new file mode 100644 index 0000000..3b33e2a --- /dev/null +++ b/src/specimens/CalendarSpecimen.vue @@ -0,0 +1,526 @@ + + + + +