diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 3489592d..6d1f218b 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -2,31 +2,30 @@ ## Current objective -Ship responsive LCP hero images on `/apple-music-soundtrack` and `/private-driving-journal`. Do not merge, deploy, or change hosting, DNS, analytics, or CSP. +Ship four themed road-trip SEO landing pages in one draft PR. Do not merge or deploy. ## Checkout -- Branch `cursor/hero-lcp-srcset-952c` off `main`. +- Branch `cursor/road-trip-journal-seo-pages-2843` off `main`. ## Material changes -- Shared LCP art is `web/assets/beta/grand-touring-home.webp` (1536x1024, 315 KB), used as the `.hero` CSS background on both pages. -- Added `grand-touring-home-800.webp` (91 KB) and `grand-touring-home-1600.webp` (258 KB) beside the original, versioned with `?v=1`. -- Both pages now use an absolutely positioned `.hero-photo` with srcset/sizes, preload, and `fetchpriority="high"`. Other public pages keep the CSS background. -- `server/tests/api.test.ts` asserts 200 + immutable cache on the resized files and preload markup on both pages. +- New public pages: `/route-66-road-trip-journal`, `/pacific-coast-highway-road-trip-journal`, `/national-parks-road-trip-journal`, `/texas-hill-country-road-trip-journal`. +- Wired like #268/#270/#273/#276: `publicPaths`, `app.get` sendFile, sitemap, server tests, verify-live expectations, feature map, canonical/meta/OG/JSON-LD/Umami, plain App Store CTA plus `campaign.js`. +- Copy from the draft, minus unconfirmed personal lines, plus design-review FAQ and intro fixes. JSON-LD FAQ text matches the visible FAQ. No em/en dashes. Journeys start only when the user taps Start Journey. +- Cross-links from `/road-trip-journal-app`, `/private-driving-journal`, and the Apple Music soundtrack footer. ## Verification - `node --test --import tsx server/tests/api.test.ts` - `node --test --import tsx server/tests/campaign.test.ts server/tests/verify-live.test.ts` -- Playwright screenshots at 390x844 and 1280x800 (before and after). Mobile selects 800w; desktop selects 1600w. +- Desktop 1280 and mobile 390 screenshots of each page in the PR body. ## Unresolved / owner decisions -- Lab Lighthouse after-numbers still need to be recorded in the PR if the environment can run Lighthouse. -- PR 262 (verify:live) remains a separate review. Do not merge or deploy from this session. +- Keep the PR as a draft. Do not merge or deploy from this session. ## Next steps -1. Keep this hero LCP PR open for review. Do not merge or deploy. +1. Keep the four-page SEO PR open for review. Do not merge or deploy. 2. Confirm JourneyDeck CI is green. diff --git a/server/src/app.ts b/server/src/app.ts index 5e3b29f3..09f73655 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -22,7 +22,7 @@ import { loadTessieRouteCoordinates } from "./tessie-route.js"; declare module "fastify" { interface FastifyRequest { principal: Principal | null } } const googleSiteVerificationPath = "/google8e611381b21882a8.html"; -const publicPaths = new Set(["/healthz", "/readyz", "/login", "/login.html", "/privacy", "/privacy.html", "/apple-music-soundtrack", "/private-driving-journal", "/driving-journal", "/road-trip-journal-app", "/road-trip-tracker-app", "/travel-journal-app-with-photos", "/driving-history-app", "/support", "/support.html", "/terms", "/terms.html", "/robots.txt", "/.well-known/api-catalog", "/openapi.json", "/api-docs", "/auth.md", "/sitemap.xml", "/manifest.webmanifest", "/favicon.ico", googleSiteVerificationPath]); +const publicPaths = new Set(["/healthz", "/readyz", "/login", "/login.html", "/privacy", "/privacy.html", "/apple-music-soundtrack", "/private-driving-journal", "/driving-journal", "/road-trip-journal-app", "/road-trip-tracker-app", "/travel-journal-app-with-photos", "/driving-history-app", "/route-66-road-trip-journal", "/pacific-coast-highway-road-trip-journal", "/national-parks-road-trip-journal", "/texas-hill-country-road-trip-journal", "/support", "/support.html", "/terms", "/terms.html", "/robots.txt", "/.well-known/api-catalog", "/openapi.json", "/api-docs", "/auth.md", "/sitemap.xml", "/manifest.webmanifest", "/favicon.ico", googleSiteVerificationPath]); const publicAuthPaths = new Set(["/api/auth/login", "/api/auth/passkey/options", "/api/auth/passkey/verify"]); const scheduledSyncPath = "/api/spotify/sync"; const securityHeaders = { @@ -324,7 +324,7 @@ export async function createApp(overrides: CreateAppOverrides = {}) { app.get(retiredLandingPath, async (_req, reply) => reply.code(404).header("x-robots-tag", "noindex, nofollow").sendFile("404.html")); } } - app.get("/", async (_req, reply) => reply.sendFile(cfg.mode === "web" ? "beta.html" : "index.html")); app.get("/app", async (_req, reply) => reply.sendFile("index.html")); app.get("/spotify-callback", async (_req, reply) => reply.sendFile("index.html")); app.get("/login", async (_req, reply) => reply.sendFile("login.html")); app.get("/.well-known/api-catalog", async (_req, reply) => reply.type("application/linkset+json").send(await readFile(join(cfg.webRoot, "api-catalog.json")))); app.get("/api-docs", async (_req, reply) => reply.sendFile("api-docs.html")); app.get("/privacy", async (_req, reply) => reply.sendFile("privacy.html")); app.get("/apple-music-soundtrack", async (_req, reply) => reply.sendFile("apple-music-soundtrack.html")); app.get("/private-driving-journal", async (_req, reply) => reply.sendFile("private-driving-journal.html")); app.get("/road-trip-journal-app", async (_req, reply) => reply.sendFile("road-trip-journal-app.html")); app.get("/road-trip-tracker-app", async (_req, reply) => reply.sendFile("road-trip-tracker-app.html")); app.get("/travel-journal-app-with-photos", async (_req, reply) => reply.sendFile("travel-journal-app-with-photos.html")); app.get("/driving-history-app", async (_req, reply) => reply.sendFile("driving-history-app.html")); app.get("/driving-journal", async (_req, reply) => reply.code(301).redirect("/private-driving-journal")); app.get("/support", async (_req, reply) => reply.sendFile("support.html")); app.get("/terms", async (_req, reply) => reply.sendFile("terms.html")); app.get("/wife", async (_req, reply) => reply.sendFile("wife.html")); + app.get("/", async (_req, reply) => reply.sendFile(cfg.mode === "web" ? "beta.html" : "index.html")); app.get("/app", async (_req, reply) => reply.sendFile("index.html")); app.get("/spotify-callback", async (_req, reply) => reply.sendFile("index.html")); app.get("/login", async (_req, reply) => reply.sendFile("login.html")); app.get("/.well-known/api-catalog", async (_req, reply) => reply.type("application/linkset+json").send(await readFile(join(cfg.webRoot, "api-catalog.json")))); app.get("/api-docs", async (_req, reply) => reply.sendFile("api-docs.html")); app.get("/privacy", async (_req, reply) => reply.sendFile("privacy.html")); app.get("/apple-music-soundtrack", async (_req, reply) => reply.sendFile("apple-music-soundtrack.html")); app.get("/private-driving-journal", async (_req, reply) => reply.sendFile("private-driving-journal.html")); app.get("/road-trip-journal-app", async (_req, reply) => reply.sendFile("road-trip-journal-app.html")); app.get("/road-trip-tracker-app", async (_req, reply) => reply.sendFile("road-trip-tracker-app.html")); app.get("/travel-journal-app-with-photos", async (_req, reply) => reply.sendFile("travel-journal-app-with-photos.html")); app.get("/driving-history-app", async (_req, reply) => reply.sendFile("driving-history-app.html")); app.get("/route-66-road-trip-journal", async (_req, reply) => reply.sendFile("route-66-road-trip-journal.html")); app.get("/pacific-coast-highway-road-trip-journal", async (_req, reply) => reply.sendFile("pacific-coast-highway-road-trip-journal.html")); app.get("/national-parks-road-trip-journal", async (_req, reply) => reply.sendFile("national-parks-road-trip-journal.html")); app.get("/texas-hill-country-road-trip-journal", async (_req, reply) => reply.sendFile("texas-hill-country-road-trip-journal.html")); app.get("/driving-journal", async (_req, reply) => reply.code(301).redirect("/private-driving-journal")); app.get("/support", async (_req, reply) => reply.sendFile("support.html")); app.get("/terms", async (_req, reply) => reply.sendFile("terms.html")); app.get("/wife", async (_req, reply) => reply.sendFile("wife.html")); app.get(googleSiteVerificationPath, async (_req, reply) => reply.type("text/html; charset=utf-8").send(await readFile(join(cfg.webRoot, "google8e611381b21882a8.html"), "utf8"))); app.setNotFoundHandler(async (req, reply) => { const requestPath = req.url.split("?")[0]; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 76edd3da..c756e538 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -135,6 +135,10 @@ test("public information and discovery pages are accessible without an authentic assert.match(roadTripJournal.body, /href="\/apple-music-soundtrack"/); assert.match(roadTripJournal.body, /https:\/\/apps\.apple\.com\/us\/app\/journeydeck\/id6806502526/); assert.match(roadTripJournal.body, /rel="canonical" href="https:\/\/journeydeck\.me\/road-trip-journal-app"/); + assert.match(roadTripJournal.body, /href="\/route-66-road-trip-journal"/); + assert.match(roadTripJournal.body, /href="\/pacific-coast-highway-road-trip-journal"/); + assert.match(roadTripJournal.body, /href="\/national-parks-road-trip-journal"/); + assert.match(roadTripJournal.body, /href="\/texas-hill-country-road-trip-journal"/); assert.doesNotMatch(roadTripJournal.body, /[\u2013\u2014]/); const roadTripTracker = await runtime.app.inject({ method: "GET", url: "/road-trip-tracker-app" }); assert.equal(roadTripTracker.statusCode, 200, roadTripTracker.body); @@ -176,13 +180,84 @@ test("public information and discovery pages are accessible without an authentic assert.match(drivingHistory.body, /https:\/\/apps\.apple\.com\/us\/app\/journeydeck\/id6806502526/); assert.match(drivingHistory.body, /rel="canonical" href="https:\/\/journeydeck\.me\/driving-history-app"/); assert.doesNotMatch(drivingHistory.body, /[\u2013\u2014]/); + const route66 = await runtime.app.inject({ method: "GET", url: "/route-66-road-trip-journal" }); + assert.equal(route66.statusCode, 200, route66.body); + assert.match(String(route66.headers["content-type"]), /text\/html/); + assert.match(route66.body, /
Keep your Route 66 drive in a private iPhone journal\. Your route, miles, music and photos, saved on your phone\. No account needed\.<\/p>/); + assert.match(route66.body, /Route 66 turned 100 in 2026/); + assert.doesNotMatch(route66.body, /Route 66 turns 100 this year/); + assert.match(route66.body, /Before you pull out of Chicago \(or Santa Monica, I'm not judging\), tap Start Journey/); + assert.match(route66.body, /
A private iPhone journal for Highway 1\. Route, miles and photos, saved on your phone\. Works offline, even where the signal doesn't\.<\/p>/); + assert.match(pacificCoast.body, /Tap Start Journey before Big Sur, put the phone down and drive/); + assert.match(pacificCoast.body, /Connect Apple Music if you want the playlist kept with the drive\./); + assert.doesNotMatch(pacificCoast.body, /I always want to know what was playing/); + assert.match(pacificCoast.body, /
No. Set up recording before driving and do not interact with the app while operating a vehicle.
- +