This file was rewritten from scratch on 2026-08-27

The previous version of this page had drifted badly from the real codebase — it described features that don't exist (a "GA4 & HTMX" architecture that was removed weeks ago), invented function and index names that never existed, and — worst of all — had a real, live Razorpay secret and the app's session-signing key hardcoded in a "sample" .env block, published on this public page. Everything below was re-verified directly against the code at Date Planner project/ and the live database on 2026-08-27. If you find something here that no longer matches the code, trust the code — this doc will drift again if nobody updates it alongside future PRs.

Updated 2026-08-30 — anonymous generation reinstated

The single biggest thing that changed since the 08-27 rewrite: the account wall in front of plan generation is gone. A visitor can now generate a plan without signing up, and the funnel is stricter about what it hands back (see Planning Engine for the new composition rules). Every section below that touches generation, quota, or zone 15 was re-verified against the code and the live diff on 2026-08-30, not just appended to — read those sections for the current behavior, not the 08-27 one.

What Plankro actually is, in one paragraph

Plankro is a website where a Delhi couple (or a group of friends) answers a few questions — which neighborhood, what mood, what budget — and gets a ready-made 3-stop evening plan in under a minute: a café, then dinner, then something to do, all within a 35-minute radius of each other. Every plan is a shareable "Room" with a link you can send on WhatsApp. The business charges ₹199/month for extras (insider tips, café discounts, a WhatsApp panic button, table pre-booking) — see the Feature Tracker below for exactly which of those extras are real today versus still just marketing copy.

Glossary — Go and web-backend terms used throughout this doc

TermWhat it means here
HandlerA function that runs when a specific URL is requested. One URL = one handler function. Same idea as a "controller" or "route function" in other frameworks.
MiddlewareA wrapper that runs before every request reaches its handler — for logging, security checks, rate limiting, etc. Requests pass through each middleware in a fixed order, like layers of an onion.
GoroutineGo's term for a lightweight background thread. Used here for anything that shouldn't make the user wait — sending an email, calling an external API.
HMAC / signed tokenA string plus a cryptographic signature proving it wasn't tampered with, without needing to look it up in a database. Used for session cookies, CSRF tokens, unsubscribe links, room-participant tokens.
WAL (Write-Ahead Log)A SQLite mode that lets many requests read the database at the same time as one request writes to it, instead of locking everyone out during a write.
MigrationA one-time change to the database's shape (add a column, add a table) that runs automatically on server startup if it hasn't run yet.
SweepA background job that runs on a timer and scans the database for rows matching some condition (e.g. "plan is tomorrow, user hasn't been emailed yet") and acts on each one.
WebhookAn HTTP request that Razorpay (the payment provider) sends to this server, unprompted, when something happens on their side (a payment clears, a subscription renews).
CSRFCross-Site Request Forgery — an attack where another website tricks a logged-in user's browser into submitting a request here without their intent. Defended against by checking where a request actually came from.
TemplateAn HTML file with placeholders ({{.SomeValue}}) that the Go server fills in with real data before sending the page to the browser. This is "server-side rendering" — no React/Vue involved.
code One binary, no framework

Built on Go's standard net/http package. Compiles to a single file (stitchplanner) that runs on a 952MB RAM VPS with room to spare. No Gin, Echo, or similar — every route is registered by hand in main.go.

speed SQLite, tuned for the box it runs on

One stitch.db file, WAL mode for concurrent reads, 25-connection pool, and an 8MB-per-connection cache size deliberately capped so 25 connections can't exceed ~200MB on a 952MB machine.

credit_card Razorpay subscriptions, live

