feat: add valibot

This commit is contained in:
Julien Calixte
2026-06-17 10:20:40 +02:00
parent 362a448848
commit 64efc91392
6 changed files with 130 additions and 69 deletions

View File

@@ -34,6 +34,18 @@ describe('useMacroplan — load & migration', () => {
expect(m.source.value).toBe(SAMPLE_PLAN)
})
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' }] }),
)
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', () => {
localStorage.setItem(
LIB_KEY,
@@ -70,13 +82,13 @@ describe('useMacroplan — active plan binding', () => {
})
describe('useMacroplan — CRUD', () => {
it('newPlan appends a sample plan and activates it', () => {
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(SAMPLE_PLAN)
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', () => {

View File

@@ -1,4 +1,5 @@
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'
@@ -7,17 +8,19 @@ import { SAMPLE_PLAN } from '../data/sample'
const STORAGE_KEY = 'macroplan:library'
const LEGACY_KEY = 'macroplan:source'
export interface StoredPlan {
id: string
name: string
source: string
}
const StoredPlanSchema = v.object({
id: v.string(),
name: v.string(),
source: v.string(),
})
export type StoredPlan = v.InferOutput<typeof StoredPlanSchema>
interface Library {
version: 1
activeId: string
plans: StoredPlan[]
}
const LibrarySchema = v.object({
version: v.literal(1),
activeId: v.string(),
plans: v.array(StoredPlanSchema),
})
type Library = v.InferOutput<typeof LibrarySchema>
/** The source's title if it fully parses, else null (so the cached name only
* ever updates on a valid parse). */
@@ -50,8 +53,9 @@ function loadLibrary(): Library {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
try {
const lib = JSON.parse(raw) as Library
if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) {
const parsed = v.safeParse(LibrarySchema, JSON.parse(raw))
if (parsed.success && parsed.output.plans.length > 0) {
const lib = parsed.output
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
}
@@ -137,7 +141,11 @@ export function useMacroplan() {
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
},
newPlan: () => {
const p = newStoredPlan(SAMPLE_PLAN)
// A new plan starts blank. The bundled sample is reserved for a genuinely
// 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('')
lib.value.plans.push(p)
switchTo(p.id)
},

View File

@@ -1,6 +1,7 @@
import * as v from 'valibot'
import { parse as parseToml } from 'smol-toml'
import { toYmd } from './week'
import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types'
import type { RawPlan, StatusLevel } from './types'
/** Thrown for any malformed source — message is safe to show the author. */
export class PlanParseError extends Error {
@@ -10,7 +11,36 @@ export class PlanParseError extends Error {
}
}
const STATUSES: StatusLevel[] = ['green', 'orange', 'red']
const STATUSES = ['green', 'orange', 'red'] 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.transform((value: string | Date) => toYmd(value)),
)
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(', ')}`)
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'), []),
delivered: v.optional(Ymd),
learning: v.optional(v.string()),
status: v.optional(Status),
note: v.optional(v.string()),
})
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'), []),
})
/** Parse + validate a Macroplan TOML source into the raw model. */
export function parseMacroplan(source: string): RawPlan {
@@ -21,67 +51,62 @@ export function parseMacroplan(source: string): RawPlan {
throw new PlanParseError(e instanceof Error ? e.message : String(e))
}
const features = asBlocks(data.feature, 'feature').map(parseFeature)
const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone)
return {
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
start: data.start != null ? toYmdOr('plan', 'start', data.start) : undefined,
end: data.end != null ? toYmdOr('plan', 'end', data.end) : undefined,
features,
milestones,
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)),
),
}
}
function asBlocks(value: unknown, key: string): Record<string, unknown>[] {
function asBlocks(value: unknown, key: string): unknown[] {
if (value == null) return []
if (!Array.isArray(value)) {
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
}
return value as Record<string, unknown>[]
return value
}
function parseFeature(f: Record<string, unknown>, i: number): RawFeature {
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
}
if (f.reestimates != null && !Array.isArray(f.reestimates)) {
throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`)
}
return {
name: String(f.name),
start: toYmdOr(where, 'start', f.start),
original: toYmdOr(where, 'original', f.original),
reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)),
delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined,
learning: f.learning != null ? String(f.learning) : undefined,
status: f.status as StatusLevel | undefined,
note: f.note != null ? String(f.note) : undefined,
}
/** "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 as { name: unknown }).name
: undefined
return name != null && name !== '' ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
}
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
if (m.requires != null && !Array.isArray(m.requires)) {
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
}
return {
name: String(m.name),
week: toYmdOr(where, 'week', m.week),
requires: ((m.requires as unknown[]) ?? []).map(String),
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
function check<S extends v.GenericSchema>(
schema: S,
value: unknown,
where: string,
field?: string,
): v.InferOutput<S> {
const result = v.safeParse(schema, value)
if (!result.success) {
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
}
return result.output
}
function toYmdOr(where: string, field: string, value: unknown): string {
try {
return toYmd(value)
} catch {
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
}
type Issue = {
readonly message: string
readonly received: string
readonly path?: ReadonlyArray<{ readonly key: unknown }>
}
/** Render an issue against the offending field, e.g. "`start` must be a date"
* — or "missing `original`" when the key is absent (valibot reports its own
* "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'
return field ? `\`${field}\` ${issue.message}` : issue.message
}