The Sky Circle
Astronomy community platform with a gamification engine, real-time sky alerts, and a two-app frontend/admin split.
- Role
- Full Stack Engineer
- Timeline
- 2024
- Team
- Solo build
- Year
- 2024
- Next.js
- React
- Supabase
- TypeScript
- Recharts
- Tailwind
Problem
Amateur astronomy is deeply social but its tooling is not. Observers log what they see in scattered notebooks and forums, miss transient events (a comet, an ISS pass, an aurora) because alerts are slow or generic, and have no shared sense of progress or friendly competition. The hobby's biggest motivator — "I saw something rare last night" — has nowhere to live.
Target user: amateur astronomers and astrophotography hobbyists, plus the community admins who curate events and moderate.
Constraints:
- Alerts about time-sensitive sky events must arrive in near-real-time.
- The community needs moderation and curation tools without bloating the member experience.
- Progression must feel fair — points have to reflect genuine difficulty (spotting a galaxy is harder than spotting the Moon).
Business goal: turn solo stargazing into a shared, gamified community that keeps people coming back for the next event.
Architecture
The Sky Circle uses a two-app split over a shared Supabase backend:
- Next.js frontend for members (feed, observations, leaderboards, alerts).
- Vite admin app for curators/moderators (events, seasonal missions, user management).
- Both read/write the same Supabase project (Postgres + Auth + Realtime), so there is one source of truth and the admin's curation instantly affects the member experience.
- Zustand manages client state; Recharts renders progression and leaderboard visuals; Tailwind drives the responsive design system across both apps.
- A gamification engine awards points per observed object, tracks 5 level tiers, grants badges, runs seasonal missions, and computes leaderboards.
- Supabase Realtime powers sky-alerts so time-sensitive events broadcast to online members immediately.
- A webhook + API-key system lets external feeds (event catalogs) push alerts; Google OAuth handles sign-in; a referral system drives growth.
Engineering Decisions
Two apps, one Supabase backend. Admin and member surfaces have very different needs, so splitting them (Next.js member app + Vite admin) kept each lean. Sharing one Supabase project means no data duplication and no sync — a curated event is live for members the moment an admin saves it. Tradeoff: shared schema is a coordination point, managed with RLS so the admin app's power does not leak to members.
Points weighted by observation difficulty. Flat points would reward quantity over skill. Weighting (Moon 5 → Planet 15 → Galaxy 25 → Rare event 50) makes the leaderboard reflect genuine observing ability, which is what the community actually respects. Tradeoff: the point table is a balancing exercise that needs occasional tuning.
Supabase Realtime for sky-alerts instead of polling. Sky events are time-sensitive — a five-minute-old aurora alert is useless. Realtime broadcast (p50 ~90ms) beats polling on both latency and server cost.
API-key + webhook ingress for external feeds. Rather than scraping event catalogs, external sources push alerts through authenticated webhooks, keeping the data fresh and the ingestion path auditable.
Source Code Walkthrough
The difficulty-weighted points engine
Awarding points is a pure function of object type, which keeps scoring testable and the leaderboard trustworthy.
// gamification/points.ts
// Why: points must reflect observation difficulty, not just count, so the
// leaderboard rewards skill. Pure + table-driven = easy to test and tune.
const POINTS: Record<ObjectType, number> = {
moon: 5,
planet: 15,
galaxy: 25,
rare_event: 50,
};
export function awardPoints(obs: Observation): PointAward {
const base = POINTS[obs.type];
const bonus = obs.isSeasonalMission ? Math.round(base * 0.5) : 0;
return { points: base + bonus, level: levelFor(obs.userTotal + base + bonus) };
}Broadcasting a sky-alert over Supabase Realtime
// alerts/broadcast.ts
// Why: sky events are time-sensitive; a Realtime channel pushes to every online
// member in ~90ms p50 instead of waiting for a poll cycle.
export async function broadcastSkyAlert(alert: SkyAlert) {
// Persist first (source of truth), then broadcast to subscribers.
await supabase.from("sky_alerts").insert(alert);
await supabase.channel("sky-alerts").send({
type: "broadcast",
event: "new_alert",
payload: alert,
});
}Performance considerations: persisting before broadcasting guarantees an offline member still sees the alert on next load; the broadcast handles the online real-time case. Improvements: geo-filter alerts so members only get events visible from their location.
Level thresholds as a pure, monotonic step function
Points are only half the system; the level a member sees is derived from their running total. Keeping that derivation pure and monotonic is what makes progression feel trustworthy — you can never lose a level by earning points.
// gamification/levels.ts
// Why: level must be a deterministic function of total points, monotonic and
// with no I/O, so the same total always yields the same level on client and
// server. Table-driven thresholds make rebalancing a data edit, not a rewrite.
const THRESHOLDS = [0, 100, 300, 700, 1500] as const; // 5 tiers, ascending
export function levelFor(totalPoints: number): Level {
if (totalPoints < 0) throw new RangeError("points cannot be negative");
// Walk from the top so the highest satisfied threshold wins.
for (let level = THRESHOLDS.length; level >= 1; level--) {
if (totalPoints >= THRESHOLDS[level - 1]) return level as Level;
}
return 1;
}Why this way: thresholds ascend and the scan returns the highest satisfied
tier, so the function is monotonic by construction — more points never means a
lower level. Pairing it with awardPoints means the whole scoring path is
pure and property-testable. Improvements: express thresholds as a config row
so admins can tune the curve without a deploy.
Ingesting an external feed through signed, API-key'd webhooks
External event catalogs are untrusted until proven otherwise. The ingress verifies an HMAC signature on the raw body and checks the API key's scope before a single row is written.
// alerts/ingress.ts
// Why: third-party feeds must not be able to spoof sky-alerts. Verify the
// signature on the RAW body (before parsing) and confirm the key is scoped to
// alert ingestion — defense in depth, not one gate.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function ingestFeedWebhook(req: Request) {
const raw = await req.text(); // raw body first — parsing a forged payload is risky
const sig = req.headers.get("x-feed-signature") ?? "";
const apiKey = req.headers.get("x-api-key") ?? "";
const key = await lookupApiKey(apiKey); // hashed lookup, never plaintext compare
if (!key || !key.scopes.includes("alerts:ingest")) {
return new Response("forbidden", { status: 403 });
}
const expected = createHmac("sha256", key.secret).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
const alert = SkyAlertSchema.parse(JSON.parse(raw)); // Zod only after trust
await broadcastSkyAlert(alert);
return new Response("ok");
}Performance considerations: the API-key lookup is a single indexed read and signature verification is constant-time, so ingress adds negligible latency to the alert path. Improvements: per-key rate limiting so a misbehaving feed cannot flood the broadcast channel.
Technical Challenges
- State management across a rich member UI. Zustand keeps feed, alerts, and leaderboard state coherent without prop-drilling or a heavy store.
- Fair, tunable gamification. The weighted point table and 5-level progression had to feel earned; scoring is a pure function so it can be unit-tested and rebalanced safely.
- Real-time delivery guarantees. Persist-then-broadcast ensures alerts are not lost for members who are offline at broadcast time.
- Two-app coordination. Keeping the admin (Vite) and member (Next.js) apps consistent against one schema, with RLS as the guardrail.
- Secure external ingress. API keys + signed webhooks so third-party event feeds cannot be spoofed.
The subtlest challenge is the shared-schema coordination cost of the two-app split. One Supabase project means the admin (Vite) and member (Next.js) apps share tables, so a schema change touches both build surfaces at once, and the member app is only as safe as the RLS policies. I accepted that cost because the alternative — two databases with a sync layer — would reintroduce exactly the staleness the product is trying to kill (a curated event must be live for members the instant an admin saves it). The mitigation is that RLS is the trust boundary, not the app code: even if the member bundle shipped an admin-only query, the policies would reject it. That turns "don't call the wrong endpoint" (a convention) into "the database refuses" (an invariant), which is the only version I trust across two independently deployed apps.
Code Quality & Testing
Type safety. Both apps run strict TypeScript with
noUncheckedIndexedAccess, which matters directly for the scoring code —
POINTS[obs.type] and THRESHOLDS[level - 1] are index accesses that would
otherwise be silently T | undefined. Supabase-generated row types flow through
the shared query layer so the member and admin apps consume the same schema
types; a column change surfaces as a type error in both apps at build time.
ObjectType and Level are string/numeric literal unions, not open strings,
so an unknown object type can't reach the points table.
Testing strategy — the scoring function is pure, so test it hard. The whole
gamification core (awardPoints + levelFor) is deliberately pure and free of
I/O, which makes it the ideal target for fast-check property tests under
Vitest. The properties encode what "fair" actually means:
// gamification/scoring.property.test.ts
import fc from "fast-check";
// Monotonicity: earning points can never lower your level.
test("levelFor is monotonic in points", () => {
fc.assert(
fc.property(
fc.nat({ max: 5000 }),
fc.nat({ max: 5000 }),
(a, b) => {
const [lo, hi] = a <= b ? [a, b] : [b, a];
expect(levelFor(hi)).toBeGreaterThanOrEqual(levelFor(lo));
},
),
);
});
// Determinism + no negative awards: the same observation always scores the same.
test("awardPoints is deterministic and non-negative", () => {
fc.assert(
fc.property(arbObservation(), (obs) => {
const first = awardPoints(obs);
const second = awardPoints(obs);
expect(first).toEqual(second);
expect(first.points).toBeGreaterThan(0);
}),
);
});Property tests are the right tool here because the leaderboard's credibility rests on invariants (monotonic, deterministic, non-negative) that must hold for every input, not just the four object types I remembered to enumerate.
RLS separation tests between the two apps. The most security-relevant tests don't run against application code at all — they run against Postgres with different JWT claims. Each test authenticates as a member principal and asserts that admin-only tables (event curation, user management) return zero rows or a policy error, then repeats as an admin to confirm the same query succeeds. This proves the trust boundary lives in the database, so it holds regardless of which app issues the query:
-- rls/member_cannot_curate.test.sql
-- Executed with a MEMBER jwt: curation tables must be invisible.
set local role authenticated;
set local request.jwt.claims = '{"sub":"member-uuid","role":"member"}';
select throws_ok(
$$ insert into curated_events (title) values ('spoofed') $$,
'42501', -- insufficient_privilege: RLS rejected the write
'members cannot insert curated events'
);Error handling & observability. The ingress path returns precise status codes (403 scope failure vs 401 signature failure) rather than a generic rejection, so a misconfigured feed partner can tell why they were rejected without leaking whether a key exists. Realtime delivery latency and webhook ingest success/failure are the two health signals monitored, matching the p50/p95 broadcast benchmarks.
Module boundaries. Scoring is a pure module; persistence and Realtime
broadcast are a separate layer (broadcastSkyAlert); untrusted ingress is
isolated behind signature + scope checks before any domain code runs. Shared
types live in one place the two apps import, which is the one seam where the
split's duplication is deliberately paid down.
CI enforcement. tsc --noEmit, ESLint, and the Vitest/fast-check suite run
for both apps in CI, and the RLS test suite runs against an ephemeral Supabase
instance so a policy regression fails the pipeline before it can widen a
member's access.
Benchmarks
Difficulty-weighted scoring runs from 5 points (Moon) to 50 (rare event) across 5 progression levels. Real-time sky-alert delivery via Supabase Realtime lands at ~90ms p50 and ~240ms p95 — fast enough that members hear about a transient event while it is still happening. See the charts above.
Lessons Learned
- What succeeded: difficulty-weighted points. The leaderboard immediately felt legitimate to serious observers.
- What failed first: an early polling-based alert system was both slow and expensive; moving to Realtime fixed both.
- What I would redesign: unify a little more shared code between the two apps to reduce duplicated types.
- Remaining tech debt: geo-aware alert filtering, and seasonal-mission configuration is more manual than it should be.
- Future improvements: photo-based observation verification and a richer badge economy.
Media
- Recording of a live sky-alert arriving and the leaderboard updating.
- Screenshots of the member feed, badges, and the Recharts progression view.
- Diagram of the two-app split over shared Supabase with Realtime.
Documentation Links
- Community: theskycircle.com
- Internal docs cover the gamification/points model, the Realtime sky-alert pipeline, and the webhook + API-key ingress for external event feeds.
Build Logs
- Requirements & planning. Identified the two motivators — timely alerts and fair progression — as the product's spine.
- Architecture. Two-app split (Next.js member + Vite admin) over one Supabase project; Realtime for alerts.
- Implementation. Built the weighted points/level engine, badges and seasonal missions, leaderboards, and persist-then-broadcast alerts; added Google OAuth, referral, and webhook/API-key ingress.
- Testing. Unit tests for the pure scoring function; verified alert delivery and RLS separation between apps.
- Deployment. Next.js frontend and Vite admin, shared Supabase backend.
- Monitoring. Realtime delivery latency and alert ingestion health.
- Retrospective. Realtime + fair scoring drove retention; geo-aware alerts are the clearest next win.