₹199/month recurring via Razorpay Subscriptions, driven by 7 distinct webhook event types. Entitlement (whether a user's Pro is currently active) is computed fresh on every request, not trusted from a stored flag.

forward_to_inbox Behavior-triggered lifecycle emails

Not a fixed day-1/day-4/day-7 drip. Six background sweeps run every 15 minutes and email users based on what they've actually done — plan due tomorrow, hit the free-plan paywall, went quiet for 30 days.

insights Meta CAPI + Pixel + GA4, all real

Server-side Meta Conversions API and client-side Pixel share an event ID for deduplication. GA4/Google Ads tags are also live. The old /track/event endpoint this doc used to describe is dead — there's a test that fails if it comes back.

web Server-rendered HTML, no HTMX

Every page is Go html/template rendered on the server. HTMX was removed from this codebase entirely (verified: zero references in any .go, .html, or .js file). Tailwind still loads from its CDN script tag on two pages — that one hasn't been removed, despite an earlier commit claiming to.

Request Pipeline

Browser
Logging → Recovery → Security Headers → CSRF → Google-Verify → Rate Limit → Auth
Route Handler (main.go registrations)
SQLite (WAL) / Razorpay / Google Places / Meta
Design decisions that actually show up in the code, verified 2026-08-27

1. Entitlement is derived, not stored. entitlement.go computes "is this user Pro right now" fresh on every request from is_pro + legacy_lifetime + pro_expires_at, instead of trusting a cached flag. This exists because of a real production bug — see the Payments & Entitlement section.
2. Boot-time credential checks. The server refuses to start without RAZORPAY_KEY_ID, RAZORPAY_SECRET, RAZORPAY_WEBHOOK_SECRET, and STITCH_SIGNING_KEY — and, as of a recent commit, it also refuses to start if the dev-mode OTP backdoor is enabled at the same time as a live Razorpay key.
3. Fail closed, not open. Session-blacklist checks treat a database error as "this session is blacklisted" rather than "let it through." Ownership checks deny access to a plan with no owner (user_id IS NULL) to everyone, including logged-in strangers.
4. Async work is tracked, not fire-and-forget. Every background email or tracking call goes through one chokepoint (goSendEmail) that a shutdown can wait for, so a server restart during a deploy doesn't silently drop a payment receipt.

FeatureStatusEvidenceNote
Zone-based planning (proximity clusters)Builtplanner_engine.go, 15 zones in DB grouped into 5 static clusters (A–E)Live DB has 15 zones, not the 14 that CLAUDE.md/marketing copy states — worth a copy correction. Zone 15's display name changed 2026-08-30 from "GTB Nagar / Hudson Lane" to "North Campus" (Kamla Nagar's market folded in) — the zone ID and slug (gtb-nagar) didn't change, only the name and the vibe tags.
Vibe-based matchingBuiltvibes.go — 7 vibes (casual, impressive, luxury, lively, adventurous, quiet, romantic) scored against each venue's mood tags
4 budget tiersBuiltsuggestions.go: getMaxCumulativeBudget — Budget ₹500 / Mid ₹1500 / Upscale ₹3000 / Luxury ₹12000Luxury's ₹12,000 cap is so high it's effectively uncapped in practice.
Composition-aware plan structure (one food, one activity, one dessert)Builtcomposition.go, stop_roles.go, zone_config.go — new 2026-08-30Only enforced in 3 zones with audited inventory (Hauz Khas, Connaught Place, North Campus) — see Planning Engine. The other 12 zones keep the older fillSlots behavior unchanged.
Wizard / plan generationBuiltPOST /generatecreateItineraryChanged 2026-08-30: no longer requires an account. The 2026-08-16 "Stage 2" account wall was removed — see Anonymous Sessions below for how an anonymous visitor is identified and rate-limited instead.
Anonymous plan generation (no signup required)BuiltSigned plankro_anon cookie, anon_generations table, 20/day per sessionThe business case: every earlier signup-first funnel loses people at the account-creation step before they've seen a single plan. This lets someone try the product first. Plans made anonymously reattach to the account automatically the moment that person signs up or logs in (claimAnonPlans).
Free tier: unlimited generations, no lifetime capBuiltusers.free_generations_used now an uncapped counter, not a quotaThe old "3 free plans, then pay" model is gone for logged-in free users too — only Pro's cooldown (5/24h) and monthly cap (25/month) still limit anyone. Known gap: the lifecycle email sweep's paywallHit trigger (lifecycle.go, fires once free_generations_used >= 3) still exists and will email a free user "you've used all your free plans" even though there is no longer a wall to hit — the trigger condition and its copy need a business-side look now that the cap is gone.
Razorpay Pro subscription (₹199/mo)Builtpayments.go, webhooks.go, live rzp_live_ key in prodReal money moves through this path — see Security section.
Date Rooms (every plan is collaborative)Builtrooms.go, room_engine.go, /room/{code}Shipped 2026-08-21 to 08-24.
Friends Mode (stop stepper, dwell timing, feasibility, trim)Builtroom_engine.go, room_engine_test.go (6 tests), rooms_test.go (24 tests)Shipped 2026-08-26/27, the newest feature in the codebase.
Mode-aware per-person pricingBuilttotal_phrasing_test.go: TestTotalPhrasingAgreesAcrossPagesTest explicitly checks all 3 surfaces (plan/dashboard/room) agree.
Editorial venue profile pagesBuilt/venue/{id}, handleVenueDetail, templates/venue_detail.html
Partner sharing (link + RSVP)Built/p/{code}, handlePartnerRSVP (token-verified, 7-day link expiry)It's a yes/no/maybe RSVP, not free-form "suggest changes" — that's the separate Rooms suggestion feature below.
Surprise ModeBuiltsurprise_mode.go, redaction enforced in handleRoomViewAn earlier venue-link leak in redaction was fixed 2026-08-19.
Pro: WhatsApp Emergency SOS / live backupBuiltsos.go, gated on user.IsPro at every call siteOnly active for plans created (not scheduled) the same calendar day.
Pro: "The X-Factor" / insider tipsCode built, data missing3 DB columns (insider_tip, x_factor, pro_tips) fully wired into the query and into venue_detail.html's render logic0 of 366 venues have any of these fields populated — verified directly against the live DB. A Pro user sees nothing here today.
Pro: Café discountsCode built, no live offersoffers.go — full QR-scan redemption flow (/r/{token}), CLI to manage codes (offer-add etc.)The redemption mechanism is genuinely well-built (server decides Pro status, waiter just reads green/red). But offer_text/offer_token are 0/366 populated, and per CLAUDE.md zero cafés have been contacted — there is nothing to redeem yet.
Pro: "We pre-book your table"Not builtNo reservation table, no handler, no notification-to-human path found anywhere in the codebaseExists only as a sentence in lifecycle-email marketing copy ("we pre-book the table"). This is pure copy, not a feature.
Per-feature Pro gatingPartialentitlement.go: proActive() is one global yes/no flagThere's no per-feature entitlement system. Shipping "X-Factor is Pro-only, discounts are free" later needs new code, not a config flip.
schema 1. A Request's Trip Through the Server
1
Middleware onion main.go
In execution order: loggingrecovery (catches panics so one bad request can't crash the process) → securityHeaderscsrf (checked on POST/PUT/DELETE, exempt for /p/, /date/rsvp/, /room/, and the Razorpay webhook) → googleVerify (serves Search Console files off disk) → rateLimit (per-IP+path, 15 requests/15min on auth and generation endpoints) → auth (reads the session cookie, checks the blacklist, and — notably — resolves the user's real, expiry-checked Pro status right here so nothing downstream has to re-derive it).
2
Route dispatch handlers*.go
Go's built-in router (http.HandleFunc("METHOD /path", fn)) matches the request to one handler function. See the full route directory below.
3
Database database.go
Handler queries SQLite through a 25-connection pool, WAL mode, parameterized queries throughout.
4
Background work fired, not awaited emails.go / tracking.go
Emails and Meta CAPI calls run in a tracked background thread (goSendEmail) so the user's response isn't held up waiting on Gmail or Meta's API.
5
Response rendered
Either an HTML page via html/template, or a JSON body for the handful of API endpoints (payments, OTP).
payments 2. Becoming, Staying, and Losing Pro
1
Checkout handlers.go: handleCreateOrder
Despite the name, POST /api/create-order creates a Razorpay subscription, not a one-time order — the naming is a legacy holdover. Requires login, checks the request's Origin header, and resolves which itinerary the purchase is attached to.
2
Payment verified handlers.go: handleVerifyPayment → payments.go: grantProFromSubscription
Razorpay checkout modal completes, signature is HMAC-verified, is_pro=1 and pro_expires_at are set.
3
Webhooks keep it in sync webhooks.go: handleRazorpayWebhook
Dispatches on 7 event types: payment.captured, refund.created/refund.processed, subscription.activated, subscription.charged (extends pro_expires_at by 30 days), subscription.halted, subscription.cancelled, subscription.completed. The old version of this doc listed only 3 of these 7.
4
Entitlement is computed, not cached entitlement.go: proActive()
Every request re-derives whether Pro is active right now from is_pro + legacy_lifetime + pro_expires_at, rather than trusting a stored flag. This exists because of a real bug: when the pricing model switched from one-time-lifetime to monthly-subscription on 2026-08-16, the old "grandfather existing Pro users as lifetime" logic briefly gave brand-new monthly subscribers permanent free Pro. subscriptionEraStart now dates that grandfather clause so it can't happen again.
forward_to_inbox 3. The 15-Minute Lifecycle Email Sweep
1
Two instant emails, not part of the sweep
EmailWelcome fires once on signup. EmailPlanReady fires once when a plan is generated. Neither waits for the timer below.
2
Six sweeps, every 15 minutes lifecycle.go: startLifecycleScheduler
First run 2 minutes after boot, then every 15 minutes, gated behind LIFECYCLE_EMAILS_ENABLED=1 (off by default). In order: dateTomorrow (plan is tomorrow, pitch Live Pass + SOS) → ratePrompt (plan was yesterday, never rated) → paywallWarm (2 of 3 free plans used) → paywallHit (all 3 used) → winback (day 7 "still deciding?", then day 21 "last call" — day 21 only fires after day 7 already has) → activateNudge (signed up 1–30 days ago, never generated a plan). These two trigger conditions are unchanged code as of 2026-08-30, but the 3-plan cap they reference no longer exists product-side — see the Feature Tracker's note on paywallHit.
3
Caps and suppression
Each sweep has its own per-run cap (30–100), plus a shared 300/day ceiling. Welcome and plan-ready emails are exempt from the cap (they're user-triggered, not sweep volume). A one-click email_optout flag suppresses everything else, no login required to set it.
4
Send-once guarantee
A database-level unique constraint on email_sends (claimSend) means a user gets exactly one email per type, even if the sweep restarts mid-run.
This replaces a fictional 8-step list

The previous version of this doc described a fixed "Day 4 Pro highlight, Day 7 upsell, Day 21 winback" journey. That system does not exist in the code — the real triggers above are based on what the user actually did (hit the paywall, went quiet), not a fixed calendar offset.

groups 4. Generating a Plan → A Room
1
One engine, two funnels
Couple mode (/plan/new?mode=couple) and Friends mode (/plan/new?mode=friends) show different templates but both POST /generate into the exact same createItinerary function. Neither requires an account — see flow 6 below for how an anonymous request is identified and rate-limited instead.
2
Venue selection forks on the zone suggestions.go: suggestActivities
Zones with an audited inventory profile (Hauz Khas, Connaught Place, North Campus — zone_config.go) route into suggestComposedPlan instead: exactly one food stop, one activity-eligible stop, one dessert-or-winddown stop, no two adjacent stops sharing a category, and no street-stall/sub-₹200 opener. Every other zone keeps the original behavior: exact zone + budget + mood match first, dropping the budget filter and retrying zone-only if fewer than 3 venues qualify, degrading the recorded matchQuality from exactmood_relaxedbudget_relaxed. fillSlots then seats a café/restaurant first, entertainment second, and fills the rest against a running budget total. See Planning Engine for why the composed path exists.
3
Every plan is a room, atomically rooms.go
The itinerary row and the creator's participant row are written in one database transaction — there is no such thing as a plan without a room. An anonymous creator's row carries an anon_session_id instead of a user_id.
4
Friends Mode: feasibility check room_engine.go
Only runs when a friends-mode organizer overrides the stop count. Sums real per-venue dwell time (duration_minutes, or a category fallback) plus a flat 15-minute hop between stops, and compares it against the duration tier's time window. If it overruns, the plan is flagged time_overrun and a "trim to N stops" suggestion is computed.
5
Trimming creates a new room rooms.go: handleRoomTrim
POST /room/{code}/trim does not edit the existing room — it re-runs generation with the trimmed stop count and redirects to a brand-new room code. The original room is untouched.
qr_code_2 5. Redeeming a Café Discount (built, not yet live)
1
The design intent offers.go
Straight from the code comment: "The couple does not carry proof of Pro status — a screenshot or a DevTools edit defeats any pass rendered on their own phone... So the café holds the secret instead." A laminated QR card at the counter encodes /r/{token}.
2
Scan → server decides handleRedeem
The couple scans the card with their own phone. The server checks whether the currently logged-in scanner is Pro and renders a green ("approved") or red ("denied") page. The waiter's entire job is reading that color.
3
Replay protection
recordRedemption logs the redemption in offer_redemptions to cap repeat use per user/venue/day.
Built but empty

This flow is fully implemented and covered by 9 tests (offers_test.go). It just has nothing to redeem yet — zero venues have an offer code assigned, and zero café partnerships have been signed (per project notes). See the Feature Tracker.

badge 6. Anonymous Sessions, Rate Limiting & Claiming (new 2026-08-30)
1
A cookie mints on the way in main.go: authMiddleware → resolveOrMintAnonSession
If a request has no valid plankro_session cookie, authMiddleware looks for an existing, signature-verified plankro_anon cookie (sessionID|expiry|HMAC, same signing key as the login cookie, 1-year expiry). If none is present, one is minted only on a route that can actually create or change a plan (/plan/new, /generate, /plan, /room/{code}/trim) — never on a plain page view. This matters for two reasons: it keeps the 366 venue pages, zone pages, and blog fully cacheable (no Set-Cookie on a static page), and it closes a real bug found during review, described below.
2
Three independent limits, not one handlers.go, main.go
Per-session: 20 generations/day per anon cookie (anon_generations table). Per-IP mint throttle: 30 brand-new anon cookies per IP per hour (checkAnonMintThrottle) — this is what actually stops someone from resetting their own 20/day limit by refusing to keep the cookie. Site-wide ceiling: 300 total generations/hour across every caller — anon, free, and Pro alike (peekGlobalGenerationCeiling/recordGlobalGeneration) — a capacity backstop for the single-writer SQLite database on a 952MB VPS, independent of who's asking.
3
Charged only after the plan actually exists
All three limits are checked before generation runs, but only charged (the row inserted / counter incremented) after createItinerary returns success. A request that fails validation or lands in a zone with no matching inventory doesn't burn a day's worth of someone's 20 free generations for a plan they never got.
4
Claimed the moment the person is identified handlers.go: claimAnonPlans
Runs on every signup and every login (not just new-account creation) — reassigns every itinerary matching that browser's anon session ID to the now-known account. Idempotent: re-running it on a plan already claimed is a no-op, so it's safe to call unconditionally on every auth event.
A real bypass, found and closed during code review before this shipped

An earlier version of step 1 minted a fresh anon cookie on any visit to /room/{code} or /plan/{code}, reasoning that a share-link viewer needs their own identity too. The problem: GET /room/{anything} needs no valid room code and no existing cookie to reach that logic, so it was a free, completely unthrottled way to manufacture new 20/day allowances — mint via /room/{random}, spend the cookie on /generate, repeat forever. Neither handleRoomView nor a stranger's isOwner check on handleViewPlan actually needed that fresh cookie to work correctly, so the fix removes minting from plain views entirely and folds /room/{code}/trim in instead (it's the one room sub-route that genuinely creates a new itinerary). Verified with a dedicated test that drives 100 room views and 100 plan views through the minting logic asserting zero cookies set, then confirms trim shares the same 30/hour budget as /generate.

