A private date planner for two — a shared calendar and map where every pin knows which day you're going there.
GitHub Pages serves every site publicly. There is no setting that changes this on a personal account, so the site is not the secret — it's an empty shell. A stranger who finds the URL downloads a login screen and nothing else.
Everything real lives in Firestore behind three locks:
- Firestore rules reject any read or write from a UID that isn't one of your two. These run on Google's servers and cannot be bypassed from a browser.
- Sign-up is disabled in Firebase, so the public API key can't be used to register a new account.
- The Maps key is referrer-restricted to your domain, so it can't be lifted from the bundle and spent.
The Firebase config and Maps key are visible in the JS bundle. That's normal and unavoidable for any static site — Firebase treats the API key as a public identifier, not a credential. The rules are the security boundary.
Things only you can do. Work top to bottom.
- Create a project at console.firebase.google.com
- Build → Authentication → Get started → Email/Password → Enable
- Authentication → Users → Add user — one account per person
⚠️ Authentication → Settings → User actions → untick "Enable create (sign-up)" Skip this and anyone holding the public API key can register themselves in.- Build → Firestore Database → Create database → Production mode
- Copy each UID from the Users tab — they go in the guest list in step 3
- Project settings → General → Your apps → Web app — copy the config values
into
.env.local(see.env.example)
- In Google Cloud Console, select the same project Firebase created
- Enable Maps JavaScript API and Places API (New)
- Enable billing. The monthly free credit covers two people many times over, but a card is required.
- APIs & Services → Credentials → Create API key
⚠️ Restrict the key: Application restrictions → HTTP referrers → addhttps://<your-username>.github.io/*andhttp://localhost:5173/*⚠️ Billing → Budgets & alerts — set a small budget so a mistake pages you instead of billing you- Google Maps Platform → Map management → Create Map ID (Vector, JS) — the custom map styling needs this
Copy firestore.rules into Firestore → Rules → Publish,
with the UIDs from step 1.6 pasted into the guest list at the top.
The committed file is a template — nothing deploys it, you paste it into the
console by hand. Your filled-in copy lives in firestore.rules.local, which is
gitignored. UIDs aren't credentials, but there's no reason to publish them.
Adding a second person later is one line: create their user in
Authentication → Users, copy the UID, uncomment the second entry in us(),
paste it in, Publish. No migration, nothing lost, no other file changes.
- Create the repo and push
- Settings → Pages → Source: GitHub Actions
- Settings → Secrets and variables → Actions — add every
VITE_*key from.env.exampleas a repository secret - If your repo isn't named
dateideas, updatebaseinvite.config.tsto match — Pages serves project sites from/<repo>/
npm installnpm run devThen open http://localhost:5173/dateideas/ — note the path, it matches the GitHub Pages base.
Two couples share this one deployment without ever seeing each other's dates.
A members/{uid} document holds a coupleId, every date carries the same
field, and the rules compare the two. Membership is a document rather than a
list in the rules, which is the point: adding someone never means editing or
re-publishing the rules, and there are no UIDs in firestore.rules at all.
To add a person:
- Firebase → Authentication → Users → Add user (their password is yours to set)
- Create
members/{their-uid}withcoupleIdset to their couple's id
That's it. Nothing is deployed, nothing is edited. An account with no members
document sees "not paired up yet" rather than an empty app, so a half-finished
setup says so instead of looking broken.
Bug reports are deliberately not scoped to a couple — they go to whoever maintains the app, which is the point of the feature.
npm run rulesDeploys firestore.rules straight from the repo. They used to be pasted into
the console by hand, which is how they drifted from what's in git.
The app precaches its own shell with a service worker and keeps a Firestore cache on the device, so it opens and works with no signal — edits queue and sync when you resurface.
What still needs a connection, unavoidably: signing in for the first time (the check happens on Google's servers) and map tiles. Already being signed in survives offline, because the session token is stored locally.
Because a service worker serves the cached copy first, a new deploy would otherwise sit unnoticed. The app checks for updates every time it regains focus, and shows a pill when one is waiting — it never reloads on its own, since that tends to happen mid-sentence.
This is built mobile-first — the phone layout is the primary one, and the wide layout is the variation.
Add it to your home screen. Both of you should: iOS Safari → Share → Add to Home Screen; Android Chrome → menu → Add to Home screen. It then launches full-screen with no browser chrome and its own pixel-heart icon, which is most of the difference between "a website" and "an app".
The icons are generated from the same pixel grid the app draws, so they never drift from the design:
node scripts/make-icons.mjsThe layout deliberately reaches under the notch (viewport-fit=cover); the
safe-frame and safe-bottom utilities in theme.css keep anything tappable
clear of the notch and the home indicator.
The visual system lives in src/theme.css and nowhere else.
Components consume its tokens rather than inventing values.
The organising idea is that the app is a handheld device, not a document — a moulded bezel with a recessed screen. On a phone that's literal; on desktop the shell widens and the screen splits in two, which is what earns the side-by-side calendar/map layout.
Before changing any colour, re-run the audit — a palette this bright hides contrast failures well:
node scripts/check-contrast.mjsIt checks every real pairing including the faded /60 opacity variants, which
is where bright themes usually fail. It exits non-zero on a regression.
Two rules worth knowing before editing:
--color-hot(#FF5CA8) is for fills and borders only. On the pink background it lands around 2.2:1 and fails WCAG as text. Pink text uses--color-deep.- Motion uses
steps()easing, never smooth curves. Pixel art doesn't interpolate, and it's the detail that sells the era.
After deploying, confirm the lock actually holds:
- Open the live URL in a private window → a login box, nothing else. Check the Network tab: no Firestore documents should come down before you sign in.
- Firestore → Rules → Rules Playground: simulate a read as an unauthenticated user, and as some third UID. Both must be denied.