chore: format

This commit is contained in:
Julien Calixte
2026-06-19 17:30:34 +02:00
parent 0ac212a4ac
commit 120a329421
30 changed files with 692 additions and 665 deletions

View File

@@ -1,18 +1,18 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, it, expect, beforeEach, vi } from "vitest"
import { mount } from "@vue/test-utils"
import { nextTick } from "vue"
// downloadSource performs a real DOM/Blob download; mock it so we can assert
// the wiring without touching the filesystem. sourceFilename stays real.
vi.mock('./composables/useSourceExport', async (importOriginal) => {
const actual = await importOriginal<typeof import('./composables/useSourceExport')>()
vi.mock("./composables/useSourceExport", async (importOriginal) => {
const actual = await importOriginal<typeof import("./composables/useSourceExport")>()
return { ...actual, downloadSource: vi.fn() }
})
import App from './App.vue'
import { downloadSource } from './composables/useSourceExport'
import { SAMPLE_PLAN } from './data/sample'
import App from "./App.vue"
import { downloadSource } from "./composables/useSourceExport"
import { SAMPLE_PLAN } from "./data/sample"
// The editor lazy-loads Shiki and the grid is heavy; neither is under test here.
const stubs = { PlanEditor: true, MacroplanGrid: true }
@@ -23,69 +23,67 @@ function mountApp() {
// dropdown links = one <a> per plan, then "New plan" and "Download .toml".
function dropdownLinks(w: ReturnType<typeof mountApp>) {
return w.findAll('.dropdown-content li a')
return w.findAll(".dropdown-content li a")
}
beforeEach(() => localStorage.clear())
describe('App — plan library wiring', () => {
it('shows the active plan name in the header switcher', () => {
expect(mountApp().text()).toContain('Q3 — Checkout revamp')
describe("App — plan library wiring", () => {
it("shows the active plan name in the header switcher", () => {
expect(mountApp().text()).toContain("Q3 — Checkout revamp")
})
it('creates a new plan from the dropdown', async () => {
it("creates a new plan from the dropdown", async () => {
const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan + New + Download
await dropdownLinks(w)[1].trigger('click') // New plan
await dropdownLinks(w)[1].trigger("click") // New plan
await nextTick()
expect(dropdownLinks(w)).toHaveLength(4) // 2 plans + New + Download
})
it('opens the confirm modal and deletes the active plan on confirm', async () => {
it("opens the confirm modal and deletes the active plan on confirm", async () => {
const w = mountApp()
await dropdownLinks(w)[1].trigger('click') // New → 2 plans
await dropdownLinks(w)[1].trigger("click") // New → 2 plans
await nextTick()
expect(dropdownLinks(w)).toHaveLength(4)
await w.find('button[title="Delete this plan"]').trigger('click')
expect(w.find('.modal-open').exists()).toBe(true)
await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find(".modal-open").exists()).toBe(true)
await w.find('.modal-action .btn-error').trigger('click') // Delete
await w.find(".modal-action .btn-error").trigger("click") // Delete
await nextTick()
expect(w.find('.modal-open').exists()).toBe(false)
expect(w.find(".modal-open").exists()).toBe(false)
expect(dropdownLinks(w)).toHaveLength(3) // back to 1 plan
})
it('cancel dismisses the modal without deleting', async () => {
it("cancel dismisses the modal without deleting", async () => {
const w = mountApp()
await w.find('button[title="Delete this plan"]').trigger('click')
expect(w.find('.modal-open').exists()).toBe(true)
await w.find('button[title="Delete this plan"]').trigger("click")
expect(w.find(".modal-open").exists()).toBe(true)
const cancel = w
.findAll('.modal-action .btn')
.find((b) => !b.classes().includes('btn-error'))!
await cancel.trigger('click')
expect(w.find('.modal-open').exists()).toBe(false)
const cancel = w.findAll(".modal-action .btn").find((b) => !b.classes().includes("btn-error"))!
await cancel.trigger("click")
expect(w.find(".modal-open").exists()).toBe(false)
expect(dropdownLinks(w)).toHaveLength(3) // plan untouched
})
it('re-seeds a fresh sample when the last plan is deleted (never empty)', async () => {
it("re-seeds a fresh sample when the last plan is deleted (never empty)", async () => {
const w = mountApp()
expect(dropdownLinks(w)).toHaveLength(3) // 1 plan
await w.find('button[title="Delete this plan"]').trigger('click')
await w.find('.modal-action .btn-error').trigger('click') // Delete the only plan
await w.find('button[title="Delete this plan"]').trigger("click")
await w.find(".modal-action .btn-error").trigger("click") // Delete the only plan
await nextTick()
expect(dropdownLinks(w)).toHaveLength(3) // still 1 plan — re-seeded
expect(w.text()).toContain('Q3 — Checkout revamp')
expect(w.text()).toContain("Q3 — Checkout revamp")
})
it('wires the dropdown .toml download to downloadSource with the active source and a .toml name', async () => {
it("wires the dropdown .toml download to downloadSource with the active source and a .toml name", async () => {
const w = mountApp()
await dropdownLinks(w)[2].trigger('click') // Download .toml (1 plan → index 2)
await dropdownLinks(w)[2].trigger("click") // Download .toml (1 plan → index 2)
expect(downloadSource).toHaveBeenCalledOnce()
const [src, filename] = (downloadSource as ReturnType<typeof vi.fn>).mock.calls[0]
expect(src).toBe(SAMPLE_PLAN)
expect(filename).toBe('macroplan-q3-checkout-revamp.toml')
expect(filename).toBe("macroplan-q3-checkout-revamp.toml")
})
})

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useMacroplan } from './composables/useMacroplan'
import { usePngExport, exportFilename } from './composables/usePngExport'
import { sourceFilename, downloadSource } from './composables/useSourceExport'
import PlanEditor from './components/PlanEditor.vue'
import MacroplanGrid from './components/MacroplanGrid.vue'
import PlanSwitcher from './components/PlanSwitcher.vue'
import { ref, computed } from "vue"
import { useMacroplan } from "./composables/useMacroplan"
import { usePngExport, exportFilename } from "./composables/usePngExport"
import { sourceFilename, downloadSource } from "./composables/useSourceExport"
import PlanEditor from "./components/PlanEditor.vue"
import MacroplanGrid from "./components/MacroplanGrid.vue"
import PlanSwitcher from "./components/PlanSwitcher.vue"
const { source, plan, error, plans, activeId, selectPlan, newPlan, deletePlan } = useMacroplan()
const { busy, toast, copyPng, downloadPng } = usePngExport()
@@ -14,7 +14,7 @@ const exportRoot = ref<HTMLElement>()
const confirmingDelete = ref(false)
const activeName = computed(
() => plans.value.find((p) => p.id === activeId.value)?.name ?? 'Untitled',
() => plans.value.find((p) => p.id === activeId.value)?.name ?? "Untitled",
)
function downloadToml() {

View File

@@ -20,8 +20,15 @@ Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as
```vue
<template>
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
class="size-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- paste paths from the Tabler SVG here -->
</svg>
</template>

View File

@@ -1,52 +1,52 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MacroplanGrid from './MacroplanGrid.vue'
import { parseMacroplan } from '../model/parse'
import { buildPlan } from '../model/plan'
import { SAMPLE_PLAN } from '../data/sample'
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import MacroplanGrid from "./MacroplanGrid.vue"
import { parseMacroplan } from "../model/parse"
import { buildPlan } from "../model/plan"
import { SAMPLE_PLAN } from "../data/sample"
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), '2026-06-17')
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), "2026-06-17")
function mountGrid() {
return mount(MacroplanGrid, { props: { plan } })
}
describe('MacroplanGrid renders the sample plan', () => {
it('renders one row per feature, in order', () => {
describe("MacroplanGrid renders the sample plan", () => {
it("renders one row per feature, in order", () => {
const names = mountGrid()
.findAll('.namecell')
.findAll(".namecell")
.map((n) => n.text())
expect(names).toEqual(['Auth', 'Payments', 'Dashboard', 'Search', 'Notifications'])
expect(names).toEqual(["Auth", "Payments", "Dashboard", "Search", "Notifications"])
})
it('renders the right markers per feature', () => {
const rows = mountGrid().findAll('.namecell')
it("renders the right markers per feature", () => {
const rows = mountGrid().findAll(".namecell")
// each feature row is namecell + week cells + learncell; grab the row text via the grid
const grid = mountGrid()
const text = grid.text()
expect(rows).toHaveLength(5)
// on-time, late, and slip glyphs all present somewhere in the grid
expect(text).toContain('◉') // Auth delivered on time
expect(text).toContain('▲') // Payments delivered late
expect(text).toContain('△') // re-estimates
expect(text).toContain('◯') // open original estimates
expect(text).toContain("◉") // Auth delivered on time
expect(text).toContain("▲") // Payments delivered late
expect(text).toContain("△") // re-estimates
expect(text).toContain("◯") // open original estimates
})
it('draws a now column and the milestone header', () => {
it("draws a now column and the milestone header", () => {
const w = mountGrid()
expect(w.find('.col-now').exists()).toBe(true)
expect(w.text()).toContain('now')
expect(w.text()).toContain('MVP go-live')
expect(w.find(".col-now").exists()).toBe(true)
expect(w.text()).toContain("now")
expect(w.text()).toContain("MVP go-live")
})
it('shows a learning for a delivered feature and a status note for an in-flight one', () => {
it("shows a learning for a delivered feature and a status note for an in-flight one", () => {
const text = mountGrid().text()
expect(text).toContain('Vendor lead time') // Payments learning
expect(text).toContain('No recovery plan yet') // Dashboard status note
expect(text).toContain("Vendor lead time") // Payments learning
expect(text).toContain("No recovery plan yet") // Dashboard status note
})
it('labels week columns', () => {
expect(mountGrid().text()).toContain('Jun 15')
it("labels week columns", () => {
expect(mountGrid().text()).toContain("Jun 15")
})
})

View File

@@ -1,48 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Plan, FeatureRow, MarkerKind } from '../model/types'
import { weekLabel, type WeekId } from '../model/week'
import { computed } from "vue"
import type { Plan, FeatureRow, MarkerKind } from "../model/types"
import { weekLabel, type WeekId } from "../model/week"
const props = defineProps<{ plan: Plan }>()
type Tone = 'success' | 'warning' | 'error' | 'neutral'
type Tone = "success" | "warning" | "error" | "neutral"
const GLYPH: Record<MarkerKind, string> = {
original: '◯',
reestimate: '△',
'delivered-on-time': '◉',
'delivered-late': '▲',
original: "◯",
reestimate: "△",
"delivered-on-time": "◉",
"delivered-late": "▲",
}
const MARKER_CLASS: Record<MarkerKind, string> = {
original: 'text-base-content/50',
reestimate: 'text-warning',
'delivered-on-time': 'text-success',
'delivered-late': 'text-error',
original: "text-base-content/50",
reestimate: "text-warning",
"delivered-on-time": "text-success",
"delivered-late": "text-error",
}
// When two markers land on the same week, the higher rank owns the cell.
const RANK: Record<MarkerKind, number> = {
'delivered-late': 3,
'delivered-on-time': 3,
"delivered-late": 3,
"delivered-on-time": 3,
reestimate: 2,
original: 1,
}
const TONE_TEXT: Record<Tone, string> = {
success: 'text-success',
warning: 'text-warning',
error: 'text-error',
neutral: 'text-primary',
success: "text-success",
warning: "text-warning",
error: "text-error",
neutral: "text-primary",
}
const TONE_BORDER: Record<Tone, string> = {
success: 'border-success',
warning: 'border-warning',
error: 'border-error',
neutral: 'border-primary',
success: "border-success",
warning: "border-warning",
error: "border-error",
neutral: "border-primary",
}
const TONE_DOT: Record<Tone, string> = {
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error',
neutral: 'bg-base-300',
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
neutral: "bg-base-300",
}
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
@@ -57,15 +57,15 @@ const gridStyle = computed(() => ({
}))
function tone(row: FeatureRow): Tone {
if (row.delivered) return row.onTime ? 'success' : 'error'
if (row.status === 'on-track') return 'success'
if (row.status === 'at-risk') return 'warning'
if (row.status === 'off-track') return 'error'
return 'neutral'
if (row.delivered) return row.onTime ? "success" : "error"
if (row.status === "on-track") return "success"
if (row.status === "at-risk") return "warning"
if (row.status === "off-track") return "error"
return "neutral"
}
function statusWord(row: FeatureRow): string {
return row.status ? row.status.replace('-', ' ') : 'in flight'
return row.status ? row.status.replace("-", " ") : "in flight"
}
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
@@ -79,7 +79,7 @@ function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
interface Cell {
// how far the bar line runs within this cell: none, center→right,
// left→center, or full width
line: 'none' | 'right' | 'left' | 'full'
line: "none" | "right" | "left" | "full"
isStart: boolean
glyph: string
glyphCls: string
@@ -91,17 +91,17 @@ const matrix = computed<Cell[][]>(() =>
weeks.value.map((w) => {
const m = markerAt(row, w)
const inBar = w >= row.startWeek && w <= row.barEndWeek
let line: Cell['line'] = 'none'
let line: Cell["line"] = "none"
if (inBar) {
const isStart = w === row.startWeek
const isEnd = w === row.barEndWeek
line = isStart && isEnd ? 'none' : isStart ? 'right' : isEnd ? 'left' : 'full'
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
}
return {
line,
isStart: inBar && w === row.startWeek,
glyph: m ? GLYPH[m] : '',
glyphCls: m ? MARKER_CLASS[m] : '',
glyph: m ? GLYPH[m] : "",
glyphCls: m ? MARKER_CLASS[m] : "",
}
}),
),
@@ -119,9 +119,9 @@ const cols = computed(() =>
function colClass(i: number): string {
const c = cols.value[i]
if (c.isNow) return 'col-now'
if (c.isMilestone) return 'col-ms'
return ''
if (c.isNow) return "col-now"
if (c.isMilestone) return "col-ms"
return ""
}
// Milestone label flags above the axis, greedily stacked onto extra rows so
@@ -134,13 +134,13 @@ const milestoneFlags = computed(() => {
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
return items.map((x) => {
const startX = NAME_W + (x.col - 2) * WK
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : '')
const text = `${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
const width = text.length * BAND_CHAR + 14
let row = 0
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
rowEnd[row] = startX + width
const title = x.m.unmet.length
? `${x.m.name} — unmet: ${x.m.unmet.join(', ')}`
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
: `${x.m.name} — all required features met`
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
})
@@ -197,7 +197,9 @@ const bandStyle = computed(() => ({
<div class="learncell">
<template v-if="row.delivered">
<span v-if="row.learning" class="note">{{ row.learning }}</span>
<span v-else class="muted">{{ row.onTime ? 'delivered on time' : 'delivered late' }}</span>
<span v-else class="muted">{{
row.onTime ? "delivered on time" : "delivered late"
}}</span>
</template>
<template v-else>
<span class="dot" :class="TONE_DOT[tone(row)]"></span>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import type { HighlighterCore } from 'shiki/core'
import { getCompletions, type CompletionContext } from './completion'
import { ref, computed, onMounted, nextTick } from "vue"
import type { HighlighterCore } from "shiki/core"
import { getCompletions, type CompletionContext } from "./completion"
const props = defineProps<{ modelValue: string; error: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
const textarea = ref<HTMLTextAreaElement>()
const backdrop = ref<HTMLElement>()
@@ -25,10 +25,10 @@ onMounted(async () => {
// Lazy-load Shiki as its own chunk so it stays out of the initial bundle.
// Fine-grained: only the TOML grammar + one light theme, JS engine (no WASM).
const [core, engine, toml, theme] = await Promise.all([
import('shiki/core'),
import('shiki/engine/javascript'),
import('shiki/langs/toml.mjs'),
import('shiki/themes/github-light.mjs'),
import("shiki/core"),
import("shiki/engine/javascript"),
import("shiki/langs/toml.mjs"),
import("shiki/themes/github-light.mjs"),
])
highlighter.value = await core.createHighlighterCore({
themes: [theme.default],
@@ -41,11 +41,11 @@ function measure() {
const el = textarea.value
if (!el) return
const cs = getComputedStyle(el)
const ctx = document.createElement('canvas').getContext('2d')!
const ctx = document.createElement("canvas").getContext("2d")!
ctx.font = `${cs.fontSize} ${cs.fontFamily}`
const lh = parseFloat(cs.lineHeight) // px value in every modern browser
metrics = {
charWidth: ctx.measureText('0000000000').width / 10,
charWidth: ctx.measureText("0000000000").width / 10,
lineHeight: Number.isNaN(lh) || lh < 4 ? parseFloat(cs.fontSize) * 1.6 : lh,
padLeft: parseFloat(cs.paddingLeft),
padTop: parseFloat(cs.paddingTop),
@@ -66,10 +66,10 @@ function position() {
const el = textarea.value
if (!el) return
const before = el.value.slice(0, el.selectionStart)
const lineIndex = before.split('\n').length - 1
const line = before.slice(before.lastIndexOf('\n') + 1)
const lineIndex = before.split("\n").length - 1
const line = before.slice(before.lastIndexOf("\n") + 1)
let col = 0
for (const ch of line) col = ch === '\t' ? col + (2 - (col % 2)) : col + 1
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
popup.value = {
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
@@ -83,7 +83,7 @@ function accept(i: number) {
const item = ctx.items[i]
const caret = ctx.from + item.insert.length
completion.value = null
emit('update:modelValue', el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
emit("update:modelValue", el.value.slice(0, ctx.from) + item.insert + el.value.slice(ctx.to))
nextTick(() => {
el.setSelectionRange(caret, caret)
el.focus()
@@ -92,7 +92,7 @@ function accept(i: number) {
}
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape') justEscaped = false
if (e.key !== "Escape") justEscaped = false
const ctx = completion.value
if (!ctx) return
const move = (delta: number) => {
@@ -100,32 +100,32 @@ function onKeydown(e: KeyboardEvent) {
selected.value = (selected.value + delta + ctx.items.length) % ctx.items.length
}
// Ctrl+N / Ctrl+P mirror ArrowDown / ArrowUp (readline-style navigation).
if (e.ctrlKey && (e.key === 'n' || e.key === 'p')) {
move(e.key === 'n' ? 1 : -1)
if (e.ctrlKey && (e.key === "n" || e.key === "p")) {
move(e.key === "n" ? 1 : -1)
return
}
switch (e.key) {
case 'ArrowDown':
case "ArrowDown":
move(1)
break
case 'ArrowUp':
case "ArrowUp":
move(-1)
break
case 'Enter':
case 'Tab':
case "Enter":
case "Tab":
e.preventDefault()
accept(selected.value)
break
case 'Escape':
case "Escape":
e.preventDefault()
completion.value = null
justEscaped = true
break
// Caret jumps would leave the popup stranded at a stale spot — close it.
case 'ArrowLeft':
case 'ArrowRight':
case 'Home':
case 'End':
case "ArrowLeft":
case "ArrowRight":
case "Home":
case "End":
completion.value = null
break
}
@@ -133,18 +133,18 @@ function onKeydown(e: KeyboardEvent) {
const highlighted = computed(() => {
// a trailing newline needs a trailing char so the last backdrop line keeps height
const code = props.modelValue.endsWith('\n') ? props.modelValue + ' ' : props.modelValue
const code = props.modelValue.endsWith("\n") ? props.modelValue + " " : props.modelValue
const hl = highlighter.value
if (!hl) return `<pre class="shiki"><code>${escapeHtml(code)}</code></pre>`
return hl.codeToHtml(code, { lang: 'toml', theme: 'github-light' })
return hl.codeToHtml(code, { lang: "toml", theme: "github-light" })
})
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!)
}
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
emit("update:modelValue", (e.target as HTMLTextAreaElement).value)
refresh()
}

View File

@@ -1,33 +1,33 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlanSwitcher from './PlanSwitcher.vue'
import { describe, it, expect } from "vitest"
import { mount } from "@vue/test-utils"
import PlanSwitcher from "./PlanSwitcher.vue"
const plans = [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Bravo' },
{ id: "a", name: "Alpha" },
{ id: "b", name: "Bravo" },
]
describe('PlanSwitcher', () => {
it('lists plan names and marks the active one', () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'b' } })
expect(w.text()).toContain('Alpha')
expect(w.text()).toContain('Bravo')
expect(w.find('a.active').text()).toContain('Bravo')
describe("PlanSwitcher", () => {
it("lists plan names and marks the active one", () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "b" } })
expect(w.text()).toContain("Alpha")
expect(w.text()).toContain("Bravo")
expect(w.find("a.active").text()).toContain("Bravo")
})
it('emits select with the clicked plan id', async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
await w.findAll('li a')[1].trigger('click') // Bravo
expect(w.emitted('select')?.[0]).toEqual(['b'])
it("emits select with the clicked plan id", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
await w.findAll("li a")[1].trigger("click") // Bravo
expect(w.emitted("select")?.[0]).toEqual(["b"])
})
it('emits new and download (with the active id) from the trailing actions', async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: 'a' } })
const actions = w.findAll('li a')
await actions[actions.length - 2].trigger('click') // New plan
await actions[actions.length - 1].trigger('click') // Download .toml
expect(w.emitted('new')).toBeTruthy()
expect(w.emitted('download')?.[0]).toEqual(['a'])
it("emits new and download (with the active id) from the trailing actions", async () => {
const w = mount(PlanSwitcher, { props: { plans, activeId: "a" } })
const actions = w.findAll("li a")
await actions[actions.length - 2].trigger("click") // New plan
await actions[actions.length - 1].trigger("click") // Download .toml
expect(w.emitted("new")).toBeTruthy()
expect(w.emitted("download")?.[0]).toEqual(["a"])
})
})

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue"
const props = defineProps<{
plans: { id: string; name: string }[]
@@ -13,7 +13,7 @@ const emit = defineEmits<{
}>()
const activeName = computed(
() => props.plans.find((p) => p.id === props.activeId)?.name ?? 'Untitled',
() => props.plans.find((p) => p.id === props.activeId)?.name ?? "Untitled",
)
</script>

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'
import { getCompletions } from './completion'
import { describe, it, expect } from "vitest"
import { getCompletions } from "./completion"
/** Split a `|`-marked string into (source, caret offset). */
function atCaret(marked: string): [string, number] {
const caret = marked.indexOf('|')
const caret = marked.indexOf("|")
return [marked.slice(0, caret) + marked.slice(caret + 1), caret]
}
@@ -13,85 +13,85 @@ function labelsAt(marked: string): string[] {
return getCompletions(source, caret)?.items.map((i) => i.label) ?? []
}
describe('completion context', () => {
it('offers plan keys and block headers on an empty top-level line', () => {
expect(labelsAt('|')).toEqual(['title', 'start', 'end', '[[feature]]', '[[milestone]]'])
describe("completion context", () => {
it("offers plan keys and block headers on an empty top-level line", () => {
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
})
it('offers feature keys inside a [[feature]] block', () => {
expect(labelsAt('[[feature]]\n|')).toEqual([
'name',
'start',
'original',
'reestimates',
'delivered',
'status',
'learning',
'note',
'[[feature]]',
'[[milestone]]',
it("offers feature keys inside a [[feature]] block", () => {
expect(labelsAt("[[feature]]\n|")).toEqual([
"name",
"start",
"original",
"reestimates",
"delivered",
"status",
"learning",
"note",
"[[feature]]",
"[[milestone]]",
])
})
it('offers milestone keys inside a [[milestone]] block', () => {
expect(labelsAt('[[milestone]]\n|')).toEqual([
'name',
'week',
'requires',
'[[feature]]',
'[[milestone]]',
it("offers milestone keys inside a [[milestone]] block", () => {
expect(labelsAt("[[milestone]]\n|")).toEqual([
"name",
"week",
"requires",
"[[feature]]",
"[[milestone]]",
])
})
it('excludes keys already written in the current block', () => {
it("excludes keys already written in the current block", () => {
const labels = labelsAt('[[feature]]\nname = "X"\noriginal = 2026-06-01\n|')
expect(labels).not.toContain('name')
expect(labels).not.toContain('original')
expect(labels).toContain('start')
expect(labels).not.toContain("name")
expect(labels).not.toContain("original")
expect(labels).toContain("start")
})
it('filters keys by the typed prefix', () => {
expect(labelsAt('[[feature]]\nst|')).toEqual(['start', 'status'])
it("filters keys by the typed prefix", () => {
expect(labelsAt("[[feature]]\nst|")).toEqual(["start", "status"])
})
it('completes a header from a leading bracket', () => {
expect(labelsAt('[[f|')).toEqual(['[[feature]]'])
it("completes a header from a leading bracket", () => {
expect(labelsAt("[[f|")).toEqual(["[[feature]]"])
})
it('suggests status values after status =', () => {
expect(labelsAt('[[feature]]\nstatus = |')).toEqual(['on-track', 'at-risk', 'off-track'])
it("suggests status values after status =", () => {
expect(labelsAt("[[feature]]\nstatus = |")).toEqual(["on-track", "at-risk", "off-track"])
})
it('filters status values by the typed prefix, inside an open quote', () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(['on-track', 'off-track'])
it("filters status values by the typed prefix, inside an open quote", () => {
expect(labelsAt('[[feature]]\nstatus = "o|')).toEqual(["on-track", "off-track"])
})
it('suggests feature names inside a milestone requires array', () => {
it("suggests feature names inside a milestone requires array", () => {
const source = '[[feature]]\nname = "Payments"\n\n[[milestone]]\nrequires = ["|'
expect(labelsAt(source)).toEqual(['Payments'])
expect(labelsAt(source)).toEqual(["Payments"])
})
it('does not offer feature names outside a milestone block', () => {
it("does not offer feature names outside a milestone block", () => {
// `requires` is meaningless at the plan level, so there is nothing to add.
expect(labelsAt('name = "Payments"\n\n[[feature]]\nrequires = ["|')).toEqual([])
})
it('returns nothing in a value position it cannot complete (a date)', () => {
expect(getCompletions(...atCaret('[[feature]]\nstart = 2026-|'))).toBeNull()
it("returns nothing in a value position it cannot complete (a date)", () => {
expect(getCompletions(...atCaret("[[feature]]\nstart = 2026-|"))).toBeNull()
})
})
describe('completion replace range', () => {
it('replaces the typed prefix, not the whole line', () => {
const source = '[[feature]]\nst'
describe("completion replace range", () => {
it("replaces the typed prefix, not the whole line", () => {
const source = "[[feature]]\nst"
const ctx = getCompletions(source, source.length)!
expect(source.slice(ctx.from, ctx.to)).toBe('st')
expect(source.slice(ctx.from, ctx.to)).toBe("st")
})
it('replaces a partial bracketed header from the bracket', () => {
const source = '[[f'
it("replaces a partial bracketed header from the bracket", () => {
const source = "[[f"
const ctx = getCompletions(source, source.length)!
expect(source.slice(ctx.from, ctx.to)).toBe('[[f')
expect(ctx.items[0].insert).toBe('[[feature]]')
expect(source.slice(ctx.from, ctx.to)).toBe("[[f")
expect(ctx.items[0].insert).toBe("[[feature]]")
})
})

View File

@@ -21,41 +21,41 @@ export interface CompletionContext {
items: Completion[]
}
type Block = 'plan' | 'feature' | 'milestone'
type Block = "plan" | "feature" | "milestone"
const PLAN_KEYS: Completion[] = [
{ label: 'title', insert: 'title = ', detail: 'plan title' },
{ label: 'start', insert: 'start = ', detail: 'left edge — date, optional' },
{ label: 'end', insert: 'end = ', detail: 'right edge — date, optional' },
{ label: "title", insert: "title = ", detail: "plan title" },
{ label: "start", insert: "start = ", detail: "left edge — date, optional" },
{ label: "end", insert: "end = ", detail: "right edge — date, optional" },
]
const FEATURE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' },
{ label: 'start', insert: 'start = ', detail: 'date, required' },
{ label: 'original', insert: 'original = ', detail: 'Original Estimate — date, required' },
{ label: 'reestimates', insert: 'reestimates = ', detail: 'list of dates' },
{ label: 'delivered', insert: 'delivered = ', detail: 'date' },
{ label: 'status', insert: 'status = ', detail: 'on-track | at-risk | off-track' },
{ label: 'learning', insert: 'learning = ', detail: 'string' },
{ label: 'note', insert: 'note = ', detail: 'string' },
{ label: "name", insert: "name = ", detail: "required" },
{ label: "start", insert: "start = ", detail: "date, required" },
{ label: "original", insert: "original = ", detail: "Original Estimate — date, required" },
{ label: "reestimates", insert: "reestimates = ", detail: "list of dates" },
{ label: "delivered", insert: "delivered = ", detail: "date" },
{ label: "status", insert: "status = ", detail: "on-track | at-risk | off-track" },
{ label: "learning", insert: "learning = ", detail: "string" },
{ label: "note", insert: "note = ", detail: "string" },
]
const MILESTONE_KEYS: Completion[] = [
{ label: 'name', insert: 'name = ', detail: 'required' },
{ label: 'week', insert: 'week = ', detail: 'date, required' },
{ label: 'requires', insert: 'requires = ', detail: 'list of feature names' },
{ label: "name", insert: "name = ", detail: "required" },
{ label: "week", insert: "week = ", detail: "date, required" },
{ label: "requires", insert: "requires = ", detail: "list of feature names" },
]
const HEADERS: Completion[] = [
{ label: '[[feature]]', insert: '[[feature]]', detail: 'new feature', filter: 'feature' },
{ label: '[[milestone]]', insert: '[[milestone]]', detail: 'new milestone', filter: 'milestone' },
{ label: "[[feature]]", insert: "[[feature]]", detail: "new feature", filter: "feature" },
{ label: "[[milestone]]", insert: "[[milestone]]", detail: "new milestone", filter: "milestone" },
]
const STATUSES = ['on-track', 'at-risk', 'off-track'] // keep in sync with parse.ts
const STATUSES = ["on-track", "at-risk", "off-track"] // keep in sync with parse.ts
/** What to suggest at `caret` in `source`, or null when nothing applies. */
export function getCompletions(source: string, caret: number): CompletionContext | null {
const lineStart = source.lastIndexOf('\n', caret - 1) + 1
const lineStart = source.lastIndexOf("\n", caret - 1) + 1
const linePrefix = source.slice(lineStart, caret)
// ── value: status = "<here>" ──────────────────────────────────────────────
@@ -71,7 +71,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
const block = currentBlock(source, lineStart)
// ── value: requires = [ "<here>" … ] (milestones only) ───────────────────
if (block === 'milestone' && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
if (block === "milestone" && /requires\s*=\s*\[[^\]]*$/.test(linePrefix)) {
const token = /("?)([^",[\]]*)$/.exec(linePrefix)!
const items = filter(
featureNames(source).map((n) => ({ label: n, insert: `"${n}"` })),
@@ -88,7 +88,7 @@ export function getCompletions(source: string, caret: number): CompletionContext
return result(lineStart + indent.length, caret, filter(HEADERS, word))
}
const keys =
block === 'feature' ? FEATURE_KEYS : block === 'milestone' ? MILESTONE_KEYS : PLAN_KEYS
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
const taken = presentKeys(source, lineStart, block)
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
return result(caret - word.length, caret, items)
@@ -111,7 +111,7 @@ const HEADER_RE = /^[ \t]*\[\[(feature|milestone)\]\]/gm
/** The block owning the line at `lineStart` — 'plan' before the first header. */
function currentBlock(source: string, lineStart: number): Block {
let block: Block = 'plan'
let block: Block = "plan"
HEADER_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = HEADER_RE.exec(source)) !== null && m.index < lineStart) {
@@ -129,7 +129,7 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
let start = 0
let end = source.length
if (block === 'plan') {
if (block === "plan") {
end = headers[0] ?? source.length
} else {
for (let i = 0; i < headers.length; i++) {
@@ -151,11 +151,11 @@ function presentKeys(source: string, lineStart: number, block: Block): Set<strin
function featureNames(source: string): string[] {
const re = /^[ \t]*\[\[(feature|milestone)\]\]|^[ \t]*name\s*=\s*"([^"]*)"/gm
const names = new Set<string>()
let block: Block = 'plan'
let block: Block = "plan"
let m: RegExpExecArray | null
while ((m = re.exec(source)) !== null) {
if (m[1]) block = m[1] as Block
else if (m[2] && block === 'feature') names.add(m[2])
else if (m[2] && block === "feature") names.add(m[2])
}
return [...names]
}

View File

@@ -1,16 +1,16 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { useMacroplan } from './useMacroplan'
import { SAMPLE_PLAN } from '../data/sample'
import { describe, it, expect, beforeEach } from "vitest"
import { nextTick } from "vue"
import { useMacroplan } from "./useMacroplan"
import { SAMPLE_PLAN } from "../data/sample"
const LIB_KEY = 'macroplan:library'
const LEGACY_KEY = 'macroplan:source'
const LIB_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
beforeEach(() => localStorage.clear())
describe('useMacroplan — load & migration', () => {
it('seeds one sample plan when storage is empty, and persists it immediately', () => {
describe("useMacroplan — load & migration", () => {
it("seeds one sample plan when storage is empty, and persists it immediately", () => {
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.activeId.value).toBe(m.plans.value[0].id)
@@ -18,80 +18,80 @@ describe('useMacroplan — load & migration', () => {
expect(localStorage.getItem(LIB_KEY)).toBeTruthy() // survives a reload
})
it('migrates a legacy single-source store into a one-plan library and drops the legacy key', () => {
it("migrates a legacy single-source store into a one-plan library and drops the legacy key", () => {
localStorage.setItem(LEGACY_KEY, SAMPLE_PLAN)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp')
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp")
expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
})
it('falls back to a fresh sample when the library JSON is corrupt', () => {
localStorage.setItem(LIB_KEY, '{ not valid json')
it("falls back to a fresh sample when the library JSON is corrupt", () => {
localStorage.setItem(LIB_KEY, "{ not valid json")
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('falls back to a fresh sample when the stored library shape is malformed', () => {
it("falls back to a fresh sample when the stored library shape is malformed", () => {
// Valid JSON, wrong shape: a plan missing its `source` — would have slipped
// through the old blind `as Library` cast.
localStorage.setItem(
LIB_KEY,
JSON.stringify({ version: 1, activeId: 'x', plans: [{ id: 'x', name: 'Bad' }] }),
JSON.stringify({ version: 1, activeId: "x", plans: [{ id: "x", name: "Bad" }] }),
)
const m = useMacroplan()
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('repairs a stale activeId instead of discarding the stored plans', () => {
it("repairs a stale activeId instead of discarding the stored plans", () => {
localStorage.setItem(
LIB_KEY,
JSON.stringify({
version: 1,
activeId: 'gone',
plans: [{ id: 'x', name: 'Kept', source: SAMPLE_PLAN }],
activeId: "gone",
plans: [{ id: "x", name: "Kept", source: SAMPLE_PLAN }],
}),
)
const m = useMacroplan()
expect(m.activeId.value).toBe('x')
expect(m.plans.value[0].name).toBe('Kept')
expect(m.activeId.value).toBe("x")
expect(m.plans.value[0].name).toBe("Kept")
})
})
describe('useMacroplan — active plan binding', () => {
it('refreshes the cached name when the active source parses to a title, and autosaves', async () => {
describe("useMacroplan — active plan binding", () => {
it("refreshes the cached name when the active source parses to a title, and autosaves", async () => {
const m = useMacroplan()
m.source.value = 'title = "Renamed"\n'
await nextTick()
expect(m.plans.value[0].name).toBe('Renamed')
expect(localStorage.getItem(LIB_KEY)).toContain('Renamed')
expect(m.plans.value[0].name).toBe("Renamed")
expect(localStorage.getItem(LIB_KEY)).toContain("Renamed")
})
it('keeps the last-good name and render when the source is mid-edit/broken', async () => {
it("keeps the last-good name and render when the source is mid-edit/broken", async () => {
const m = useMacroplan()
const goodPlan = m.plan.value
m.source.value = 'title = "Renamed"\n[[feature]]\n' // feature missing name → parse error
await nextTick()
expect(m.error.value).toBeTruthy()
expect(m.plan.value).toBe(goodPlan) // last-good render retained
expect(m.plans.value[0].name).toBe('Q3 — Checkout revamp') // name unchanged
expect(m.plans.value[0].name).toBe("Q3 — Checkout revamp") // name unchanged
})
})
describe('useMacroplan — CRUD', () => {
it('newPlan appends a blank plan and activates it (the sample only seeds an empty library)', () => {
describe("useMacroplan — CRUD", () => {
it("newPlan appends a blank plan and activates it (the sample only seeds an empty library)", () => {
const m = useMacroplan()
const firstId = m.activeId.value
m.newPlan()
expect(m.plans.value).toHaveLength(2)
expect(m.activeId.value).not.toBe(firstId)
expect(m.source.value).toBe('') // blank page, not a copy of the sample
expect(m.source.value).toBe("") // blank page, not a copy of the sample
})
it('deletePlan removes the active plan and re-points to the preceding one', () => {
it("deletePlan removes the active plan and re-points to the preceding one", () => {
const m = useMacroplan()
m.newPlan() // 2 plans; second is active
const [first, second] = m.plans.value
@@ -100,14 +100,14 @@ describe('useMacroplan — CRUD', () => {
expect(m.activeId.value).toBe(first.id)
})
it('deleting the last plan re-seeds a fresh sample (never empty)', () => {
it("deleting the last plan re-seeds a fresh sample (never empty)", () => {
const m = useMacroplan()
m.deletePlan(m.activeId.value)
expect(m.plans.value).toHaveLength(1)
expect(m.source.value).toBe(SAMPLE_PLAN)
})
it('switching to a broken plan shows its own empty state, not the previous render', async () => {
it("switching to a broken plan shows its own empty state, not the previous render", async () => {
const m = useMacroplan()
const aId = m.activeId.value
m.newPlan()

View File

@@ -1,12 +1,12 @@
import { ref, shallowRef, computed, watch } from 'vue'
import * as v from 'valibot'
import { parseMacroplan } from '../model/parse'
import { buildPlan } from '../model/plan'
import type { Plan } from '../model/types'
import { SAMPLE_PLAN } from '../data/sample'
import { ref, shallowRef, computed, watch } from "vue"
import * as v from "valibot"
import { parseMacroplan } from "../model/parse"
import { buildPlan } from "../model/plan"
import type { Plan } from "../model/types"
import { SAMPLE_PLAN } from "../data/sample"
const STORAGE_KEY = 'macroplan:library'
const LEGACY_KEY = 'macroplan:source'
const STORAGE_KEY = "macroplan:library"
const LEGACY_KEY = "macroplan:source"
const StoredPlanSchema = v.object({
id: v.string(),
@@ -33,7 +33,7 @@ function titleOf(source: string): string | null {
}
function newStoredPlan(source: string): StoredPlan {
return { id: crypto.randomUUID(), name: titleOf(source) ?? 'Untitled', source }
return { id: crypto.randomUUID(), name: titleOf(source) ?? "Untitled", source }
}
function save(lib: Library): void {
@@ -145,7 +145,7 @@ export function useMacroplan() {
// empty library — first run (loadLibrary) or deleting the last plan
// (deletePlan) — and the ≥1-plan invariant keeps the library non-empty
// here, so "+ New" never re-clones the sample over an existing library.
const p = newStoredPlan('')
const p = newStoredPlan("")
lib.value.plans.push(p)
switchTo(p.id)
},

View File

@@ -1,17 +1,17 @@
import { describe, it, expect } from 'vitest'
import { exportFilename } from './usePngExport'
import { describe, it, expect } from "vitest"
import { exportFilename } from "./usePngExport"
describe('exportFilename', () => {
it('slugifies the plan title into a .png name', () => {
expect(exportFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.png')
describe("exportFilename", () => {
it("slugifies the plan title into a .png name", () => {
expect(exportFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.png")
})
it('collapses runs of punctuation/space and trims edge dashes', () => {
expect(exportFilename(' Hello, World!! ')).toBe('macroplan-hello-world.png')
it("collapses runs of punctuation/space and trims edge dashes", () => {
expect(exportFilename(" Hello, World!! ")).toBe("macroplan-hello-world.png")
})
it('falls back to a generic name when the title has no usable characters', () => {
expect(exportFilename('')).toBe('macroplan-plan.png')
expect(exportFilename('—— ··')).toBe('macroplan-plan.png')
it("falls back to a generic name when the title has no usable characters", () => {
expect(exportFilename("")).toBe("macroplan-plan.png")
expect(exportFilename("—— ··")).toBe("macroplan-plan.png")
})
})

View File

@@ -1,19 +1,19 @@
import { ref } from 'vue'
import { ref } from "vue"
type Toast = { kind: 'ok' | 'err'; text: string } | null
type Toast = { kind: "ok" | "err"; text: string } | null
/** Lowercase, dash-collapsed slug of a plan title — no extension, no fallback. */
export function slugify(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
/** Slugified, stable download name derived from the plan title. */
export function exportFilename(title: string): string {
return `macroplan-${slugify(title) || 'plan'}.png`
return `macroplan-${slugify(title) || "plan"}.png`
}
/**
@@ -28,26 +28,26 @@ export function usePngExport() {
const toast = ref<Toast>(null)
let timer: ReturnType<typeof setTimeout> | undefined
function flash(kind: 'ok' | 'err', text: string) {
function flash(kind: "ok" | "err", text: string) {
toast.value = { kind, text }
if (timer) clearTimeout(timer)
timer = setTimeout(() => (toast.value = null), 3000)
}
async function render(el: HTMLElement): Promise<Blob> {
const { toBlob } = await import('html-to-image')
const { toBlob } = await import("html-to-image")
// `exporting` expands the wrapper to max-content and un-clips the grid so the
// whole timeline is captured even when it scrolls horizontally on screen.
el.classList.add('exporting')
el.classList.add("exporting")
// reading layout flushes the class's style changes before we measure
const width = el.scrollWidth
const height = el.scrollHeight
try {
const blob = await toBlob(el, { width, height, pixelRatio: 2, cacheBust: true })
if (!blob) throw new Error('renderer returned no image')
if (!blob) throw new Error("renderer returned no image")
return blob
} finally {
el.classList.remove('exporting')
el.classList.remove("exporting")
}
}
@@ -57,29 +57,29 @@ export function usePngExport() {
try {
// ClipboardItem accepts a Promise<Blob>, so the async render stays inside
// the user-gesture window — Safari rejects a write started after an await.
await navigator.clipboard.write([new ClipboardItem({ 'image/png': render(el) })])
flash('ok', 'PNG copied to clipboard')
await navigator.clipboard.write([new ClipboardItem({ "image/png": render(el) })])
flash("ok", "PNG copied to clipboard")
} catch {
flash('err', 'Couldnt copy — use Download instead')
flash("err", "Couldnt copy — use Download instead")
} finally {
busy.value = false
}
}
async function downloadPng(el?: HTMLElement | null, filename = 'macroplan.png') {
async function downloadPng(el?: HTMLElement | null, filename = "macroplan.png") {
if (!el || busy.value) return
busy.value = true
try {
const blob = await render(el)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
flash('ok', 'PNG downloaded')
flash("ok", "PNG downloaded")
} catch {
flash('err', 'Export failed — please retry')
flash("err", "Export failed — please retry")
} finally {
busy.value = false
}

View File

@@ -1,13 +1,13 @@
import { describe, it, expect } from 'vitest'
import { sourceFilename } from './useSourceExport'
import { describe, it, expect } from "vitest"
import { sourceFilename } from "./useSourceExport"
describe('sourceFilename', () => {
it('slugifies the plan title into a .toml name', () => {
expect(sourceFilename('Q3 — Checkout revamp')).toBe('macroplan-q3-checkout-revamp.toml')
describe("sourceFilename", () => {
it("slugifies the plan title into a .toml name", () => {
expect(sourceFilename("Q3 — Checkout revamp")).toBe("macroplan-q3-checkout-revamp.toml")
})
it('falls back to a generic name when the title has no usable characters', () => {
expect(sourceFilename('')).toBe('macroplan-plan.toml')
expect(sourceFilename('—— ··')).toBe('macroplan-plan.toml')
it("falls back to a generic name when the title has no usable characters", () => {
expect(sourceFilename("")).toBe("macroplan-plan.toml")
expect(sourceFilename("—— ··")).toBe("macroplan-plan.toml")
})
})

View File

@@ -1,15 +1,15 @@
import { slugify } from './usePngExport'
import { slugify } from "./usePngExport"
/** Slugified, stable download name for a plan's TOML source. */
export function sourceFilename(title: string): string {
return `macroplan-${slugify(title) || 'plan'}.toml`
return `macroplan-${slugify(title) || "plan"}.toml`
}
/** Download a plan's TOML source as a .toml file (client-side, no backend). */
export function downloadSource(source: string, filename: string): void {
const blob = new Blob([source], { type: 'text/plain;charset=utf-8' })
const blob = new Blob([source], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from "vue"
import "./style.css"
import App from "./App.vue"
createApp(App).mount('#app')
createApp(App).mount("#app")

View File

@@ -1,35 +1,35 @@
import * as v from 'valibot'
import { parse as parseToml } from 'smol-toml'
import { toYmd } from './week'
import type { RawPlan, StatusLevel } from './types'
import * as v from "valibot"
import { parse as parseToml } from "smol-toml"
import { toYmd } from "./week"
import type { RawPlan, StatusLevel } from "./types"
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'PlanParseError'
this.name = "PlanParseError"
}
}
const STATUSES = ['on-track', 'at-risk', 'off-track'] as const satisfies readonly StatusLevel[]
const STATUSES = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[]
// ── Field schemas ──────────────────────────────────────────────────────────
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
// normalized to yyyy-mm-dd via `toYmd`.
const Ymd = v.pipe(
v.union([v.date(), v.string()], 'must be a date (e.g. 2026-06-01)'),
v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
v.transform((value: string | Date) => toYmd(value)),
)
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(', ')}`)
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
const Name = v.pipe(v.string('is required'), v.nonEmpty('is required'))
const Name = v.pipe(v.string("is required"), v.nonEmpty("is required"))
const FeatureSchema = v.object({
name: Name,
start: Ymd,
original: Ymd,
reestimates: v.optional(v.array(Ymd, 'must be a list of dates'), []),
reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []),
delivered: v.optional(Ymd),
learning: v.optional(v.string()),
status: v.optional(Status),
@@ -39,7 +39,10 @@ const FeatureSchema = v.object({
const MilestoneSchema = v.object({
name: Name,
week: Ymd,
requires: v.optional(v.array(v.string('must be a feature name'), 'must be a list of feature names'), []),
requires: v.optional(
v.array(v.string("must be a feature name"), "must be a list of feature names"),
[],
),
})
/** Parse + validate a Macroplan TOML source into the raw model. */
@@ -52,14 +55,14 @@ export function parseMacroplan(source: string): RawPlan {
}
return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
start: data.start != null ? check(Ymd, data.start, 'plan', 'start') : undefined,
end: data.end != null ? check(Ymd, data.end, 'plan', 'end') : undefined,
features: asBlocks(data.feature, 'feature').map((f, i) =>
check(FeatureSchema, f, blockWhere('feature', f, i)),
title: data.title != null ? String(data.title) : "Untitled Macroplan",
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
features: asBlocks(data.feature, "feature").map((f, i) =>
check(FeatureSchema, f, blockWhere("feature", f, i)),
),
milestones: asBlocks(data.milestone, 'milestone').map((m, i) =>
check(MilestoneSchema, m, blockWhere('milestone', m, i)),
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
),
}
}
@@ -75,10 +78,10 @@ function asBlocks(value: unknown, key: string): unknown[] {
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
function blockWhere(kind: string, block: unknown, i: number): string {
const name =
block != null && typeof block === 'object' && 'name' in block
block != null && typeof block === "object" && "name" in block
? (block as { name: unknown }).name
: undefined
return name != null && name !== '' ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
}
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
@@ -106,7 +109,7 @@ type Issue = {
* "Invalid key" wording for that, which isn't fit to show the author). */
function friendly(issue: Issue, fallbackField?: string): string {
const key = issue.path?.[0]?.key
const field = typeof key === 'string' ? key : fallbackField
if (issue.received === 'undefined') return field ? `missing \`${field}\`` : 'missing value'
const field = typeof key === "string" ? key : fallbackField
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
return field ? `\`${field}\` ${issue.message}` : issue.message
}

View File

@@ -1,11 +1,11 @@
import { describe, it, expect } from 'vitest'
import { parseMacroplan, PlanParseError } from './parse'
import { buildPlan } from './plan'
import { mondayOf, weekRange } from './week'
import type { FeatureRow } from './types'
import { SAMPLE_PLAN } from '../data/sample'
import { describe, it, expect } from "vitest"
import { parseMacroplan, PlanParseError } from "./parse"
import { buildPlan } from "./plan"
import { mondayOf, weekRange } from "./week"
import type { FeatureRow } from "./types"
import { SAMPLE_PLAN } from "../data/sample"
const TODAY = '2026-06-17' // a Wednesday → week of Mon 2026-06-15
const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
function rowOf(source: string, name: string): FeatureRow {
const plan = buildPlan(parseMacroplan(source), TODAY)
@@ -14,168 +14,168 @@ function rowOf(source: string, name: string): FeatureRow {
return row
}
describe('week math', () => {
it('snaps any day to its Monday', () => {
expect(mondayOf('2026-06-17')).toBe('2026-06-15') // Wed → Mon
expect(mondayOf('2026-06-15')).toBe('2026-06-15') // Mon → Mon
expect(mondayOf('2026-06-21')).toBe('2026-06-15') // Sun → that week's Mon
expect(mondayOf('2026-06-01')).toBe('2026-06-01') // Mon
describe("week math", () => {
it("snaps any day to its Monday", () => {
expect(mondayOf("2026-06-17")).toBe("2026-06-15") // Wed → Mon
expect(mondayOf("2026-06-15")).toBe("2026-06-15") // Mon → Mon
expect(mondayOf("2026-06-21")).toBe("2026-06-15") // Sun → that week's Mon
expect(mondayOf("2026-06-01")).toBe("2026-06-01") // Mon
})
it('builds an inclusive contiguous Monday range', () => {
expect(weekRange('2026-06-01', '2026-06-22')).toEqual([
'2026-06-01',
'2026-06-08',
'2026-06-15',
'2026-06-22',
it("builds an inclusive contiguous Monday range", () => {
expect(weekRange("2026-06-01", "2026-06-22")).toEqual([
"2026-06-01",
"2026-06-08",
"2026-06-15",
"2026-06-22",
])
})
})
describe('F2 — on-time / late classification (ADR-0001)', () => {
describe("F2 — on-time / late classification (ADR-0001)", () => {
const base = (extra: string) =>
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-15\n${extra}\n`
it('delivered on the Original Estimate week → ◉, no ◯, onTime true', () => {
const r = rowOf(base('delivered = 2026-06-15'), 'X')
it("delivered on the Original Estimate week → ◉, no ◯, onTime true", () => {
const r = rowOf(base("delivered = 2026-06-15"), "X")
expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
expect(r.markers[0].week).toBe('2026-06-15')
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe("2026-06-15")
})
it('delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery', () => {
const r = rowOf(base('delivered = 2026-06-08'), 'X')
it("delivered earlier than the Original Estimate → still on-time ◉, bar ends at delivery", () => {
const r = rowOf(base("delivered = 2026-06-08"), "X")
expect(r.onTime).toBe(true)
expect(r.markers.map((m) => m.kind)).toEqual(['delivered-on-time'])
expect(r.markers[0].week).toBe('2026-06-08')
expect(r.barEndWeek).toBe('2026-06-08') // no ◯ dangling in the future
expect(r.markers.map((m) => m.kind)).toEqual(["delivered-on-time"])
expect(r.markers[0].week).toBe("2026-06-08")
expect(r.barEndWeek).toBe("2026-06-08") // no ◯ dangling in the future
})
it('delivered after the Original Estimate → ▲ late, ◯ baseline preserved', () => {
const r = rowOf(base('delivered = 2026-06-29'), 'X')
it("delivered after the Original Estimate → ▲ late, ◯ baseline preserved", () => {
const r = rowOf(base("delivered = 2026-06-29"), "X")
expect(r.onTime).toBe(false)
const kinds = r.markers.map((m) => `${m.kind}@${m.week}`).sort()
expect(kinds).toEqual(['delivered-late@2026-06-29', 'original@2026-06-15'])
expect(r.barEndWeek).toBe('2026-06-29')
expect(kinds).toEqual(["delivered-late@2026-06-29", "original@2026-06-15"])
expect(r.barEndWeek).toBe("2026-06-29")
})
it('late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)', () => {
const r = rowOf(
base('reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20'),
'X',
)
it("late delivery with multiple slips keeps ◯ + every △ + ▲ (judged vs original, not re-estimate)", () => {
const r = rowOf(base("reestimates = [2026-06-29, 2026-07-13]\ndelivered = 2026-07-20"), "X")
expect(r.onTime).toBe(false) // 07-20 > original 06-15, regardless of the 07-13 re-estimate
expect(r.slipCount).toBe(2)
const byKind = r.markers.reduce<Record<string, string[]>>((acc, m) => {
;(acc[m.kind] ??= []).push(m.week)
return acc
}, {})
expect(byKind.original).toEqual(['2026-06-15'])
expect(byKind.reestimate?.sort()).toEqual(['2026-06-29', '2026-07-13'])
expect(byKind['delivered-late']).toEqual(['2026-07-20'])
expect(byKind.original).toEqual(["2026-06-15"])
expect(byKind.reestimate?.sort()).toEqual(["2026-06-29", "2026-07-13"])
expect(byKind["delivered-late"]).toEqual(["2026-07-20"])
})
it('in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate', () => {
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), 'X')
it("in-flight (undelivered) → ◯ only, onTime null, bar ends at the furthest estimate", () => {
const r = rowOf(base('reestimates = [2026-06-29]\nstatus = "off-track"'), "X")
expect(r.onTime).toBeNull()
expect(r.delivered).toBe(false)
expect(r.status).toBe('off-track')
expect(r.barEndWeek).toBe('2026-06-29') // furthest open estimate
expect(r.markers.some((m) => m.kind === 'original')).toBe(true)
expect(r.status).toBe("off-track")
expect(r.barEndWeek).toBe("2026-06-29") // furthest open estimate
expect(r.markers.some((m) => m.kind === "original")).toBe(true)
})
})
describe('bar extent relative to now', () => {
describe("bar extent relative to now", () => {
const feat = (extra: string) =>
`[[feature]]\nname = "X"\nstart = 2026-06-01\noriginal = 2026-06-08\n${extra}\n`
it('extends an overdue undelivered bar to the now week, keeping ◯ at the estimate', () => {
it("extends an overdue undelivered bar to the now week, keeping ◯ at the estimate", () => {
// original 2026-06-08 is before now (week of 2026-06-15) and undelivered → overdue
const r = rowOf(feat('status = "off-track"'), 'X')
const r = rowOf(feat('status = "off-track"'), "X")
expect(r.delivered).toBe(false)
expect(r.barEndWeek).toBe('2026-06-15') // runs up to now, past the 06-08 estimate
expect(r.markers.find((m) => m.kind === 'original')?.week).toBe('2026-06-08')
expect(r.barEndWeek).toBe("2026-06-15") // runs up to now, past the 06-08 estimate
expect(r.markers.find((m) => m.kind === "original")?.week).toBe("2026-06-08")
})
it('does not extend a delivered bar past its delivery, even when now is later', () => {
const r = rowOf(feat('delivered = 2026-06-08'), 'X')
expect(r.barEndWeek).toBe('2026-06-08') // ends at delivery, not at the now week
it("does not extend a delivered bar past its delivery, even when now is later", () => {
const r = rowOf(feat("delivered = 2026-06-08"), "X")
expect(r.barEndWeek).toBe("2026-06-08") // ends at delivery, not at the now week
})
it('ends an on-track undelivered bar at the future estimate (no extension back to now)', () => {
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', 'X')
expect(r.barEndWeek).toBe('2026-07-06')
it("ends an on-track undelivered bar at the future estimate (no extension back to now)", () => {
const r = rowOf('[[feature]]\nname="X"\nstart=2026-06-22\noriginal=2026-07-06\n', "X")
expect(r.barEndWeek).toBe("2026-07-06")
})
})
describe('plan derivation', () => {
it('derives a contiguous week range and places the now line', () => {
describe("plan derivation", () => {
it("derives a contiguous week range and places the now line", () => {
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
expect(plan.weeks[0]).toBe('2026-05-25') // authored span start (lead-in before earliest Feature)
expect(plan.weeks.at(-1)).toBe('2026-08-03') // authored span end (trailing past last marker)
expect(plan.weeks[0]).toBe("2026-05-25") // authored span start (lead-in before earliest Feature)
expect(plan.weeks.at(-1)).toBe("2026-08-03") // authored span end (trailing past last marker)
// contiguous, weekly
expect(plan.weeks).toContain('2026-06-29')
expect(plan.nowWeek).toBe('2026-06-15')
expect(plan.weeks).toContain("2026-06-29")
expect(plan.nowWeek).toBe("2026-06-15")
expect(plan.nowInRange).toBe(true)
})
it('flags a Milestones unmet required Features (undelivered or delivered after the milestone)', () => {
it("flags a Milestones unmet required Features (undelivered or delivered after the milestone)", () => {
const plan = buildPlan(parseMacroplan(SAMPLE_PLAN), TODAY)
const mvp = plan.milestones.find((m) => m.name === 'MVP go-live')!
expect(mvp.week).toBe('2026-07-06')
const mvp = plan.milestones.find((m) => m.name === "MVP go-live")!
expect(mvp.week).toBe("2026-07-06")
// Auth delivered 06-15 (met); Payments delivered 07-20 > 07-06 (unmet); Dashboard undelivered (unmet)
expect(mvp.unmet.sort()).toEqual(['Dashboard', 'Payments'])
expect(mvp.unmet.sort()).toEqual(["Dashboard", "Payments"])
})
})
describe('authored plan span (start / end)', () => {
const body = '[[feature]]\nname="X"\nstart=2026-06-08\noriginal=2026-06-15\ndelivered=2026-06-15\n'
describe("authored plan span (start / end)", () => {
const body =
'[[feature]]\nname="X"\nstart=2026-06-08\noriginal=2026-06-15\ndelivered=2026-06-15\n'
it('extends the range earlier to `start` and later to `end`', () => {
it("extends the range earlier to `start` and later to `end`", () => {
const plan = buildPlan(parseMacroplan(`start = 2026-06-01\nend = 2026-06-29\n${body}`), TODAY)
expect(plan.weeks).toEqual(weekRange('2026-06-01', '2026-06-29'))
expect(plan.weeks[0]).toBe('2026-06-01') // before the earliest Feature week (06-08)
expect(plan.weeks.at(-1)).toBe('2026-06-29') // after the last marker (06-15)
expect(plan.weeks).toEqual(weekRange("2026-06-01", "2026-06-29"))
expect(plan.weeks[0]).toBe("2026-06-01") // before the earliest Feature week (06-08)
expect(plan.weeks.at(-1)).toBe("2026-06-29") // after the last marker (06-15)
})
it('snaps authored bounds to their Monday', () => {
it("snaps authored bounds to their Monday", () => {
const plan = buildPlan(parseMacroplan(`start = 2026-06-03\nend = 2026-06-24\n${body}`), TODAY)
expect(plan.weeks[0]).toBe('2026-06-01') // Wed 06-03 → Mon 06-01
expect(plan.weeks.at(-1)).toBe('2026-06-22') // Wed 06-24 → Mon 06-22
expect(plan.weeks[0]).toBe("2026-06-01") // Wed 06-03 → Mon 06-01
expect(plan.weeks.at(-1)).toBe("2026-06-22") // Wed 06-24 → Mon 06-22
})
it('only extends — a marker outside the authored bounds is never clipped', () => {
it("only extends — a marker outside the authored bounds is never clipped", () => {
// end 06-08 is before the Feature's 06-15 delivery → the range still includes it
const plan = buildPlan(parseMacroplan(`start = 2026-06-08\nend = 2026-06-08\n${body}`), TODAY)
expect(plan.weeks[0]).toBe('2026-06-08')
expect(plan.weeks.at(-1)).toBe('2026-06-15')
expect(plan.weeks[0]).toBe("2026-06-08")
expect(plan.weeks.at(-1)).toBe("2026-06-15")
})
it('renders an empty plan across the authored bounds when there are no Features', () => {
const plan = buildPlan(parseMacroplan('start = 2026-06-01\nend = 2026-06-22\n'), TODAY)
it("renders an empty plan across the authored bounds when there are no Features", () => {
const plan = buildPlan(parseMacroplan("start = 2026-06-01\nend = 2026-06-22\n"), TODAY)
expect(plan.rows).toHaveLength(0)
expect(plan.weeks).toEqual(weekRange('2026-06-01', '2026-06-22'))
expect(plan.weeks).toEqual(weekRange("2026-06-01", "2026-06-22"))
})
it('rejects a non-date span bound', () => {
expect(() => parseMacroplan('start = 123\n')).toThrow(/start/)
it("rejects a non-date span bound", () => {
expect(() => parseMacroplan("start = 123\n")).toThrow(/start/)
})
})
describe('parse validation', () => {
it('rejects a feature missing its Original Estimate', () => {
describe("parse validation", () => {
it("rejects a feature missing its Original Estimate", () => {
expect(() => parseMacroplan('[[feature]]\nname = "A"\nstart = 2026-06-01\n')).toThrow(
PlanParseError,
)
})
it('rejects an invalid status', () => {
it("rejects an invalid status", () => {
expect(() =>
parseMacroplan('[[feature]]\nname="A"\nstart=2026-06-01\noriginal=2026-06-08\nstatus="blue"\n'),
parseMacroplan(
'[[feature]]\nname="A"\nstart=2026-06-01\noriginal=2026-06-08\nstatus="blue"\n',
),
).toThrow(/status/)
})
it('parses the bundled sample without error', () => {
it("parses the bundled sample without error", () => {
const raw = parseMacroplan(SAMPLE_PLAN)
expect(raw.features).toHaveLength(5)
expect(raw.milestones).toHaveLength(3)

View File

@@ -1,5 +1,5 @@
import { mondayOf, weekRange, type WeekId } from './week'
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from './types'
import { mondayOf, weekRange, type WeekId } from "./week"
import type { RawPlan, RawFeature, Plan, FeatureRow, Marker, MilestoneLine } from "./types"
/**
* Derive the render-ready Plan from the raw model (component C2).
@@ -17,7 +17,7 @@ export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan
const row = rows.find((r) => r.name === name)
if (!row) return true // references an unknown Feature → can't be met
const delivery = row.markers.find(
(x) => x.kind === 'delivered-on-time' || x.kind === 'delivered-late',
(x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
)
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
})
@@ -56,14 +56,14 @@ function buildRow(f: RawFeature, nowWeek: WeekId): FeatureRow {
const onTime = delivered ? deliveredWeek! <= originalWeek : null
const markers: Marker[] = []
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: 'reestimate' })
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: "reestimate" })
if (delivered) {
markers.push({ week: deliveredWeek!, kind: onTime ? 'delivered-on-time' : 'delivered-late' })
markers.push({ week: deliveredWeek!, kind: onTime ? "delivered-on-time" : "delivered-late" })
}
// The Original Estimate `◯` stands unless an on-time/early delivery already
// occupies (or precedes) it — then the delivery marker speaks for it.
if (!(delivered && onTime)) {
markers.push({ week: originalWeek, kind: 'original' })
markers.push({ week: originalWeek, kind: "original" })
}
const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))

View File

@@ -1,6 +1,6 @@
import type { WeekId } from './week'
import type { WeekId } from "./week"
export type StatusLevel = 'on-track' | 'at-risk' | 'off-track'
export type StatusLevel = "on-track" | "at-risk" | "off-track"
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
@@ -32,10 +32,10 @@ export interface RawPlan {
// ── Derived model: render-ready (C2 output) ──
export type MarkerKind =
| 'original' // ◯ Original Estimate, not yet delivered
| 'reestimate' // △ a slip to a later week
| 'delivered-on-time' // ◉ delivered on/before the Original Estimate
| 'delivered-late' // ▲ delivered after the Original Estimate
| "original" // ◯ Original Estimate, not yet delivered
| "reestimate" // △ a slip to a later week
| "delivered-on-time" // ◉ delivered on/before the Original Estimate
| "delivered-late" // ▲ delivered after the Original Estimate
export interface Marker {
week: WeekId

View File

@@ -10,14 +10,14 @@ export type WeekId = string // 'yyyy-mm-dd', always a Monday
*/
export function toYmd(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10)
if (typeof value === 'string') return value.slice(0, 10)
if (typeof value === "string") return value.slice(0, 10)
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
}
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
// timezone boundary.
function utcNoon(ymd: string): Date {
const [y, m, d] = ymd.split('-').map(Number)
const [y, m, d] = ymd.split("-").map(Number)
return new Date(Date.UTC(y, m - 1, d, 12))
}
@@ -51,9 +51,9 @@ export function weekRange(start: WeekId, end: WeekId): WeekId[] {
/** Short column label for a week, e.g. "Jun 15". */
export function weekLabel(week: WeekId): string {
return utcNoon(week).toLocaleDateString('en-US', {
month: 'short',
day: '2-digit',
timeZone: 'UTC',
return utcNoon(week).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
timeZone: "UTC",
})
}