Compare commits
2 Commits
c66f13c1c5
...
6264c9959b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6264c9959b | ||
|
|
8e7a903fe4 |
@@ -71,20 +71,29 @@ const edges = computed(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const legendItems = (() => {
|
// Legend is derived from the transitions the machine actually has, so each
|
||||||
const items: [string, SmKind][] = [
|
// blueprint shows exactly its own transition kinds — no phantom loadMore() on
|
||||||
["succeed()", "succeed"],
|
// Calendar, no missing navigate()/changeScope().
|
||||||
["fail()", "fail"],
|
const KIND_LABELS: Record<SmKind, string> = {
|
||||||
["refresh()", "refresh"],
|
succeed: "succeed()",
|
||||||
["loadMore()", "loadMore"],
|
fail: "fail()",
|
||||||
]
|
refresh: "refresh()",
|
||||||
|
loadMore: "loadMore()",
|
||||||
|
navigate: "navigate(δ)",
|
||||||
|
changeScope: "changeScope(s)",
|
||||||
|
}
|
||||||
|
const KIND_ORDER: SmKind[] = ["succeed", "fail", "refresh", "loadMore", "navigate", "changeScope"]
|
||||||
|
|
||||||
|
const legendItems = computed(() => {
|
||||||
|
const present = new Set(props.machine.edges.map((e) => e.kind))
|
||||||
let lxp = 70
|
let lxp = 70
|
||||||
return items.map(([t, k]) => {
|
return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
|
||||||
|
const t = KIND_LABELS[k]
|
||||||
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 }
|
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 }
|
||||||
lxp += 24 + t.length * 6.6 + 26
|
lxp += 24 + t.length * 6.6 + 26
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
})()
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface SmNode {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore"
|
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" | "navigate" | "changeScope"
|
||||||
|
|
||||||
export interface SmEdge {
|
export interface SmEdge {
|
||||||
from: string
|
from: string
|
||||||
@@ -78,6 +78,8 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
|
|||||||
fail: "#ff8f8f",
|
fail: "#ff8f8f",
|
||||||
refresh: "#ffb84d",
|
refresh: "#ffb84d",
|
||||||
loadMore: "#8fd0ff",
|
loadMore: "#8fd0ff",
|
||||||
|
navigate: "#b79cff",
|
||||||
|
changeScope: "#67d0c0",
|
||||||
init: "#4f7099",
|
init: "#4f7099",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import type { Component } from "vue"
|
|||||||
import type { Blueprint } from "./blueprint"
|
import type { Blueprint } from "./blueprint"
|
||||||
import { list } from "./listBlueprint"
|
import { list } from "./listBlueprint"
|
||||||
import { grid } from "./gridBlueprint"
|
import { grid } from "./gridBlueprint"
|
||||||
|
import { calendar } from "./calendarBlueprint"
|
||||||
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
||||||
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
||||||
|
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
blueprint: Blueprint
|
blueprint: Blueprint
|
||||||
@@ -17,6 +19,7 @@ export interface RegistryEntry {
|
|||||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||||
list: { blueprint: list, specimen: ListSpecimen },
|
list: { blueprint: list, specimen: ListSpecimen },
|
||||||
grid: { blueprint: grid, specimen: GridSpecimen },
|
grid: { blueprint: grid, specimen: GridSpecimen },
|
||||||
|
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
||||||
|
|||||||
208
src/data/calendarBlueprint.ts
Normal file
208
src/data/calendarBlueprint.ts
Normal 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,
|
||||||
|
}
|
||||||
526
src/specimens/CalendarSpecimen.vue
Normal file
526
src/specimens/CalendarSpecimen.vue
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ activeId: string }>()
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
|
// A month event bar, placed by (week row, start weekday, day span).
|
||||||
|
interface MonthEvent {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
week: number
|
||||||
|
start: number // 0 = Mon … 6 = Sun
|
||||||
|
span: number // days
|
||||||
|
clip?: boolean // clipped at the viewport boundary
|
||||||
|
skeleton?: boolean
|
||||||
|
hl?: boolean
|
||||||
|
hlTag?: string
|
||||||
|
}
|
||||||
|
// A week-view event block, placed on the time axis.
|
||||||
|
interface WeekEvent {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
day: number // 0 = Mon … 4 = Fri
|
||||||
|
hour: number // 0-based slot from the first row
|
||||||
|
hl?: boolean
|
||||||
|
}
|
||||||
|
// Shared header/chrome fields — both scopes have a navigable title + scope toggle.
|
||||||
|
interface BaseFrame {
|
||||||
|
title: string
|
||||||
|
scopeActive: "Day" | "Week" | "Month"
|
||||||
|
navHl?: boolean
|
||||||
|
scopeHl?: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
interface MonthFrame extends BaseFrame {
|
||||||
|
scope: "month"
|
||||||
|
gridHl?: boolean
|
||||||
|
gridTag?: string
|
||||||
|
badge?: string
|
||||||
|
badgeNote?: string
|
||||||
|
events: MonthEvent[]
|
||||||
|
}
|
||||||
|
interface WeekFrame extends BaseFrame {
|
||||||
|
scope: "week"
|
||||||
|
hours: string[]
|
||||||
|
events: WeekEvent[]
|
||||||
|
}
|
||||||
|
type Frame = MonthFrame | WeekFrame
|
||||||
|
|
||||||
|
// April 2026, laid out as in the ontology README (Mon-first weeks).
|
||||||
|
// Leading 30 (Mar) and trailing 1-4 (May) are adjacent-month days.
|
||||||
|
const WEEKS: { day: number; adjacent?: boolean }[][] = [
|
||||||
|
[30, 1, 2, 3, 4, 5, 6].map((d, i) => ({ day: d, adjacent: i === 0 })),
|
||||||
|
[7, 8, 9, 10, 11, 12, 13].map((d) => ({ day: d })),
|
||||||
|
[14, 15, 16, 17, 18, 19, 20].map((d) => ({ day: d })),
|
||||||
|
[21, 22, 23, 24, 25, 26, 27].map((d) => ({ day: d })),
|
||||||
|
[28, 29, 30, 1, 2, 3, 4].map((d, i) => ({ day: d, adjacent: i >= 3 })),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Events used across the month frames.
|
||||||
|
const meeting = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
|
||||||
|
key: "meeting",
|
||||||
|
label: "Meeting",
|
||||||
|
week: 0,
|
||||||
|
start: 2,
|
||||||
|
span: 2,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
const sprint = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
|
||||||
|
key: "sprint",
|
||||||
|
label: "Sprint review",
|
||||||
|
week: 1,
|
||||||
|
start: 0,
|
||||||
|
span: 1,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
const review = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
|
||||||
|
key: "review",
|
||||||
|
label: "Design review",
|
||||||
|
week: 3,
|
||||||
|
start: 1,
|
||||||
|
span: 3,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
const frame = computed<Frame>(() => {
|
||||||
|
switch (props.activeId) {
|
||||||
|
case "full":
|
||||||
|
return {
|
||||||
|
scope: "month",
|
||||||
|
title: "April 2026",
|
||||||
|
scopeActive: "Month",
|
||||||
|
navHl: true,
|
||||||
|
scopeHl: true,
|
||||||
|
gridHl: true,
|
||||||
|
gridTag: "◈ RENDER",
|
||||||
|
events: [meeting(), sprint(), review()],
|
||||||
|
}
|
||||||
|
case "navigate":
|
||||||
|
return {
|
||||||
|
scope: "month",
|
||||||
|
title: "April 2026",
|
||||||
|
scopeActive: "Month",
|
||||||
|
navHl: true,
|
||||||
|
badge: "loadState = Loading",
|
||||||
|
badgeNote: "navigate(+1) → fetching May 2026…",
|
||||||
|
note: "navigate(+1) advances focusDate by one scope unit (a month here) → loadState ← Loading, viewport re-fetched.",
|
||||||
|
events: [meeting(), sprint(), review()],
|
||||||
|
}
|
||||||
|
case "render":
|
||||||
|
return {
|
||||||
|
scope: "month",
|
||||||
|
title: "April 2026",
|
||||||
|
scopeActive: "Month",
|
||||||
|
gridHl: true,
|
||||||
|
gridTag: "◈ RENDER",
|
||||||
|
note: "visible = { e ∈ members | e.startAt ∈ [viewportStart, viewportEnd) } — the trailing event is clipped at viewportEnd; its data is not truncated.",
|
||||||
|
events: [
|
||||||
|
meeting({ hl: true, hlTag: "spans Wed–Thu" }),
|
||||||
|
sprint(),
|
||||||
|
review(),
|
||||||
|
{ key: "overflow", label: "Offsite ▸", week: 4, start: 4, span: 3, clip: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case "load":
|
||||||
|
return {
|
||||||
|
scope: "month",
|
||||||
|
title: "April 2026",
|
||||||
|
scopeActive: "Month",
|
||||||
|
badge: "loadState = Refreshing",
|
||||||
|
badgeNote: "re-fetching viewport range…",
|
||||||
|
note: "Skeleton bars mirror real event bars so the transition to Idle causes no layout shift. members = ∅ is a valid result, not an error.",
|
||||||
|
events: [
|
||||||
|
meeting({ skeleton: true }),
|
||||||
|
sprint({ skeleton: true }),
|
||||||
|
review({ skeleton: true }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case "scope":
|
||||||
|
return {
|
||||||
|
scope: "week",
|
||||||
|
title: "Week of Apr 21",
|
||||||
|
scopeActive: "Week",
|
||||||
|
scopeHl: true,
|
||||||
|
note: "changeScope(Week) keeps focusDate (Apr 21) and re-fetches the new [viewportStart, viewportEnd) — layout switches to a time axis.",
|
||||||
|
hours: ["09:00", "10:00", "11:00", "12:00"],
|
||||||
|
events: [
|
||||||
|
{ key: "standup", label: "Stand-up", day: 0, hour: 0 },
|
||||||
|
{ key: "oneone", label: "1:1", day: 4, hour: 0 },
|
||||||
|
{ key: "design", label: "Design review", day: 2, hour: 2, hl: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
scope: "month",
|
||||||
|
title: "April 2026",
|
||||||
|
scopeActive: "Month",
|
||||||
|
events: [meeting(), sprint(), review()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMonth = computed(() => frame.value.scope === "month")
|
||||||
|
|
||||||
|
function barStyle(e: MonthEvent) {
|
||||||
|
return {
|
||||||
|
left: `calc(${(e.start / 7) * 100}% + 3px)`,
|
||||||
|
width: `calc(${(e.span / 7) * 100}% - 6px)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specimen">
|
||||||
|
<!-- header: scope toggle + navigation -->
|
||||||
|
<div class="cal-head">
|
||||||
|
<div
|
||||||
|
class="nav"
|
||||||
|
:class="frame.navHl ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="frame.navHl ? '◈ NAVIGATE' : undefined"
|
||||||
|
>
|
||||||
|
<span class="arrow">‹</span>
|
||||||
|
<span class="title">{{ frame.title }}</span>
|
||||||
|
<span class="arrow">›</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="scopes"
|
||||||
|
:class="frame.scopeHl ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="frame.scopeHl ? '◈ SCOPE' : undefined"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="s in ['Day', 'Week', 'Month']"
|
||||||
|
:key="s"
|
||||||
|
class="scope-btn"
|
||||||
|
:class="{ on: frame.scopeActive === s }"
|
||||||
|
>{{ s }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="frame.scope === 'month' && frame.badge" class="spec-toolbar">
|
||||||
|
<span class="badge">{{ frame.badge }}</span>
|
||||||
|
<span v-if="frame.badgeNote" class="badge-note">{{ frame.badgeNote }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||||
|
|
||||||
|
<!-- MONTH grid -->
|
||||||
|
<div
|
||||||
|
v-if="isMonth && frame.scope === 'month'"
|
||||||
|
class="monthbox"
|
||||||
|
:class="frame.gridHl ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="frame.gridTag"
|
||||||
|
>
|
||||||
|
<div class="wd-row">
|
||||||
|
<div v-for="d in WEEKDAYS" :key="d" class="wd">{{ d }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(week, wi) in WEEKS" :key="wi" class="week">
|
||||||
|
<div class="daycells">
|
||||||
|
<div v-for="(c, ci) in week" :key="ci" class="daycell" :class="{ adjacent: c.adjacent }">
|
||||||
|
<span class="dnum">{{ c.day }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="e in frame.events.filter((x) => x.week === wi)"
|
||||||
|
:key="e.key"
|
||||||
|
class="ev"
|
||||||
|
:class="{ skeleton: e.skeleton, clip: e.clip, hl: e.hl }"
|
||||||
|
:style="barStyle(e)"
|
||||||
|
:data-tag="e.hlTag"
|
||||||
|
>
|
||||||
|
<span v-if="!e.skeleton" class="ev-label">{{ e.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WEEK time-axis grid -->
|
||||||
|
<div v-else-if="frame.scope === 'week'" class="weekbox">
|
||||||
|
<div class="wk-head">
|
||||||
|
<div class="wk-corner" />
|
||||||
|
<div v-for="d in WEEKDAYS.slice(0, 5)" :key="d" class="wk-day">{{ d }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(h, hi) in frame.hours" :key="h" class="wk-row">
|
||||||
|
<div class="wk-hour">{{ h }}</div>
|
||||||
|
<div v-for="di in 5" :key="di" class="wk-cell">
|
||||||
|
<div
|
||||||
|
v-for="e in frame.events.filter((x) => x.day === di - 1 && x.hour === hi)"
|
||||||
|
:key="e.key"
|
||||||
|
class="wk-ev"
|
||||||
|
:class="{ hl: e.hl }"
|
||||||
|
>
|
||||||
|
{{ e.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.specimen {
|
||||||
|
--ink: var(--color-base-content);
|
||||||
|
--ink-faint: #4f7099;
|
||||||
|
--amber: var(--color-accent);
|
||||||
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
--cyan: var(--color-secondary);
|
||||||
|
--red: var(--color-error);
|
||||||
|
--paper: var(--color-base-100);
|
||||||
|
--line: rgba(200, 226, 255, 0.14);
|
||||||
|
--line-strong: rgba(200, 226, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
.cal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--paper);
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
.nav .arrow {
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav .title {
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.scopes {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.scope-btn {
|
||||||
|
padding: 4px 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.scope-btn:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.scope-btn.on {
|
||||||
|
color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* toolbar */
|
||||||
|
.spec-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.badge-note {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* month grid */
|
||||||
|
.monthbox {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.wd-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wd {
|
||||||
|
padding: 5px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.week {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.week:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.daycells {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
.daycell {
|
||||||
|
min-height: 46px;
|
||||||
|
border-left: 1px dashed var(--line);
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.daycell:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.dnum {
|
||||||
|
font-size: 11px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 70%, transparent);
|
||||||
|
}
|
||||||
|
.daycell.adjacent .dnum {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
/* event bars, positioned over the day cells of their week */
|
||||||
|
.ev {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ev-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ev.clip {
|
||||||
|
border-right-style: dashed;
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, #000 78%, transparent);
|
||||||
|
mask-image: linear-gradient(90deg, #000 78%, transparent);
|
||||||
|
}
|
||||||
|
.ev.skeleton {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
.ev.skeleton::after {
|
||||||
|
content: "";
|
||||||
|
width: 62%;
|
||||||
|
height: 8px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--ink-faint),
|
||||||
|
var(--ink-faint) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 12px
|
||||||
|
);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* week / time-axis grid */
|
||||||
|
.weekbox {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.wk-head,
|
||||||
|
.wk-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
.wk-head {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wk-corner {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wk-day {
|
||||||
|
padding: 5px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: color-mix(in srgb, var(--ink) 55%, transparent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wk-row {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.wk-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.wk-hour {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.wk-cell {
|
||||||
|
min-height: 30px;
|
||||||
|
border-left: 1px dashed var(--line);
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.wk-cell:first-of-type {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.wk-ev {
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callouts */
|
||||||
|
.hl {
|
||||||
|
outline: 1.5px dashed var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hl[data-tag]::after {
|
||||||
|
content: attr(data-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.hl.tag-b[data-tag]::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
.ev.hl {
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.wk-ev.hl {
|
||||||
|
outline-color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user