VeePeenini, Part 6: Quality of Life
· Vitor Pontual · 5 min read
With the game fair and the lounge live, the rest of the work was quality of life. None of it is headline material. All of it decides whether the app earns a spot on the home screen or gets swiped off it. This is the stuff I find most satisfying to get right, because it’s invisible when it works and grating when it doesn’t.
Push notifications and an app-icon badge
A trade is a social act. Someone makes you an offer and then waits. If you don’t know it’s there, the whole loop stalls. So the app got web push: you get pinged when someone offers, counters, accepts, or declines a trade. And the installed app icon carries a number badge, the same red dot you know from every other app, that works even while the app is closed.
The service worker handles the push and the badge together:
// file: public/sw.js
event.waitUntil(
(async () => {
await self.registration.showNotification(title, {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
tag: data.tag,
renotify: true,
data: { url },
});
if (typeof data.badgeCount === "number" && "setAppBadge" in self.navigator) {
try {
if (data.badgeCount > 0) {
await self.navigator.setAppBadge(data.badgeCount);
} else {
await self.navigator.clearAppBadge();
}
} catch {
// Badging unsupported or PWA not installed — non-fatal.
}
}
})(),
);
Two product decisions hid inside this. First, the badge counts both incoming trades and stickers you’ve won but haven’t placed yet, not unopened packs. Packs pile up and would keep the badge permanently lit, which trains people to ignore it. Incoming trades and unplaced stickers are things a person actually needs to act on. Second, iOS only allows push for a PWA that’s been added to the home screen, so the notifications toggle detects a plain browser tab and shows install instructions instead of a dead switch that silently does nothing. A toggle that lies is worse than no toggle.
The send path had its own small war story. The home server intermittently fails to resolve the push gateway’s DNS, so a single send can fail for reasons that have nothing to do with the subscription. Dead subscriptions get pruned forever; transport errors get one retry.
// file: src/lib/push.ts
if (status === 404 || status === 410) {
// Subscription is gone for good — prune, never retry.
dead.push(r.endpoint);
return;
}
// No HTTP status === transport/DNS error (the container hits
// occasional EAI_AGAIN). Retry once before giving up on this push.
if (status === undefined) {
try {
await webpush.sendNotification(subscription, body);
sent += 1;
return;
} catch (retryErr) {
console.error("[push] send failed (after retry):", (retryErr as Error).message);
return;
}
}
Trade notes
Trades got a one-line note you can attach to an offer or a reply. “Pleasure doing business.” “You’re robbing me here.” It’s a tiny thing, and it’s exactly the kind of human texture that spills the group chat into the app. There’s a single place that cleans every note, so there’s only one rule for what a message can be, enforced once instead of in four different routes:
// file: src/lib/game/trade-executor.ts
export function normalizeTradeMessage(
message: string | null | undefined,
): string | null {
if (message == null) return null;
const collapsed = message.replace(/\s+/g, " ").trim();
if (collapsed.length === 0) return null;
return collapsed.slice(0, TRADE_MESSAGE_MAX);
}
The trade marketplace, defaulting to “cards you need” so you aren’t scrolling hundreds of names trying to spot the ones that matter.
Knowing who actually showed up
The app could tell me someone’s streak, how many days in a row they’d claimed a pack, but it couldn’t tell me which exact days they’d shown up. A counter isn’t a history. So I added an append-only attendance log, written from the auth session callback. The important property is that it can never get in the way of logging in: it dedupes in memory so it writes at most once per person per day, and if the write fails it swallows the error and lets a later request retry.
// file: src/lib/game/daily-login.ts
if (seenUsers.has(userId)) return;
seenUsers.add(userId);
try {
await db.execute(sql`
INSERT INTO daily_logins (user_id, login_date)
VALUES (${userId}, ${today}::date)
ON CONFLICT (user_id, login_date) DO NOTHING
`);
} catch (e) {
seenUsers.delete(userId); // let a later request retry
console.error("recordDailyLogin failed:", e);
}
It’s the kind of data you always wish you’d started keeping on day one, so I started keeping it. Forward from here it’s exact; the past is gone.
A deploy that can move
Last thing. I have a move coming up, which means the app will need to shut down on the server it runs on now and come back up at a second location on different hardware. Rather than treat that as a future fire drill, I had the deploy taught to target more than one host, so relocating the whole thing is a config change instead of a rebuild. It’s cheap to do calmly now and miserable to do under pressure later. A lot of good infrastructure work is just paying small costs early so you don’t pay large ones at the worst time.
That’s the build, more or less caught up to today. The last post is the one I can’t finish yet, because it hasn’t happened: the tournament itself. Part 7 is what I’m watching for.