Project Directory & Code Map

All Go files live flat in the repo root (no internal package split). Grouped here by what they actually do, verified against the code on 2026-08-27:

FileWhat it actually does
Server core
main.goBoot sequence, all route registrations, the middleware chain, CLI subcommand dispatch, graceful shutdown.
database.goSQLite connection setup, full schema, every migration, seeding.
models.goPlain data structs: Zone, Activity, Itinerary, ItineraryItem, Participant, Suggestion, User.
clock.go9 lines. Not a timezone engine — it's a test seam (var now = time.Now) so tests can freeze time. NowIST()/ISTDate() etc. described by the old doc don't exist.
auth.goSession cookie signing/verification, bcrypt password hashing, logout blacklist.
HTTP handlers
handlers.goThe bulk of page and API handlers — home, plan viewing, checkout, partner view, ICS export, safeRedirect.
handlers_otp.goOTP request/verify endpoints, per-email rate limiting, the dev-mode test-account backdoor.
otp.goOTP code generation and hashing, sending the OTP email.
rooms.goRoom view, join, suggest, and trim handlers. The collaborative-planning surface.
room_engine.goStop-count ranges, dwell-time calculation, feasibility/overrun detection, trim-target logic.
zonepages.goNeighborhood landing pages (/date-plans/{slug}) and their nearby-zone recommendations.
hubs.goBlog hub and date-plans hub listing pages.
sitemap.goXML sitemap generation for /sitemap.xml.
Planning & venues
planner_engine.go29 lines. A static lookup table mapping each of the 15 zones to one of 5 geographic clusters — not a distance algorithm.
suggestions.goVenue selection: budget enforcement, zone/mood fallback degradation, slot-filling. Forks into composition.go for zones with an audited inventory profile — new 2026-08-30.
composition.go (new 2026-08-30)The composition-aware planner: enforces one food / one activity / one dessert-or-winddown stop, no adjacent same-category stops, no cheap opener, and a budget-band match — for zones with a zone_config.go entry only.
stop_roles.go (new 2026-08-30)Derives activities.stop_role and activities.is_food from category on every server boot, plus a short, commented list of hand-reviewed exceptions (a dessert business wrongly filed as a cafe, a venue that's genuinely both food and dessert).
zone_config.go (new 2026-08-30)Per-zone tuning for the composition planner — which zones enforce it, their audited budget bands, and any extra categories that count as "activity" locally (e.g. North Campus's market).
vibes.goThe 7 vibe tags and their scoring against venue mood tags.
plan_mode.goCouple/friends mode constant + normalization (unknown modes default to couple).
surprise_mode.goRedaction logic for Surprise Mode — hides stops 2+ from non-owner viewers.
venues.goHand-written SEO blog content for 10 venues. Not the planner's venue pool — see below.
venues_batch_three.go, venues_batch_four.goThe actual planner venue seed data — INSERT INTO activities statements. 366 venues total.
places.goGoogle Places API caching (30-day TTL), disables itself gracefully if no API key is set.
phone.goIndian phone number normalization, feeding Meta CAPI phone hashing.
Money
payments.goRazorpay subscription creation, granting/renewing Pro.
webhooks.goRazorpay webhook listener — 7 event types handled.
entitlement.goThe single source of truth for "is this user Pro right now" — computed live, not cached.
entitlement_backfill.goOne-time migration fixing rows where is_pro=1 but expiry was left NULL.
pro_downgrade.goDaily cleanup sweep for expired Pro users — a consistency job, not the source of truth (that's entitlement.go).
offers.goThe café discount QR redemption system, plus its CLI management commands.
reconcile.goCLI tool that calls the Razorpay API directly to fix orders/subscriptions that never got a webhook.
merge_dry_run.goFinds case-duplicate accounts (abc@x.com vs ABC@x.com) before merging them for real.
Communication & tracking
lifecycle.goThe 6-sweep behavior-triggered email engine described above.
emails.goReal Gmail SMTP sending (not a third-party ESP), the tracked async dispatch chokepoint.
tracking.goServer-side Meta Conversions API (CAPI) sender.
meta.goNot Meta Graph API config, despite the name — it's the PageMeta struct for SEO/Open Graph tags.
sos.goPro-gated WhatsApp emergency link generator.

main.go — Server Entrypoint & Middleware

Startup sequence

  1. loadEnv() reads .env into the process (no-op if the file is missing).
  2. Boot fails immediately if RAZORPAY_KEY_ID, RAZORPAY_SECRET, STITCH_SIGNING_KEY, or RAZORPAY_WEBHOOK_SECRET are unset. New: it also fails if PLANKRO_DEV_MODE=1 is combined with a live (rzp_live_) Razorpay key — that combination would leave the hardcoded-OTP test login reachable in production.
  3. Checks for a CLI subcommand in os.Args[1] (migrate, reconcile-payments, merge-accounts, offer-add/list/remove/stats, suppress-lifecycle-backlog) and exits early if matched — these run instead of starting the server.
  4. initDB() — opens SQLite, runs schema + migrations.
  5. Loads eligible zone landing pages, initializes the Google Places client, parses all templates.
  6. Starts two background schedulers: the lifecycle email sweep and the Pro-downgrade sweep.
  7. Registers every route (see the Route Directory) and wraps the mux in the middleware chain.
  8. Starts the HTTP listener; on SIGINT/SIGTERM, stops accepting new connections, drains in-flight HTTP requests (10s budget) and in-flight background emails/CAPI sends (10s budget), then exits.

Middleware chain, in actual execution order

main.go — order confirmed from the wiring code, not from comments (a stale comment nearby claims a different order)
logging → recovery → securityHeaders → csrf → googleVerify → rateLimit → auth
LayerWhat it does
loggingLogs method, path, remote address, and duration for every request.
recoveryCatches panics so one broken request can't take down the whole process.
securityHeadersSets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, CSP, and HSTS (HTTPS only).
csrfOn POST/PUT/DELETE, requires the Origin or Referer header to match an allowlist. Exempt: /p/, /date/rsvp/, /room/ (may be opened from a WhatsApp in-app browser that strips these headers) and the Razorpay webhook (verified by HMAC signature instead).
googleVerifyServes /google*.html Search Console verification files directly off disk.
rateLimitIn-memory per-IP+path counter, 15 requests / 15 minutes, applied to login, signup, OTP request/verify, plan lookup, and offer redemption. Changed 2026-08-30: /generate was removed from this list — generation now has its own dedicated, more targeted rate limiting (per-session daily cap, per-IP mint throttle, site-wide hourly ceiling) living in auth and the handler logic instead. See flow diagram 6.
authReads the plankro_session cookie, verifies its signature, checks the blacklist, loads the user, and resolves their real (expiry-checked) Pro status here so every downstream handler and template sees a correct value. Changed 2026-08-30: if there's no valid user session, this layer now also resolves-or-mints a signed plankro_anon cookie instead of leaving the request fully anonymous — see Anonymous Sessions.

Graceful shutdown

main.go — shutdown sequence
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
server.Shutdown(ctx)      // 1. stop accepting new connections, drain in-flight requests

// 2. wait (bounded) for background emails / CAPI sends to finish
drainInFlightEmails(ctx)

database.go — Schema & Storage

SQLite, single file, tuned deliberately for a 952MB RAM production box.

Connection & pragma settings — corrected from the previous version of this doc

database.go — pool + DSN pragmas (pragmas are set via the connection string, not db.Exec — PRAGMA-via-Exec only lands on one pooled connection)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)

// DSN: ?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=5000&_cache_size=-8000&_temp_store=MEMORY
Two corrections to the old doc

Cache size is 8MB per connection (-8000), not 64MB. A code comment explains why: 8MB × 25 connections = ~200MB ceiling; 64MB × 25 would be 1.6GB, more RAM than the whole VPS has.
foreign_keys is deliberately NOT turned on. A code comment states production already carries foreign-key violations (itineraries owned by since-deleted users) that turning this on would start rejecting.

Tables

TablePurpose
zonesThe 15 Delhi neighborhoods (id, name, display name, coordinates, vibe tags).
activitiesThe venue catalog — 366 rows, 348 active. Category, mood tags, budget tier, phone, rating, links, Google Places fields, duration_minutes, the still-empty Pro columns (insider_tip, x_factor, pro_tips, offer_text, offer_token), and — new 2026-08-30 — stop_role and is_food, both re-derived from category on every boot by stop_roles.go, never hand-edited.
usersAccount, password hash, and every migrated addition: plan type, free/monthly generation counters, phone, opt-ins, pro_expires_at, display_name, legacy_lifetime.
itinerariesThe generated plan/room. Code, zone, owner, mood, budget, scheduled date, the Friends Mode additions (plan_mode, custom_hours, feasibility, trim_to_count), and — new 2026-08-30 — anon_session_id, populated instead of user_id when the creator wasn't logged in.
itinerary_itemsOne row per stop — activity, custom name, start time, duration, sort order, who added it.
anon_generations (new 2026-08-30)One row per anonymous plan generation actually completed — session_id, ip, created_at. Read to enforce the 20/day-per-session cap; rows older than 48 hours are pruned on each check.
places_api_calls (new 2026-08-30)Daily counter of real Google Places API calls made (places.go) — tracking only today, no enforcement yet.
payment_orders, subscriptionsRazorpay order/subscription records. subscriptions.last_payment_id prevents double-crediting a renewal.
session_blacklistHMAC'd tokens of logged-out sessions, checked (fail-closed) on every request.
otps, otp_request_logOTP codes (hashed) and the per-email rate-limit log.
schema_metaKey/value store — currently holds the venue-data content hash used to skip re-seeding on unchanged boot.
favorite_venuesUser-saved venues.
email_sendsOne row per lifecycle email actually sent — the unique constraint that makes claimSend safe against sweep restarts. Has a suppressed flag.
participantsRoom membership — one row per person who's joined a room, including the creator.
room_suggestionsFree-form suggestions left by room participants, with status/resolved_at.
offer_redemptionsCafé discount redemption log — caps replay per user/venue/day.

Real index names (the old doc invented different ones)

idx_itineraries_scheduled_date, idx_users_lifecycle, idx_email_sends_suppressed_sent_at, idx_activities_zone_budget, idx_activities_zone_active_cat, plus lookup indexes for OTP-by-email, subscriptions/itineraries-by-user, and room participants/suggestions.

Startup seeding shortcut

On boot, the server SHA-256-hashes the venue seed data and skips the entire re-insert pass if it's unchanged from the last recorded hash in schema_meta — keeps a clean-slate boot fast.

Complete Route Directory

Every route currently registered in main.go, grouped by area. Pulled directly from the route registrations, not reconstructed from memory — the previous doc's route table was missing most of these.

MethodPathHandlerWhat it does
Marketing & misc pages
GET/handleHomeLanding page / plan wizard entry.
GET/robots.txt, /sitemap.xml, /favicon.icoinline / handleSitemapStandard site files.
GET/no-matchhandleNoMatchShown when the wizard finds no venues for a combination.
GET/pricing, /privacy, /terms, /refund, /contactrespective handlersStatic content pages.
GET/blog, /blog/{slug}, /blog/venue/{slug}handleBlogHub, handleBlogPage, handleVenuePageSEO blog content — separate from the planner's real venue pool.
GET/date-plans, /date-plans/{slug}, /date-plans/{slug}/handleDatePlansHub, handleZonePageRedirect, handleZonePageNeighborhood landing pages for SEO.
GET/venue/{id}handleVenueDetailEditorial venue profile page.
Plan generation & viewing
GET/plan/newhandlePlanNewThe wizard, mode-branched via ?mode=couple|friends.
POST/generatehandleGeneratePlanRuns the venue-matching engine, creates the room. Changed 2026-08-30: no login required — an anonymous caller is charged against their 20/day session cap and the site-wide hourly ceiling instead of free_generations_used.
GET/plan/{code}handleViewPlanPlan view. Owner check now also matches on anon_session_id for a plan generated without an account (2026-08-30).
GET/planhandleDeepLinkPlanQuery-param deep link resolver. Same 2026-08-30 change as /generate — no login required.
POST/plan/{code}/surprisehandleToggleSurpriseOwner-only toggle for Surprise Mode.
GET/plan/{code}/export/icshandleExportICSCalendar file export.
GET/plan/{code}/confirmation, /date/{code}/confirmationhandleConfirmationPagePost-generation confirmation screen.
Date Rooms
GET/room/{code}handleRoomViewUniversal landing page for any plan — every plan is a room.
POST/room/{code}/joinhandleRoomJoinGuest sets a display name and gets a participant cookie.
POST/room/{code}/suggesthandleRoomSuggestParticipant leaves a free-form suggestion.
POST/room/{code}/trimhandleRoomTrimFriends Mode: creates a new, shorter room when the schedule overruns.
Partner sharing (separate from Rooms)
GET/p/{code}handlePartnerViewToken-guarded read-only partner view.
POST/p/{code}/rsvphandlePartnerRSVPYes/no/maybe RSVP; token constant-time-compared, 7-day link expiry.
Auth & OTP
GET/POST/signup, /loginrespective handlersStandard forms.
POST/logouthandleLogoutBlacklists the session token.
POST/api/request-otp, /api/verify-otphandleRequestOTP, handleVerifyOTPPasswordless OTP login/verify.
GET/unsubscribehandleUnsubscribeOne-click lifecycle email opt-out.
Payments
POST/api/create-orderhandleCreateOrderCreates a Razorpay subscription despite the "order" name.
POST/api/verify-paymenthandleVerifyPaymentVerifies the Razorpay signature and grants Pro.
POST/api/razorpay/webhookhandleRazorpayWebhook7 event types, HMAC-verified.
GET/r/{token}handleRedeemCafé discount QR redemption.
Dashboard & misc
GET/dashboard, /my-dateshandleDashboard, handleMyDatesDashboardLogged-in user's plan history.
POST/my-dates/rate, /my-dates/favoritehandleRateItinerary, handleToggleFavoriteVenuePost-date feedback and favoriting.
GET/date/{code}/livehandleDigitalProPassLiveLive Pass page, Pro-gated.
GET/healthinlineUptime/DB connectivity check.
Routes the old doc invented that don't exist

/api/check-updates and /api/create-subscription are not real routes — they were part of the old doc's fictional HTMX live-sync description. The real subscription-creation endpoint is POST /api/create-order.

Auth, Sessions & OTP

Session cookie format

plankro_session holds userID|expiryUnixTimestamp|hexHMACSignature. No fixed expiry constant — duration is set by the caller at login. Verified by recomputing the HMAC and checking the timestamp.

Logout blacklists, doesn't just clear

Logout HMACs the token and inserts it into session_blacklist, so a copied cookie stops working immediately rather than at natural expiry. The blacklist check fails closed — a database error is treated as "blacklisted," a deliberate choice per the code comment.

Passwords

bcrypt, cost factor 12.

Other signed tokens, domain-prefixed

CSRF tokens (csrf:), unsubscribe links (unsub:), room participant tokens (participant:) — the prefix stops one token type from being replayed as another.

safeRedirect

Actually lives in handlers.go, not auth.go. Blocks protocol-relative open-redirect attempts (//evil.com, /\evil.com) via regex, allowing only same-site paths.

Ownership checks fail closed

requireItineraryOwnership (used for Pro-gated actions like Surprise Mode and payment/order lookups) denies access to any plan with user_id IS NULL — unclaimed pre-account-era plans can't be claimed by anyone, logged in or not. This function is unchanged and login-only on purpose: every action it gates already requires an account (Pro features, payments), so an anonymous caller was never meant to pass it. Plain plan viewing (handleViewPlan) uses a separate, anon-aware ownership check — see below.

Anonymous sessions (new 2026-08-30)

A visitor with no account can now generate and revisit a plan. Identity for that visitor is a second, separate signed cookie — plankro_anon, format sessionID|expiryUnixTimestamp|hexHMACSignature, same signing key and verification style as plankro_session, 1-year expiry. It answers "which browser is this" the way plankro_session answers "which account is this" — the two are independent and a request only ever carries one or the other.

Minted narrowly, not on every page

Only on /plan/new, /generate, /plan (deep link), and /room/{code}/trim — the routes that can actually create or mutate a plan. An existing cookie is still read and honored on every other route (including /signup and OTP verify, so claiming works), it just won't mint a fresh one there. See flow diagram 6 for the bypass this closed.

Three independent rate limits

20 generations/day per session, 30 new-session mints per IP per hour, and a 300/hour site-wide ceiling covering every caller regardless of auth state. Each is checked before generation and charged only after it succeeds.

Claims into an account automatically

claimAnonPlans reassigns every itinerary matching the caller's anon session to their account, on every signup and every login — not just the first one. Idempotent by design.

Plan-view ownership is anon-aware

handleViewPlan's owner check now reads: owned by the logged-in user, or the itinerary has no user_id and its stored anon_session_id matches the caller's current cookie. An empty anon session never matches a stored one, so a stranger viewing someone else's shared link is correctly never treated as the owner.

The dev-mode test backdoor — and its new safety guard

When PLANKRO_DEV_MODE=1, requesting an OTP for tester@plankro.com, test@plankro.com, or admin@plankro.com returns an instant "VIP Pro Test Account" message, and any of the codes 123456, 000000, or 777777 verifies successfully for those three emails only.

Boot-time hard-fail added recently

main.go now refuses to start if PLANKRO_DEV_MODE=1 is set alongside a live (rzp_live_) Razorpay key — closing the possibility of this backdoor ever being reachable in a real production deploy, by construction rather than by convention.

OTP rate limiting

OTP requests are covered by the same per-IP+path rate limiter described in the middleware section (15/15min), plus a dedicated otp_request_log table for per-email tracking.

Payments, Entitlement & Offers

This is the money path — a live rzp_live_ Razorpay key runs in production. Every change here is a money-path change.

Webhook events handled — corrected from the old doc

EventAction
payment.capturedhandlePaymentCapturedEvent
refund.created, refund.processedhandleRefundEvent
subscription.activatedhandleSubscriptionStatusEvent
subscription.chargedhandleSubscriptionChargedEvent — extends pro_expires_at by 30 days, keyed on last_payment_id to prevent double-crediting
subscription.haltedhandleSubscriptionStatusEvent
subscription.cancelledhandleSubscriptionStatusEvent
subscription.completedhandleSubscriptionStatusEvent

entitlement.go — the fix for a real production bug

Before this file existed, is_pro was the only gate anywhere, and pro_expires_at was written by some grant paths but read by nothing except a disabled sweep. A customer who paid for one month kept Pro forever. proActive() now derives entitlement live on every request:

subscriptionEraStart (2026-08-16) marks the day pricing flipped from one-time-lifetime to monthly-subscription — the grandfather rule only applies to payments before that date, closing the exact loophole that once granted a brand-new monthly subscriber permanent free Pro on launch day.

Offers — the café discount system

See the redemption flow diagram above. Managed via CLI: offer-add, offer-list, offer-remove, offer-stats — string subcommands, e.g. go run . offer-add ....

Reconciliation & account cleanup tools

ToolWhat it fixes
reconcile.go / reconcile-paymentsCalls the Razorpay API directly to find and fix orders/subscriptions that never received a webhook.
merge_dry_run.go / merge-accountsFinds and merges case-insensitive duplicate accounts (a legacy artifact from before emails were lowercased on signup).
pro_downgrade.goDaily consistency sweep for expired Pro users — a cleanup pass, not the entitlement source of truth.

Lifecycle Email Engine

Full flow diagram is in the System Flow Diagrams section above. This section covers the delivery mechanics.

emails.go — the tracked async chokepoint every email/CAPI call goes through
var pendingEmails sync.WaitGroup

func goSendEmail(fn func()) {
    pendingEmails.Add(1)
    go func() {
        defer pendingEmails.Done()
        defer func() {
            if r := recover(); r != nil {
                log.Printf("recovered from email panic: %v", r)
            }
        }()
        fn()
    }()
}

Without the panic recovery here, one broken email template would crash the entire server process for every user, not just fail that one email. Real Gmail SMTP is used directly (smtp.gmail.com:587, STARTTLS, app-password auth) — no third-party ESP. Bulk lifecycle sends optionally route through a separate Gmail account (GMAIL_LIFECYCLE_USER) so a bulk-send spike can never throttle the account that also sends OTP codes and payment receipts. In local dev with no Gmail credentials configured, it logs instead of sending.

Analytics & Tracking

All three tracking systems below are real and live — this corrects several wrong claims in the old doc.

Meta Pixel (client-side)

Rendered via the metaPixel template function in main.go, with a hardcoded fallback pixel ID so it never renders nothing — the pixel is live in production.

Meta CAPI (server-side)

tracking.go: sendCAPIEvent posts to Meta Graph API v21.0, SHA-256-hashes email/phone, reads client IP from X-Forwarded-For, and reads the _fbp/_fbc cookies. Called from 5 places across handlers.go, handlers_otp.go, and webhooks.go. Silently no-ops if META_CAPI_TOKEN/META_PIXEL_ID aren't set.

GA4 / Google Ads

A separate googleTags template function (in main.go, not tracking.go), lazy-loaded via requestIdleCallback. Reads GOOGLE_ANALYTICS_ID/GOOGLE_ADS_ID.

Corrections to the old doc

meta.go is not Meta Graph API configuration — it's the PageMeta struct used for SEO/Open Graph tags (description, OG title/image, canonical URL). Completely different responsibility than the old doc claimed.
The old /track/event endpoint is dead. There's a test (TestMetaPixelRendersWithFallbackID in main_test.go) that fails if it comes back, or if a template hardcodes a raw pixel snippet instead of using the shared {{template "meta-pixel" .}} partial.

Date Rooms & Friends Mode

What a "Room" is

There's no separate "plan" object anymore — every generated plan is a room. createItinerary writes the itinerary row and the creator's participants row inside one transaction. /room/{code} is the universal landing page. A guest without a pk_room_{code} cookie is shown room_join.html and must enter a display name before seeing the plan.

Couple vs. Friends mode — same engine, different funnel

Both modes POST /generate into the same createItinerary function with the same PlanParams struct. The only fork is which template renders (plan_new_couple.html vs. plan_new_friends.html) and how downstream code reads the stored plan_mode — pricing phrasing, the stop-count stepper, and the feasibility check are all conditioned on it. An unrecognized mode value (e.g. a mangled WhatsApp-pasted URL) defaults to couple mode.

Friends Mode mechanics

Stop-count stepper

Baseline ranges by duration tier: Quick 1–2 stops, Half-Day 2–3, Full-Day 4–5. A friends-mode organizer can override this, stored in custom_hours/trim_to_count.

Real dwell timing

Each stop's hold time uses the venue's real stored duration_minutes if present (232 of 366 venues have one), otherwise a category fallback: café 45min, restaurant 90min, entertainment 120min, outdoor/shopping 60min, adventure 90min, default 60min.

Feasibility check — friends-mode only

Total dwell time + a flat 15-minute hop between every stop is compared against the tier's time window (Quick 180min, Half-Day 300min, Full-Day 480min). This check only runs when a friends-mode organizer has overridden the stop count — couple-mode plans never trigger it.

Trim creates a new room

POST /room/{code}/trim re-runs generation with the computed trimmed stop count and redirects to a new room code — it does not edit the original room in place. Server-side guarded: only fires when the stored plan state is actually time_overrun, the trim count is never taken from the client.

Per-person pricing

Couple mode shows "₹X for two"; friends mode shows "₹Y/person" — verified consistent across /plan/{code}, /dashboard, and /room/{code} by TestTotalPhrasingAgreesAcrossPages.

Visual identity

Friends Mode has its own accent color and icon set, distinct from the couple-mode palette.

Surprise Mode redaction

A plan is hidden from a viewer only if is_hidden=1 AND the plan has an owner AND the current viewer isn't that owner — a room guest with no login (nil viewer) counts as "not the owner," so guests are redacted too. Redaction replaces stop 2+'s name with "Surprise Stop N 🤫" and wipes description, note, price, links, image, and the activity ID (which also kills the venue-detail link). This happens in-memory on every render — the database always holds the full plan. An earlier bug that leaked the venue link even while the name was hidden was fixed on 2026-08-19.

Planning Engine & Venue Matching

Proximity clusters — a lookup table, not a distance algorithm

planner_engine.go is 29 lines. It hard-codes 5 clusters matching Delhi's real geography:

ClusterAreaZones
ACentral & HeritageConnaught Place, Chandni Chowk, Nizamuddin
BSouth DistrictHauz Khas, GK II, Lajpat Nagar, Saket, Mehrauli
CNorth & North-WestKarol Bagh, Rohini, Pitampura, North Campus (renamed from "GTB Nagar / Hudson Lane" 2026-08-30 — same zone ID, folds in Kamla Nagar's market)
DEast & Noida(single zone)
EWest & GurgaonCyber Hub, Dwarka

This exists to stop a plan from crossing the whole city, not to calculate travel time between two points.

Venue selection algorithm

suggestActivities tries an exact zone + budget + mood match first. If fewer than 3 venues qualify, it drops the budget filter and retries zone-only — the recorded matchQuality degrades from exactmood_relaxedbudget_relaxed. If still empty (or below a friends-mode override count), it returns "no match" rather than an incomplete plan. fillSlots seats a café/restaurant first, entertainment/outdoor second, then fills remaining slots against a running cumulative budget total — Budget ₹500, Mid ₹1500, Upscale ₹3000, Luxury ₹12000 (Luxury's cap is high enough to be effectively unconstrained).

Composition-aware planning for audited zones (new 2026-08-30)

The problem this fixes: the fallback logic above (drop budget, then drop mood) is good at guaranteeing 3 venues, but says nothing about what kind of venues they are. In practice this let a plan come back as two cafes and a restaurant — no real activity stop — or open on a ₹150 street-food stall before dinner. Neither is what "a café, then dinner, then something to do" (the one-paragraph pitch at the top of this doc) actually promises.

suggestComposedPlan (composition.go) enforces the real shape instead, for zones with an entry in zone_config.go — currently Hauz Khas, Connaught Place, and North Campus, chosen because those three have audited price and role data behind them (see the comments in zone_config.go for the actual venue counts per role, per zone). Every other zone is untouched — it keeps the exact fallback behavior described above.

Roles, not raw categories

stop_roles.go derives stop_role (one or more of "food", "activity", "dessert", "winddown") and is_food from each venue's category on every boot — a cafe or restaurant can never be "activity", that's not a per-row judgment call, the underlying rule has no path from either category to that role. A handful of hand-reviewed exceptions cover venues the category-only rule gets wrong (a dessert business mis-filed as a general cafe, one venue that's genuinely both food and dessert).

One food, one activity, one dessert-or-winddown

The engine picks the highest-vibe-scoring combination of exactly one venue per role that lands inside the zone's audited budget band for the requested tier, orders them so no two adjacent stops share a category, and refuses to open on a street stall or a sub-₹200 food/dessert stop.

Fails loud instead of substituting

If a zone genuinely has no eligible venue for a role (e.g. no activity-eligible venue at the requested budget), it returns a distinct ErrThinInventory rather than quietly serving a second cafe in the activity slot — the same "no match" page as before, but for a different, honestly-reported reason.

North Campus gets its own budget bands

Priced roughly 40% lower than Hauz Khas/Connaught Place based on its own price distribution, not the dataset-wide defaults — reflecting that it's a genuinely cheaper zone, not just a smaller one. It's also the one zone where "shopping" counts as the activity slot (the market), a rule specific to this zone rather than applied dataset-wide.

North Campus is running thin on activity supply

As of this write-up, North Campus has only 2 activity-eligible venues in the live catalog, and the zone is configured to fail loudly (ServeOnThinInventory: false) rather than degrade quietly if that pool empties out. One venue going inactive is a realistic way to start seeing "no match" for North Campus. This isn't a code problem — it's a data problem, and it's the reason a venue walk of Kamla Nagar/North Campus is the next planned step, not another engineering pass.

Two separate "venue" datasets — don't conflate them

Vibes

7 total: casual, impressive, luxury, lively, adventurous, quiet, romantic. Each maps to a bucket of mood tags scored against a venue's own tags.

Google Places caching

places.go wraps the Google Places API (New), gated on GOOGLE_PLACES_API_KEY — it disables itself gracefully (not a crash) if unset. Results cache for 30 days; a background, non-blocking refresh runs only for selected venues whose cache has gone stale, deduping concurrent refresh calls for the same venue.

Venue Profiles & Café Discounts

Venue profile pages

/venue/{id}, rendered by handleVenueDetail via templates/venue_detail.html. Shows Google rating, review count, hours, address, price level, and — if populated — insider tips.

Café discount redemption

Full flow documented in the System Flow Diagrams section: a physical QR card, a server-side Pro check, a green/red result page. Fully built, covered by 9 tests, currently empty of any real offer codes.

X-Factor / insider tips — verified empty

Directly queried the live database on 2026-08-27: SELECT COUNT(*) ... WHERE insider_tip != '' and the equivalent for x_factor and offer_text all return 0 of 366. The render path exists and works; there is simply nothing in it yet.

WhatsApp Emergency SOS

sos.go — Pro-gated at every call site (/plan/{code}, the Live Pass page, and the partner view via the plan owner's Pro status). "Same day" is evaluated against the plan's creation date in IST, not a separately scheduled date. Triggering it opens a plain wa.me link to a fixed business WhatsApp number, prefilled with the plan title, code, zone, and stop names, ending "Can you help us pivot right now?" — no in-app form, just a tap.

Templates, Frontend & CSP

HTMX — confirmed fully gone

Grepped every .go, .html, and .js file in the repo: zero references to hx- attributes or htmx. This matches the project's no-HTMX rule.

Tailwind CDN — still live, despite an earlier removal claim

The script tag <script src="https://cdn.tailwindcss.com"> still loads on room_new.html and plan.html. A commit on 2026-08-17 explicitly restored it on plan.html ("real root cause of badge overlap") after an earlier removal — so the old doc's "Unused JavaScript Removal" claim describes a change that was later reverted.

Content-Security-Policy — as actually set in main.go

main.go — CSP header, verbatim
default-src 'self';
script-src 'self' 'unsafe-inline' https://*.razorpay.com https://unpkg.com
           https://cdn.tailwindcss.com https://www.googletagmanager.com https://connect.facebook.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.razorpay.com;
font-src 'self' https://fonts.gstatic.com https://*.razorpay.com;
img-src 'self' data: https:;
connect-src 'self' https://www.google-analytics.com https://*.razorpay.com https://www.facebook.com;
frame-src 'self' https://*.razorpay.com;
frame-ancestors 'none'; form-action 'self'

img-src is deliberately broad (any https: host) — a 2026-08-17 commit widened it specifically to allow hotlinking venue images from Google Places. unpkg.com is a dead entry now that HTMX is gone and worth removing; cdn.tailwindcss.com is not dead, per above.

Templates (29 files in templates/, 6 in templates/partials/)

Grouped by area: marketing/SEO (index, hub, seo_blog, seo_venue, zone_page, privacy, terms, refund, contact), wizard/generation (plan_new_couple, plan_new_friends, no_match), plan/room viewing (plan, plan_share, room, room_new, room_join, live_pass, confirmation), auth (login, signup), venues (venue_detail, redeem), dashboards (dashboard, my_dates), misc (pricing, meta, schema, icons, whatsapp). Shared partials: page_shell, plan_wizard, plan_wizard_js, site_footer, site_header, theme_js.

Brand & Design System — no shared stylesheet, tokens duplicated per template

There is no .css file in the repo. Every template carries its own inline <style> block of CSS custom properties (this is what style-src 'unsafe-inline' in the CSP above exists for). Only 5 of 30 templates — index, plan_new_couple, plan_new_friends, room, room_join — pull their tokens from the shared page_shell partial; the other 25 (hub, pricing, venue_detail, login, signup, dashboard, legal pages, etc.) each hardcode their own copy inline. The two sets have drifted from each other — see below.

3 time-of-day themes, switched via data-theme

{{template "theme-js"}} in theme_js.html picks a theme from the client clock (day 06:00–16:00, evening 16:00–20:00, night 20:00–06:00) and sets document.documentElement.setAttribute('data-theme', …). The wizard lets a user override it explicitly; everywhere else the clock is the only input. Attribute values are not day/evening/night — day maps to data-theme="light", evening to data-theme="evening", night to data-theme="dark".

Verified token values — page_shell.html (index, wizard, room pages)
Day    (:root, default)      bg #F3F2EE   accent #8C7355   heading Cormorant Garamond
Evening (data-theme="evening") bg #16111D   accent #E29578   heading Cormorant Garamond
Night   (data-theme="dark")    bg #0D0B0A   accent #DE9E48   heading Playfair Display
Body font all 3 themes: Inter
Verified token values — hub/pricing/login/venue_detail/signup/no_match (25 standalone pages)
Day    (:root, default)      bg #F3F2EE   accent #8C7355   heading Cormorant Garamond
Evening (data-theme="evening") bg #FDF0D6   accent #E07030   heading Playfair Display
Night   (data-theme="dark")    bg #0C0A09/#0D0B0A  accent #FFB347/#DE9E48  heading Playfair Display
Body font all 3 themes: Inter

Drift, confirmed by grep across all 30 templates: the shared page_shell partial's Evening mode is a dark plum (#16111D, Cormorant Garamond heading) — but every one of the 25 standalone templates independently defines Evening as a warm cream (#FDF0D6, Playfair Display heading). Day and Night agree everywhere. Practically: the homepage and the plan wizard render a dark evening theme; every other page (pricing, venue pages, dashboard, login, legal) renders a cream evening theme. This isn't a bug being tracked — it's the actual current behavior, and worth knowing before assuming "the evening theme" means one thing across the site.

Friends-mode accent override — page_shell only

html[data-theme][data-mode="friends"] selectors in page_shell.html swap the accent to teal/green (#0E6E5C day, #26A69A evening, #2DD4BF night) when a room is in friends mode. Grep confirms data-mode="friends" exists only in page_shell.html — so this override only reaches the 5 shell-based pages (index, both wizard funnels, room, room_join). The 25 standalone pages have no friends-mode selector at all and always render the default earthy/orange/gold accent regardless of mode.

Core tokens (page_shell.html, Day defaults)

page_shell.html — :root token list, verbatim
--bg-main: #F3F2EE;      --bg-surface: #FFFFFF;   --bg-surface-low: #FAFAF8;
--bg-surface-high: #EEEDE9;  --text-primary: #19191B; --text-secondary: #2C2C2E;
--text-muted: #63615D;   --accent: #8C7355;       --accent-ink: #6E5940;
--accent-glow: rgba(140,115,85,0.1);  --accent-hover: rgba(140,115,85,0.15);
--border-ui: rgba(25,25,27,0.08);     --border-active: #8C7355;
--shadow-card: 0 8px 40px rgba(0,0,0,0.04);
--font-heading: 'Cormorant Garamond', serif;   --font-body: 'Inter', sans-serif;

Fonts load from Google Fonts (fonts.googleapis.com/fonts.gstatic.com, whitelisted in the CSP above): Cormorant Garamond, Playfair Display, Inter, plus the Material Symbols Outlined icon font — used in 13 of 30 templates for nav icons, the theme toggle, and inline UI icons. auto_awesome (✨) is the primary/most-used icon per the brand guide. Theme transitions animate over 0.6s cubic-bezier(0.16,1,0.3,1) on background/color, and headings transition font-family over the same curve when the theme swaps typefaces.

Security & Concurrency Controls

This page previously leaked live production secrets

An earlier version of this exact file had a real Razorpay secret and the app's session-signing key hardcoded in a ".env example" block, published on a public URL. Both values have been removed from this page. If they have not yet been rotated in production and purged from this repo's git history, treat that as unfinished, not done.

CSRF & Origin verification

isValidOrigin() checks the Origin/Referer header against an allowlist for all state-changing requests, with the WhatsApp-webview and webhook exemptions noted in the middleware section.

Defense-in-depth headers

X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, HSTS, full CSP above.

Fail-closed session blacklist

A database error while checking session_blacklist is treated as "blacklisted" — a deliberate paranoid choice, not an oversight.

SQL injection prevention

Parameterized queries throughout; no string-concatenated SQL found.

Boot-time credential + dev-mode guards

Server refuses to start without required Razorpay/signing credentials, and refuses to start if the dev-mode OTP backdoor is combined with a live payment key.

Ownership fails closed

A plan with no owner (user_id IS NULL) can't be claimed by anyone, logged in or not.

2026-08-30 — six issues found and closed before the anon-generation change shipped

Reinstating anonymous generation was reviewed for what it could break, not just whether it built and passed tests. Six real issues surfaced across two review passes; all six were fixed and each has a test proving the fix, before this landed on main.

Anon plans were never claimed on a returning login

claimAnonPlans originally only ran on new-account creation. A returning user logging back into an existing account never triggered it — their pre-login anon plans were permanently, silently unreachable. Now runs on every login.

Anon quota was charged before the plan existed

The 20/day counter incremented at the rate-limit check, before createItinerary ran — a validation failure or a thin-inventory zone burned a real day's allowance for a plan the caller never got. Now charged only after success.

A sliding rate-limit window that never actually closed

The first version of the new rate-limit buckets reset off "time since the last hit" — under continuous traffic (exactly the abuse case they exist to stop), a tripped limit would never expire. Rewritten to a fixed window anchored on when it opened.

The global capacity ceiling counted rejected requests

Charging the site-wide 300/hour ceiling on every attempt — including ones already rejected by their own per-user quota — meant exactly the caller the ceiling exists to survive (one retrying past their own limit) would exhaust it fastest. Split into a free peek and a charge-on-success.

An empty anon session could rate a stranger's plan

itineraries.anon_session_id defaults to '', not NULL, on every plan created before this column existed. A rating request from a caller with no anon cookie at all produced an empty session ID that matched anon_session_id = '' — every one of those legacy rows, by code alone. Fixed with an explicit empty-string guard.

A free, unthrottled way to manufacture unlimited quota

An earlier version of the anon-cookie minting logic minted on any /room/{code} or /plan/{code} view. Since that needs no valid code and no existing cookie, it was an unthrottled side door around the 20/day cap — mint via a view, spend the cookie on /generate, repeat. Full description and the closing fix in flow diagram 6.

Test Suites & CLI Tools

Test files (~180 test functions total, as of 2026-08-30)

File~TestsWhat it covers
main_test.go90The bulk of the suite — schema, payments, auth, CSRF, general handler coverage. Grew from 81 on 2026-08-30 with anon-session, mint-throttle, global-ceiling, and legacy-plan-safety tests.
composition_test.go (new 2026-08-30)7The composition planner: at-most-one-food-stop, both other roles covered, no adjacent same-category stops, no cheap opener, budget-band matching.
rooms_test.go24Room creation, join, suggest, trim.
phase7_test.go10Phone normalization, session hydration, CAPI event-ID matching.
offers_test.go9Café discount redemption flow.
phase8_test.go8Email suppression, IST daily send-cap boundaries.
entitlement_test.go8proActive() logic — the entitlement bug fix.
suggestions_test.go8Venue-matching fallback degradation.
room_engine_test.go6Stop-count clamping, dwell timing, feasibility banners.
clock_test.go4The time-freeze test seam.
blast_radius_test.go2Changed templates still render at every call site; signup errors keep the redirect.
templates_test.go3No duplicate form IDs, theme data-attribute present, landing page keeps its SEO surface.
csrf_test.go1CSRF exemption covers /room/* routes.
total_phrasing_test.go1Pricing phrasing agrees across plan/dashboard/room.
sos_test.go1SOS gate on the plan view.
suggestions_sweep_test.go1Combinatorial sweep across zones/vibes/budgets for complete itineraries.
Running tests
go test -v ./...
go test -race -count=1 ./...

CLI subcommands — corrected: these are string subcommands, not flags

The old doc's -reconcile-payments/-downgrade-pro -dry-run/-merge-dry-run syntax is wrong. The real invocation reads os.Args[1] directly:

Actual invocation
go run . migrate
go run . reconcile-payments
go run . merge-accounts
go run . offer-add
go run . offer-list
go run . offer-remove
go run . offer-stats
go run . suppress-lifecycle-backlog

Developer Quickstart

Never paste real secrets into this file

Every value below is a placeholder. This exact page previously had real production values here — do not repeat that. Pull real values from the actual VPS .env through a secrets-safe channel, never committed to this docs repo.

.env — placeholders only
PORT=8081
PLANKRO_DEV_MODE=1
STITCH_SIGNING_KEY=<generate-a-new-64-char-hex-secret>
RAZORPAY_KEY_ID=<your-razorpay-key-id>
RAZORPAY_SECRET=<your-razorpay-secret>
RAZORPAY_WEBHOOK_SECRET=<your-webhook-secret>
RAZORPAY_PLAN_PRO=<your-razorpay-plan-id>
GMAIL_USER=<your-email@gmail.com>
GMAIL_APP_PASSWORD=<your-gmail-app-password>
GMAIL_LIFECYCLE_USER=<optional-separate-lifecycle-email>
GMAIL_LIFECYCLE_PASSWORD=<optional-separate-app-password>
LIFECYCLE_EMAILS_ENABLED=1
META_PIXEL_ID=<your-pixel-id>
META_CAPI_TOKEN=<your-capi-token>
GOOGLE_PLACES_API_KEY=<your-places-key>
GOOGLE_ANALYTICS_ID=<your-ga4-id>
GOOGLE_ADS_ID=<your-ads-id>

Running locally

Terminal
go run .
# or
go build -o stitchplanner .
./stitchplanner

Dev-mode test login

Only reachable when PLANKRO_DEV_MODE=1 and the Razorpay key is a rzp_test_ key (the app refuses to boot otherwise). Emails: tester@plankro.com, test@plankro.com, admin@plankro.com. Codes: 123456, 000000, 777777.

Recent Engineering Timeline

Pulled from actual git history, not reconstructed. Two clusters of work stand out:

2026-08-30 — Anonymous generation reinstated + composition-aware planner

Business reason: the 08-16 account wall traded away every visitor who wouldn't sign up before seeing a plan. This reopens the top of the funnel — try first, account only needed to keep or come back to a plan — while adding real defenses (per-session, per-IP, and site-wide rate limits) the original guest-mode era never had. Technical changes: signed plankro_anon sessions with claim-on-login, a composition-aware planner enforcing one food/one activity/one dessert stop in 3 audited zones (composition.go, stop_roles.go, zone_config.go), zone 15 renamed "North Campus" with its own budget bands, and the free tier's 3-plan lifetime cap removed entirely. Process: reviewed for blast radius before and after implementation — found and closed 6 real issues (claim-on-login gap, charge-before-success, a sliding rate-limit window, a ceiling that counted rejected attempts, an empty-anon-ID rating vulnerability, and a free-mint bypass) — see Security & Concurrency for the full list, each with its own regression test.

2026-08-24 to 08-27 — Rooms & Friends Mode

Unified every plan into a room, gated the funnel behind an account, made friends mode its own product with a distinct visual identity, enforced Pro expiry at request time, added café offer redemption, built the stop-count override + feasibility + trim engine, and shipped mode-aware per-person pricing. This is the newest and most actively developed part of the codebase.

2026-08-16 to 08-19 — "Stage 2–5" hardening pass

Required an account to generate a plan, tightened ownership checks to deny NULL-owner plans, added per-email OTP rate limiting, gated the dev backdoor, shipped true graceful shutdown with drained async email/CAPI queues, added SMTP timeouts, pruned dead indexes/columns, added venue cover images and the /venue/{id} profile hub, and fixed a Surprise Mode redaction leak.