← All components
Week 7 · 2026-10-09

Trip Planner with Live Map

Plan a multi-day trip on a real map: search places, organise stops by day, see driving routes with times and distances, split the budget, pack, and share it with a link.

Loading your trip…

Source

Demo.tsx
import TripPlanner from "./TripPlanner";

export default function Demo() {
  return <TripPlanner />;
}
TripPlanner.module.css
.card {
  --surface: #fcfcfb;
  --ink: #0b0b0b;
  --ink-2: #52514e;
  --muted: #898781;
  --border: rgba(11, 11, 11, 0.1);
  --field: #f4f3f0;
  --accent: #2a78d6;
  --danger: #d92d20;

  position: relative;
  width: 100%;
  max-width: 1150px;
  padding: 20px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: var(--surface);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px -12px rgba(0, 0, 0, 0.18);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  container-type: inline-size;
}

@media (prefers-color-scheme: dark) {
  .card {
    --surface: #18181b;
    --ink: #ffffff;
    --ink-2: #c3c2b7;
    --border: rgba(255, 255, 255, 0.1);
    --field: #232327;
    --accent: #6da7ec;
    --danger: #f97066;
    box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
  }
  /* Dark map tiles without needing a separate dark tile server. */
  .map :global(.leaflet-tile-pane) { filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.88) saturate(0.6); }
  .map { background: #1f2023; }
}

.loading { padding: 80px 0; color: var(--muted); text-align: center; }

/* Header */
.head { margin-bottom: 14px; }
.titleRow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
.tripName {
  flex: 1;
  min-width: 200px;
  padding: 4px 8px;
  margin-left: -8px;
  border: 1px solid transparent;
  border-radius: 8px;
  outline: none;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.01em;
}
.tripName:hover { border-color: var(--border); }
.tripName:focus { border-color: var(--accent); background: var(--field); }
.headActions { display: flex; flex-wrap: wrap; gap: 6px; }
.meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 8px; color: var(--ink-2); font-size: 13px; }
.meta label { display: inline-flex; align-items: center; gap: 6px; }
.meta input {
  height: 30px;
  padding: 0 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--field);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  color-scheme: light dark;
}
.meta input[type="number"] { width: 64px; }
.metaStat { color: var(--muted); }

.ghost {
  height: 32px;
  padding: 0 12px;
  border: 1px solid var(--border);
  border-radius: 9px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  font-weight: 500;
  cursor: pointer;
}
.ghost:hover { background: var(--field); }
.ghost:focus-visible,
.tabs button:focus-visible,
.dayStrip button:focus-visible,
.stopMain:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Body */
.body { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(320px, 1fr); gap: 14px; }
@container (max-width: 760px) {
  .body { grid-template-columns: 1fr; }
  .map { height: 340px !important; }
}

