How To Read This Doc
Written for someone who can read code and reason about a system, but hasn't written Go before. Every Go-specific term is explained the first time it shows up.
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.
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
| Term | What it means here |
|---|---|
| Handler | A 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. |
| Middleware | A 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. |
| Goroutine | Go'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 token | A 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. |
| Migration | A 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. |
| Sweep | A 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. |
| Webhook | An HTTP request that Razorpay (the payment provider) sends to this server, unprompted, when something happens on their side (a payment clears, a subscription renews). |
| CSRF | Cross-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. |
| Template | An 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. |
Architecture Overview
One Go binary, one SQLite file, server-rendered HTML. No microservices, no queue, no ORM, no frontend 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.
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.
₹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.
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.
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.
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
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.
Feature Tracker
Every feature the product advertises, checked directly against the code and the live database on 2026-08-27. Not rounded up — "code exists but has no data to show" is marked as such, not as built.
| Feature | Status | Evidence | Note |
|---|---|---|---|
| Zone-based planning (proximity clusters) | Built | planner_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 matching | Built | vibes.go — 7 vibes (casual, impressive, luxury, lively, adventurous, quiet, romantic) scored against each venue's mood tags | — |
| 4 budget tiers | Built | suggestions.go: getMaxCumulativeBudget — Budget ₹500 / Mid ₹1500 / Upscale ₹3000 / Luxury ₹12000 | Luxury's ₹12,000 cap is so high it's effectively uncapped in practice. |
| Composition-aware plan structure (one food, one activity, one dessert) | Built | composition.go, stop_roles.go, zone_config.go — new 2026-08-30 | Only 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 generation | Built | POST /generate → createItinerary | Changed 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) | Built | Signed plankro_anon cookie, anon_generations table, 20/day per session | The 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 cap | Built | users.free_generations_used now an uncapped counter, not a quota | The 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) | Built | payments.go, webhooks.go, live rzp_live_ key in prod | Real money moves through this path — see Security section. |
| Date Rooms (every plan is collaborative) | Built | rooms.go, room_engine.go, /room/{code} | Shipped 2026-08-21 to 08-24. |
| Friends Mode (stop stepper, dwell timing, feasibility, trim) | Built | room_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 pricing | Built | total_phrasing_test.go: TestTotalPhrasingAgreesAcrossPages | Test explicitly checks all 3 surfaces (plan/dashboard/room) agree. |
| Editorial venue profile pages | Built | /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 Mode | Built | surprise_mode.go, redaction enforced in handleRoomView | An earlier venue-link leak in redaction was fixed 2026-08-19. |
| Pro: WhatsApp Emergency SOS / live backup | Built | sos.go, gated on user.IsPro at every call site | Only active for plans created (not scheduled) the same calendar day. |
| Pro: "The X-Factor" / insider tips | Code built, data missing | 3 DB columns (insider_tip, x_factor, pro_tips) fully wired into the query and into venue_detail.html's render logic | 0 of 366 venues have any of these fields populated — verified directly against the live DB. A Pro user sees nothing here today. |
| Pro: Café discounts | Code built, no live offers | offers.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 built | No reservation table, no handler, no notification-to-human path found anywhere in the codebase | Exists only as a sentence in lifecycle-email marketing copy ("we pre-book the table"). This is pure copy, not a feature. |
| Per-feature Pro gating | Partial | entitlement.go: proActive() is one global yes/no flag | There's no per-feature entitlement system. Shipping "X-Factor is Pro-only, discounts are free" later needs new code, not a config flip. |
System & Business Flow Diagrams
The five flows that matter most: a request's trip through the server, becoming Pro, the lifecycle email sweep, generating a room, and redeeming a café discount.
logging → recovery (catches panics so one bad request can't crash the process) → securityHeaders → csrf (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).http.HandleFunc("METHOD /path", fn)) matches the request to one handler function. See the full route directory below.goSendEmail) so the user's response isn't held up waiting on Gmail or Meta's API.html/template, or a JSON body for the handful of API endpoints (payments, OTP).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.is_pro=1 and pro_expires_at are set.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.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.EmailWelcome fires once on signup. EmailPlanReady fires once when a plan is generated. Neither waits for the timer below.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.email_optout flag suppresses everything else, no login required to set it.email_sends (claimSend) means a user gets exactly one email per type, even if the sweep restarts mid-run.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.
/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.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 exact → mood_relaxed → budget_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.anon_session_id instead of a user_id.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.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./r/{token}.recordRedemption logs the redemption in offer_redemptions to cap repeat use per user/venue/day.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.
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.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.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.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:
| File | What it actually does |
|---|---|
| Server core | |
main.go | Boot sequence, all route registrations, the middleware chain, CLI subcommand dispatch, graceful shutdown. |
database.go | SQLite connection setup, full schema, every migration, seeding. |
models.go | Plain data structs: Zone, Activity, Itinerary, ItineraryItem, Participant, Suggestion, User. |
clock.go | 9 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.go | Session cookie signing/verification, bcrypt password hashing, logout blacklist. |
| HTTP handlers | |
handlers.go | The bulk of page and API handlers — home, plan viewing, checkout, partner view, ICS export, safeRedirect. |
handlers_otp.go | OTP request/verify endpoints, per-email rate limiting, the dev-mode test-account backdoor. |
otp.go | OTP code generation and hashing, sending the OTP email. |
rooms.go | Room view, join, suggest, and trim handlers. The collaborative-planning surface. |
room_engine.go | Stop-count ranges, dwell-time calculation, feasibility/overrun detection, trim-target logic. |
zonepages.go | Neighborhood landing pages (/date-plans/{slug}) and their nearby-zone recommendations. |
hubs.go | Blog hub and date-plans hub listing pages. |
sitemap.go | XML sitemap generation for /sitemap.xml. |
| Planning & venues | |
planner_engine.go | 29 lines. A static lookup table mapping each of the 15 zones to one of 5 geographic clusters — not a distance algorithm. |
suggestions.go | Venue 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.go | The 7 vibe tags and their scoring against venue mood tags. |
plan_mode.go | Couple/friends mode constant + normalization (unknown modes default to couple). |
surprise_mode.go | Redaction logic for Surprise Mode — hides stops 2+ from non-owner viewers. |
venues.go | Hand-written SEO blog content for 10 venues. Not the planner's venue pool — see below. |
venues_batch_three.go, venues_batch_four.go | The actual planner venue seed data — INSERT INTO activities statements. 366 venues total. |
places.go | Google Places API caching (30-day TTL), disables itself gracefully if no API key is set. |
phone.go | Indian phone number normalization, feeding Meta CAPI phone hashing. |
| Money | |
payments.go | Razorpay subscription creation, granting/renewing Pro. |
webhooks.go | Razorpay webhook listener — 7 event types handled. |
entitlement.go | The single source of truth for "is this user Pro right now" — computed live, not cached. |
entitlement_backfill.go | One-time migration fixing rows where is_pro=1 but expiry was left NULL. |
pro_downgrade.go | Daily cleanup sweep for expired Pro users — a consistency job, not the source of truth (that's entitlement.go). |
offers.go | The café discount QR redemption system, plus its CLI management commands. |
reconcile.go | CLI tool that calls the Razorpay API directly to fix orders/subscriptions that never got a webhook. |
merge_dry_run.go | Finds case-duplicate accounts (abc@x.com vs ABC@x.com) before merging them for real. |
| Communication & tracking | |
lifecycle.go | The 6-sweep behavior-triggered email engine described above. |
emails.go | Real Gmail SMTP sending (not a third-party ESP), the tracked async dispatch chokepoint. |
tracking.go | Server-side Meta Conversions API (CAPI) sender. |
meta.go | Not Meta Graph API config, despite the name — it's the PageMeta struct for SEO/Open Graph tags. |
sos.go | Pro-gated WhatsApp emergency link generator. |
main.go — Server Entrypoint & Middleware
Startup sequence
loadEnv()reads.envinto the process (no-op if the file is missing).- Boot fails immediately if
RAZORPAY_KEY_ID,RAZORPAY_SECRET,STITCH_SIGNING_KEY, orRAZORPAY_WEBHOOK_SECRETare unset. New: it also fails ifPLANKRO_DEV_MODE=1is combined with a live (rzp_live_) Razorpay key — that combination would leave the hardcoded-OTP test login reachable in production. - 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. initDB()— opens SQLite, runs schema + migrations.- Loads eligible zone landing pages, initializes the Google Places client, parses all templates.
- Starts two background schedulers: the lifecycle email sweep and the Pro-downgrade sweep.
- Registers every route (see the Route Directory) and wraps the mux in the middleware chain.
- 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
logging → recovery → securityHeaders → csrf → googleVerify → rateLimit → auth
| Layer | What it does |
|---|---|
logging | Logs method, path, remote address, and duration for every request. |
recovery | Catches panics so one broken request can't take down the whole process. |
securityHeaders | Sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, CSP, and HSTS (HTTPS only). |
csrf | On 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). |
googleVerify | Serves /google*.html Search Console verification files directly off disk. |
rateLimit | In-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. |
auth | Reads 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
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
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
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
| Table | Purpose |
|---|---|
zones | The 15 Delhi neighborhoods (id, name, display name, coordinates, vibe tags). |
activities | The 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. |
users | Account, password hash, and every migrated addition: plan type, free/monthly generation counters, phone, opt-ins, pro_expires_at, display_name, legacy_lifetime. |
itineraries | The 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_items | One 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, subscriptions | Razorpay order/subscription records. subscriptions.last_payment_id prevents double-crediting a renewal. |
session_blacklist | HMAC'd tokens of logged-out sessions, checked (fail-closed) on every request. |
otps, otp_request_log | OTP codes (hashed) and the per-email rate-limit log. |
schema_meta | Key/value store — currently holds the venue-data content hash used to skip re-seeding on unchanged boot. |
favorite_venues | User-saved venues. |
email_sends | One row per lifecycle email actually sent — the unique constraint that makes claimSend safe against sweep restarts. Has a suppressed flag. |
participants | Room membership — one row per person who's joined a room, including the creator. |
room_suggestions | Free-form suggestions left by room participants, with status/resolved_at. |
offer_redemptions | Café 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.
| Method | Path | Handler | What it does |
|---|---|---|---|
| Marketing & misc pages | |||
| GET | / | handleHome | Landing page / plan wizard entry. |
| GET | /robots.txt, /sitemap.xml, /favicon.ico | inline / handleSitemap | Standard site files. |
| GET | /no-match | handleNoMatch | Shown when the wizard finds no venues for a combination. |
| GET | /pricing, /privacy, /terms, /refund, /contact | respective handlers | Static content pages. |
| GET | /blog, /blog/{slug}, /blog/venue/{slug} | handleBlogHub, handleBlogPage, handleVenuePage | SEO blog content — separate from the planner's real venue pool. |
| GET | /date-plans, /date-plans/{slug}, /date-plans/{slug}/ | handleDatePlansHub, handleZonePageRedirect, handleZonePage | Neighborhood landing pages for SEO. |
| GET | /venue/{id} | handleVenueDetail | Editorial venue profile page. |
| Plan generation & viewing | |||
| GET | /plan/new | handlePlanNew | The wizard, mode-branched via ?mode=couple|friends. |
| POST | /generate | handleGeneratePlan | Runs 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} | handleViewPlan | Plan view. Owner check now also matches on anon_session_id for a plan generated without an account (2026-08-30). |
| GET | /plan | handleDeepLinkPlan | Query-param deep link resolver. Same 2026-08-30 change as /generate — no login required. |
| POST | /plan/{code}/surprise | handleToggleSurprise | Owner-only toggle for Surprise Mode. |
| GET | /plan/{code}/export/ics | handleExportICS | Calendar file export. |
| GET | /plan/{code}/confirmation, /date/{code}/confirmation | handleConfirmationPage | Post-generation confirmation screen. |
| Date Rooms | |||
| GET | /room/{code} | handleRoomView | Universal landing page for any plan — every plan is a room. |
| POST | /room/{code}/join | handleRoomJoin | Guest sets a display name and gets a participant cookie. |
| POST | /room/{code}/suggest | handleRoomSuggest | Participant leaves a free-form suggestion. |
| POST | /room/{code}/trim | handleRoomTrim | Friends Mode: creates a new, shorter room when the schedule overruns. |
| Partner sharing (separate from Rooms) | |||
| GET | /p/{code} | handlePartnerView | Token-guarded read-only partner view. |
| POST | /p/{code}/rsvp | handlePartnerRSVP | Yes/no/maybe RSVP; token constant-time-compared, 7-day link expiry. |
| Auth & OTP | |||
| GET/POST | /signup, /login | respective handlers | Standard forms. |
| POST | /logout | handleLogout | Blacklists the session token. |
| POST | /api/request-otp, /api/verify-otp | handleRequestOTP, handleVerifyOTP | Passwordless OTP login/verify. |
| GET | /unsubscribe | handleUnsubscribe | One-click lifecycle email opt-out. |
| Payments | |||
| POST | /api/create-order | handleCreateOrder | Creates a Razorpay subscription despite the "order" name. |
| POST | /api/verify-payment | handleVerifyPayment | Verifies the Razorpay signature and grants Pro. |
| POST | /api/razorpay/webhook | handleRazorpayWebhook | 7 event types, HMAC-verified. |
| GET | /r/{token} | handleRedeem | Café discount QR redemption. |
| Dashboard & misc | |||
| GET | /dashboard, /my-dates | handleDashboard, handleMyDatesDashboard | Logged-in user's plan history. |
| POST | /my-dates/rate, /my-dates/favorite | handleRateItinerary, handleToggleFavoriteVenue | Post-date feedback and favoriting. |
| GET | /date/{code}/live | handleDigitalProPassLive | Live Pass page, Pro-gated. |
| GET | /health | inline | Uptime/DB connectivity check. |
/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
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 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.
bcrypt, cost factor 12.
CSRF tokens (csrf:), unsubscribe links (unsub:), room participant tokens (participant:) — the prefix stops one token type from being replayed as another.
safeRedirectActually lives in handlers.go, not auth.go. Blocks protocol-relative open-redirect attempts (//evil.com, /\evil.com) via regex, allowing only same-site paths.
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.
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.
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.
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.
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.
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
| Event | Action |
|---|---|
payment.captured | handlePaymentCapturedEvent |
refund.created, refund.processed | handleRefundEvent |
subscription.activated | handleSubscriptionStatusEvent |
subscription.charged | handleSubscriptionChargedEvent — extends pro_expires_at by 30 days, keyed on last_payment_id to prevent double-crediting |
subscription.halted | handleSubscriptionStatusEvent |
subscription.cancelled | handleSubscriptionStatusEvent |
subscription.completed | handleSubscriptionStatusEvent |
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:
is_pro = 0→ never active, full stop.legacy_lifetime = 1→ always active (grandfathered one-time payers from before the subscription model).- A
NULLexpiry on a non-grandfathered Pro user → not active (previously the bug:NULLwas silently treated as lifetime).
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
| Tool | What it fixes |
|---|---|
reconcile.go / reconcile-payments | Calls the Razorpay API directly to find and fix orders/subscriptions that never received a webhook. |
merge_dry_run.go / merge-accounts | Finds and merges case-insensitive duplicate accounts (a legacy artifact from before emails were lowercased on signup). |
pro_downgrade.go | Daily 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.
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.
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.
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.
A separate googleTags template function (in main.go, not tracking.go), lazy-loaded via requestIdleCallback. Reads GOOGLE_ANALYTICS_ID/GOOGLE_ADS_ID.
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
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.
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.
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.
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.
Couple mode shows "₹X for two"; friends mode shows "₹Y/person" — verified consistent across /plan/{code}, /dashboard, and /room/{code} by TestTotalPhrasingAgreesAcrossPages.
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:
| Cluster | Area | Zones |
|---|---|---|
| A | Central & Heritage | Connaught Place, Chandni Chowk, Nizamuddin |
| B | South District | Hauz Khas, GK II, Lajpat Nagar, Saket, Mehrauli |
| C | North & North-West | Karol Bagh, Rohini, Pitampura, North Campus (renamed from "GTB Nagar / Hudson Lane" 2026-08-30 — same zone ID, folds in Kamla Nagar's market) |
| D | East & Noida | (single zone) |
| E | West & Gurgaon | Cyber 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 exact → mood_relaxed → budget_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.
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).
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.
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.
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.
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
venues.go'svenueDatamap — 10 hand-written long-form SEO blog entries. Not used by the planner.venues_batch_three.go/venues_batch_four.go— the real planner pool, seeded into theactivitiestable. 366 total venues, 348 active, across 15 zones.
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/{id}, rendered by handleVenueDetail via templates/venue_detail.html. Shows Google rating, review count, hours, address, price level, and — if populated — insider tips.
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.
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
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".
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
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)
--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
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.
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.
X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, HSTS, full CSP above.
A database error while checking session_blacklist is treated as "blacklisted" — a deliberate paranoid choice, not an oversight.
Parameterized queries throughout; no string-concatenated SQL found.
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.
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.
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.
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.
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.
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.
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.
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 | ~Tests | What it covers |
|---|---|---|
main_test.go | 90 | The 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) | 7 | The composition planner: at-most-one-food-stop, both other roles covered, no adjacent same-category stops, no cheap opener, budget-band matching. |
rooms_test.go | 24 | Room creation, join, suggest, trim. |
phase7_test.go | 10 | Phone normalization, session hydration, CAPI event-ID matching. |
offers_test.go | 9 | Café discount redemption flow. |
phase8_test.go | 8 | Email suppression, IST daily send-cap boundaries. |
entitlement_test.go | 8 | proActive() logic — the entitlement bug fix. |
suggestions_test.go | 8 | Venue-matching fallback degradation. |
room_engine_test.go | 6 | Stop-count clamping, dwell timing, feasibility banners. |
clock_test.go | 4 | The time-freeze test seam. |
blast_radius_test.go | 2 | Changed templates still render at every call site; signup errors keep the redirect. |
templates_test.go | 3 | No duplicate form IDs, theme data-attribute present, landing page keeps its SEO surface. |
csrf_test.go | 1 | CSRF exemption covers /room/* routes. |
total_phrasing_test.go | 1 | Pricing phrasing agrees across plan/dashboard/room. |
sos_test.go | 1 | SOS gate on the plan view. |
suggestions_sweep_test.go | 1 | Combinatorial sweep across zones/vibes/budgets for complete itineraries. |
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:
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
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.
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
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:
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.
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.
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.