From 5527aa0d621778d69da51c7b1a01d90cfdfc0e63 Mon Sep 17 00:00:00 2001 From: CodeDotJS Date: Tue, 6 Oct 2026 04:25:15 +0530 Subject: [PATCH] feat(saved): add a saved tab and a header network status --- .cursor/rules/00-product.mdc | 2 +- .cursor/rules/60-deployment.mdc | 2 +- .cursor/rules/70-phone.mdc | 2 +- README.md | 4 +- docs/DEPLOYMENT.md | 6 +- docs/PRODUCT.md | 12 ++-- index.html | 1 + public/sitemap.xml | 3 + src/App.tsx | 44 +++++++----- src/i18n/copy.ts | 15 +++++ src/index.css | 115 +++++++++++++++++++++++++------- src/lib/nav.test.ts | 4 +- src/lib/nav.ts | 1 + src/lib/plan/plan.test.tsx | 39 ++++++++++- src/ui/PlanView.tsx | 24 ------- src/ui/SavedView.tsx | 56 ++++++++++++++++ src/ui/TabBar.tsx | 41 ++++++------ 17 files changed, 271 insertions(+), 100 deletions(-) create mode 100644 src/ui/SavedView.tsx diff --git a/.cursor/rules/00-product.mdc b/.cursor/rules/00-product.mdc index 3f75e41..a6439b5 100644 --- a/.cursor/rules/00-product.mdc +++ b/.cursor/rules/00-product.mdc @@ -9,7 +9,7 @@ Build a free, ad-free, mobile-first PWA. Working name: Delhi Metro Simple. It is The home task is: choose origin, choose destination, show one readable route. Answer which train, where to change, and the fare when the payload includes one. -Primary destinations are Plan, Map, and Help. Navigation is a compact top bar with an icon and a text label, so it does not cover the page. Wide screens use two panes: controls on the left, the route or the line on the right. +Primary destinations are Plan, Map, City, Saved, and Help. Navigation is a compact top bar with an icon and a text label, so it does not cover the page. Wide screens use two panes: controls on the left, the route or the line on the right. Out of v1: accounts, payments, ticket sales, ads, analytics, chatbots, social feeds, and mandatory install, onboarding, or location permission. diff --git a/.cursor/rules/60-deployment.mdc b/.cursor/rules/60-deployment.mdc index 097f254..0fece7c 100644 --- a/.cursor/rules/60-deployment.mdc +++ b/.cursor/rules/60-deployment.mdc @@ -15,7 +15,7 @@ When the user says to deploy: 2. Commit on that branch. `type(scope): description`. One commit is one change. 3. Push the branch and open a pull request in this repository. 4. Wait for the `check` job in the CI workflow. It runs `npm ci`, `npm test`, and `npm run build`, rejects `dist` above 20,000 files or any file over 25 MiB, then uploads a preview. `npm run smoke` is not in the job. -5. Open the preview at `https://.detro.pages.dev`. A slash in the branch name becomes a hyphen. Confirm `/`, `/map`, `/city`, and `/help` load. If the check fails, stop and report the check and the URL. +5. Open the preview at `https://.detro.pages.dev`. A slash in the branch name becomes a hyphen. Confirm `/`, `/map`, `/city`, `/saved`, and `/help` load. If the check fails, stop and report the check and the URL. 6. Merge into `main` only after that job is green. Branch protection requires the `check` job. 7. The merge publishes production at `https://detro.pages.dev`. diff --git a/.cursor/rules/70-phone.mdc b/.cursor/rules/70-phone.mdc index 3c0881c..d833854 100644 --- a/.cursor/rules/70-phone.mdc +++ b/.cursor/rules/70-phone.mdc @@ -13,7 +13,7 @@ Check 320, 390, and 430 CSS pixels. The page does not scroll sideways. - The route strip keeps one colored line. Station names wrap underneath it and do not overlap. - A station name stays on one line. A title wraps at the arrow between the two stations. - A pair of actions may sit side by side. If a label needs three lines, stack that row. -- The header is the mark plus four tabs. It stays on screen, clears the safe area, and does not cover the first line of the page. +- The header is the mark plus five tabs. It stays on screen, clears the safe area, and does not cover the first line of the page. - On City, the map is visible without scrolling. The trip sheet is the part that scrolls. - Do not add a banner, a tutorial, or extra chrome to fix spacing. - Default type on a phone is compact. Help has a text-size slider. Do not leave a layout that only becomes readable after the user drags that slider. diff --git a/README.md b/README.md index 2a88a4c..5ce1115 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The network, the fares, the platforms, and the first and last trains come from a | Map | One line at a time, with the stations on that line. Start here and Go here set the trip. Station details list the facilities saved for that station. | | City | 154 stations with a saved latitude and longitude, on street tiles kept in this repo. The ride is drawn between the ends that have positions. | | Offline | After this browser has loaded the app once, the snapshot still plans the trip with the network off. | -| Saved trips | Stored on this device. A shared link carries the two station codes. | +| Saved trips | Own tab. Stored on this device. A shared link carries the two station codes. | | Help | Usage, text size, and the snapshot date. | 254 stations are in the snapshot. The city map omits the ones with no saved position. The plan and the line map still include them. @@ -31,7 +31,7 @@ The network, the fares, the platforms, and the first and last trains come from a | Piece | Where | Job | | --- | --- | --- | -| App | `src/` | Plan, map, city, and help. | +| App | `src/` | Plan, map, city, saved, and help. | | Snapshot | `data/en/` | Lines, stations, briefs, coordinates, and one journey file per origin. | | Street tiles | `public/map-tiles/` | Metro area, zoom 9 through 14. | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index d98b8c8..ed407c1 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -8,7 +8,7 @@ Every later change uses a branch and a pull request. Do not commit or push direc 1. Branch from `main` and commit there. 2. Open a pull request. The `check` job in the CI workflow tests the build, enforces the file limits, and uploads a preview at `https://.detro.pages.dev`. A slash in the branch name becomes a hyphen. -3. Confirm `/`, `/map`, `/city`, and `/help` on that preview. +3. Confirm `/`, `/map`, `/city`, `/saved`, and `/help` on that preview. 4. Merge only after the `check` job is green. Branch protection requires that job. 5. The merge publishes production at `https://detro.pages.dev`. @@ -25,7 +25,7 @@ The numbered procedure below is the first-time setup. It is already done. Do not 5. Create the Cloudflare Pages project `detro` as Direct Upload, not a Git-connected build. The production branch is `main`. The public URL is `https://detro.pages.dev`. 6. Create a Cloudflare API token that can edit that Pages project only. Store it as the GitHub secret `CLOUDFLARE_API_TOKEN`, and store the account id as `CLOUDFLARE_ACCOUNT_ID`. Do not print the token and do not commit it. 7. Add `.github/workflows/ci.yml` as specified below. Require that check on `main` before a merge. -8. Open a pull request and confirm the preview URL loads `/`, `/map`, `/city`, and `/help`. +8. Open a pull request and confirm the preview URL loads `/`, `/map`, `/city`, `/saved`, and `/help`. 9. Merge to `main` and open `https://detro.pages.dev` on a phone. Turn the network off and reload. Search, a saved fare, a station brief, the line map, and the city map must still open. Stop at the first failed step and report it. Do not retry a deploy by connecting Pages to Git. @@ -149,7 +149,7 @@ No app environment variables. No install of private packages. ## Addresses inside the app -The app uses `/`, `/map`, `/city`, and `/help`. Pages must serve `index.html` for those paths when a file of that name does not exist. `public/_redirects` contains: +The app uses `/`, `/map`, `/city`, `/saved`, and `/help`. Pages must serve `index.html` for those paths when a file of that name does not exist. `public/_redirects` contains: ```text /* /index.html 200 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index e922021..accf9e6 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -18,17 +18,19 @@ A child with an adult, an older adult, a first-time visitor, and someone unfamil ## Destinations -1. **Plan a trip** — origin, destination, swap, and “Show my route.” On a wide screen the route sits beside the form. A saved trip can be opened or removed. +1. **Plan a trip** — origin, destination, swap, and “Show my route.” On a wide screen the route sits beside the form. 2. **Map** — one line at a time, with a sentence that says to pick a line and then a station. Start here and Go here sit with the chosen station. On a wide screen the line diagram sits beside the list. -3. **Help** — how to use the app, verified contacts, text size, offline status, privacy, and sources. +3. **City** — stations with a saved coordinate on street tiles. The ride is drawn between the ends that have positions. +4. **Saved** — trips stored on this device. Open one to see the ride, or remove it. +5. **Help** — how to use the app, verified contacts, text size, offline status, privacy, and sources. -Plan, Map, and Help live in a top bar. The bar does not cover the page. +Plan, Map, City, Saved, and Help live in a top bar. The bar does not cover the page. -Station details open from search, the map, and journey results. They are not a fourth top-level destination. +Station details open from search, the map, and journey results. They are not a top-level destination. ## Home -The first screen shows “Where do you want to go?”, From, To, swap, and one primary route button. “Use my location” is secondary. Recent or saved trips appear only when some exist. +The first screen shows “Where do you want to go?”, From, To, swap, and one primary route button. “Use my location” is secondary. Saved trips live under Saved, not on Plan. Search supports English and Hindi names, aliases, and spelling variants. Suggestions show the line when membership is known. Similar names stay distinct. A fuzzy match is never selected silently. diff --git a/index.html b/index.html index f03ec6d..8b30c7c 100644 --- a/index.html +++ b/index.html @@ -110,6 +110,7 @@

