feat: add valibot
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user