A minimal issue tracker.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Data layer | Relay + Supabase GraphQL (pg_graphql) |
| Language | TypeScript (strict mode) |
| Styling | Tailwind CSS v4 |
| Validation | Zod v4 |
| Real-time | Supabase Realtime |
| Testing | Vitest + Testing Library |
git clone https://github.com/DoctorZalman/flamingo-issue-tracker.git
cd flamingo-issue-tracker
yarn installCreate a Supabase project at supabase.com and enable the pg_graphql extension under Database → Extensions.
Copy .env.example to .env.local and fill in your values:
cp .env.example .env.localNEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_keyExecute the SQL migrations from the project setup guide in your Supabase SQL Editor. The migrations create the following tables: users, issues, comments, labels, issue_labels.
npx get-graphql-schema \
-h "apikey=YOUR_ANON_KEY" \
-h "Authorization=Bearer YOUR_ANON_KEY" \
"https://YOUR_PROJECT_REF.supabase.co/graphql/v1" \
> schema.graphqlyarn devThis runs Next.js and the Relay compiler in watch mode concurrently.
yarn test:runThis was the hardest part of the assignment. pg_graphql generates a schema with conventions that conflict with Relay's expectations out of the box.
| Problem | Solution |
|---|---|
Relay expects id: ID! as global identifier |
pg_graphql uses nodeId: ID! — configured via schemaConfig.nodeInterfaceIdField: 'nodeId' in relay.config.js |
Relay store uses nodeId, Realtime payloads use UUID |
Maintained a Map<uuid, nodeId> seeded from initial query load |
graphql@17 peer conflict |
Pinned graphql@^16.13.2 — relay-runtime@20 does not support v17 |
| Turbopack ignores Babel transforms | Always run next dev without Turbopack |
Cursor scalar causes strict mode errors |
Added Cursor: 'string' to customScalarTypes in relay.config.js |
Non-nullable Int! cannot have defaultValue in Relay |
Changed pagination argument to Int (nullable) |
module.exports = {
schemaConfig: {
nodeInterfaceIdField: "nodeId",
nodeInterfaceIdVariableName: "nodeId",
},
customScalarTypes: {
UUID: "string",
Datetime: "string",
Cursor: "string",
},
};getDataID: (node) => {
if (typeof node.nodeId === "string") return node.nodeId;
return node.id as string;
};Every fragment includes nodeId as the Relay record identity key.
Fragment co-location — each component owns its data requirements via a Relay fragment. IssueDetail, IssueEditForm, and CommentThread each have their own fragment spread from the parent query. This makes components self-contained and easy to refactor.
Uncontrolled inputs with refs — IssueEditForm and CommentThread use useRef instead of useState for form fields to avoid re-renders on every keystroke. Zod validates on submit only.
Optimistic updates — StatusSelector uses optimisticResponse so the UI reflects the change instantly. On error, Relay reverts the store and a toast notifies the user.
Realtime via commitLocalUpdate — instead of refetching the entire query on each Supabase Realtime event, changes are written directly to the Relay store. INSERT creates a new record and prepends an edge to the connection. UPDATE patches fields on the existing record. DELETE removes the edge from the connection.
Singleton Relay environment — a single environment instance is shared across the app to ensure the store is consistent.
Shared UI components — Button, Select, Textarea, Label, Badge, Avatar, Container are extracted as reusable primitives with consistent dark mode and focus styles. Focus ring color and brand accent use #ffc008 throughout.
Route constants — all internal routes are defined in src/lib/routes.ts to avoid hardcoded strings across the codebase.
Dark mode — class-based via tailwind.config.ts (darkMode: 'class') with a blocking inline script in layout.tsx to apply the saved theme before hydration, preventing flash.
Issue list reflects changes from other users without manual refresh using Supabase Realtime + commitLocalUpdate.
Scope: Real-time sync is implemented for the issue list (/issues) only. The detail page (/issues/[id]) does not receive live updates — changes made in another tab will be visible after navigation back to the list or a manual refresh.
How it works:
useRealtimeIssueshook subscribes topostgres_changeson theissuestable- INSERT → creates a new Relay store record and prepends an edge to the connection
- UPDATE → finds the existing record by UUID and patches changed fields
- DELETE → removes the edge from the connection
- The hook is mounted at page level (
/issues/page.tsx) to survive list re-renders - A module-level
Map<uuid, nodeId>bridges Supabase UUID payloads to Relay's base64 nodeIds registerNodeId()is called fromIssueListto seed the map on initial load
Unit tests are written with Vitest and Testing Library.
yarn test # watch mode
yarn test:run # single runCoverage includes:
IssueEditSchemaandCommentSchema— Zod validation logicBadge— label rendering, custom and default colorsAvatar— image rendering, initials fallbackSelect— options rendering, onChange, disabled stateIssueFilters— status and priority callbacks
Tests run automatically on git push via Husky.
Authentication — the app uses permissive anon RLS policies for demo purposes. In production, proper auth (Supabase Auth + row-level policies per user) would be required.
Create issue flow — there is no UI to create new issues. With more time I'd add a modal or inline form with optimistic insert into the Relay connection.
Label filtering — client-side only. pg_graphql does not support nested relation filters on issuesFilter, so label filtering is done in-memory after fetching. A proper solution would require a custom SQL function or view exposed via pg_graphql.
Labels update strategy — on save, all existing labels are deleted and re-inserted. A diff-based approach (delete removed, insert added) would be more efficient but adds complexity.
Real-time scope — only the issue list has live updates. The detail page requires a manual refresh to see changes made by other users. Extending realtime to the detail page would require a separate Supabase channel subscription per issue.
Optimistic INSERT — new issues from other tabs appear via Realtime, but locally created issues don't use optimistic insert. This would require generating a temporary nodeId on the client before the server responds.
Test coverage — tests cover UI primitives and validation schemas.
Error handling — the Error Boundary catches render errors but network errors during mutations only show a toast. A more robust solution would include retry logic and per-field error states from the server.
Accessibility — basic semantic HTML and aria labels are in place. A full audit would include keyboard navigation for the status dropdown, focus trapping in any modals, and screen reader testing.