DETRO — Delhi Metro Simple

Plan a route Delhi Metro line map Delhi Metro city map + Saved trips Help diff --git a/public/sitemap.xml b/public/sitemap.xml index 78742fa..da3e241 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -9,6 +9,9 @@ https://detro.pages.dev/city + + https://detro.pages.dev/saved + https://detro.pages.dev/help diff --git a/src/App.tsx b/src/App.tsx index 0d2f597..a215aaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,10 @@ import type { Journey, Lang, Suggestion } from "./lib/transit/types" import { HelpView } from "./ui/HelpView" import { MapView } from "./ui/MapView" import { PlanView } from "./ui/PlanView" +import { SavedView } from "./ui/SavedView" import { CityMap } from "./ui/CityMap" import { pathForTab, tabFromPath } from "./lib/nav" -import { TabBar, type Tab } from "./ui/TabBar" +import { SignalMark, TabBar, type Tab } from "./ui/TabBar" const snapshot = loadSnapshot() const snapshotDate = snapshot.fetchedAt.slice(0, 10) @@ -41,6 +42,12 @@ function pageMeta(tab: Tab): { title: string; description: string } { description: "See a Delhi Metro ride on the city map in DETRO. Choose a start and a destination and follow the line between them.", } } + if (tab === "saved") { + return { + title: "Saved trips · DETRO", + description: "Open a saved Delhi Metro trip in DETRO. Trips stay on this phone. Free, ad-free, and independent of DMRC.", + } + } if (tab === "help") { return { title: "Help · DETRO", @@ -447,7 +454,8 @@ export function App() { DETRO Delhi Metro Simple - + + {tab === "plan" ? ( ({ - fromCode: trip.fromCode, - toCode: trip.toCode, - label: `${namesFor(snapshot.stations, lang).get(trip.fromCode) ?? trip.fromCode} → ${namesFor(snapshot.stations, lang).get(trip.toCode) ?? trip.toCode}`, - }))} - onOpenTrip={openTrip} - onRemoveTrip={(fromCode, toCode) => { - try { - setTrips(removeTrip(localStorage, { fromCode, toCode })) - } catch { - setTrips((current) => current.filter((trip) => trip.fromCode !== fromCode || trip.toCode !== toCode)) - } - }} onSave={() => { if (!state.from || !state.to) return setTrips(saveTrip(localStorage, { fromCode: state.from.code, toCode: state.to.code })) @@ -512,6 +507,25 @@ export function App() { }} /> ) : null} + {tab === "saved" ? ( + ({ + fromCode: trip.fromCode, + toCode: trip.toCode, + fromName: namesFor(snapshot.stations, lang).get(trip.fromCode) ?? trip.fromCode, + toName: namesFor(snapshot.stations, lang).get(trip.toCode) ?? trip.toCode, + }))} + onOpenTrip={openTrip} + onRemoveTrip={(fromCode, toCode) => { + try { + setTrips(removeTrip(localStorage, { fromCode, toCode })) + } catch { + setTrips((current) => current.filter((trip) => trip.fromCode !== fromCode || trip.toCode !== toCode)) + } + }} + /> + ) : null} {tab === "map" ? ( string @@ -195,6 +200,9 @@ export const copy = { plan: "Plan", map: "Map", help: "Help", + saved: "Saved", + onlineStatus: "Online", + offlineStatus: "Offline", lineLabel: "Line", findOnLine: "Find a station on this line", zoomIn: "Zoom in", @@ -226,6 +234,8 @@ export const copy = { sources: "Data comes from the Delhi Metro website backend. This app is independent.", noBrief: "More detail for this station is not in the saved snapshot yet.", savedTrips: "Saved trips", + savedLead: "Trips you save stay on this phone.", + savedEmpty: "Save a trip from Plan after you have a route.", saveTrip: "Save trip", removeTrip: "Remove", removeSaved: (label) => `Remove ${label}`, @@ -324,6 +334,9 @@ export const copy = { plan: "योजना", map: "नक्शा", help: "सहायता", + saved: "सेव", + onlineStatus: "ऑनलाइन", + offlineStatus: "ऑफ़लाइन", lineLabel: "लाइन", findOnLine: "इस लाइन पर स्टेशन खोजें", zoomIn: "बड़ा करें", @@ -355,6 +368,8 @@ export const copy = { sources: "डेटा दिल्ली मेट्रो वेबसाइट के बैकएंड से है। यह ऐप स्वतंत्र है।", noBrief: "इस स्टेशन का और विवरण सेव की गई सूची में अभी नहीं है।", savedTrips: "सेव यात्राएँ", + savedLead: "सेव की गई यात्राएँ इस फ़ोन पर रहती हैं।", + savedEmpty: "रास्ता दिखने के बाद योजना से यात्रा सेव करें।", saveTrip: "यात्रा सेव करें", removeTrip: "हटाएँ", removeSaved: (label) => `${label} हटाएँ`, diff --git a/src/index.css b/src/index.css index 707ab9c..e1ea7a8 100644 --- a/src/index.css +++ b/src/index.css @@ -196,12 +196,39 @@ summary:active { summary:active { transform: none; } } -.signal-mark { - width: 1.05rem; - height: 1.05rem; +.signal { + grid-area: signal; + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: 0; + min-height: 32px; + padding: 0 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1; + white-space: nowrap; +} + +.signal svg { + width: 1.15rem; + height: 1.15rem; flex: none; } +.signal-on { + color: var(--ink); + background: var(--shade); +} + +.signal-off { + color: var(--ink); + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); +} + .color-rail { display: flex; height: 0.4rem; @@ -220,16 +247,20 @@ summary:active { position: sticky; top: 0; z-index: 5; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "mark signal" + "tabs tabs"; + align-items: center; + gap: 6px 8px; padding: max(8px, env(safe-area-inset-top)) 12px 8px; background: #fff; border-bottom: 1px solid var(--line); } .app-bar .mark { + grid-area: mark; display: flex; align-items: center; gap: 0.45rem; @@ -266,6 +297,7 @@ summary:active { } .tabbar { + grid-area: tabs; position: static; display: flex; align-items: stretch; @@ -280,17 +312,19 @@ summary:active { .tabbar a { flex: 1 1 0; min-width: 0; - min-height: 36px; + min-height: 44px; display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 0.35rem; - padding: 0 8px; + gap: 2px; + padding: 6px 2px; background: transparent; color: var(--ink); border: 0; border-radius: calc(var(--radius) - 2px); cursor: pointer; + font-size: 0.78rem; font-weight: 600; text-decoration: none; white-space: nowrap; @@ -303,9 +337,8 @@ summary:active { @media (min-width: 720px) { .app-bar { - flex-direction: row; - align-items: center; - justify-content: space-between; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "mark tabs signal"; gap: 16px; min-height: 0; padding: 8px 20px; @@ -313,12 +346,22 @@ summary:active { .tabbar { width: auto; - margin-left: auto; + justify-self: end; } .tabbar a { flex: 0 0 auto; - padding: 0 18px; + flex-direction: row; + gap: 0.35rem; + min-height: 36px; + padding: 0 12px; + font-size: 1rem; + } +} + +@media (min-width: 720px) and (max-width: 859px) { + .mark-tag { + display: none; } } @@ -386,7 +429,7 @@ select { margin: 0; } .zoom-row button, .station-actions button, .station-list button, -.trip-list button { +.saved-list button { min-height: 48px; padding: 0 12px; background: #fff; @@ -968,7 +1011,7 @@ ul[role="listbox"] li { margin: 0; } .trip-row li { margin: 0; } -.trip-list { +.saved-list { list-style: none; display: flex; flex-direction: column; @@ -977,20 +1020,41 @@ ul[role="listbox"] li { margin: 0; } padding: 0; } -.trip-list li { +.saved-list li { display: flex; align-items: stretch; gap: var(--space); + min-width: 0; margin: 0; } -.trip-list li > button:first-child { +.saved-open { flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.45rem; text-align: left; } -.trip-list .remove { +.saved-from, +.saved-to { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.saved-arrow { flex: none; + color: var(--muted); +} + +.saved-list .remove { + flex: none; + min-width: 44px; background: transparent; } @@ -2295,9 +2359,14 @@ summary { } .tabbar a { - gap: 0.2rem; - padding: 0 4px; - font-size: 0.78rem; + gap: 1px; + padding: 4px 0; + font-size: 0.68rem; + } + + .tab-icon { + width: 1rem; + height: 1rem; } } diff --git a/src/lib/nav.test.ts b/src/lib/nav.test.ts index 598d97b..2a4cc31 100644 --- a/src/lib/nav.test.ts +++ b/src/lib/nav.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it } from "vitest" import { pathForTab, tabFromPath } from "./nav" describe("section paths", () => { - it("reads the four sections", () => { + it("reads the five sections", () => { expect(tabFromPath("/")).toBe("plan") expect(tabFromPath("/plan")).toBeNull() expect(tabFromPath("/map/")).toBe("map") expect(tabFromPath("/city")).toBe("city") + expect(tabFromPath("/saved")).toBe("saved") expect(tabFromPath("/help")).toBe("help") expect(tabFromPath("/other")).toBeNull() }) @@ -15,6 +16,7 @@ describe("section paths", () => { expect(pathForTab("plan")).toBe("/") expect(pathForTab("map")).toBe("/map") expect(pathForTab("city")).toBe("/city") + expect(pathForTab("saved")).toBe("/saved") expect(pathForTab("help")).toBe("/help") }) }) diff --git a/src/lib/nav.ts b/src/lib/nav.ts index 08e4b18..abf3c03 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -5,6 +5,7 @@ export function tabFromPath(pathname: string): Tab | null { if (path === "/") return "plan" if (path === "/map") return "map" if (path === "/city") return "city" + if (path === "/saved") return "saved" if (path === "/help") return "help" return null } diff --git a/src/lib/plan/plan.test.tsx b/src/lib/plan/plan.test.tsx index d255cd7..c8f9059 100644 --- a/src/lib/plan/plan.test.tsx +++ b/src/lib/plan/plan.test.tsx @@ -9,6 +9,8 @@ import routeRiRck from "../../../tests/fixtures/dmrc/route-ri-rck.json" import { journeySteps } from "../transit/present" import { HelpView } from "../../ui/HelpView" import { PlanView } from "../../ui/PlanView" +import { SavedView } from "../../ui/SavedView" +import { SignalMark, TabBar } from "../../ui/TabBar" import { createPlanState, planReducer, routeBlockReason } from "./state" describe("station selection", () => { @@ -210,6 +212,22 @@ describe("plan screen", () => { }) it("names a control that removes one saved trip", () => { + const html = renderToStaticMarkup( + undefined} + onRemoveTrip={() => undefined} + />, + ) + expect(html).toContain("Saved") + expect(html).toContain("RITHALA") + expect(html).toContain("KASHMERE GATE") + expect(html).toContain('aria-label="Remove RITHALA → KASHMERE GATE"') + expect(html).not.toContain("Where do you want to go?") + }) + + it("keeps saved trips off the plan form", () => { const html = renderToStaticMarkup( { phase="idle" message={null} journey={null} - trips={[{ fromCode: "RI", toCode: "KG", label: "RITHALA → KASHMERE GATE" }]} {...handlers} />, ) - expect(html).toContain('aria-label="Remove RITHALA → KASHMERE GATE"') + expect(html).toContain("Where do you want to go?") + expect(html).not.toContain("saved-list") + expect(html).not.toContain("Save a trip from Plan") + }) + + it("lists Saved between City and Help", () => { + const html = renderToStaticMarkup( + undefined} />, + ) + expect(html.indexOf('href="/city"')).toBeGreaterThan(-1) + expect(html.indexOf('href="/city"')).toBeLessThan(html.indexOf('href="/saved"')) + expect(html.indexOf('href="/saved"')).toBeLessThan(html.indexOf('href="/help"')) + expect(html).toContain("Saved") + expect(html).not.toContain("Online") + }) + + it("names the network status on its own mark", () => { + expect(renderToStaticMarkup()).toContain("Online") + expect(renderToStaticMarkup()).toContain("Offline") }) it("marks the field filled from the map", () => { diff --git a/src/ui/PlanView.tsx b/src/ui/PlanView.tsx index 533046a..d93ba09 100644 --- a/src/ui/PlanView.tsx +++ b/src/ui/PlanView.tsx @@ -44,9 +44,6 @@ type Props = { onHighlight: (index: number) => void onSwap: () => void onSubmit: () => void - trips?: Array<{ fromCode: string; toCode: string; label: string }> - onOpenTrip?: (fromCode: string, toCode: string) => void - onRemoveTrip?: (fromCode: string, toCode: string) => void onSave?: () => void onShare?: (journey: Journey) => void onViewMap?: (journey: Journey) => void @@ -70,9 +67,6 @@ export function PlanView({ onHighlight, onSwap, onSubmit, - trips = [], - onOpenTrip, - onRemoveTrip, onSave, onShare, onViewMap, @@ -204,24 +198,6 @@ export function PlanView({ {phase === "loading" ? copy.loadingRoute : copy.showRoute} - - {trips.length > 0 ? ( -
-

