diff --git a/README.md b/README.md index 9b904ff..ba1568c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ![Entry rules for the demo trip](docs/entry.png) - **Budget.** Write a card's price as it appears on the confirmation – “€189”, “1 500 THB”, “300 zł” – and tick *Paid* once it is paid. The trip header shows what the trip costs in one currency, and *💰 Budget* breaks it down by day and by kind of card (stays, transport, food…), with what is paid and what is still to pay, against an optional spending limit. Prices in other currencies are converted at the European Central Bank's daily rates (from frankfurter.dev, no key needed); the last rates fetched are kept for offline use. A stay or rental counts on the day it starts, and cards in *Ideas* are listed but not counted. The printable itinerary, the phone app (the 💰 button and each day's spend) and `get_trip` (MCP) show the totals too. -- **Dive planning.** Dive cards hold site, centre, number of dives, depth and gas. TripDeck warns about flights less than 12 h after a single dive or 18 h after repetitive diving (DAN guidance), about going above 300 m altitude in that window, and suggests a rest day after long dive streaks. +- **Dive planning.** Dive cards hold site, centre, number of dives, depth, gas and the mix once you know it (EAN32, Tx 18/45). TripDeck warns about flights less than 12 h after a single dive or 18 h after repetitive diving (DAN guidance), about going above 300 m altitude in that window, and suggests a rest day after long dive streaks. With [AI Diving Monitor](https://github.com/Torgh/ai-diving-monitor) connected to the same Claude Code or Codex, its coach prepares you for each dive card from your own dive log and the card shows ✅ *Prepared* or ⚠️ *Check prep*, with a one-line summary and a link to the full preparation. - **Plan with Claude or ChatGPT (MCP).** Ask in plain language, for example “plan day 3 around Gozo”, “what's worth seeing near my hotel?” or “find my Booking.com and GetYourGuide confirmations in Gmail and add them”. The cards appear on the board within seconds. Changes made on the board at the same time are merged with Claude's, not lost. - **No keys needed for maps.** OpenStreetMap powers the map (OpenFreeMap tiles), search (Photon), nearby places (Overpass, with a Wikipedia fallback), routing (OSRM) and elevation (Open-Meteo). If you add Google Maps keys, TripDeck uses Google's richer data and falls back to OSM if a Google API fails. Public-transport lines and time zones need Google; without it, public transport is estimated and journeys show no clock changes. - **Your choice of storage: this PC, OneDrive or Google Drive.** Trips start in `~/.tripdeck/trips`. Choose OneDrive and they go into a visible **TripDeck** folder in OneDrive, synced by the OneDrive app, with no sign-in needed. Choose Google and they go into *My Drive › TripDeck*; TripDeck can't see your other Drive files. Each trip is one `Name (id).tripdeck.json` file, the same format as *Export → JSON*. Switch in *Settings* at any time; your trips are copied to the new place. @@ -44,7 +44,7 @@ bun install bun run dev # http://localhost:3000 (bound to 127.0.0.1) ``` -Click the demo trip (**Try ›** on an empty list, **Load demo trip** once you have trips) to explore a seven-day Malta & Gozo dive week. It always starts on the latest Saturday, so it is under way and shows today's column. It uses every card type, travel by car, on foot, by bus, scooter and bicycle, has three stays with check-in and check-out times, a five-day rental car, a scooter picked up at the Gozo ferry and returned at the farmhouse, a boat tour to Comino that picks you up at the harbour and visits three places, bookings with prices and statuses (one dive day still waiting for confirmation, one still to book) against a €1,500 budget, the entry rules for its Polish passports (Malta is in the EU: no visa, but a European Health Insurance Card to get before you go), opening hours, AI tips and an AI-suggested idea. The flights from and to Kraków and both Gozo ferries know where they land, the hotel is reached before check-in, and the way into Valletta is the TD13 bus with its real stops (shown even without a Google key). It also contains three deliberate problems: a cathedral visit on a Sunday when it is closed, a late arrival for the noon gun, and a flight home less than 18 h after diving. +Click the demo trip (**Try ›** on an empty list, **Load demo trip** once you have trips) to explore a seven-day Malta & Gozo dive week. It always starts on the latest Saturday, so it is under way and shows today's column. It uses every card type, travel by car, on foot, by bus, scooter and bicycle, has three stays with check-in and check-out times, a five-day rental car, a scooter picked up at the Gozo ferry and returned at the farmhouse, a boat tour to Comino that picks you up at the harbour and visits three places, bookings with prices and statuses (one dive day still waiting for confirmation, one still to book), a dive prepared and one with something to check, against a €1,500 budget, the entry rules for its Polish passports (Malta is in the EU: no visa, but a European Health Insurance Card to get before you go), opening hours, AI tips and an AI-suggested idea. The flights from and to Kraków and both Gozo ferries know where they land, the hotel is reached before check-in, and the way into Valletta is the TD13 bus with its real stops (shown even without a Google key). It also contains three deliberate problems: a cathedral visit on a Sunday when it is closed, a late arrival for the noon gun, and a flight home less than 18 h after diving. Everything else is configured in the app under **⚙ Settings**, with no `.env` editing needed. Settings are stored in `~/.tripdeck/config.json` on your machine. @@ -71,6 +71,7 @@ For Claude Desktop, add the same command to `mcpServers` in its config. The MCP | `set_leg` | Travel mode between two cards (incl. ferry/boat/flight minutes) | | `search_places`, `nearby_places` | Find places by name, or notable places around a card | | `optimize_day` | Real travel-time matrix and the fastest order, keeping the ferry, boat and flight legs you entered; Claude decides and calls `reorder_day` | +| `save_dive_prep` | The coach of [AI Diving Monitor](https://github.com/Torgh/ai-diving-monitor) shows on a dive card how ready you are (prepared, or something to check), with a summary and a link to the full preparation | | `save_entry_check`, `set_entry_done` | Claude researches visas, passport rules and arrival forms for the trip's passports on the web and saves them (links only to the official sites it used as sources); tick formalities off. The board shows them under 🛂 Entry | `get_trip` also lists the public-transport lines the board found for a leg (`via 12:24 🚆 ARL: Suvarnabhumi → Phaya Thai (7 stops) → …`), so Claude can tell you where to change. It starts with **To sort out** (visas and forms to apply for, with deadlines; bookings to make or confirm; nights without a bed) and ends every day with where you sleep. diff --git a/app/globals.css b/app/globals.css index 6c47b32..52d5140 100644 --- a/app/globals.css +++ b/app/globals.css @@ -694,6 +694,16 @@ body:has(.home)::before { background: var(--info-soft); color: var(--info); } +.badge.prep-check { + background: var(--warn-soft); + color: var(--warn); +} +a.badge { + text-decoration: none; +} +a.badge:hover { + text-decoration: underline; +} .linklike { border: none; background: none; diff --git a/components/Board.tsx b/components/Board.tsx index 754dfb3..dea4a0c 100644 --- a/components/Board.tsx +++ b/components/Board.tsx @@ -27,6 +27,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { memo, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { DivePrepBadge } from "@/components/DivePrep"; import { routeKey } from "@/lib/geo"; import { todayIndex } from "@/lib/itinerary"; import { dayColor } from "@/lib/mapLayers"; @@ -39,6 +40,7 @@ import { transitStepLine, vehicleIcon } from "@/lib/transit"; import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, + diveGas, IDEAS, isRoutable, itemIcon, @@ -596,9 +598,10 @@ const ItemCard = memo(function ItemCard({ {item.dive && ( {item.dive.dives} dive{item.dive.dives > 1 ? "s" : ""} - {item.dive.maxDepthM ? ` · ${item.dive.maxDepthM} m` : ""} · {item.dive.gas} + {item.dive.maxDepthM ? ` · ${item.dive.maxDepthM} m` : ""} · {diveGas(item.dive)} )} + {item.dive?.prep && } {item.stay && {item.stay.nights} night{item.stay.nights > 1 ? "s" : ""}} {item.stay?.checkIn && in {item.stay.checkIn}} {item.stay?.checkOut && out {item.stay.checkOut}} diff --git a/components/DivePrep.tsx b/components/DivePrep.tsx new file mode 100644 index 0000000..1d43052 --- /dev/null +++ b/components/DivePrep.tsx @@ -0,0 +1,29 @@ +import { DIVE_PREP_ICON, DIVE_PREP_LABEL, divePrepUrl, type DivePrep } from "@/lib/types"; + +/** "✅ Prepared" / "⚠️ Check prep" on a dive card, opening the preparation in AI Diving Monitor. */ +export function DivePrepBadge({ prep }: { prep: DivePrep }) { + const url = divePrepUrl(prep); + const label = ( + <> + {DIVE_PREP_ICON[prep.status]} {DIVE_PREP_LABEL[prep.status]} + + ); + const className = `badge prep-${prep.status}`; + return url ? ( + e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > + {label} + + ) : ( + + {label} + + ); +} diff --git a/components/ItemEditor.tsx b/components/ItemEditor.tsx index 8e663fe..a67270b 100644 --- a/components/ItemEditor.tsx +++ b/components/ItemEditor.tsx @@ -18,6 +18,9 @@ import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, BOOKING_STATUSES, + DIVE_PREP_ICON, + DIVE_PREP_LABEL, + divePrepUrl, type AiTips, type Booking, type BookingStatus, @@ -429,7 +432,28 @@ export function ItemEditor({ + + {draft.dive?.prep && ( +
+ {DIVE_PREP_ICON[draft.dive.prep.status]} {DIVE_PREP_LABEL[draft.dive.prep.status]}: {draft.dive.prep.summary}{" "} + {divePrepUrl(draft.dive.prep) && ( + + Open in AI Diving Monitor + + )}{" "} + +
+ )}
TripDeck warns about flights and altitude after diving using DAN's published guidance. Always follow your dive computer and training. diff --git a/components/TripPrint.tsx b/components/TripPrint.tsx index 61be3f1..17f4730 100644 --- a/components/TripPrint.tsx +++ b/components/TripPrint.tsx @@ -13,7 +13,7 @@ import { dayColor } from "@/lib/mapLayers"; import { useTrip } from "@/lib/store"; import { dayDate, formatDayLabel, formatDuration, formatHm } from "@/lib/time"; import { computeTripTimeline, getLeg, type Issue } from "@/lib/timeline"; -import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, itemIcon, MODE_LABEL, type Item } from "@/lib/types"; +import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, DIVE_PREP_ICON, DIVE_PREP_LABEL, diveGas, itemIcon, MODE_LABEL, type Item } from "@/lib/types"; import { TripMap } from "./TripMap"; /** Printable itinerary; the browser's print dialog saves it as a PDF. */ @@ -260,10 +260,11 @@ function ItemDetails({ item }: { item: Item }) { if (item.dive) { const d = item.dive; bits.push( - [`${d.dives} dive${d.dives > 1 ? "s" : ""}`, d.site, d.maxDepthM ? `max ${d.maxDepthM} m` : "", d.gas, d.center] + [`${d.dives} dive${d.dives > 1 ? "s" : ""}`, d.site, d.maxDepthM ? `max ${d.maxDepthM} m` : "", diveGas(d), d.center] .filter(Boolean) .join(" · "), ); + if (d.prep) bits.push(`${DIVE_PREP_ICON[d.prep.status]} ${DIVE_PREP_LABEL[d.prep.status]}: ${d.prep.summary}`); } if (item.tour?.stops.length) bits.push(`Visits: ${item.tour.stops.map((s) => s.name).join(" → ")}`); const b = item.booking; diff --git a/lib/demo.ts b/lib/demo.ts index 058d917..ef59b84 100644 --- a/lib/demo.ts +++ b/lib/demo.ts @@ -169,7 +169,19 @@ export function demoTrip(): Trip { title: "Cirkewwa – Rozi & P29 wrecks", startTime: "08:30", durationMin: 240, - dive: { dives: 2, maxDepthM: 30, gas: "nitrox", center: "Local dive centre", site: "Cirkewwa" }, + dive: { + dives: 2, + maxDepthM: 30, + gas: "nitrox", + mix: "EAN32", + center: "Local dive centre", + site: "Cirkewwa", + prep: { + status: "ready", + summary: "Five dives to 27 m or deeper in the log; EAN32 is good to 33.8 m. Keep the ascent under 10 m/min.", + updatedAt: "2026-10-23T12:00:00.000Z", + }, + }, booking: { provider: "Dive centre", reference: "DEMO-DIVE1", price: "€110", status: "pending" }, location: { lat: 35.9886, lng: 14.3289, address: "Cirkewwa, Malta" }, }, @@ -226,7 +238,17 @@ export function demoTrip(): Trip { type: "dive", title: "Blue Hole & Coral Cave, Dwejra", durationMin: 240, - dive: { dives: 2, maxDepthM: 25, gas: "air", site: "Blue Hole" }, + dive: { + dives: 2, + maxDepthM: 25, + gas: "air", + site: "Blue Hole", + prep: { + status: "check", + summary: "The Coral Cave and the arch are overhead: no cavern dive in the log yet. Ask the centre for a guide.", + updatedAt: "2026-10-23T12:00:00.000Z", + }, + }, location: { lat: 36.0532, lng: 14.1884, address: "Dwejra, Gozo" }, }, { diff --git a/lib/types.test.ts b/lib/types.test.ts index 8dbb19c..a6f5d7c 100644 --- a/lib/types.test.ts +++ b/lib/types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { itemIcon, legKey } from "./types"; +import { diveGas, divePrepUrl, itemIcon, legKey } from "./types"; describe("itemIcon", () => { it("shows the vehicle of a rental and the kind of transport", () => { @@ -20,3 +20,18 @@ describe("legKey", () => { expect(legKey("a", "b")).toBe("a>b"); }); }); + +describe("dives", () => { + it("names the gas by its mix once known", () => { + expect(diveGas({ gas: "nitrox" })).toBe("nitrox"); + expect(diveGas({ gas: "nitrox", mix: " EAN32 " })).toBe("EAN32"); + expect(diveGas({ gas: "trimix", mix: " " })).toBe("trimix"); + }); + + it("opens only web links from a preparation", () => { + expect(divePrepUrl({ url: "http://localhost:3001/trips/t#card-a" })).toBe("http://localhost:3001/trips/t#card-a"); + expect(divePrepUrl({ url: "HTTPS://example.com" })).toBe("HTTPS://example.com"); + expect(divePrepUrl({ url: "javascript:alert(1)" })).toBeUndefined(); + expect(divePrepUrl({})).toBeUndefined(); + }); +}); diff --git a/lib/types.ts b/lib/types.ts index 9d59b11..0eeca00 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -51,6 +51,22 @@ export interface PlaceInfo { export type Gas = "air" | "nitrox" | "trimix"; +/** "ready": good to go; "check": something to sort out first (the mix, a refresher, a depth not dived yet). */ +export type DivePrepStatus = "ready" | "check"; + +/** + * The diver's preparation for a dive, saved by their coach from AI Diving Monitor (through the + * MCP servers of both apps). TripDeck shows it; the preparation itself lives in AI Diving Monitor. + */ +export interface DivePrep { + status: DivePrepStatus; + /** One or two sentences: how ready the diver is and what to sort out. */ + summary: string; + /** The full preparation in AI Diving Monitor. */ + url?: string; + updatedAt: string; +} + export interface DiveDetails { site?: string; center?: string; @@ -58,8 +74,24 @@ export interface DiveDetails { dives: number; maxDepthM?: number; gas: Gas; + /** The mix, once known: "EAN32", "Tx 18/45". `gas` stays its kind. */ + mix?: string; + prep?: DivePrep; } +/** The gas as divers write it: the mix when known, else its kind. */ +export function diveGas(dive: Pick): string { + return dive.mix?.trim() || dive.gas; +} + +/** The preparation's link, only when it is a web address: it comes from the coach, not from TripDeck. */ +export function divePrepUrl(prep: Pick): string | undefined { + return prep.url && /^https?:\/\//i.test(prep.url) ? prep.url : undefined; +} + +export const DIVE_PREP_LABEL: Record = { ready: "Prepared", check: "Check prep" }; +export const DIVE_PREP_ICON: Record = { ready: "✅", check: "⚠️" }; + export type TransportKind = "flight" | "ferry" | "train" | "bus" | "car_rental" | "boat" | "other"; export interface TransportDetails { diff --git a/mcp/server.test.ts b/mcp/server.test.ts index 630c020..4b0a091 100644 --- a/mcp/server.test.ts +++ b/mcp/server.test.ts @@ -72,6 +72,7 @@ describe.skipIf(!bun)("MCP server", () => { "optimize_day", "remove_item", "reorder_day", + "save_dive_prep", "save_entry_check", "search_places", "set_entry_done", @@ -128,6 +129,35 @@ describe.skipIf(!bun)("MCP server", () => { expect(await ok("list_trips")).toMatch(/Malta test.*2 days · 2 cards/); }); + it("keeps the mix and shows the coach's dive preparation on the card", async () => { + const tripId = idIn(await ok("create_trip", { name: "Dahab", destination: "Dahab", start_date: "2026-11-14", days: 3 }), /trip_id ([\w-]+)/); + const dive = idIn( + await ok("add_item", { trip_id: tripId, day: 2, type: "dive", title: "Canyon", dive: { dives: 1, max_depth_m: 30, gas: "nitrox", mix: "EAN32" } }), + /\[([\w-]+)\]/, + ); + const food = idIn(await ok("add_item", { trip_id: tripId, day: 2, type: "food", title: "Lunch" }), /\[([\w-]+)\]/); + expect(await ok("get_trip", { trip_id: tripId })).toContain("1 dive(s), max 30 m, EAN32"); + + const url = `http://localhost:3001/trips/${tripId}#card-${dive}`; + expect(await ok("save_dive_prep", { trip_id: tripId, item_id: dive, status: "check", summary: "Deepest so far 24 m: do a 25 m dive first.", url })).toBe( + '"Canyon": check prep.', + ); + expect(await ok("get_trip", { trip_id: tripId })).toContain(`dive prep: check prep – Deepest so far 24 m: do a 25 m dive first. (${url})`); + + // Changing the dive keeps the preparation and the mix unless a new mix is given. + await ok("update_item", { trip_id: tripId, item_id: dive, dive: { dives: 2, max_depth_m: 28 } }); + const saved = JSON.parse(fs.readFileSync(path.join(home, "trips", `${tripId}.json`), "utf8")).items[dive].dive; + expect(saved).toMatchObject({ dives: 2, maxDepthM: 28, gas: "nitrox", mix: "EAN32", prep: { status: "check", url } }); + await ok("update_item", { trip_id: tripId, item_id: dive, dive: { dives: 2, mix: "" } }); + expect(await ok("get_trip", { trip_id: tripId })).toContain("2 dive(s), nitrox"); + + expect((await call("save_dive_prep", { trip_id: tripId, item_id: dive, status: "ready" })).text).toContain("short summary"); + expect((await call("save_dive_prep", { trip_id: tripId, item_id: dive, status: "ready", summary: "x", url: "javascript:alert(1)" })).isError).toBe(true); + expect((await call("save_dive_prep", { trip_id: tripId, item_id: food, status: "ready", summary: "x" })).text).toContain("not a dive card"); + expect(await ok("save_dive_prep", { trip_id: tripId, item_id: dive, status: "none" })).toBe('Removed the preparation from "Canyon".'); + expect(await ok("get_trip", { trip_id: tripId })).not.toContain("dive prep:"); + }); + it("adds up the prices under Budget, paid apart, against a limit", async () => { const tripId = idIn( await ok("create_trip", { name: "Gozo budget", destination: "Malta", start_date: "2027-05-01", days: 2 }), diff --git a/mcp/server.ts b/mcp/server.ts index bc7049a..04f1987 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -54,6 +54,9 @@ import * as ops from "../lib/tripOps"; import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, + DIVE_PREP_LABEL, + diveGas, + divePrepUrl, IDEAS, isRoutable, ITEM_TYPE_LABEL, @@ -88,7 +91,8 @@ How to work: - Organised tours (type activity): the card's place is the pick-up / meeting point (travel times go there); list the places the tour visits in tour_stops so they show on the map. - Flights, ferries, trains (type transport): place_query = where it departs, transport.to_place_query = where it arrives, start_time = departure and transport.arrival_time = arrival, both in local time as on the ticket (arrival_next_day when it lands the next day). The route to the next card then starts at the arrival, so no flight leg is needed. get_trip notes when the clocks change and how long the journey really takes. - Public transport (leg mode TRANSIT): the board plans it for the time in the plan and get_trip lists the lines under the leg ("via 🚆 ARL: … → 🚇 …"). Suggest it from airports and stations; if it says no public transport at that time (night flights), suggest a car instead. -- Dives: get_trip reports no-fly and altitude warnings (DAN guidance: ≥12 h after a single no-deco dive, ≥18 h after repetitive or multi-day diving). Point them out, but they are planning hints, not a substitute for the diver's computer or training. +- Dives: get_trip reports no-fly and altitude warnings (DAN guidance: ≥12 h after a single no-deco dive, ≥18 h after repetitive or multi-day diving). Point them out, but they are planning hints, not a substitute for the diver's computer or training. dive.gas is the kind (air, nitrox, trimix); save the mix in dive.mix once the user knows it ("EAN32", "Tx 18/45"). +- Dive preparation: when the diver uses AI Diving Monitor (its MCP server), its coach prepares each dive card from the diver's log; save the verdict on the card with save_dive_prep (ready, or check when something is still to sort out) with a one-line summary and the link to the full preparation, so the board shows it. - Answer in the user's language. Keep card titles short; the app UI is in English.`; const server = new McpServer({ name: "tripdeck", title: "TripDeck", version: "0.2.0" }, { instructions: INSTRUCTIONS }); @@ -177,8 +181,10 @@ function itemLine(item: Item): string { const bits: string[] = []; if (item.dive) { bits.push( - `${item.dive.dives} dive(s)${item.dive.maxDepthM ? `, max ${item.dive.maxDepthM} m` : ""}, ${item.dive.gas}${item.dive.site ? `, site ${item.dive.site}` : ""}${item.dive.center ? `, with ${item.dive.center}` : ""}`, + `${item.dive.dives} dive(s)${item.dive.maxDepthM ? `, max ${item.dive.maxDepthM} m` : ""}, ${diveGas(item.dive)}${item.dive.site ? `, site ${item.dive.site}` : ""}${item.dive.center ? `, with ${item.dive.center}` : ""}`, ); + const prep = item.dive.prep; + if (prep) bits.push(`dive prep: ${DIVE_PREP_LABEL[prep.status].toLowerCase()} – ${prep.summary}${prep.url ? ` (${prep.url})` : ""}`); } if (item.transport) { const to = item.transport.toLocation; @@ -368,6 +374,7 @@ const cardFields = { dives: z.number().int().min(1).max(5), max_depth_m: z.number().min(0).max(150).optional(), gas: z.enum(["air", "nitrox", "trimix"]).optional(), + mix: z.string().max(40).optional().describe('The mix once known: "EAN32", "Tx 18/45". "" removes it.'), site: z.string().max(120).optional(), center: z.string().max(120).optional(), }) @@ -460,8 +467,11 @@ async function applyCard(trip: Trip, base: Item, a: CardArgs): Promise<{ item: I dives: a.dive.dives, maxDepthM: a.dive.max_depth_m, gas: a.dive.gas ?? item.dive?.gas ?? "air", + mix: a.dive.mix === undefined ? item.dive?.mix : a.dive.mix.trim() || undefined, site: a.dive.site, center: a.dive.center, + // The preparation belongs to the coach (save_dive_prep), not to the card's details. + prep: item.dive?.prep, }; } else if (item.type === "dive" && !item.dive) item.dive = { dives: 2, gas: "air" }; if (a.transport) { @@ -1053,6 +1063,36 @@ server.registerTool( }), ); +server.registerTool( + "save_dive_prep", + { + title: "Save dive preparation", + description: + "Shows on a dive card how ready the diver is, as prepared by the coach of AI Diving Monitor from the diver's dive log: ready, or check (something to sort out first: the mix, a refresher, a depth not dived yet), a one-line summary and the link to the full preparation. status none removes it.", + inputSchema: { + trip_id: tripId, + item_id: z.string().describe("The dive card's id."), + status: z.enum(["ready", "check", "none"]), + summary: z.string().max(300).optional().describe("One or two sentences; needed unless status is none."), + url: z.string().url().max(500).optional().describe("The preparation in AI Diving Monitor, e.g. http://localhost:3001/trips/#card-."), + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }, + tool(async (a) => { + if (a.url && !divePrepUrl({ url: a.url })) throw new HttpError(400, "The link must be an http(s) address."); + if (a.status !== "none" && !a.summary?.trim()) throw new HttpError(400, "Give a short summary of the preparation."); + let title = ""; + await updateTrip(a.trip_id, (t) => { + const item = requireItem(t, a.item_id); + if (item.type !== "dive" || !item.dive) throw new HttpError(400, `"${item.title}" is not a dive card.`); + title = item.title; + const prep = a.status === "none" ? undefined : { status: a.status, summary: a.summary!.trim(), url: a.url, updatedAt: new Date().toISOString() }; + return { ...t, items: { ...t.items, [item.id]: { ...item, dive: { ...item.dive, prep } } } }; + }); + return a.status === "none" ? `Removed the preparation from "${title}".` : `"${title}": ${DIVE_PREP_LABEL[a.status].toLowerCase()}.`; + }), +); + server.registerTool( "set_entry_done", { diff --git a/mobile/src/DayView.tsx b/mobile/src/DayView.tsx index 75ebe56..a667171 100644 --- a/mobile/src/DayView.tsx +++ b/mobile/src/DayView.tsx @@ -9,6 +9,9 @@ import { vehicleIcon } from "@/lib/transit"; import { BOOKING_STATUS_ICON, BOOKING_STATUS_LABEL, + DIVE_PREP_ICON, + DIVE_PREP_LABEL, + diveGas, itemIcon, MODE_LABEL, RENTAL_VEHICLE_LABEL, @@ -210,8 +213,9 @@ function subtitle(trip: Trip, item: Item, dayIndex: number): string | undefined `${d.dives} dive${d.dives > 1 ? "s" : ""}`, d.site, d.maxDepthM != null && `max ${d.maxDepthM} m`, - d.gas !== "air" && d.gas, + diveGas(d) !== "air" && diveGas(d), d.center, + d.prep && `${DIVE_PREP_ICON[d.prep.status]} ${DIVE_PREP_LABEL[d.prep.status]}`, ] .filter(Boolean) .join(" · ");