Skip to content
Back to endeavors
Canes Cellys logo

Canes Cellys

Did the Canes score you free stuff?

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • Cloudflare Workers
  • Cloudflare KV
  • Claude API

The idea

After every Hurricanes game, local sponsors run post-game promos. The deals rotate season to season, but the complexity doesn’t. Each promo has its own trigger logic, redemption window and edge cases. A free Bo-Berry Biscuit from Bojangles might require a power play goal. A BOGO from Alpaca might only kick in after a road win. Han-Dee Hugo’s might offer a free Pepsi, but only after a shutout. Some triggers check yesterday’s box score, others depend on today’s schedule or the monthly calendar. A few only matter if the game was home or away.

No single place to check what you actually earned. I wanted to fix that. And I wanted a system that could adapt when sponsors change their rules or new deals show up next season, without rewriting the app.

Who it’s for

  • The morning-after fan. Wakes up, checks the score, wants to know if they earned free food before heading out. Needs a fast answer, not a deep dive.
  • The game-day fan. Has the app open during the game. Watching the shutout hold, counting PP goals, hoping for a hat trick. Wants to see promos build in real time.
  • The share-it fan. Texts the link to the group chat after a big win. Wants a clean preview in iMessage and a quick summary they can screenshot.
  • Me, the admin. Needs to update promo rules, swap sponsors, upload logos, and tweak AI prompts without pushing code.

Scriptable script

Problem: I wanted a quick personal check: did last night’s game score me any free food?

Solution: Built a Scriptable script that hits the NHL Stats API, evaluates six promo trigger conditions (power play goals, shutouts, away wins, hat tricks, etc.), and returns a JSON result I could wire into iOS Shortcuts.

Outcome: Worked great for me, but nobody else could use it without Scriptable installed.

Web app on Cloudflare

Problem: I wanted something I could share with other fans. A link I could text after a game.

Solution: Ported the engine to a Next.js app on Cloudflare Pages using the OpenNext adapter for Workers compatibility. The core fetches from the undocumented NHL Stats API and handles the timezone edge case where Cloudflare Workers run UTC but the NHL operates in Eastern. Added smart caching with KV: 5-minute TTL for live scores, 2 hours for final box scores, up to 6 hours for monthly schedules.

Outcome: Shipped at CanesCellys.com with auto-deploy on merge to main. Rebranded from “Caniac Promo Checker” to “Canes Cellys” with dark mode, a hero image and proper OG metadata for social sharing.

The promo engine

Problem: Seven sponsors, seven different trigger conditions, different redemption windows. Some promos depend on yesterday’s game, others on today’s schedule. Hardcoding the logic meant touching code every time rules changed.

Solution: Built a dispatch-table pattern where each trigger type (power play goal, shutout win, away win, hat trick, game on a specific day, last home game of the month) maps to a pure function. Promos are evaluated as definitions: a sponsor, a trigger reference, a timing window and a message. The engine checks all seven, encodes results as a binary string, and returns which ones are active with supporting data (hat trick player name, PP goal count, etc.).

Outcome: Adding a new promo means adding a definition entry and a checker function. No if/else chains, no spaghetti. The engine handles seven promos across Gym Tacos, Bojangles, Alpaca, Chick-fil-A, Han-Dee Hugo’s, Carolina Pro Shop, and Take 5.

Admin dashboard

Problem: Promo rules change mid-season. The Chick-fil-A trigger switched from “last home game of the month” to “last home game win.” I didn’t want to push code every time a sponsor tweaked their deal.

Solution: Built a KV-backed admin dashboard with three tabs. The Promos tab has collapsible cards for each sponsor with drag-to-reorder, logo uploads, emoji pickers, redemption steps (rendered as markdown), and active/inactive toggles. The Triggers tab lets me create and manage trigger instances with usage counts and watch configuration. The Settings tab controls AI combo messages, cache management, display field toggles and the “no promos” fallback message.

Outcome: When the CFA trigger changed, it was a config update instead of a code change. Schema versioning handles migrations automatically so old config formats upgrade on read.