{copy.savedTrips}

-
    - {trips.map((trip) => ( -
  • - - -
  • - ))} -
-
- ) : null}
{result ?? } diff --git a/src/ui/SavedView.tsx b/src/ui/SavedView.tsx new file mode 100644 index 0000000..5e5b92a --- /dev/null +++ b/src/ui/SavedView.tsx @@ -0,0 +1,56 @@ +import { ArrowRight } from "lucide-react" +import type { Copy } from "../i18n/copy" + +type Trip = { + fromCode: string + toCode: string + fromName: string + toName: string +} + +export function SavedView({ + copy, + trips, + onOpenTrip, + onRemoveTrip, +}: { + copy: Copy + trips: Trip[] + onOpenTrip: (fromCode: string, toCode: string) => void + onRemoveTrip: (fromCode: string, toCode: string) => void +}) { + return ( +
+

{copy.saved}

+

{copy.savedLead}

+ {trips.length === 0 ? ( +
+

{copy.savedEmpty}

+
+ ) : ( +
    + {trips.map((trip) => { + const label = `${trip.fromName} → ${trip.toName}` + return ( +
  • + + +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/ui/TabBar.tsx b/src/ui/TabBar.tsx index faf8caa..7998bd8 100644 --- a/src/ui/TabBar.tsx +++ b/src/ui/TabBar.tsx @@ -1,23 +1,23 @@ +import { Wifi, WifiOff } from "lucide-react" import { pathForTab } from "../lib/nav" import type { Copy } from "../i18n/copy" -export type Tab = "plan" | "map" | "city" | "help" +export type Tab = "plan" | "map" | "city" | "saved" | "help" export function TabBar({ copy, tab, - online, onTab, }: { copy: Copy tab: Tab - online: boolean onTab: (tab: Tab) => void }) { const items: Array<{ id: Tab; label: string }> = [ { id: "plan", label: copy.plan }, { id: "map", label: copy.map }, { id: "city", label: copy.cityMap }, + { id: "saved", label: copy.saved }, { id: "help", label: copy.help }, ] return ( @@ -35,32 +35,22 @@ export function TabBar({ > {item.label} - {item.id === "help" ? : null} ))} ) } -function SignalMark({ online }: { online: boolean }) { +export function SignalMark({ copy, online }: { copy: Copy; online: boolean }) { + const label = online ? copy.onlineStatus : copy.offlineStatus return ( - - - {online ? "Online" : "Offline"} + + {online ? ( + ) } @@ -89,6 +79,13 @@ function TabIcon({ name }: { name: Tab }) { ) } + if (name === "saved") { + return ( + + ) + } return (