.mapWrap { position: relative; min-width: 0; }
.map { height: 600px; overflow: hidden; border: 1px solid var(--border); border-radius: 14px; z-index: 0; }
.dayStrip {
  position: absolute;
  top: 10px;
  left: 56px;
  right: 10px;
  z-index: 500;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  pointer-events: none;
}
.dayStrip button {
  padding: 6px 12px;
  border: 2px solid var(--c);
  border-radius: 99px;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
  color: var(--ink);
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  pointer-events: auto;
}
.dayStrip button[aria-selected="true"] { background: var(--c); color: #fff; }
.mapHint {
  position: absolute;
  bottom: 10px;
  left: 10px;
  z-index: 500;
  margin: 0;
  padding: 4px 10px;
  border-radius: 99px;
  background: rgba(0, 0, 0, 0.62);
  color: #fff;
  font-size: 11.5px;
  pointer-events: none;
}

.pin {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border: 2.5px solid #fff;
  border-radius: 50% 50% 50% 4px;
  background: var(--c);
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35);
  color: #fff;
  font: 700 12px system-ui, sans-serif;
  transform: rotate(-45deg);
  opacity: 0.55;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.pin[data-active="true"] { opacity: 1; }
.pin[data-open="true"] { transform: rotate(-45deg) scale(1.25); }
/* The teardrop is rotated; turn the number back upright. */
.pin b { transform: rotate(45deg); font-weight: 700; line-height: 1; }

.popup { display: flex; flex-direction: column; gap: 8px; min-width: 170px; font-family: system-ui, sans-serif; }
.popup strong { font-size: 13px; }
.popup button { padding: 6px 10px; border: 0; border-radius: 8px; color: #fff; font: 600 12.5px system-ui, sans-serif; cursor: pointer; }

/* Panel */
.panel { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.tabs { display: flex; gap: 2px; padding: 3px; border-radius: 10px; background: var(--field); }
.tabs button {
  flex: 1;
  padding: 7px 6px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: 12.5px;
  white-space: nowrap;
  cursor: pointer;
}
.tabs button[aria-selected="true"] { background: var(--surface); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); color: var(--ink); font-weight: 600; }

.search { display: flex; gap: 6px; }
.search input,
.addRow input {
  flex: 1;
  min-width: 0;
  height: 38px;
  padding: 0 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  outline: none;
  background: var(--field);
  color: var(--ink);
  font: inherit;
  font-size: 14px;
}
.search input:focus,
.addRow input:focus { border-color: var(--accent); }
.search button,
.addRow button {
  height: 38px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: var(--ink);
  color: var(--surface);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}

.results { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 6px; border: 1px solid var(--border); border-radius: 12px; list-style: none; }
.results li { display: flex; align-items: center; gap: 6px; }
.resultPeek {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
  padding: 6px 8px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.resultPeek:hover { background: var(--field); }
.resultPeek strong { font-size: 13.5px; }
.resultPeek span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.addBtn { flex: none; padding: 5px 10px; border: 0; border-radius: 8px; color: #fff; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.noResults { padding: 8px; color: var(--muted); font-size: 13px; }
.closeResults { width: 100%; padding: 4px; border: 0; background: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; }

.dayHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 10px 12px;
  border-left: 4px solid var(--c);
  border-radius: 6px 12px 12px 6px;
  background: color-mix(in srgb, var(--c) 10%, transparent);
}
.dayHead div { display: flex; align-items: baseline; gap: 8px; }
.dayHead strong { font-size: 15px; }
.dayHead div span { color: var(--ink-2); font-size: 13px; }
.dayStats { color: var(--ink-2); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }

.stops { display: flex; flex-direction: column; max-height: 360px; margin: 0; padding: 0 2px 0 0; overflow-y: auto; list-style: none; }
.emptyDay { padding: 20px 8px; color: var(--muted); font-size: 13px; text-align: center; }
.leg { display: flex; align-items: center; height: 26px; margin-left: 13px; padding-left: 20px; border-left: 2px dashed var(--border); color: var(--muted); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.stop { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); transition: box-shadow 0.15s ease; }
.stop[data-open="true"] { border-color: var(--accent); box-shadow: 0 6px 18px -8px rgba(0, 0, 0, 0.25); }
.stop:hover { box-shadow: 0 4px 12px -8px rgba(0, 0, 0, 0.3); }
.stopMain {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 9px 10px;
  border: 0;
  border-radius: 12px;
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.num { display: grid; flex: none; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-size: 12px; font-weight: 700; }
.stopText { display: flex; flex: 1; flex-direction: column; min-width: 0; }
.stopText strong { overflow: hidden; font-size: 13.5px; text-overflow: ellipsis; white-space: nowrap; }
.stopText span { color: var(--muted); font-size: 12px; }
.grip { color: var(--muted); font-size: 12px; letter-spacing: -2px; cursor: grab; }

.editor { display: flex; flex-direction: column; gap: 8px; padding: 0 12px 12px; }
.editor label { display: flex; flex-direction: column; gap: 3px; color: var(--ink-2); font-size: 11.5px; }
.editor input,
.editor select {
  height: 32px;
  min-width: 0;
  padding: 0 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--field);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  color-scheme: light dark;
}
.row3 { display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 6px; }
.editorActions { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px; }
.editorActions > span { display: flex; gap: 4px; }
.inline { flex-direction: row !important; align-items: center; gap: 6px !important; }
.iconBtn,
.danger { height: 30px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: none; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
.iconBtn:disabled { opacity: 0.35; cursor: default; }
.danger { color: var(--danger); }

.dayActions { display: flex; gap: 6px; }

/* Budget */
.budget,
.packing { display: flex; flex-direction: column; gap: 10px; }
.bigNumbers { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.bigNumbers div { display: flex; flex-direction: column; padding: 12px; border-radius: 12px; background: var(--field); }
.bigNumbers strong { font-size: 22px; font-variant-numeric: tabular-nums; }
.bigNumbers span { color: var(--muted); font-size: 12px; }
.breakdown,
.extras,
.packing ul { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
.breakdown li { display: grid; grid-template-columns: 90px 1fr 80px; align-items: center; gap: 8px; font-size: 13px; }
.bar { height: 8px; overflow: hidden; border-radius: 99px; background: var(--field); }
.bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
.amount { text-align: right; font-variant-numeric: tabular-nums; }
.subhead { margin: 6px 0 0; color: var(--ink-2); font-size: 12.5px; font-weight: 600; }
.extras li,
.packing li { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 10px; font-size: 13px; }
.extras li span:first-child { flex: 1; }
.extras button,
.packing li button { padding: 0 4px; border: 0; background: none; color: var(--muted); font-size: 16px; cursor: pointer; }
.addRow { display: flex; gap: 6px; }
.addRow input[type="number"] { flex: 0 0 90px; }
.hint { margin: 0; color: var(--muted); font-size: 12px; }

.progress { height: 8px; overflow: hidden; border-radius: 99px; background: var(--field); }
.progress span { display: block; height: 100%; border-radius: inherit; background: #1baf7a; transition: width 0.3s ease; }
.packing li label { display: flex; flex: 1; align-items: center; gap: 10px; cursor: pointer; }
.packing li input { width: 16px; height: 16px; accent-color: #1baf7a; }
.packing li[data-done="true"] label { color: var(--muted); text-decoration: line-through; }

.toast {
  position: absolute;
  bottom: 54px;
  left: 50%;
  z-index: 1000;
  padding: 8px 16px;
  border-radius: 99px;
  background: var(--ink);
  color: var(--surface);
  font-size: 13px;
  font-weight: 500;
  transform: translateX(-50%);
  animation: rise 0.25s ease;
}
.credit { margin: 12px 0 0; color: var(--muted); font-size: 11.5px; }

@keyframes rise {
  from { opacity: 0; transform: translate(-50%, 8px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}
TripPlanner.tsx
"use client";

import "leaflet/dist/leaflet.css";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import type * as Leaflet from "leaflet";
import {
  CATEGORIES,
  DAY_COLORS,
  dayDate,
  decodeTrip,
  encodeTrip,
  sampleTrip,
  toIcs,
  uid,
  type Category,
  type Stop,
  type Trip,
} from "./model";
import { km, mins, reverseGeocode, routeFor, searchPlaces, type Place, type Route } from "./services";
import styles from "./TripPlanner.module.css";

const STORAGE_KEY = "gallery-trip-v1";
type Tab = "plan" | "budget" | "packing";

const money = (v: number) => `৳${Math.round(v).toLocaleString("en-IN")}`;
const colorOf = (i: number) => DAY_COLORS[i % DAY_COLORS.length];
const fmtDay = (d: Date) => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });

function download(text: string, name: string, type: string) {
  const url = URL.createObjectURL(new Blob([text], { type }));
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

export default function TripPlanner() {
  const [trip, setTrip] = useState<Trip | null>(null);
  const [tab, setTab] = useState<Tab>("plan");
  const [day, setDay] = useState(0);
  const [openStop, setOpenStop] = useState<string | null>(null);
  const [routes, setRoutes] = useState<Record<string, Route>>({});
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Place[] | null>(null);
  const [searching, setSearching] = useState(false);
  const [toast, setToast] = useState("");
  const [packDraft, setPackDraft] = useState("");
  const [extraDraft, setExtraDraft] = useState({ label: "", cost: "" });
  const [mapReady, setMapReady] = useState(false);

  const mapEl = useRef<HTMLDivElement>(null);
  const map = useRef<Leaflet.Map | null>(null);
  const L = useRef<typeof Leaflet | null>(null);
  const layer = useRef<Leaflet.LayerGroup | null>(null);
  const tripRef = useRef(trip);
  tripRef.current = trip;
  const dayRef = useRef(day);
  dayRef.current = day;
  const dragging = useRef<{ day: number; index: number } | null>(null);

  const notify = (msg: string) => {
    setToast(msg);
    setTimeout(() => setToast(""), 2200);
  };

  // ── Load: a shared link wins, then saved trip, then the sample ──────────────────────
  useEffect(() => {
    (async () => {
      const shared = window.location.hash.match(/trip=([^&]+)/)?.[1];
      if (shared) {
        const t = await decodeTrip(shared);
        if (t) {
          setTrip(t);
          notify("Opened a shared trip");
          return;
        }
      }
      try {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
          setTrip(JSON.parse(saved));
          return;
        }
      } catch {}
      setTrip(sampleTrip());
    })();
  }, []);

  useEffect(() => {
    if (!trip) return;
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(trip));
    } catch {}
  }, [trip]);

  const update = (fn: (t: Trip) => Trip) => setTrip((t) => (t ? fn(structuredClone(t)) : t));

  // ── Map setup (Leaflet touches window, so it is imported on the client only) ────────
  useEffect(() => {
    let disposed = false;
    (async () => {
      const lib = (await import("leaflet")).default;
      if (disposed || !mapEl.current) return;
      L.current = lib;
      const m = lib.map(mapEl.current, { zoomControl: true, scrollWheelZoom: true }).setView([21.4, 92.0], 11);
      lib
        .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
          maxZoom: 19,
          attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
        })
        .addTo(m);
      layer.current = lib.layerGroup().addTo(m);
      m.on("click", (e: Leaflet.LeafletMouseEvent) => showAddPopup(e.latlng.lat, e.latlng.lng));
      map.current = m;
      setMapReady(true);
    })();
    return () => {
      disposed = true;
      map.current?.remove();
      map.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function showAddPopup(lat: number, lng: number) {
    const lib = L.current;
    const m = map.current;
    if (!lib || !m || !tripRef.current) return;
    const box = document.createElement("div");
    box.className = styles.popup;
    const title = document.createElement("strong");
    title.textContent = "Looking up this place…";
    const btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = `Add to Day ${dayRef.current + 1}`;
    btn.style.background = colorOf(dayRef.current);
    box.append(title, btn);
    const popup = lib.popup().setLatLng([lat, lng]).setContent(box).openOn(m);
    let name = "Pinned place";
    reverseGeocode(lat, lng).then((n) => {
      name = n;
      title.textContent = n;
    });
    btn.onclick = () => {
      addStop({ name, lat, lng, detail: "" }, dayRef.current);
      m.closePopup(popup);
    };
  }

  // ── Routes: re-fetch a day's route when its stops change (debounced, cached) ────────
  const signature = trip?.days.map((d) => d.id + ":" + d.stops.map((s) => `${s.lat},${s.lng}`).join("|")).join("/") ?? "";
  useEffect(() => {
    if (!trip) return;
    const timer = setTimeout(async () => {
      const next: Record<string, Route> = {};
      for (const d of trip.days) next[d.id] = await routeFor(d.stops);
      setRoutes(next);
    }, 500);
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [signature]);

  // ── Draw markers and routes ─────────────────────────────────────────────────────────
  useEffect(() => {
    const lib = L.current;
    const group = layer.current;
    if (!mapReady || !lib || !group || !trip) return;
    group.clearLayers();
    trip.days.forEach((d, di) => {
      const active = di === day;
      const color = colorOf(di);
      const route = routes[d.id];
      const line = route?.coords.length ? route.coords : d.stops.map((s) => [s.lat, s.lng] as [number, number]);
      if (line.length > 1) {
        lib
          .polyline(line, { color, weight: active ? 5 : 3, opacity: active ? 0.9 : 0.35, dashArray: route?.estimated ? "6 8" : undefined })
          .addTo(group);
      }
      d.stops.forEach((s, si) => {
        const icon = lib.divIcon({
          className: "",
          html: `<span class="${styles.pin}" data-active="${active}" data-open="${openStop === s.id}" style="--c:${color}"><b>${si + 1}</b></span>`,
          iconSize: [28, 28],
          iconAnchor: [14, 32],
        });
        lib
          .marker([s.lat, s.lng], { icon, zIndexOffset: active ? 1000 : 0, title: s.name })
          .on("click", () => {
            setDay(di);
            setTab("plan");
            setOpenStop(s.id);
          })
          .addTo(group);
      });
    });
  }, [trip, routes, day, openStop, mapReady]);

  // Fit the selected day on screen.
  useEffect(() => {
    const lib = L.current;
    const m = map.current;
    const stops = trip?.days[day]?.stops ?? [];
    if (!mapReady || !lib || !m || !stops.length) return;
    if (stops.length === 1) m.flyTo([stops[0].lat, stops[0].lng], 14, { duration: 0.6 });
    else m.flyToBounds(lib.latLngBounds(stops.map((s) => [s.lat, s.lng])), { padding: [50, 50], duration: 0.6 });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [day, mapReady, trip?.days.length]);

  // ── Actions ─────────────────────────────────────────────────────────────────────────
  function addStop(place: Place, dayIndex: number) {
    const s: Stop = { id: uid(), name: place.name, lat: place.lat, lng: place.lng, category: "sight", time: "", cost: 0, note: "" };
    update((t) => {
      t.days[dayIndex].stops.push(s);
      return t;
    });
    setOpenStop(s.id);
    setResults(null);
    setQuery("");
    notify(`Added to Day ${dayIndex + 1}`);
  }

  function editStop(dayIndex: number, id: string, patch: Partial<Stop>) {
    update((t) => {
      const s = t.days[dayIndex].stops.find((x) => x.id === id);
      if (s) Object.assign(s, patch);
      return t;
    });
  }

  function moveStop(from: { day: number; index: number }, toDay: number, toIndex: number) {
    update((t) => {
      const [s] = t.days[from.day].stops.splice(from.index, 1);
      const target = t.days[toDay].stops;
      const index = from.day === toDay && toIndex > from.index ? toIndex - 1 : toIndex;
      target.splice(Math.min(index, target.length), 0, s);
      return t;
    });
  }

  async function search() {
    const q = query.trim();
    if (!q) return;
    setSearching(true);
    try {
      const b = map.current?.getBounds();
      setResults(await searchPlaces(q, b && { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() }));
    } catch {
      setResults([]);
      notify("Search is unavailable right now");
    } finally {
      setSearching(false);
    }
  }

  async function share() {
    if (!trip) return;
    const url = `${window.location.origin}${window.location.pathname}#trip=${await encodeTrip(trip)}`;
    await navigator.clipboard.writeText(url);
    notify("Share link copied. Anyone with it sees this trip");
  }

  const onDrop = (e: DragEvent, toDay: number, toIndex: number) => {
    e.preventDefault();
    e.stopPropagation();
    if (dragging.current) moveStop(dragging.current, toDay, toIndex);
    dragging.current = null;
  };

  // ── Derived numbers ─────────────────────────────────────────────────────────────────
  const totals = useMemo(() => {
    if (!trip) return null;
    const byCat = new Map<string, number>();
    for (const d of trip.days) for (const s of d.stops) byCat.set(s.category, (byCat.get(s.category) ?? 0) + s.cost);
    const extras = trip.extras.reduce((a, x) => a + x.cost, 0);
    if (extras) byCat.set("extras", extras);
    const total = [...byCat.values()].reduce((a, b) => a + b, 0);
    return { byCat, total, perPerson: total / Math.max(1, trip.travelers) };
  }, [trip]);

  if (!trip || !totals) {
    return (
      <div className={styles.card}>
        <div className={styles.loading}>Loading your trip…</div>
      </div>
    );
  }

  const current = trip.days[day] ?? trip.days[0];
  const route = routes[current?.id];
  const dayDistance = route?.legs.reduce((a, l) => a + l.distance, 0) ?? 0;
  const dayDuration = route?.legs.reduce((a, l) => a + l.duration, 0) ?? 0;
  const packed = trip.packing.filter((p) => p.done).length;

  return (
    <div className={styles.card}>
      <header className={styles.head}>
        <div className={styles.titleRow}>
          <input className={styles.tripName} value={trip.name} onChange={(e) => update((t) => ({ ...t, name: e.target.value }))} aria-label="Trip name" />
          <div className={styles.headActions}>
            <button type="button" className={styles.ghost} onClick={share}>
              Share link
            </button>
            <button type="button" className={styles.ghost} onClick={() => download(toIcs(trip), `${trip.name || "trip"}.ics`, "text/calendar")}>
              Add to calendar
            </button>
            <button
              type="button"
              className={styles.ghost}
              onClick={() => {
                if (window.confirm("Start over with the sample trip? Your current plan will be replaced.")) {
                  setTrip(sampleTrip());
                  setDay(0);
                  history.replaceState(null, "", window.location.pathname);
                }
              }}
            >
              Reset
            </button>
          </div>
        </div>
        <div className={styles.meta}>
          <label>
            Starts
            <input type="date" value={trip.start} onChange={(e) => e.target.value && update((t) => ({ ...t, start: e.target.value }))} />
          </label>
          <label>
            Travellers
            <input
              type="number"
              min={1}
              max={50}
              value={trip.travelers}
              onChange={(e) => update((t) => ({ ...t, travelers: Math.max(1, Math.min(50, Number(e.target.value) || 1)) }))}
            />
          </label>
          <span className={styles.metaStat}>
            {trip.days.length} days · {trip.days.reduce((a, d) => a + d.stops.length, 0)} stops · {money(totals.total)} total
          </span>
        </div>
      </header>

      <div className={styles.body}>
        <div className={styles.mapWrap}>
          <div ref={mapEl} className={styles.map} aria-label="Trip map. Click anywhere to add a place." />
          <div className={styles.dayStrip} role="tablist" aria-label="Days">
            {trip.days.map((d, i) => (
              <button
                key={d.id}
                type="button"
                role="tab"
                aria-selected={day === i}
                style={{ ["--c" as string]: colorOf(i) }}
                onClick={() => setDay(i)}
                onDragOver={(e) => e.preventDefault()}
                onDrop={(e) => onDrop(e, i, trip.days[i].stops.length)}
              >
                Day {i + 1}
              </button>
            ))}
          </div>
          <p className={styles.mapHint}>Click the map to add a place</p>
        </div>

        <section className={styles.panel}>
          <div className={styles.tabs} role="tablist">
            {(
              [
                ["plan", "Itinerary"],
                ["budget", `Budget · ${money(totals.perPerson)}/person`],
                ["packing", `Packing ${packed}/${trip.packing.length}`],
              ] as const
            ).map(([k, label]) => (
              <button key={k} type="button" role="tab" aria-selected={tab === k} onClick={() => setTab(k)}>
                {label}
              </button>
            ))}
          </div>

          {tab === "plan" && (
            <>
              <form
                className={styles.search}
                onSubmit={(e) => {
                  e.preventDefault();
                  search();
                }}
              >
                <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search a place, e.g. Inani Beach" aria-label="Search places" />
                <button type="submit" disabled={searching}>
                  {searching ? "…" : "Search"}
                </button>
              </form>
              {results && (
                <ul className={styles.results}>
                  {results.length === 0 && <li className={styles.noResults}>No places found.</li>}
                  {results.map((r, i) => (
                    <li key={i}>
                      <button
                        type="button"
                        className={styles.resultPeek}
                        onClick={() => map.current?.flyTo([r.lat, r.lng], 14, { duration: 0.6 })}
                      >
                        <strong>{r.name}</strong>
                        <span>{r.detail}</span>
                      </button>
                      <button type="button" className={styles.addBtn} style={{ background: colorOf(day) }} onClick={() => addStop(r, day)}>
                        + Day {day + 1}
                      </button>
                    </li>
                  ))}
                  <li>
                    <button type="button" className={styles.closeResults} onClick={() => setResults(null)}>
                      Close results
                    </button>
                  </li>
                </ul>
              )}

              <div className={styles.dayHead} style={{ ["--c" as string]: colorOf(day) }}>
                <div>
                  <strong>Day {day + 1}</strong>
                  <span>{fmtDay(dayDate(trip, day))}</span>
                </div>
                <span className={styles.dayStats}>
                  {current.stops.length > 1 ? `${km(dayDistance)} · ${mins(dayDuration)}${route?.estimated ? " (est.)" : ""}` : "Add stops to see the route"}
                </span>
              </div>

              <ol className={styles.stops} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, day, current.stops.length)}>
                {current.stops.length === 0 && <li className={styles.emptyDay}>No stops yet. Search above or click the map.</li>}
                {current.stops.map((s, i) => {
                  const leg = route?.legs[i - 1];
                  const open = openStop === s.id;
                  return (
                    <li key={s.id} className={styles.stopWrap}>
                      {i > 0 && leg && (
                        <div className={styles.leg}>
                          <span>
                            {km(leg.distance)} · {mins(leg.duration)}
                          </span>
                        </div>
                      )}
                      <div
                        className={styles.stop}
                        data-open={open}
                        draggable
                        onDragStart={() => (dragging.current = { day, index: i })}
                        onDragOver={(e) => e.preventDefault()}
                        onDrop={(e) => onDrop(e, day, i)}
                      >
                        <button
                          type="button"
                          className={styles.stopMain}
                          onClick={() => {
                            setOpenStop(open ? null : s.id);
                            map.current?.flyTo([s.lat, s.lng], Math.max(map.current.getZoom(), 13), { duration: 0.5 });
                          }}
                          aria-expanded={open}
                        >
                          <span className={styles.num} style={{ background: colorOf(day) }}>
                            {i + 1}
                          </span>
                          <span className={styles.stopText}>
                            <strong>{s.name}</strong>
                            <span>
                              {s.time && `${s.time} · `}
                              {CATEGORIES.find((c) => c.key === s.category)?.label}
                              {s.cost > 0 && ` · ${money(s.cost)}`}
                            </span>
                          </span>
                          <span className={styles.grip} aria-hidden="true">
                            ⋮⋮
                          </span>
                        </button>
                        {open && (
                          <div className={styles.editor}>
                            <label>
                              Name
                              <input value={s.name} onChange={(e) => editStop(day, s.id, { name: e.target.value })} />
                            </label>
                            <div className={styles.row3}>
                              <label>
                                Time
                                <input type="time" value={s.time} onChange={(e) => editStop(day, s.id, { time: e.target.value })} />
                              </label>
                              <label>
                                Type
                                <select value={s.category} onChange={(e) => editStop(day, s.id, { category: e.target.value as Category })}>
                                  {CATEGORIES.map((c) => (
                                    <option key={c.key} value={c.key}>
                                      {c.label}
                                    </option>
                                  ))}
                                </select>
                              </label>
                              <label>
                                Cost (৳)
                                <input type="number" min={0} value={s.cost || ""} onChange={(e) => editStop(day, s.id, { cost: Math.max(0, Number(e.target.value) || 0) })} />
                              </label>
                            </div>
                            <label>
                              Note
                              <input value={s.note} placeholder="Tickets, booking ref, tips…" onChange={(e) => editStop(day, s.id, { note: e.target.value })} />
                            </label>
                            <div className={styles.editorActions}>
                              <label className={styles.inline}>
                                Move to
                                <select value={day} onChange={(e) => moveStop({ day, index: i }, Number(e.target.value), Infinity)}>
                                  {trip.days.map((_, di) => (
                                    <option key={di} value={di}>
                                      Day {di + 1}
                                    </option>
                                  ))}
                                </select>
                              </label>
                              <span>
                                <button type="button" className={styles.iconBtn} disabled={i === 0} onClick={() => moveStop({ day, index: i }, day, i - 1)} aria-label="Move up">
                                  ↑
                                </button>
                                <button
                                  type="button"
                                  className={styles.iconBtn}
                                  disabled={i === current.stops.length - 1}
                                  onClick={() => moveStop({ day, index: i }, day, i + 2)}
                                  aria-label="Move down"
                                >
                                  ↓
                                </button>
                                <button
                                  type="button"
                                  className={styles.danger}
                                  onClick={() =>
                                    update((t) => {
                                      t.days[day].stops = t.days[day].stops.filter((x) => x.id !== s.id);
                                      return t;
                                    })
                                  }
                                >
                                  Remove
                                </button>
                              </span>
                            </div>
                          </div>
                        )}
                      </div>
                    </li>
                  );
                })}
              </ol>

              <div className={styles.dayActions}>
                <button
                  type="button"
                  className={styles.ghost}
                  onClick={() => {
                    update((t) => ({ ...t, days: [...t.days, { id: uid(), stops: [] }] }));
                    setDay(trip.days.length);
                  }}
                >
                  + Add day
                </button>
                {trip.days.length > 1 && (
                  <button
                    type="button"
                    className={styles.ghost}
                    onClick={() => {
                      if (current.stops.length && !window.confirm(`Delete Day ${day + 1} and its ${current.stops.length} stops?`)) return;
                      update((t) => ({ ...t, days: t.days.filter((_, i) => i !== day) }));
                      setDay(Math.max(0, day - 1));
                    }}
                  >
                    Delete Day {day + 1}
                  </button>
                )}
              </div>
            </>
          )}

          {tab === "budget" && (
            <div className={styles.budget}>
              <div className={styles.bigNumbers}>
                <div>
                  <strong>{money(totals.total)}</strong>
                  <span>trip total</span>
                </div>
                <div>
                  <strong>{money(totals.perPerson)}</strong>
                  <span>per person ({trip.travelers})</span>
                </div>
              </div>
              <ul className={styles.breakdown}>
                {[...totals.byCat.entries()]
                  .filter(([, v]) => v > 0)
                  .sort((a, b) => b[1] - a[1])
                  .map(([k, v]) => (
                    <li key={k}>
                      <span>{k === "extras" ? "Extras" : CATEGORIES.find((c) => c.key === k)?.label}</span>
                      <span className={styles.bar}>
                        <span style={{ width: `${(v / Math.max(1, totals.total)) * 100}%` }} />
                      </span>
                      <span className={styles.amount}>{money(v)}</span>
                    </li>
                  ))}
              </ul>
              <h4 className={styles.subhead}>Extras (tickets, transport, tips)</h4>
              <ul className={styles.extras}>
                {trip.extras.map((x) => (
                  <li key={x.id}>
                    <span>{x.label}</span>
                    <span>{money(x.cost)}</span>
                    <button type="button" aria-label={`Remove ${x.label}`} onClick={() => update((t) => ({ ...t, extras: t.extras.filter((e) => e.id !== x.id) }))}>
                      ×
                    </button>
                  </li>
                ))}
              </ul>
              <form
                className={styles.addRow}
                onSubmit={(e) => {
                  e.preventDefault();
                  const cost = Number(extraDraft.cost);
                  if (!extraDraft.label.trim() || !(cost > 0)) return;
                  update((t) => ({ ...t, extras: [...t.extras, { id: uid(), label: extraDraft.label.trim(), cost }] }));
                  setExtraDraft({ label: "", cost: "" });
                }}
              >
                <input placeholder="e.g. Bus tickets" value={extraDraft.label} onChange={(e) => setExtraDraft((d) => ({ ...d, label: e.target.value }))} />
                <input placeholder="৳" type="number" min={0} value={extraDraft.cost} onChange={(e) => setExtraDraft((d) => ({ ...d, cost: e.target.value }))} />
                <button type="submit">Add</button>
              </form>
              <p className={styles.hint}>Stop costs are set on each stop in the itinerary.</p>
            </div>
          )}

          {tab === "packing" && (
            <div className={styles.packing}>
              <div className={styles.progress}>
                <span style={{ width: `${(packed / Math.max(1, trip.packing.length)) * 100}%` }} />
              </div>
              <ul>
                {trip.packing.map((p) => (
                  <li key={p.id} data-done={p.done}>
                    <label>
                      <input
                        type="checkbox"
                        checked={p.done}
                        onChange={() => update((t) => ({ ...t, packing: t.packing.map((x) => (x.id === p.id ? { ...x, done: !x.done } : x)) }))}
                      />
                      {p.label}
                    </label>
                    <button type="button" aria-label={`Remove ${p.label}`} onClick={() => update((t) => ({ ...t, packing: t.packing.filter((x) => x.id !== p.id) }))}>
                      ×
                    </button>
                  </li>
                ))}
              </ul>
              <form
                className={styles.addRow}
                onSubmit={(e) => {
                  e.preventDefault();
                  if (!packDraft.trim()) return;
                  update((t) => ({ ...t, packing: [...t.packing, { id: uid(), label: packDraft.trim(), done: false }] }));
                  setPackDraft("");
                }}
              >
                <input placeholder="Add an item" value={packDraft} onChange={(e) => setPackDraft(e.target.value)} />
                <button type="submit">Add</button>
              </form>
            </div>
          )}
        </section>
      </div>

      {toast && (
        <div className={styles.toast} role="status">
          {toast}
        </div>
      )}
      <p className={styles.credit}>Map data © OpenStreetMap contributors · Routing by OSRM · Search by Nominatim. Your trip is saved in this browser.</p>
    </div>
  );
}
model.ts
export type Category = "sight" | "food" | "hotel" | "transport" | "shop" | "other";
export type Stop = { id: string; name: string; lat: number; lng: number; category: Category; time: string; cost: number; note: string };
export type Day = { id: string; stops: Stop[] };
export type Extra = { id: string; label: string; cost: number };
export type PackItem = { id: string; label: string; done: boolean };
export type Trip = { name: string; start: string; travelers: number; days: Day[]; extras: Extra[]; packing: PackItem[] };

export const DAY_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#7c4dff", "#e2508a", "#0e9aa7", "#c98500"];
export const CATEGORIES: { key: Category; label: string }[] = [
  { key: "sight", label: "Sightseeing" },
  { key: "food", label: "Food" },
  { key: "hotel", label: "Stay" },
  { key: "transport", label: "Transport" },
  { key: "shop", label: "Shopping" },
  { key: "other", label: "Other" },
];

let n = 0;
export const uid = () => `${Date.now().toString(36)}${(n++).toString(36)}${Math.random().toString(36).slice(2, 5)}`;

const stop = (name: string, lat: number, lng: number, category: Category, time = "", cost = 0, note = ""): Stop => ({
  id: uid(),
  name,
  lat,
  lng,
  category,
  time,
  cost,
  note,
});

export function isoDate(d: Date) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

export function dayDate(trip: Trip, index: number) {
  const [y, m, d] = trip.start.split("-").map(Number);
  return new Date(y, m - 1, d + index);
}

/** A ready-made 3-day Cox's Bazar trip so the planner is useful on first open. */
export function sampleTrip(): Trip {
  const start = new Date();
  start.setDate(start.getDate() + 14);
  return {
    name: "Cox's Bazar weekend",
    start: isoDate(start),
    travelers: 4,
    days: [
      {
        id: uid(),
        stops: [
          stop("Cox's Bazar Airport", 21.4522, 91.9639, "transport", "10:30", 0, "Flight from Dhaka"),
          stop("Hotel in Kolatoli", 21.4116, 91.9841, "hotel", "12:00", 14000, "2 rooms × 2 nights"),
          stop("Laboni Point Beach", 21.4237, 91.9733, "sight", "16:30", 0, "Sunset walk"),
          stop("Burmese Market", 21.4396, 91.9781, "shop", "19:00", 2500),
        ],
      },
      {
        id: uid(),
        stops: [
          stop("Himchari National Park", 21.3577, 92.0233, "sight", "09:00", 400, "Waterfall + hilltop view"),
          stop("Inani Beach", 21.2236, 92.0498, "sight", "11:30", 0, "Coral stones at low tide"),
          stop("Seafood lunch on Marine Drive", 21.2785, 92.0447, "food", "13:30", 3200),
          stop("Hotel in Kolatoli", 21.4116, 91.9841, "hotel", "17:00", 0),
        ],
      },
      {
        id: uid(),
        stops: [
          stop("Ramu Buddhist Temple", 21.4247, 92.1006, "sight", "09:30", 0),
          stop("Dried-fish market", 21.4482, 91.9729, "shop", "12:00", 1500),
          stop("Cox's Bazar Airport", 21.4522, 91.9639, "transport", "15:00", 0, "Flight back"),
        ],
      },
    ],
    extras: [
      { id: uid(), label: "Return flights (4)", cost: 26000 },
      { id: uid(), label: "CNG & tuk-tuk rides", cost: 3000 },
    ],
    packing: [
      "NID / passport",
      "Phone charger & power bank",
      "Sunscreen",
      "Sunglasses & hat",
      "Swimwear",
      "Flip-flops",
      "Light cotton clothes",
      "Medicines",
      "Cash for markets",
    ].map((label, i) => ({ id: uid(), label, done: i < 2 })),
  };
}

// ── Share links: the whole trip, gzip-compressed into the URL hash ────────────────────
const toB64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64 = (s: string) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));

async function pipe(bytes: Uint8Array, stream: CompressionStream | DecompressionStream) {
  return new Uint8Array(await new Response(new Blob([bytes as BlobPart]).stream().pipeThrough(stream)).arrayBuffer());
}

export async function encodeTrip(trip: Trip) {
  const json = new TextEncoder().encode(JSON.stringify(trip));
  if (typeof CompressionStream === "undefined") return `j.${toB64(json)}`;
  return `z.${toB64(await pipe(json, new CompressionStream("gzip")))}`;
}

export async function decodeTrip(code: string): Promise<Trip | null> {
  try {
    const [kind, data] = [code.slice(0, 1), code.slice(2)];
    const bytes = kind === "z" ? await pipe(fromB64(data), new DecompressionStream("gzip")) : fromB64(data);
    const trip = JSON.parse(new TextDecoder().decode(bytes));
    return Array.isArray(trip?.days) ? trip : null;
  } catch {
    return null;
  }
}

/** An .ics calendar: one all-day overview per day plus a 1-hour event for every timed stop. */
export function toIcs(trip: Trip) {
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "");
  const esc = (s: string) => s.replace(/([,;\\])/g, "\\$1").replace(/\n/g, "\\n");
  const ymd = (d: Date) => isoDate(d).replace(/-/g, "");
  const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Component Gallery//Trip Planner//EN", "CALSCALE:GREGORIAN"];
  trip.days.forEach((day, i) => {
    const date = dayDate(trip, i);
    const next = new Date(date);
    next.setDate(next.getDate() + 1);
    lines.push(
      "BEGIN:VEVENT",
      `UID:${day.id}@trip`,
      `DTSTAMP:${stamp}`,
      `DTSTART;VALUE=DATE:${ymd(date)}`,
      `DTEND;VALUE=DATE:${ymd(next)}`,
      `SUMMARY:${esc(`${trip.name}: Day ${i + 1}`)}`,
      `DESCRIPTION:${esc(day.stops.map((s, j) => `${j + 1}. ${s.time ? `${s.time} ` : ""}${s.name}`).join("\n"))}`,
      "END:VEVENT",
    );
    for (const s of day.stops) {
      if (!s.time) continue;
      const [h, m] = s.time.split(":").map(Number);
      const startAt = `${ymd(date)}T${String(h).padStart(2, "0")}${String(m).padStart(2, "0")}00`;
      const endAt = `${ymd(date)}T${String((h + 1) % 24).padStart(2, "0")}${String(m).padStart(2, "0")}00`;
      lines.push(
        "BEGIN:VEVENT",
        `UID:${s.id}-${i}@trip`,
        `DTSTAMP:${stamp}`,
        `DTSTART:${startAt}`,
        `DTEND:${endAt}`,
        `SUMMARY:${esc(s.name)}`,
        `LOCATION:${esc(`${s.lat.toFixed(5)},${s.lng.toFixed(5)}`)}`,
        ...(s.note ? [`DESCRIPTION:${esc(s.note)}`] : []),
        "END:VEVENT",
      );
    }
  });
  lines.push("END:VCALENDAR");
  return lines.join("\r\n");
}
services.ts
// Free, key-less OpenStreetMap services. Search runs only on Enter (Nominatim asks for ≤1 request/s,
// no autocomplete), and routes are cached per day so edits don't re-request unchanged legs.

export type Place = { name: string; detail: string; lat: number; lng: number };
export type Leg = { distance: number; duration: number };
export type Route = { coords: [number, number][]; legs: Leg[]; estimated: boolean };

const NOMINATIM = "https://nominatim.openstreetmap.org";
const OSRM = "https://router.project-osrm.org/route/v1/driving";

export async function searchPlaces(query: string, view?: { west: number; south: number; east: number; north: number }) {
  const params = new URLSearchParams({ q: query, format: "jsonv2", limit: "6", "accept-language": "en" });
  if (view) params.set("viewbox", `${view.west},${view.north},${view.east},${view.south}`);
  const res = await fetch(`${NOMINATIM}/search?${params}`);
  if (!res.ok) throw new Error("Search failed");
  const data: { name?: string; display_name: string; lat: string; lon: string }[] = await res.json();
  return data.map<Place>((d) => {
    const [first, ...rest] = d.display_name.split(", ");
    return { name: d.name || first, detail: rest.slice(0, 3).join(", "), lat: Number(d.lat), lng: Number(d.lon) };
  });
}

export async function reverseGeocode(lat: number, lng: number) {
  try {
    const params = new URLSearchParams({ lat: String(lat), lon: String(lng), format: "jsonv2", zoom: "17", "accept-language": "en" });
    const res = await fetch(`${NOMINATIM}/reverse?${params}`);
    const d: { name?: string; display_name?: string } = await res.json();
    return d.name || d.display_name?.split(", ").slice(0, 2).join(", ") || "Pinned place";
  } catch {
    return "Pinned place";
  }
}

/** Great-circle distance in metres. */
export function haversine(a: { lat: number; lng: number }, b: { lat: number; lng: number }) {
  const R = 6371e3;
  const toRad = (d: number) => (d * Math.PI) / 180;
  const dLat = toRad(b.lat - a.lat);
  const dLng = toRad(b.lng - a.lng);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}

/** Straight lines with a road-detour factor and ~35 km/h, used when routing is unavailable. */
export function estimateRoute(points: { lat: number; lng: number }[]): Route {
  const legs = points.slice(1).map((p, i) => {
    const distance = haversine(points[i], p) * 1.3;
    return { distance, duration: distance / (35_000 / 3600) };
  });
  return { coords: points.map((p) => [p.lat, p.lng]), legs, estimated: true };
}

const cache = new Map<string, Route>();

export async function routeFor(points: { lat: number; lng: number }[]): Promise<Route> {
  if (points.length < 2) return { coords: [], legs: [], estimated: false };
  const key = points.map((p) => `${p.lng.toFixed(5)},${p.lat.toFixed(5)}`).join(";");
  const hit = cache.get(key);
  if (hit) return hit;
  try {
    const res = await fetch(`${OSRM}/${key}?overview=full&geometries=geojson`);
    const data = await res.json();
    if (data.code !== "Ok") throw new Error(data.code);
    const r = data.routes[0];
    const route: Route = {
      coords: r.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng]),
      legs: r.legs.map((l: Leg) => ({ distance: l.distance, duration: l.duration })),
      estimated: false,
    };
    cache.set(key, route);
    return route;
  } catch {
    return estimateRoute(points);
  }
}

export const km = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(m < 10_000 ? 1 : 0)} km`);
export const mins = (s: number) => {
  const m = Math.round(s / 60);
  return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, "0")} min`;
};