Sharing and social

Problem: After a big win, fans want to share results. A plain link doesn’t tell you anything in a group chat.

Solution: Added native Web Share API on mobile with a share button. Dynamic OG metadata generates previews that show the current promo count. The share message includes which promos are active so the link preview is useful before you even tap it.

Outcome: Links preview cleanly in iMessage and social apps. The share CTA reads like a fan, not an ad.

AI combo messages

Problem: When multiple promos trigger at once (a shutout road win on a Tuesday, say), listing them isn’t fun. “Bojangles + Alpaca + Gym Tacos” reads like a receipt.

Solution: Wired up the Claude API (Haiku model, 2-second timeout) to generate a playful combo message when two or more promos are active. The admin dashboard controls the prompt template so I can adjust the tone without touching code. Results cache in KV with version-stamped keys that auto-expire when the config changes. If the AI call times out, a generic fallback fires immediately and a background retry caches the result for next time.

Outcome: Multi-promo days get a custom message instead of a bullet list. The fallback keeps the page fast regardless of API latency.

Live game tracking

Problem: During a game, you want to know if promos are building. Is the shutout still intact? Are we close to a hat trick?

Solution: Added Promo Watch, real-time progress badges on the game card that update every 3 minutes. Tracks PP goals, shutout status, away win progress, and hat trick contenders during live games. Each tracker shows contextual detail: “2 PP goals” or “Ehlers: 2/3 goals” for hat trick watch. When a threshold is met, the badge flips from “tracking” to “confirmed.”

Outcome: The app went from a morning-after check to something worth opening during the game.

All Promos page and redemption guides

Problem: Fans wanted a reference for every active promo, not just today’s results. Each deal has different redemption steps (wear gear, use an app, show up by midnight) and it’s easy to forget the details.

Solution: Built a dedicated /promos page listing every sponsor promo with “Active Today” badges, sponsor logos, offer details and collapsible “How to Redeem” sections with step-by-step markdown instructions. Display fields are admin-configurable so I can show or hide sections without a deploy.

Outcome: The page works as a season-long reference guide. Fans bookmark it and check back when they’re unsure how to actually claim a deal.

Changelog and sound

Problem: Returning users had no idea what changed between visits. And promo confirmations felt flat on screen.

Solution: Added a /changelog page with a timeline of updates grouped by date. Also added a sound toggle that plays an audio clip when promos are active. The toggle evolved into a beat-reactive mode using the Web Audio API: frequency analysis detects kick drum transients and drives dynamic glow effects on promo cards, the message banner and the game card in sync with the music. When muted, the Petey icon pulses with a subtle glow to signal that sound is available.

Outcome: Small touches that make the app feel like a living product, not a static page.

Season stats

Problem: After a big promo night, there was no way to see the bigger picture. How many cellys all season? Which player triggered the most? What was the best game?

Solution: Built a /stats page that evaluates every completed game in the season against the current promo definitions. The engine processes games in chunks of 10 per request to stay within Cloudflare’s free-tier subrequest limit, caching results in KV. The page shows an earned cellys donut chart, a player leaderboard ranked by who triggered the most promos, a month-grouped game log, and a standing deals frequency list. When the season ends, a season-in-review hero highlights the top stats. Each section has its own shareable OG image generated with satori, and a season selector dropdown supports browsing archived seasons with frozen promo definitions.

Outcome: The app went from a daily check to a full season companion. Fans can see streaks, compare players, and share stats cards.

Testing

Problem: Seven promos, multiple trigger types, live game states, timezone edge cases, schema migrations. Lots of ways for things to break silently.

Solution: 228 Jest tests cover the promo engine, component rendering, season stats aggregation, AI prompt building and response parsing. A separate CLI test suite runs 16 real-date scenarios against the live NHL API with rate limiting and retry logic. The test harness validates all seven promos individually and in combination.

Outcome: Catches edge cases (month boundaries, UTC/Eastern day rollovers, stale trigger references) before they hit production.