diff --git a/README.md b/README.md index 30544e2..1ad24a8 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,26 @@ Every command supports `--json` for piping into `jq` or Claude. | Command | Description | |---|---| -| `mna variants add --name [--notes=...]` | Add a new variant. | +| `mna variants add --name [--notes=...]` | Add a new variant. Dates are required — see below. | | `mna variants duplicate ` | Duplicate a variant. | -| `mna variants edit [--name=...] [--notes=...]` | Update variant fields. | +| `mna variants edit [--name=...] [--notes=...] []` | Update variant fields. Omitting the date flags leaves the dates alone. | | `mna variants select ` | Set the selected variant. | | `mna variants delete [--yes]` | Delete a variant. | +A variant carries the trip's dates, and the API refuses to store one without them. Give +either shape, whole — the CLI rejects a half-filled or mixed set before it sends anything: + +- **Exact:** `--start-date=YYYY-MM-DD --end-date=YYYY-MM-DD` +- **Flexible:** `--depart-not-before --depart-not-after --return-not-before --return-not-after --min-nights --max-nights` + +```bash +mna variants add --name "Beach option" --start-date 2026-09-01 --end-date 2026-09-08 +mna variants add --name "Flexible option" \ + --depart-not-before 2026-09-01 --depart-not-after 2026-09-03 \ + --return-not-before 2026-09-10 --return-not-after 2026-09-12 \ + --min-nights 7 --max-nights 10 +``` + ### Destinations | Command | Description | diff --git a/skills/mna/SKILL.md b/skills/mna/SKILL.md index 5b02c41..bab2a6c 100644 --- a/skills/mna/SKILL.md +++ b/skills/mna/SKILL.md @@ -79,7 +79,10 @@ the playbook below. 0. **Bootstrap** — `mna whoami`; everything `--json`. 1. **Frame & anchor** — confirm participants, exact dates, origin/return, vibe, budget, the main goal. Anchor on an existing trip (`trips list`) or `mna trips create --name "…"`. -2. **Shape variants with the user** — agree on 2–3 comparable strategies. `mna variants add`, or +2. **Shape variants with the user** — agree on 2–3 comparable strategies. The variant carries the + trip's dates, and `mna variants add` requires them: + `mna variants add --name "…" --start-date --end-date ` + (or the six flexible-dates flags — see `references/cli-and-schemas.md`). Or `mna variants duplicate` to fork a baseline and then `variants edit` the notes. 3. **Lay out destinations** — add in travel order with dates: `mna destinations add --place "" diff --git a/skills/mna/references/cli-and-schemas.md b/skills/mna/references/cli-and-schemas.md index aa67244..35fdcb3 100644 --- a/skills/mna/references/cli-and-schemas.md +++ b/skills/mna/references/cli-and-schemas.md @@ -8,7 +8,8 @@ the field shapes you can't guess. Examples below use neutral placeholders. ``` trips list | show [--all-options] | create --name | edit | delete | share | unshare -variants add --name | duplicate | edit | select | delete +variants add --name [--notes] | duplicate + edit [--name --notes ] | select | delete destinations add --place [--start-date --end-date --notes --return-to-home] edit [--place --notes --start-date --end-date --return-to-home/--no-…] reorder --order=k1,k2,k3 | delete @@ -70,7 +71,7 @@ home-return drive on the `--return-to-home` destination so the per-variant total ```json { "name": "City walking tour", "start": "2026-07-06T10:00", "end": "2026-07-06T13:00", "totalCost": 0, "currency": "EUR", "link": "…", "notes": "…", - "location": { "name": "Old Town", "formattedAddress": "…", "coordinates": { "latitude": 0.0, "longitude": 0.0 } } } + "location": { "name": "Old Town", "formattedAddress": "…", "coordinates": { "lat": 0.0, "lng": 0.0 } } } ``` ## Location shape — a non-obvious gotcha @@ -86,10 +87,10 @@ Coordinates persist **only** when sent nested: "coordinates": { "lat": 0.000000, "lng": 0.000000 } } ``` -(Option locations use `coordinates.lat`/`.lng`; **event** locations use -`coordinates.latitude`/`.longitude`.) Get exact coordinates from the search result, or geocode -the address (e.g. OpenStreetMap Nominatim). After setting, **verify with `trips show`** — never -trust the 2xx alone. +Coordinate keys are `lat`/`lng` **everywhere, including event locations** — an event body with +`coordinates.latitude`/`.longitude` is a 500, not a silent drop (verified against production). +Get exact coordinates from the search result, or geocode the address (e.g. OpenStreetMap +Nominatim). After setting, **verify with `trips show`** — never trust the 2xx alone. ## Dates @@ -97,6 +98,22 @@ trust the 2xx alone. and event `start/end` are all ISO date-time. Accept `YYYY-MM-DD` from the user and normalize. Using `T12:00:00.000Z` (noon UTC) avoids timezone off-by-one on the displayed calendar date. +### Variant dates — required on create + +The variant is what carries the trip's dates, and the API refuses to store one without them. +`variants add` takes one of two complete shapes; a half-filled or mixed set is rejected locally: + +```bash +mna variants add --name "Beach option" --start-date 2026-09-01 --end-date 2026-09-08 +mna variants add --name "Flexible option" \ + --depart-not-before 2026-09-01 --depart-not-after 2026-09-03 \ + --return-not-before 2026-09-10 --return-not-after 2026-09-12 \ + --min-nights 7 --max-nights 10 +``` + +`variants edit` takes the same flags; leave them off and the existing dates stay untouched. +Switching a variant between the two shapes is just an edit with the other flag set. + ## Verify-after-write Mutation responses are intentionally thin (often just the new key). Confirm the state with diff --git a/src/commands/variants/add.ts b/src/commands/variants/add.ts index a67ca4e..8b144a9 100644 --- a/src/commands/variants/add.ts +++ b/src/commands/variants/add.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { requireVariantDates, variantDateArgs } from './dates' export const variantsAddCommand = defineCommand({ meta: { name: 'add', description: 'Add a new variant to a trip.' }, @@ -11,16 +12,19 @@ export const variantsAddCommand = defineCommand({ tripId: { type: 'positional', description: 'Trip ID.' }, name: { type: 'string', required: true, description: 'Variant name.' }, notes: { type: 'string', description: 'Free-form variant notes.' }, + ...variantDateArgs, json: { type: 'boolean', default: false, description: 'Output as JSON.' }, }, async run({ args }) { try { + const dates = requireVariantDates(args) + const creds = await loadCredentials() const apiKey = resolveApiKey(creds) requireApiKey(apiKey) const client = createApiClient({ baseUrl: resolveBaseUrl(creds), apiKey }) - const body: Record = { name: args.name } + const body: Record = { name: args.name, dates } if (args.notes !== undefined) body.notes = args.notes const { data, error } = await client.POST('/v1/trips/{id}/variants', { diff --git a/src/commands/variants/dates.test.ts b/src/commands/variants/dates.test.ts new file mode 100644 index 0000000..05afad9 --- /dev/null +++ b/src/commands/variants/dates.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test' +import { buildVariantDates, requireVariantDates } from './dates' + +const FLEXIBLE_ARGS = { + 'depart-not-before': '2026-09-01', + 'depart-not-after': '2026-09-03', + 'return-not-before': '2026-09-10', + 'return-not-after': '2026-09-12', + 'min-nights': '7', + 'max-nights': '10', +} + +describe('buildVariantDates', () => { + test('returns undefined when no date flag is given', () => { + expect(buildVariantDates({ name: 'Beach option' })).toBeUndefined() + }) + + test('builds an exact-dates object from --start-date / --end-date', () => { + expect(buildVariantDates({ 'start-date': '2026-09-01', 'end-date': '2026-09-08' })).toEqual({ + startDate: '2026-09-01T00:00:00.000Z', + endDate: '2026-09-08T00:00:00.000Z', + }) + }) + + test('builds a flexi-dates object with numeric night counts', () => { + expect(buildVariantDates(FLEXIBLE_ARGS)).toEqual({ + departLeavingNotBeforeDate: '2026-09-01T00:00:00.000Z', + departArrivingNotAfterDate: '2026-09-03T00:00:00.000Z', + returnLeavingNotBeforeDate: '2026-09-10T00:00:00.000Z', + returnArrivingNotAfterDate: '2026-09-12T00:00:00.000Z', + minNights: 7, + maxNights: 10, + }) + }) + + test('rejects a half-filled exact-dates pair, naming the missing flag', () => { + expect(() => buildVariantDates({ 'start-date': '2026-09-01' })).toThrow( + /Missing --end-date/, + ) + }) + + test('rejects a half-filled flexi-dates set', () => { + const { 'max-nights': _dropped, ...partial } = FLEXIBLE_ARGS + expect(() => buildVariantDates(partial)).toThrow(/Missing --max-nights/) + }) + + test('rejects mixing the two shapes', () => { + expect(() => + buildVariantDates({ 'start-date': '2026-09-01', 'min-nights': '7' }), + ).toThrow(/Mixed date shapes/) + }) + + test('rejects a non-integer night count', () => { + expect(() => buildVariantDates({ ...FLEXIBLE_ARGS, 'min-nights': 'seven' })).toThrow( + /Invalid night count for --min-nights/, + ) + }) + + test('rejects an unparseable date with the flag name', () => { + expect(() => + buildVariantDates({ 'start-date': 'next tuesday', 'end-date': '2026-09-08' }), + ).toThrow(/Invalid date for --start-date/) + }) +}) + +describe('requireVariantDates', () => { + test('fails fast when dates are absent, listing both shapes', () => { + expect(() => requireVariantDates({ name: 'Beach option' })).toThrow( + /cannot be created without dates/, + ) + }) + + test('passes a complete shape through', () => { + expect(requireVariantDates({ 'start-date': '2026-09-01', 'end-date': '2026-09-08' })).toEqual( + { startDate: '2026-09-01T00:00:00.000Z', endDate: '2026-09-08T00:00:00.000Z' }, + ) + }) +}) diff --git a/src/commands/variants/dates.ts b/src/commands/variants/dates.ts new file mode 100644 index 0000000..6b6d448 --- /dev/null +++ b/src/commands/variants/dates.ts @@ -0,0 +1,119 @@ +import { normalizeToIsoDateTime } from '../../util/dates' + +const EXACT_DATE_FIELDS: Record = { + 'start-date': 'startDate', + 'end-date': 'endDate', +} + +const FLEXIBLE_DATE_FIELDS: Record = { + 'depart-not-before': 'departLeavingNotBeforeDate', + 'depart-not-after': 'departArrivingNotAfterDate', + 'return-not-before': 'returnLeavingNotBeforeDate', + 'return-not-after': 'returnArrivingNotAfterDate', +} + +const FLEXIBLE_NIGHT_FIELDS: Record = { + 'min-nights': 'minNights', + 'max-nights': 'maxNights', +} + +const EXACT_FLAGS = Object.keys(EXACT_DATE_FIELDS) +const FLEXIBLE_FLAGS = [...Object.keys(FLEXIBLE_DATE_FIELDS), ...Object.keys(FLEXIBLE_NIGHT_FIELDS)] + +/** Date flags shared by `variants add` and `variants edit`. */ +export const variantDateArgs = { + 'start-date': { + type: 'string', + description: 'Exact dates: first day of the trip (YYYY-MM-DD or ISO date-time).', + }, + 'end-date': { + type: 'string', + description: 'Exact dates: last day of the trip (YYYY-MM-DD or ISO date-time).', + }, + 'depart-not-before': { + type: 'string', + description: 'Flexible dates: earliest outbound departure.', + }, + 'depart-not-after': { + type: 'string', + description: 'Flexible dates: latest outbound arrival.', + }, + 'return-not-before': { + type: 'string', + description: 'Flexible dates: earliest return departure.', + }, + 'return-not-after': { + type: 'string', + description: 'Flexible dates: latest return arrival.', + }, + 'min-nights': { type: 'string', description: 'Flexible dates: minimum nights away.' }, + 'max-nights': { type: 'string', description: 'Flexible dates: maximum nights away.' }, +} as const + +function suppliedFlags(args: Record, flags: string[]): string[] { + return flags.filter((flag) => args[flag] !== undefined && args[flag] !== '') +} + +function flagList(flags: string[]): string { + return flags.map((flag) => `--${flag}`).join(', ') +} + +function parseNightCount(value: string, flag: string): number { + const nights = Number(value.trim()) + if (!Number.isInteger(nights) || nights < 0) { + throw new Error(`Invalid night count for ${flag}: "${value}". Use a whole number of nights.`) + } + return nights +} + +/** + * Builds the variant `dates` body from the date flags, or returns undefined when + * none were given. The API accepts only a complete exact-dates or complete + * flexible-dates object, so anything half-filled throws before the request goes out. + */ +export function buildVariantDates( + args: Record, +): Record | undefined { + const exact = suppliedFlags(args, EXACT_FLAGS) + const flexible = suppliedFlags(args, FLEXIBLE_FLAGS) + + if (exact.length > 0 && flexible.length > 0) { + throw new Error( + `Mixed date shapes: use either exact dates (${flagList(EXACT_FLAGS)}) or flexible dates (${flagList(FLEXIBLE_FLAGS)}), not both.`, + ) + } + if (exact.length === 0 && flexible.length === 0) return undefined + + const isExact = exact.length > 0 + const required = isExact ? EXACT_FLAGS : FLEXIBLE_FLAGS + const missing = required.filter((flag) => !(isExact ? exact : flexible).includes(flag)) + if (missing.length > 0) { + throw new Error( + `Incomplete dates: ${flagList(required)} must be given together. Missing ${flagList(missing)}.`, + ) + } + + const dates: Record = {} + for (const [flag, field] of Object.entries( + isExact ? EXACT_DATE_FIELDS : FLEXIBLE_DATE_FIELDS, + )) { + dates[field] = normalizeToIsoDateTime(String(args[flag]), `--${flag}`) + } + if (!isExact) { + for (const [flag, field] of Object.entries(FLEXIBLE_NIGHT_FIELDS)) { + dates[field] = parseNightCount(String(args[flag]), `--${flag}`) + } + } + return dates +} + +/** Same as `buildVariantDates`, for the create path where the API demands dates. */ +export function requireVariantDates(args: Record): Record { + const dates = buildVariantDates(args) + if (dates === undefined) { + throw new Error( + `A variant cannot be created without dates. Supply exact dates (${flagList(EXACT_FLAGS)}) or flexible dates (${flagList(FLEXIBLE_FLAGS)}).`, + ) + } + return dates +} diff --git a/src/commands/variants/edit.test.ts b/src/commands/variants/edit.test.ts new file mode 100644 index 0000000..7bb0578 --- /dev/null +++ b/src/commands/variants/edit.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { variantsAddCommand } from './add' +import { variantsEditCommand } from './edit' + +type RunFn = (ctx: { args: Record }) => Promise +const runAdd = (args: Record) => + (variantsAddCommand.run as unknown as RunFn)({ args }) +const runEdit = (args: Record) => + (variantsEditCommand.run as unknown as RunFn)({ args }) + +let originalFetch: typeof fetch +let originalKey: string | undefined +let captured: { method: string; body: unknown } | undefined + +beforeEach(() => { + originalFetch = global.fetch + originalKey = process.env.MNA_API_KEY + process.env.MNA_API_KEY = 'mna_test' + captured = undefined + global.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init) + const text = await req.text() + captured = { method: req.method, body: text ? JSON.parse(text) : undefined } + return new Response(null, { status: 200 }) + }) as unknown as typeof fetch +}) + +afterEach(() => { + global.fetch = originalFetch + process.env.MNA_API_KEY = originalKey + if (originalKey === undefined) Reflect.deleteProperty(process.env, 'MNA_API_KEY') +}) + +describe('variants add — date flags', () => { + test('sends exact dates alongside the name', async () => { + await runAdd({ + tripId: 't', + name: 'Beach option', + 'start-date': '2026-09-01', + 'end-date': '2026-09-08', + json: false, + }) + + expect(captured?.method).toBe('POST') + expect(captured?.body).toEqual({ + name: 'Beach option', + dates: { + startDate: '2026-09-01T00:00:00.000Z', + endDate: '2026-09-08T00:00:00.000Z', + }, + }) + }) + + test('sends flexi dates with numeric night bounds', async () => { + await runAdd({ + tripId: 't', + name: 'Flexible option', + 'depart-not-before': '2026-09-01', + 'depart-not-after': '2026-09-03', + 'return-not-before': '2026-09-10', + 'return-not-after': '2026-09-12', + 'min-nights': '7', + 'max-nights': '10', + json: false, + }) + + expect(captured?.body).toEqual({ + name: 'Flexible option', + dates: { + departLeavingNotBeforeDate: '2026-09-01T00:00:00.000Z', + departArrivingNotAfterDate: '2026-09-03T00:00:00.000Z', + returnLeavingNotBeforeDate: '2026-09-10T00:00:00.000Z', + returnArrivingNotAfterDate: '2026-09-12T00:00:00.000Z', + minNights: 7, + maxNights: 10, + }, + }) + }) +}) + +describe('variants edit — date flags', () => { + test('omits dates when no date flag is given', async () => { + await runEdit({ tripId: 't', variantId: 'v', name: 'Renamed', json: false }) + + expect(captured?.method).toBe('PATCH') + expect(captured?.body).toEqual({ name: 'Renamed' }) + }) + + test('sends a complete dates object when the flags are given', async () => { + await runEdit({ + tripId: 't', + variantId: 'v', + 'start-date': '2026-09-02', + 'end-date': '2026-09-09', + json: false, + }) + + expect(captured?.body).toEqual({ + dates: { + startDate: '2026-09-02T00:00:00.000Z', + endDate: '2026-09-09T00:00:00.000Z', + }, + }) + }) +}) diff --git a/src/commands/variants/edit.ts b/src/commands/variants/edit.ts index 608568c..e6441be 100644 --- a/src/commands/variants/edit.ts +++ b/src/commands/variants/edit.ts @@ -4,6 +4,7 @@ import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/crede import { renderJson } from '../../render/json' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' +import { buildVariantDates, variantDateArgs } from './dates' export const variantsEditCommand = defineCommand({ meta: { name: 'edit', description: 'Update fields on an existing variant.' }, @@ -12,6 +13,7 @@ export const variantsEditCommand = defineCommand({ variantId: { type: 'positional', description: 'Variant ID.' }, name: { type: 'string', description: 'New variant name.' }, notes: { type: 'string', description: 'New variant notes.' }, + ...variantDateArgs, json: { type: 'boolean', default: false, description: 'Output as JSON.' }, }, async run({ args }) { @@ -19,8 +21,10 @@ export const variantsEditCommand = defineCommand({ const updates: Record = {} if (args.name !== undefined) updates.name = args.name if (args.notes !== undefined) updates.notes = args.notes + const dates = buildVariantDates(args) + if (dates !== undefined) updates.dates = dates if (Object.keys(updates).length === 0) { - throw new Error('Specify at least one of --name, --notes.') + throw new Error('Specify at least one of --name, --notes, or the date flags.') } const creds = await loadCredentials()