Fishing Journal

Written by

in

What I Asked For

Most fishing log apps fall into one of two camps: a spreadsheet with a species dropdown bolted on, or a full social network with leaderboards and sponsor placements. I wanted neither. I wanted the thing I’d actually reach for standing on a bank with wet hands — log the fish, log where I was, move on. No feed to scroll, no stranger’s 40-inch striper to feel bad about.

But “just a log” gets boring fast if it’s only a list. The problem worth solving underneath was: what turns a pile of timestamped rows into something that feels like it knows you’re a fisherman? Part of the answer was context — the app shouldn’t just accept “Largemouth Bass” as a string, it should know what a largemouth bass is: typical size, what it eats, when it’s biting, whether this particular one you just weighed is actually a personal-milestone fish or just a normal Tuesday. That meant building a real species reference — not calling out to some API mid-cast with spotty signal, just a curated list of California freshwater species baked into the app, so a catch detail page can tell you your 9-pound largemouth cleared the trophy line for that species without you knowing the number offhand.

The second piece was location. A log without a map is just a diary. I wanted GPS capture to be the path of least resistance — tap “use my location” while you’re standing there, not type in a lake name from memory later that night — with typed locations still working and getting geocoded for the times you’re logging from the couch. That turns into a map of everywhere you’ve caught something, which is a much better answer to “where should I fish this weekend” than my own memory is.

The last piece was other people, in a specific, narrow way. Not a public feed — I wanted to be able to add my actual fishing buddies and see their catches, and have them see mine, without either of us performing for an audience. Invite by email, accept or decline, done. If we’re not friends, you see nothing of mine. That’s the whole social surface area, on purpose.

What It Is

Fishing Journal is a Next.js 16 (App Router) app on React 19, styled with Tailwind 4 and a shadcn/base-ui component set. Data lives in Postgres via Neon, queried through Drizzle ORM against two tables — catches and friendships — kept deliberately small; no polymorphic species table, no join-table sprawl.

Auth is Clerk, and every query and server action is scoped by userId from auth() at the data layer, not just hidden in the UI — friend visibility is enforced the same way, checked server-side against an accepted friendships row before another user’s catches are ever fetched. Photo uploads go to Vercel Blob, but the upload path doesn’t trust the browser’s claimed MIME type — it reads the actual file bytes with file-type and only accepts what it detects as a real JPG, PNG, WebP, HEIC, or HEIF, which closes the standard “rename a script to .jpg” hole.

Location capture is Mapbox end to end: the browser’s Geolocation API when you tap “use my location,” a forward-geocoding call against Mapbox’s API as a fallback when you only typed a place name, and mapbox-gl rendering every located catch as a pin on a dedicated map page. The species side is a single hand-curated TypeScript file — several dozen California freshwater species with habitat, bait, season, difficulty, and a trophySize string like "8+ lb" — and trophy detection is just a regex over that string compared against whatever the catch actually weighed, so there’s no second hardcoded number to drift out of sync with the species data.

Stats — this week, this month, this year, personal records, an activity heatmap — run entirely in-process over the user’s own catches using date-fns, no separate aggregation table. “Week” is deliberately a rolling trailing 7 days rather than a calendar week, so a Monday reset doesn’t make Sunday’s catch vanish from the tab. Friends are a single self-referential table with a pending/accepted status; inviting someone looks them up by email through Clerk’s own user list rather than maintaining a shadow contacts table, and either side of a friendship can remove it, since the delete just matches on requesterId or recipientId regardless of who sent the original request. It’s deployed on Vercel, next to the Blob store and the Neon database it already needed to be next to.