diff --git a/README.md b/README.md index 1ad24a8..d735560 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ Every command supports `--json` for piping into `jq` or Claude. | Command | Description | |---|---| | `mna trips list [--status=planning\|ready\|finished\|cancelled] [--include-example]` | List trips. | -| `mna trips show [--all-options]` | Show one trip in detail. | +| `mna trips show [--all-options]` | Show one trip in detail, with an accommodation table per variant (cost, guest rating in its source scale, facilities). `--all-options` adds the unselected options. | | `mna trips create --name [--cover-photo ]` | Create a new trip. | | `mna trips edit [--name=...] [--cover-photo=...] [--status=...]` | Update trip-level fields. | | `mna trips delete [--yes]` | Delete a trip permanently. | diff --git a/openapi.json b/openapi.json index df0a2c8..af1a79a 100644 --- a/openapi.json +++ b/openapi.json @@ -628,6 +628,9 @@ } } }, + "400": { + "description": "Missing or unrecognisable dates." + }, "404": { "description": "Trip not found or no edit access." } @@ -725,6 +728,9 @@ "200": { "description": "Variant updated." }, + "400": { + "description": "Unrecognisable dates." + }, "404": { "description": "Trip or variant not found, or no edit access." } @@ -2965,9 +2971,48 @@ }, "googlePlaceId": { "type": "string" + }, + "locationiqPlaceId": { + "type": "string" } } }, + "RoomDetailsOutV1Dto": { + "type": "object", + "properties": { + "numberOfRooms": { + "type": "number" + }, + "sizeInM2": { + "type": "number" + } + } + }, + "ExternalRatingV1Dto": { + "type": "object", + "properties": { + "score": { + "type": "number", + "description": "Rating score as published by the source (e.g. 8.8)." + }, + "scale": { + "type": "number", + "description": "Maximum of the rating scale (e.g. 10 for Booking.com, 5 for Google). Required whenever score is set." + }, + "count": { + "type": "number", + "description": "Number of reviews behind the score." + }, + "source": { + "type": "string", + "description": "Where the rating came from (e.g. \"booking.com\")." + } + }, + "required": [ + "score", + "scale" + ] + }, "AccommodationOptionV1Dto": { "type": "object", "properties": { @@ -2977,6 +3022,18 @@ "name": { "type": "string" }, + "type": { + "type": "string", + "enum": [ + "hotel", + "hostel", + "apartment", + "house", + "camping", + "other" + ], + "description": "Type of accommodation." + }, "totalCost": { "type": "number" }, @@ -3004,12 +3061,59 @@ "checkOutTime": { "type": "string" }, + "roomDetails": { + "$ref": "#/components/schemas/RoomDetailsOutV1Dto" + }, "sourceUrl": { "type": "string" }, "notes": { "type": "string" }, + "imageUrl": { + "type": "string", + "description": "URL of a photo of the accommodation." + }, + "description": { + "type": "string", + "description": "Short description of the accommodation." + }, + "url": { + "type": "string", + "description": "Link to the listing or the property website." + }, + "features": { + "type": "array", + "description": "Facilities this accommodation offers.", + "items": { + "type": "string", + "enum": [ + "beachfront", + "beachNearby", + "privateBeachArea", + "swimmingPool", + "kidsPool", + "kidsPlayArea", + "privateKitchen", + "sharedKitchen", + "washingMachine", + "familyRooms", + "airConditioning", + "freeParking", + "freeWifi", + "balconyTerrace", + "petsAllowed" + ] + } + }, + "externalRating": { + "description": "Guest rating from an external source.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalRatingV1Dto" + } + ] + }, "createdBy": { "$ref": "#/components/schemas/UserInfoV1Dto" } @@ -3412,6 +3516,9 @@ "googlePlaceId": { "type": "string" }, + "locationiqPlaceId": { + "type": "string" + }, "name": { "type": "string" }, @@ -3442,7 +3549,7 @@ "properties": { "name": { "type": "string", - "description": "Name of the accommodation (e.g. \"Hilton Times Square\")." + "description": "Display name of the accommodation exactly as a human would call it (e.g. \"Villa Ave\"). Never embed price, town, dates, status or commentary here — those belong in totalCost, location, checkIn/checkOut and notes." }, "type": { "type": "string", @@ -3494,10 +3601,55 @@ "$ref": "#/components/schemas/RoomDetailsV1Dto" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, \"user has stayed here before\", trade-offs. Anything without a dedicated field goes here, not in name." }, "sourceUrl": { "type": "string" + }, + "imageUrl": { + "type": "string", + "description": "URL of a photo of the accommodation. Hotlinked, not copied." + }, + "description": { + "type": "string", + "description": "Short description of the accommodation." + }, + "url": { + "type": "string", + "description": "Link to the listing or the property website." + }, + "features": { + "type": "array", + "description": "Facilities this accommodation offers.", + "items": { + "type": "string", + "enum": [ + "beachfront", + "beachNearby", + "privateBeachArea", + "swimmingPool", + "kidsPool", + "kidsPlayArea", + "privateKitchen", + "sharedKitchen", + "washingMachine", + "familyRooms", + "airConditioning", + "freeParking", + "freeWifi", + "balconyTerrace", + "petsAllowed" + ] + } + }, + "externalRating": { + "description": "Guest rating from an external source.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalRatingV1Dto" + } + ] } }, "required": [ @@ -3576,7 +3728,8 @@ "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here." }, "link": { "type": "string" @@ -3614,7 +3767,8 @@ "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here." }, "link": { "type": "string" @@ -3664,7 +3818,7 @@ "properties": { "name": { "type": "string", - "description": "Event name (e.g. \"Louvre Museum\")." + "description": "Display name of the event exactly as a human would call it (e.g. \"Louvre Museum\"). Never embed price, dates, status or commentary here — those belong in totalCost, start/end and notes." }, "start": { "type": "string", @@ -3692,7 +3846,8 @@ "$ref": "#/components/schemas/CreateEventLocationV1Dto" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here, not in name." } }, "required": [ @@ -3704,7 +3859,7 @@ "properties": { "name": { "type": "string", - "description": "Trip name as shown in the UI.", + "description": "Trip name as shown in the UI, exactly as a human would call it. Never embed dates, costs, status or commentary here.", "example": "Japan 2026" }, "coverPhoto": { @@ -3722,7 +3877,7 @@ "properties": { "name": { "type": "string", - "description": "New trip name." + "description": "New trip name, exactly as a human would call it. Never embed dates, costs, status or commentary here." }, "coverPhoto": { "type": "string", @@ -3792,7 +3947,7 @@ "description": "Variant name (e.g. \"Beach option\", \"Mountain option\")." }, "dates": { - "description": "Either an exact-dates or flexi-dates structure for this variant.", + "description": "Dates for this variant — required. Either exact dates ({ startDate, endDate }) or flexible dates ({ departLeavingNotBeforeDate, departArrivingNotAfterDate, returnLeavingNotBeforeDate, returnArrivingNotAfterDate, minNights, maxNights }).", "oneOf": [ { "$ref": "#/components/schemas/ExactDatesV1Dto" @@ -3808,7 +3963,8 @@ } }, "required": [ - "name" + "name", + "dates" ] }, "DuplicateVariantResultV1Dto": { @@ -3973,7 +4129,8 @@ "properties": { "name": { "type": "string", - "description": "Name of the accommodation." + "nullable": true, + "description": "Display name of the accommodation exactly as a human would call it (e.g. \"Villa Ave\") — never embed price, town, dates, status or commentary here; those belong in totalCost, location, checkIn/checkOut and notes. Pass null to clear." }, "type": { "type": "string", @@ -3985,7 +4142,8 @@ "camping", "other" ], - "description": "Type of accommodation." + "nullable": true, + "description": "Type of accommodation. Pass null to clear." }, "totalCost": { "type": "number", @@ -4018,6 +4176,7 @@ }, "freeCancellationUntil": { "type": "string", + "nullable": true, "format": "date-time", "description": "Free cancellation deadline. Pass null to clear." }, @@ -4025,10 +4184,62 @@ "$ref": "#/components/schemas/RoomDetailsV1Dto" }, "notes": { - "type": "string" + "type": "string", + "nullable": true, + "description": "Free-form commentary: provenance, caveats, \"user has stayed here before\", trade-offs. Anything without a dedicated field goes here, not in name. Pass null to clear." }, "sourceUrl": { - "type": "string" + "type": "string", + "nullable": true, + "description": "Link the option was captured from. Pass null to clear." + }, + "imageUrl": { + "type": "string", + "nullable": true, + "description": "URL of a photo of the accommodation. Hotlinked, not copied. Pass null to clear." + }, + "description": { + "type": "string", + "nullable": true, + "description": "Short description of the accommodation. Pass null to clear." + }, + "url": { + "type": "string", + "nullable": true, + "description": "Link to the listing or the property website. Pass null to clear." + }, + "features": { + "type": "array", + "description": "Facilities this accommodation offers.", + "items": { + "type": "string", + "enum": [ + "beachfront", + "beachNearby", + "privateBeachArea", + "swimmingPool", + "kidsPool", + "kidsPlayArea", + "privateKitchen", + "sharedKitchen", + "washingMachine", + "familyRooms", + "airConditioning", + "freeParking", + "freeWifi", + "balconyTerrace", + "petsAllowed" + ] + } + }, + "externalRating": { + "nullable": true, + "description": "Guest rating from an external source. Pass null to clear.", + "allOf": [ + { + "$ref": "#/components/schemas/ExternalRatingV1Dto" + } + ] } } }, @@ -4087,7 +4298,8 @@ "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here." }, "link": { "type": "string" @@ -4119,7 +4331,8 @@ "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here." }, "link": { "type": "string" @@ -4158,7 +4371,7 @@ "properties": { "name": { "type": "string", - "description": "Display name of the event." + "description": "Display name of the event exactly as a human would call it (e.g. \"Louvre Museum\"). Never embed price, dates, status or commentary here — those belong in totalCost, start/end and notes." }, "start": { "type": "string", @@ -4201,7 +4414,8 @@ "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here, not in name." } } }, @@ -4812,14 +5026,14 @@ "properties": { "name": { "type": "string", - "description": "Goal name (e.g. \"See the Northern Lights\")." + "description": "Display name of the goal exactly as a human would call it (e.g. \"See the Northern Lights\"). Never embed budget, place, status or commentary here — those belong in estimatedBudget, location and notes." }, "description": { "type": "string" }, "notes": { "type": "string", - "description": "Free-form notes." + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here, not in name." }, "location": { "$ref": "#/components/schemas/GoalLocationV1Dto" @@ -4919,13 +5133,15 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "description": "Display name of the goal exactly as a human would call it. Never embed budget, place, status or commentary here — those belong in estimatedBudget, location and notes." }, "description": { "type": "string" }, "notes": { - "type": "string" + "type": "string", + "description": "Free-form commentary: provenance, caveats, trade-offs. Anything without a dedicated field goes here, not in name." }, "location": { "$ref": "#/components/schemas/GoalLocationV1Dto" diff --git a/skills/mna/SKILL.md b/skills/mna/SKILL.md index bab2a6c..fd4d96f 100644 --- a/skills/mna/SKILL.md +++ b/skills/mna/SKILL.md @@ -14,9 +14,8 @@ description: >- # Planning trips with My Next Adventure (`mna`) `mna` is a CLI over the My Next Adventure API (`app.mynextadventure.cloud`). The app is the -**source of truth** for a trip. Your job is to drive it as a thoughtful travel-planning -partner: do the research and the arithmetic, keep the plan organized, and let the user make -the real calls. +**source of truth** for a trip. Your job is to run the process: do the research and the +arithmetic, keep the plan structured, and let the user make the real calls. ## Bootstrap @@ -36,88 +35,137 @@ trip └── event ← an activity/booking (optional) ``` Most things support multiple **options** so the user (and collaborators) can compare and vote; -exactly one per kind is **selected** and counts toward the plan. After any write, the response -is thin — **re-fetch `mna trips show --json` to confirm it actually persisted.** - -## How to work — the method - -The *way of working* matters more than the commands. Internalize these principles, then follow -the playbook below. - -1. **The app holds the plan; keep a local mirror.** Treat the trip in `mna` as source of truth, - and also keep a lightweight `PLAN.md` in the working directory — IDs, decisions made, open - questions, running totals. It's what lets you resume cleanly in a later session. -2. **Anchor on what exists.** Before creating anything, check `mna trips list --json` and/or ask - — the user may already have the trip going. Don't spawn a duplicate. -3. **Brainstorm before building.** Nail who's going, exact dates, origin/return, the vibe, the - budget band, and the non-negotiables — and let the *user* define the variant strategy. Don't - invent structure they didn't ask for. -4. **Variants are comparable whole-trip strategies**, not tweaks — e.g. "single base the whole - time" vs "stopover + base" vs "stopover + base + a different return stop". Fork a baseline - with `variants duplicate`, then diverge. -5. **Recommend, don't enumerate.** Present 2–4 candidates with the *one trade-off that matters* - and a clear pick — not an exhaustive menu. Reserve a hard question (or `AskUserQuestion`) for - genuine forks: which stopover, which place, splurge vs value. -6. **Research real, bookable data.** Use a hotel connector for accommodation and **verify - availability for the exact dates before recommending** (see - `references/research-and-costing.md`). Never invent prices or "it's available". -7. **Cost with looked-up numbers, show the math.** Fuel prices, distances, nightly rates — look - them up, compute, and state what's excluded (food, tolls, activities). No hand-waved figures. -8. **Be geography-aware.** Order legs to avoid backtracking, mark the return-home leg, and keep - far-flung sights as day-trips rather than extra bases. Break long drives with a stopover. -9. **The user's edits are authoritative.** Manual price changes (e.g. a loyalty discount applied - by hand), geocoded location fixes, spelling corrections — keep them, never "correct" them - back to the scraped value. When you add an option, note that the real price may differ. -10. **Iterate as the brief sharpens.** Accommodation priorities shift mid-search ("actually, - lakefront, calmer, with restaurants nearby"). Re-run the search against the new criteria - rather than defending the old shortlist. -11. **Set precise, real data.** Exact coordinates, per-destination dates, free-cancellation - deadlines — so the plan maps correctly and is genuinely actionable. - -## The playbook - -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. 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 "" - --start-date --end-date --notes "…"`. Mark the home leg with - `--return-to-home`. Fix ordering with `mna destinations reorder … --order=k1,k2,k3`. - For stopovers, research candidates, present with a recommendation, let the user pick. -4. **Transport per leg** — usually how you arrive at each destination (own car, train, flight). - Cost it (look up fuel/fares; see the reference). Add via - `mna options add … transport --from-json ` and `mna options select …`. Put the - home-return drive on the return-to-home destination so totals stay clean. -5. **Accommodation per destination** — research with a hotel connector, present a curated spread - (price tiers + location trade-offs + ratings) with a recommendation, **verify availability**, - then add the shortlist (`options add … accommodation --from-json`), set the chosen one - `options select …`, and set its exact location, `--free-cancellation-until`, and check-in/out - dates. Replace stale placeholder options rather than piling new ones on top. -6. **Events** — only if the user wants them. The app is not a day-by-day itinerary planner and - many users explicitly don't want that — ask, don't assume. -7. **Choose & total** — `mna variants select `; sum selected accommodation + - transport; present the variant comparison with subtotals and what's excluded. -8. **Sync & verify** — update `PLAN.md`, and confirm every change with `mna trips show --json`. - -## Writing options and events - -Options and events are created from a JSON body (`--from-json `). The exact field shapes, -the enums, the **nested-coordinates location requirement**, and the date/cancellation fields are -in **`references/cli-and-schemas.md`** — read it before building option/event bodies. - -## Research and costing - -How to drive a hotel connector (search → verify availability → map into an option), run -area/beachfront searches, and compute own-car fuel costs from looked-up prices live in -**`references/research-and-costing.md`**. - -## Full command reference - -The complete command map (auth, trips, variants, destinations, options, events, access/voting, -goals, collections) is in the repo `README.md`. When a body shape is unclear, fetch and read -`https://api.mynextadventure.cloud/v1/openapi.json` — it's the contract of record. +exactly one per kind is **selected** and counts toward the plan. + +## The process + +Five phases. Most bad plans come from researching before eliciting, or building structure +before the destination is actually decided. + +### 1. Elicit + +Before any research, establish: + +- **Anchors** — the fixed points nothing else can move: a booked flight, an event date, a + pickup, a hard return-by date. +- **Party** — who travels, which legs (trips can split), and children's ages, which change room + eligibility and price. +- **Dates** — exact if known, otherwise a window plus a night count. +- **Budget and home currency** — the ceiling, and the currency every total gets presented in. + Never infer it from the destination or carry it over from a previous trip. +- **This trip's preferences** — not last trip's. "Have you been there, and did you like it?" is + a good question; "yes, that's why we're going back" is as valid an answer as "yes, so + somewhere new". Flag when a candidate is effectively a repeat, then let them decide. +- **Origin and mode** — own vehicle (ask for real consumption), rental, rail, air. + +Then check what exists: `mna trips list --json`. They may already have this trip going — anchor +on it rather than spawning a duplicate. Let the *user* define the variant strategy; don't invent +structure they didn't ask for. Reserve a hard question (or `AskUserQuestion`) for genuine forks. + +**Detect the mode.** Short answers, no engagement with the trade-offs, "you just pick" — that's a +request, not disinterest. Stop interviewing, make the smallest safe assumptions, state them, and +come back with one recommendation and a one-line rationale. + +### 2. Research real availability + +Never price from memory. Estimates run *low*, and in high season the gap is big enough to flip +conclusions — so anything not from a live search is labelled an estimate. + +- Search with the real constraints: the exact dates for *that variant*, adults + children's + ages, the user's currency, hard requirements as filters. +- **If the destination isn't settled, compare places before properties.** One representative + option per candidate place with its transfer time, total and honest downside; drill into + listings only for the shortlist. The data model assumes the place is known — often it's the + open question. +- **Verify availability by name for the exact dates before recommending.** Search ranking hides + available places, and a property free for a long stay may be unavailable for a short one. +- Present 2–4 candidates with the one trade-off that matters and a clear pick — not a menu. When + priorities shift mid-search, re-run against the new criteria instead of defending the old list. + +### 3. Structure it in MNA + +Build what the user agreed to, one level at a time: + +| Level | Command | Notes | +|---|---|---| +| trip | `mna trips create --name "…"` | the container | +| variant | `mna variants add --name "…" --start-date --end-date `, or `variants duplicate ` | a whole-trip **strategy** ("single base" vs "stopover + base"), not a tweak. Dates are required — exact, or the six flexible-date flags (see reference). Fork a baseline, then diverge. | +| destination | `mna destinations add --place "" --start-date --end-date ` | in travel order; `--return-to-home` marks the home leg; `destinations reorder … --order=k1,k2,k3` fixes order | +| option | `mna options add --from-json `, then `mna options select …` | `` = `accommodation` \| `transport` \| `getting-around` | +| event | `mna events add --from-json ` | only if the user wants them — the app is not a day-by-day itinerary planner, and many users don't want one | + +Order legs to avoid backtracking, keep far-flung sights as day-trips rather than extra bases, +and break a long drive with a stopover. Attach each transport leg to the destination it +*arrives at*, and put the journey home on the return-to-home destination so totals stay clean. + +Set real data, not placeholders: exact coordinates on each option's `location`, per-destination +dates, check-in/out, `--free-cancellation-until` on accommodation. Replace stale options rather +than piling new ones on top. Write responses are thin — **re-fetch +`mna trips show --json`** and confirm the field you set actually persisted. + +### 4. Compare end-to-end + +Compare *trips*, not nightly rates. Total each variant as transfer there + stay + local +mobility + journey home, in the user's currency, and state what's excluded (food, activities, +tolls, parking). + +`mna trips show --all-options` prints each destination's accommodation with cost, guest +rating and facilities, so a shortlist is comparable without re-reading your own notes. + +### 5. Decide, select, share + +`mna variants select ` and `mna options select …` record the decisions — a plan with +nothing selected has no totals. Then `mna trips share ` for a link, `mna access invite` +for collaborators, and `mna vote option|event` when a group is choosing. + +Keep a lightweight `PLAN.md` in the working directory alongside the app: IDs, decisions made, +open questions, running totals. It's what makes a later session resumable. + +## Checks worth running + +Not facts to know — categories to look up for *this* destination, season and party. + +- **Local booking conventions.** Changeover days, minimum stays and weekly-versus-nightly pricing + vary by region and season, and can lock or free specific date windows. Check before concluding + a place is unavailable or overpriced. +- **Short-stay penalties.** Where the week is the real product, a few nights can price well above + pro-rata. Compare candidates at the *same* night count or the comparison lies. +- **Getting there beyond fuel or fare.** Tolls, road-use charges, ferries, congestion zones, + parking, baggage fees — these reorder candidates, not just inflate them, and crossings cost + time as well as money. +- **What the marketing word means locally.** "Beach", "central", "sea view", "family friendly" + aren't standardised. Verify against the map, the photos and the reviews before promising it. +- **Property-level versus unit-level facilities.** A shared kitchen at property level is not a + kitchen in the apartment — that's why `privateKitchen` and `sharedKitchen` are separate. +- **Whether the party shape has a filter.** Adults-only, family rooms, accessible rooms, pet + policies — use them when they're on-brief, ignore them when they're not. +- **Fixed points make routing.** When the itinerary must include a pickup or an event, compare + variants by total transfer time and days lost. Routes that move *forward* through the fixed + point usually beat a there-and-back detour. + +## Field semantics + +The app renders and totals these fields; misuse degrades the plan quietly. + +- **`name` is a display name** — "Villa Ave", not "Villa Ave (best value, book by Friday)". + Price belongs in `totalCost` + `currency`, the place in `location`, the score in + `externalRating`, amenities in `features`, links in `url` / `sourceUrl`. +- **`notes` is for commentary** — where you found it, what still needs verifying, why it was + ruled out. Anything with its own field goes in that field, or it renders twice, goes stale + independently, and can't be sorted or compared. +- **Ratings keep their source's scale.** Store `externalRating.scale` beside the score (out of + 10, out of 5) and quote the pair. Never normalise, and never rank a /10 score against a /5 one. +- **The user's edits are authoritative.** A hand-corrected price, a fixed location or spelling — + keep it, and don't revert to the scraped value on the next sync. + +## Where the details live + +- **`references/cli-and-schemas.md`** — the JSON body shapes for options and events, the enums + (including the 15 accommodation `features`), the **flat-vs-nested location trap**, and the + date/cancellation fields. Read it before building any `--from-json` body. +- **`references/research-and-costing.md`** — driving a hotel connector (search → verify + availability → map into an option), area-specific searches, and own-vehicle fuel arithmetic. +- **repo `README.md`** — the complete command map (auth, trips, variants, destinations, options, + events, access/voting, goals, collections). +- **`https://api.mynextadventure.cloud/v1/openapi.json`** — the contract of record when a body + shape is unclear. diff --git a/skills/mna/references/cli-and-schemas.md b/skills/mna/references/cli-and-schemas.md index 35fdcb3..a7a14f3 100644 --- a/skills/mna/references/cli-and-schemas.md +++ b/skills/mna/references/cli-and-schemas.md @@ -36,8 +36,13 @@ collections list|show|create|edit|delete|add-goal|remove-goal|share|open-shared "totalCost": 1200, "currency": "EUR", "location": { ...see "Location shape" below... }, "roomDetails": { "numberOfRooms": 1 }, // optional: sizeInM2 + "externalRating": { "score": 9.7, "scale": 10, "count": 240, "source": "booking.com" }, + "features": ["beachfront", "privateKitchen", "freeParking"], + "description": "Top-floor flat with a sea-facing terrace.", + "url": "https://…", // the listing / property site + "imageUrl": "https://…", // hotlinked photo, not copied "sourceUrl": "https://…", - "notes": "9.7 (240 reviews). Beachfront, kitchen, free parking.", + "notes": "Owner replies within the hour; parking is a shared yard.", "checkIn": "2026-07-07T12:00:00.000Z", // ISO date-time "checkOut": "2026-07-15T12:00:00.000Z", "freeCancellationUntil": "2026-07-02T12:00:00.000Z" @@ -45,6 +50,20 @@ collections list|show|create|edit|delete|add-goal|remove-goal|share|open-shared ``` `checkIn`/`checkOut`/`checkInTime`/`checkOutTime`/`freeCancellationUntil` can also be set with `options edit … --free-cancellation-until ` (accommodation only) without a JSON file. +Everything else goes through `--from-json`, on both `options add` and `options edit`. + +`externalRating.scale` is **required whenever `score` is set** — Booking.com publishes out of 10, +Google out of 5. Store both and quote the pair (`9.7/10`); never normalise or compare across scales. + +`features` (all optional, use only what the listing actually states): +`beachfront`, `beachNearby`, `privateBeachArea`, `swimmingPool`, `kidsPool`, `kidsPlayArea`, +`privateKitchen`, `sharedKitchen`, `washingMachine`, `familyRooms`, `airConditioning`, +`freeParking`, `freeWifi`, `balconyTerrace`, `petsAllowed`. +`privateKitchen` and `sharedKitchen` are different things: Booking lists "Shared kitchen" at +property level, which is not what a user means by "an apartment with a kitchen". + +Put ratings and facilities in these fields rather than in `notes` — `mna trips show` renders them, +and `notes` should carry what no field covers. `type` and `roomDetails` come back on read too. ### transport ```json @@ -76,26 +95,36 @@ home-return drive on the `--return-to-home` destination so the per-variant total ## Location shape — a non-obvious gotcha -For accommodation/transport `location`, the OpenAPI advertises flat `address` / `latitude` / -`longitude` — **but the API drops those silently.** Only `name` persists from the flat form. -Coordinates persist **only** when sent nested: +Option locations are **written flat and read back nested**. Send `address` / `latitude` / +`longitude`; a nested `formattedAddress` + `coordinates` object is silently dropped and only +`name` survives (verified against production on both `options add` and `options edit`): ```json "location": { "name": "Seaside Apartment", - "formattedAddress": ", , ", - "coordinates": { "lat": 0.000000, "lng": 0.000000 } + "address": ", , ", + "latitude": 0.000000, + "longitude": 0.000000, + "locationiqPlaceId": "…" } ``` -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. +`trips show` returns the same place as `formattedAddress` + `coordinates.lat`/`.lng` — that's the +read shape, not something to echo back on a write. `locationiqPlaceId` (and `googlePlaceId`) +round-trip, so pass whichever your geocoder gave you and the app can re-resolve the place later. +`location` is **replaced wholesale**, never merged: a partial `location` in an `options edit` body +clears whatever you left out. 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. Event locations are a different DTO but coordinate keys are still `lat`/`lng` — an event +body with `coordinates.latitude`/`.longitude` is a 500, not a silent drop (verified against +production). ## Dates -`destinations --start-date/--end-date`, accommodation `checkIn/checkOut/freeCancellationUntil`, -and event `start/end` are all ISO date-time. Accept `YYYY-MM-DD` from the user and normalize. +`variants add` requires dates: `--start-date/--end-date` for exact, or all six of +`--depart-not-before/--depart-not-after/--return-not-before/--return-not-after/--min-nights/--max-nights` +for flexible. `destinations --start-date/--end-date`, accommodation +`checkIn/checkOut/freeCancellationUntil`, 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 diff --git a/skills/mna/references/research-and-costing.md b/skills/mna/references/research-and-costing.md index 0b37ceb..2b9cff4 100644 --- a/skills/mna/references/research-and-costing.md +++ b/skills/mna/references/research-and-costing.md @@ -17,8 +17,9 @@ the source, the workflow is the same: - require a kitchen when needed (filter to apartments / holiday homes). - hard requirements as filters (free parking, beachfront). - a price ceiling and type/photo filters to shape the spread. -2. **For "on the water / at the nice beaches" type asks**, search the specific area by name or by - coordinates (lat/lng + radius) with a beachfront filter. A generic city search buries them. +2. **For location-specific asks** — on the water, by the lifts, near the trailhead, walkable to + the old town — search that area by name or by coordinates (lat/lng + radius) with the matching + filter. A generic city-wide search buries exactly the places they asked for. 3. **Present a curated spread, not the raw list** — typically a value pick, a proven/most-reviewed pick, and a splurge/location pick. For each: nightly + total, rating *and review count*, and the one location trade-off (central vs waterfront, walkable vs needs-the-car). Give a clear @@ -29,24 +30,32 @@ the source, the workflow is the same: by-name availability check returns nothing, it's genuinely gone — drop it. This distinguishes "ranked out of the list" from "actually unavailable". 5. **Map the chosen places into accommodation options** (`options add … accommodation --from-json`) - with the quoted total, exact `coordinates`, `sourceUrl`, rating in `notes`, then - `options select` the pick and set `--free-cancellation-until` + check-in/out. + with the quoted total, the exact location, `sourceUrl`, and the listing's own metadata — + `externalRating` (score **plus its scale** and review count), `features`, `description`, `url`, + `imageUrl` — then `options select` the pick and set `--free-cancellation-until` + check-in/out. + Only `notes` is free text; everything with a field goes in its field. Reviews: weight **review count**, not just score — a 10/10 from 4 reviews is far less proven than -a 9.7 from 240. Flag thin-review places as such. +a 9.7 from 240. Flag thin-review places as such. Keep each score in the scale its source published +it in (`scale: 10` for Booking.com, `5` for Google) and never convert between them. +When mapping facilities, `privateKitchen` means a kitchen in the unit; a property-level +"Shared kitchen" is `sharedKitchen` and is not what a user asking for a kitchen wants. ## Costing own-car transport (fuel) The user usually just wants the fuel number. Do it honestly: -1. **Look up current fuel prices** for the *countries the route passes through*, for the right - grade. Don't assume. -2. **Route-weight** them — most of a long drive may be in one country; weight by rough km share. -3. **Convert to the user's currency** (look up the FX rate). -4. **Fuel = distance_km × (L/100km ÷ 100) × price_per_L.** Use the consumption the user gives. +1. **Look up current fuel prices** for the *jurisdictions the route passes through* — countries, + or states/provinces where fuel is taxed locally — for the right grade. Don't assume. +2. **Route-weight** them — most of a long drive may be in one of them; weight by rough distance + share. +3. **Convert to the user's currency** (look up the FX rate) and work in the units they use. +4. **Fuel = distance × consumption × price.** Metric: `km × (L/100km ÷ 100) × price_per_L`. + US customary: `miles ÷ mpg × price_per_gallon`. Use the consumption figure the user gives. Compute per leg; attach each leg's fuel to its destination's transport option; fold the home-return drive into the return-to-home leg. -5. **State what's excluded** — tolls, vignettes, parking — unless asked to include them. +5. **State what's excluded** — tolls and road-use charges, ferries, parking — unless asked to + include them. For other modes (train, flights), price the actual fares from a connector or the carrier, not a guess, and note the class/conditions. diff --git a/src/commands/trips/show.ts b/src/commands/trips/show.ts index 947629f..459f51d 100644 --- a/src/commands/trips/show.ts +++ b/src/commands/trips/show.ts @@ -3,6 +3,7 @@ import { createApiClient } from '../../api/client' import { loadCredentials, resolveApiKey, resolveBaseUrl } from '../../auth/credentials-store' import { renderJson } from '../../render/json' import { renderTable } from '../../render/table' +import { accommodationRows } from '../../render/accommodation' import { colors } from '../../render/colors' import { reportAndExit, requireApiKey } from '../../util/errors' @@ -71,6 +72,24 @@ export const tripsShowCommand = defineCommand({ })) ?? [], emptyMessage: 'No variants yet.', }) + + for (const variant of data.variants ?? []) { + const rows = accommodationRows(variant.destinations) + if (rows.length === 0) continue + + stdout.write(`\n${colors.bold('Accommodation')} ${colors.dim(`· ${variant.name}`)}\n`) + renderTable({ + columns: [ + { header: 'Destination', key: 'destination', maxWidth: 18 }, + { header: 'Option', key: 'option', maxWidth: 28 }, + { header: 'Cost', key: 'cost', maxWidth: 12 }, + { header: 'Rating', key: 'rating', maxWidth: 13 }, + { header: 'Details', key: 'details', maxWidth: 28 }, + { header: 'Facilities', key: 'features', maxWidth: 36 }, + ], + rows, + }) + } } catch (err) { reportAndExit(err) } diff --git a/src/render/accommodation.test.ts b/src/render/accommodation.test.ts new file mode 100644 index 0000000..2ab2bd0 --- /dev/null +++ b/src/render/accommodation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' +import { accommodationRows, formatFeatures, formatRating, formatStayDetails } from './accommodation' + +describe('formatRating', () => { + test('keeps the source scale rather than normalising', () => { + expect(formatRating({ score: 8.8, scale: 10, count: 92 })).toBe('8.8/10 (92)') + expect(formatRating({ score: 4.4, scale: 5, count: 310 })).toBe('4.4/5 (310)') + }) + + test('omits the review count when absent', () => { + expect(formatRating({ score: 9, scale: 10 })).toBe('9/10') + }) + + test('renders nothing when unrated', () => { + expect(formatRating(undefined)).toBe('') + }) +}) + +describe('formatFeatures', () => { + test('labels features and keeps private and shared kitchens apart', () => { + expect(formatFeatures(['privateKitchen', 'freeParking'])).toBe('Private kitchen, Free parking') + expect(formatFeatures(['sharedKitchen'])).toBe('Shared kitchen') + }) + + test('renders nothing when there are no features', () => { + expect(formatFeatures(undefined)).toBe('') + expect(formatFeatures([])).toBe('') + }) +}) + +describe('formatStayDetails', () => { + test('combines type, room count and size', () => { + const details = formatStayDetails({ + key: 'o1', + totalCost: 1200, + currency: 'EUR', + location: {}, + type: 'apartment', + roomDetails: { numberOfRooms: 2, sizeInM2: 45 }, + }) + expect(details).toBe('apartment · 2 rooms · 45 m²') + }) + + test('drops the parts the option does not carry', () => { + expect( + formatStayDetails({ + key: 'o1', + totalCost: 1200, + currency: 'EUR', + location: {}, + roomDetails: { numberOfRooms: 1 }, + }), + ).toBe('1 room') + }) + + test('treats a zero room count or size as unset', () => { + expect( + formatStayDetails({ + key: 'o1', + totalCost: 1200, + currency: 'EUR', + location: {}, + type: 'apartment', + roomDetails: { numberOfRooms: 0, sizeInM2: 0 }, + }), + ).toBe('apartment') + }) +}) + +describe('accommodationRows', () => { + test('marks the selected option and carries the new metadata', () => { + const rows = accommodationRows([ + { + destination: 'Split', + destinationKey: 'd1', + selectedAccommodation: 'o2', + accommodationOptions: [ + { key: 'o1', name: 'Hostel Central', totalCost: 400, currency: 'EUR', location: {} }, + { + key: 'o2', + name: 'Seaside Apartment', + totalCost: 1200, + currency: 'EUR', + location: {}, + type: 'apartment', + externalRating: { score: 8.8, scale: 10, count: 92 }, + features: ['beachfront', 'privateKitchen'], + }, + ], + transportOptions: [], + gettingAroundOptions: [], + }, + ]) + + expect(rows).toHaveLength(2) + expect(rows[0]!.option).toBe(' Hostel Central') + expect(rows[1]!.option).toBe('✓ Seaside Apartment') + expect(rows[1]!.cost).toBe('1200 EUR') + expect(rows[1]!.rating).toBe('8.8/10 (92)') + expect(rows[1]!.features).toBe('Beachfront, Private kitchen') + }) + + test('handles a trip with no destinations', () => { + expect(accommodationRows(undefined)).toEqual([]) + }) +}) diff --git a/src/render/accommodation.ts b/src/render/accommodation.ts new file mode 100644 index 0000000..91f28a8 --- /dev/null +++ b/src/render/accommodation.ts @@ -0,0 +1,72 @@ +import type { components } from '../api/generated/schema' + +type AccommodationOption = components['schemas']['AccommodationOptionV1Dto'] +type ExternalRating = components['schemas']['ExternalRatingV1Dto'] +type Destination = components['schemas']['TripDestinationV1Dto'] + +const FEATURE_LABELS: Record = { + beachfront: 'Beachfront', + beachNearby: 'Beach nearby', + privateBeachArea: 'Private beach', + swimmingPool: 'Pool', + kidsPool: 'Kids pool', + kidsPlayArea: 'Kids play area', + privateKitchen: 'Private kitchen', + sharedKitchen: 'Shared kitchen', + washingMachine: 'Washing machine', + familyRooms: 'Family rooms', + airConditioning: 'A/C', + freeParking: 'Free parking', + freeWifi: 'Free WiFi', + balconyTerrace: 'Balcony/terrace', + petsAllowed: 'Pets allowed', +} + +/** Booking.com rates out of 10 and Google out of 5, so a score without its scale is ambiguous. */ +export function formatRating(rating: ExternalRating | undefined): string { + if (rating === undefined) return '' + const reviews = rating.count === undefined ? '' : ` (${rating.count})` + return `${rating.score}/${rating.scale}${reviews}` +} + +export function formatFeatures(features: AccommodationOption['features']): string { + return (features ?? []).map((feature) => FEATURE_LABELS[feature] ?? feature).join(', ') +} + +/** A zero room count or size is a placeholder the app writes, not a measurement. */ +export function formatStayDetails(option: AccommodationOption): string { + const rooms = option.roomDetails?.numberOfRooms + const size = option.roomDetails?.sizeInM2 + return [ + option.type, + rooms ? `${rooms} room${rooms === 1 ? '' : 's'}` : undefined, + size ? `${size} m²` : undefined, + ] + .filter((part) => part !== undefined) + .join(' · ') +} + +export interface AccommodationRow { + destination: string + option: string + cost: string + rating: string + details: string + features: string +} + +export function accommodationRows(destinations: Destination[] | undefined): AccommodationRow[] { + return (destinations ?? []).flatMap((destination) => + (destination.accommodationOptions ?? []).map((option) => { + const selected = option.key === destination.selectedAccommodation + return { + destination: destination.destination, + option: `${selected ? '✓ ' : ' '}${option.name ?? option.key}`, + cost: `${option.totalCost} ${option.currency}`, + rating: formatRating(option.externalRating), + details: formatStayDetails(option), + features: formatFeatures(option.features), + } + }), + ) +}