From 0a69ccabfbe36c15ec793581043f8f48e9e4dcc4 Mon Sep 17 00:00:00 2001 From: Sjoerd de Vries Date: Wed, 20 May 2026 17:45:46 +0200 Subject: [PATCH] feat: AI schedule JSON parser --- src/__tests__/ai/parser.test.ts | 28 ++++++++++++++++++++++++++++ src/ai/parser.ts | 21 +++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/__tests__/ai/parser.test.ts create mode 100644 src/ai/parser.ts diff --git a/src/__tests__/ai/parser.test.ts b/src/__tests__/ai/parser.test.ts new file mode 100644 index 0000000..2e29297 --- /dev/null +++ b/src/__tests__/ai/parser.test.ts @@ -0,0 +1,28 @@ +import { parseScheduleJson } from '../../ai/parser'; + +describe('parseScheduleJson', () => { + it('parses a valid JSON array', () => { + const input = JSON.stringify([ + { date: '2026-05-22', planned_min: 60, rationale: 'Continue level' }, + { date: '2026-05-24', planned_min: 75, rationale: 'Step up' }, + ]); + const result = parseScheduleJson(input); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ date: '2026-05-22', planned_min: 60, rationale: 'Continue level' }); + }); + + it('parses JSON wrapped in a markdown code block', () => { + const input = '```json\n[{"date":"2026-05-22","planned_min":60,"rationale":"test"}]\n```'; + const result = parseScheduleJson(input); + expect(result).toHaveLength(1); + expect(result[0]!.date).toBe('2026-05-22'); + }); + + it('throws on invalid JSON', () => { + expect(() => parseScheduleJson('not json')).toThrow(); + }); + + it('throws if entries are missing required fields', () => { + expect(() => parseScheduleJson(JSON.stringify([{ date: '2026-05-22' }]))).toThrow(); + }); +}); diff --git a/src/ai/parser.ts b/src/ai/parser.ts new file mode 100644 index 0000000..0783d4d --- /dev/null +++ b/src/ai/parser.ts @@ -0,0 +1,21 @@ +export interface ScheduleProposal { + date: string; + planned_min: number; + rationale: string; +} + +export function parseScheduleJson(raw: string): ScheduleProposal[] { + const cleaned = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim(); + const parsed = JSON.parse(cleaned); + + if (!Array.isArray(parsed)) throw new Error('Expected a JSON array'); + + return parsed.map((entry: unknown, i: number) => { + if (typeof entry !== 'object' || entry === null) throw new Error(`Entry ${i} is not an object`); + const e = entry as Record; + if (typeof e['date'] !== 'string') throw new Error(`Entry ${i} missing "date"`); + if (typeof e['planned_min'] !== 'number') throw new Error(`Entry ${i} missing "planned_min"`); + if (typeof e['rationale'] !== 'string') throw new Error(`Entry ${i} missing "rationale"`); + return { date: e['date'], planned_min: e['planned_min'], rationale: e['rationale'] }; + }); +}