A private date planner for couples. Calendar + Google Map + a list, with each
partner rating a date blind after the fact. Live at
https://kjubikstronk.github.io/dateideas/ — Firebase project dates-8910a.
Two couples use it today: mck097@gmail.com + alice@dates.local (couple
founders), and milan@obradovic.at + sarah@hutter.at (couple milan-sarah).
The site is public; the data is not. GitHub Pages can't restrict access, so the deployed page is an empty shell — a login box and nothing else. All privacy is enforced by Firestore rules on Google's servers. The Firebase and Maps keys are in the bundle deliberately; they're identifiers, not secrets. Sign-up is disabled in the console, so nobody can self-register.
Membership is a document, not a rule. members/{uid} holds one field,
coupleId; every date carries the same field and the rules compare them.
firestore.rules contains no UIDs at all.
To add a person: create the account in Firebase Auth, then create
members/{their-uid} with the right coupleId. No code, no rules edit, no
deploy. An account with no members doc sees "not paired up yet".
npm run rules # deploys firestore.rules from the repo — never paste into the console
npm run ui # preview mode: sample data, no Firebase, no login.env.local (keys) and the Firebase MCP need firebase login once.
Firestore rejects any query it can't prove is safe. An unscoped
collection(db,'dates') read is denied outright once the rules scope by
coupleId — not filtered, denied. Client query and rules must agree. This is
why the couples migration had to ship in two ordered steps.
Rules take effect instantly; data doesn't migrate itself. Deploying rules that require a field before backfilling that field locks everyone out. Deploy the app first, then tighten the rules.
Map from @vis.gl/react-google-maps shadows the global Map. It's
aliased to GoogleMap in DateMap.tsx. Don't un-alias it.
No hooks after an early return. This has bitten twice. Lint catches it —
run npm run lint before committing.
DateCard is memoised and keyed by id, so it survives every Firestore
snapshot. Any local state seeded at mount goes stale when the partner writes.
Seed on open, not on mount.
Dates are yyyy-MM-dd strings, never Date objects or timestamps. They sort
and compare correctly and can't drift by timezone. A date happening today is
not past — use <, not <=.
Fonts are self-hosted (@fontsource, imported in main.tsx). Never
re-add the Google Fonts <link>: it sends every visitor's IP to Google
before any consent, which German courts have ruled a GDPR breach. Bundled,
they are also precached, so the pixel type works offline.
One service worker only. vite-plugin-pwa owns it. Adding Firebase
messaging later needs injectManifest and a single combined worker, not a
second file at the same scope.
Places bills by the highest field tier requested. rating is the only
Enterprise-tier field left and buys just the 4.3 ★ on the map candidate card.
Detail lookups are cached per session — don't remove that, every map POI tap is
otherwise billable.
Y2K handheld device: 3px ink borders, hard offset shadows with no blur,
steps() easing only, border-radius: 0.
- ink
#1A1033· paper#FFE5F1· card#FFFDFE· hot#FF5CA8· deep#B31E67· lavender#B8A6FF· aqua#5BE0E6· mute#6B6480 - Hot pink is fills and borders only — it fails contrast as text. Pink text uses deep. Everything must pass WCAG AA.
- Mobile-first, 375px primary. Touch targets ≥44px. Inputs ≥16px or iOS zooms the page and won't zoom back.
- Emoji, not custom pixel art, for categories and weather. Decided; my hand-drawn set was rejected and rightly so.
- Never restyle Google's map tiles to the app palette — pink roads are
illegible. Dark themes use Google's own dark scheme (
colorScheme), which needs the real map ID inVITE_GOOGLE_MAPS_MAP_ID;DEMO_MAP_IDignores it. - Agenda entries are flat rows; a card lifts only while being worked on. The device bezel keeps the only 6px shadow.
Per-person, in localStorage, never Firestore. Picked from the bezel menu.
- Every colour lives in
src/theme.css.@themeis the default (pink); each other theme is a[data-theme="…"]block. TypeScript holds only ids, names, glyph grids and adarkflag — never a hex. - Tokens are roles, not colours.
lineborders,textbody copy,shadowhard shadows,on-filltext on a saturated fill,bezel/on-bezelthe case,rulehairline dividers. On pink several share#1A1033; on a dark theme they must not. Choose by what the colour is doing — a blind rename totexthas broken contrast three times. deepis "the sibling ofhotsafe as text", not "the darker one." On a dark ground it is lighter thanhot.- Contrast is a gate:
npm run contrastparsestheme.cssand checks every theme.npm run checkruns lint, contrast, tests and build. useThemeis a shared store (useSyncExternalStore). A per-componentuseStatelet the palette change while every glyph kept the old theme.- The glyph swaps with the theme via
PixelHeart. It must survive 8px (calendar markers): check withnode scripts/check-glyph.mjs <THEME>. <dialog>resets inherited colour toCanvasText, so.sheetsetscolorexplicitly. Don't remove it.- Spooky season (
src/lib/season.ts): the first open in October flips the theme to Halloween and the greeting (bats, moon) announces it, once a year. A theme the season applied goes back to pink in November; one picked from the menu stays. Test it by faking the date (Playwrightpage.clock).
- Probe security live rather than assuming: unauthenticated reads/writes of
dates,members,reportsmust all return 403. - The in-app browser cannot composite — CSS transitions freeze mid-flight,
requestAnimationFramenever fires, and Google Maps tiles never paint. Verify logic and inline styles, not appearance. Never schedule anything on rAF alone. - Safari engine check: with
npm run uirunning,npm run safaridrives WebKit on an emulated iPhone 13 and saves screenshots. It is not iOS (no home-screen or push behaviour), but it catches Safari rendering and CSS differences. The in-app browser can't show animation; this can. - Deployed and local bundle hashes differ legitimately (CI uses
npm install, notnpm ci). Use the Actions status to confirm a deploy, not a hash.
Notifications — planned, not started. Decided: GitHub Actions cron + FCM
(free, no Blaze upgrade). Girlfriend has an iPhone 13, so Web Push is viable,
but on iOS it works only from a home-screen install — never in a Safari tab.
Needs: combined service worker, permission asked after scheduling (not on load),
tokens on members/{uid} behind a narrow rules exception, daily sender, real
device testing.
Before any public release — three product decisions, not bugs:
- There is no sign-up flow. Every couple is manual work.
reportsare readable by all members — one couple's bug reports are visible to every other. Fine for people who know each other; a leak with strangers.- The Maps budget is €2. It would be exhausted fast at scale, and the map then silently dies for everyone.
Full release plan (store packaging, costs, organic social, TikTok and
Instagram ads): docs/release-plan.md.
Decided since: reports stay readable by everyone for now; later they go to a developer-only inbox. Worth doing at release:
- Maps (decided): Google while private (free allowance covers it). At launch, switch fully to MapLibre + OpenFreeMap tiles + Photon search: one map, no cap, no bill. Only loss is the ★ rating. NOT Google-until-the-cap: a map that dies mid-month drives people off, two maps is double the work, and Google's terms bar its place data on a non-Google map. Keep search as you type (debounce, 2+ letters, cache prefixes). Rough cost: €0 until thousands of users, ~€25–50/month at tens of thousands — cover it with paid themes as a supporter perk. Firebase leaves its free plan at that size too.
- Consent: Google Maps loads on every open, not only on the map tab. Load it lazily, or behind a "tap to load the map" notice; a privacy page must list Google (Firebase, Maps) and Open-Meteo. Account deletion, imprint.
- Old iPhones: Tailwind v4 needs Safari 16.4+ (iOS 16.4, spring 2023). Older devices render broken; consider an "update your iPhone" notice.
Also note: this is a web app. "Release on mobile" means Add to Home Screen. A store listing would need Capacitor-style wrapping, review, and Apple's $99/year.
Dropped deliberately — couple nameplate, "go again" duplicate button,
inline day-picking on someday cards, calendar .ics subscription, JSON export,
surprise-me dice, custom pixel stickers, photos on memories.
The owner is watching a weekly token budget. Prefer Sonnet for routine work and save Opus for security rules, race conditions and genuine debugging. Be economical, verify rather than assume, and say plainly when something is unverified.