Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion components/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,6 +40,7 @@ import { transitStepLine, vehicleIcon } from "@/lib/transit";
import {
BOOKING_STATUS_ICON,
BOOKING_STATUS_LABEL,
diveGas,
IDEAS,
isRoutable,
itemIcon,
Expand Down Expand Up @@ -596,9 +598,10 @@ const ItemCard = memo(function ItemCard({
{item.dive && (
<span>
{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)}
</span>
)}
{item.dive?.prep && <DivePrepBadge prep={item.dive.prep} />}
{item.stay && <span>{item.stay.nights} night{item.stay.nights > 1 ? "s" : ""}</span>}
{item.stay?.checkIn && <span>in {item.stay.checkIn}</span>}
{item.stay?.checkOut && <span>out {item.stay.checkOut}</span>}
Expand Down
29 changes: 29 additions & 0 deletions components/DivePrep.tsx
Original file line number Diff line number Diff line change
@@ -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 ? (
<a
className={className}
href={url}
target="_blank"
rel="noopener noreferrer"
title={`${prep.summary}\nOpen the preparation in AI Diving Monitor`}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{label}
</a>
) : (
<span className={className} title={prep.summary}>
{label}
</span>
);
}
24 changes: 24 additions & 0 deletions components/ItemEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -429,7 +432,28 @@ export function ItemEditor({
<option value="trimix">Trimix</option>
</select>
</label>
<label>
Mix
<input
value={draft.dive?.mix ?? ""}
placeholder={draft.dive?.gas === "trimix" ? "Tx 18/45" : draft.dive?.gas === "nitrox" ? "EAN32" : ""}
onChange={(e) => set({ dive: { ...draft.dive!, mix: e.target.value || undefined } })}
/>
</label>
</div>
{draft.dive?.prep && (
<div className={`small prep prep-${draft.dive.prep.status}`}>
{DIVE_PREP_ICON[draft.dive.prep.status]} <strong>{DIVE_PREP_LABEL[draft.dive.prep.status]}:</strong> {draft.dive.prep.summary}{" "}
{divePrepUrl(draft.dive.prep) && (
<a href={divePrepUrl(draft.dive.prep)} target="_blank" rel="noopener noreferrer">
Open in AI Diving Monitor
</a>
)}{" "}
<button type="button" className="linklike" onClick={() => set({ dive: { ...draft.dive!, prep: undefined } })}>
Remove
</button>
</div>
)}
<div className="small muted">
TripDeck warns about flights and altitude after diving using DAN&apos;s published guidance. Always follow
your dive computer and training.
Expand Down
5 changes: 3 additions & 2 deletions components/TripPrint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 24 additions & 2 deletions lib/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
Expand Down Expand Up @@ -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" },
},
{
Expand Down
17 changes: 16 additions & 1 deletion lib/types.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
});
});
32 changes: 32 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,47 @@ 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;
/** Number of dives in this outing (e.g. a two-tank boat trip = 2). */
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<DiveDetails, "gas" | "mix">): 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<DivePrep, "url">): string | undefined {
return prep.url && /^https?:\/\//i.test(prep.url) ? prep.url : undefined;
}

export const DIVE_PREP_LABEL: Record<DivePrepStatus, string> = { ready: "Prepared", check: "Check prep" };
export const DIVE_PREP_ICON: Record<DivePrepStatus, string> = { ready: "✅", check: "⚠️" };

export type TransportKind = "flight" | "ferry" | "train" | "bus" | "car_rental" | "boat" | "other";

export interface TransportDetails {
Expand Down
30 changes: 30 additions & 0 deletions mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }),
Expand Down
Loading
Loading