Build a System Design Practice Platform
Build a full-stack system design platform with judging and progress tracking.
Introduction
30 Second Summary
System design practice often stops at a sketch and a vague conversation. Without repeatable tests, it is hard to tell whether an architecture improved or merely became more complicated.
In this project, you will build an offline-first system design practice platform with React, React Flow, and Supabase. Guests can practise locally, while signed-in learners sync drafts, submit designs to a cloud judge, and track progress across sessions.
What You'll Build
You will open a challenge, assemble an architecture on the canvas, run visible checks, then submit it to a protected judge and watch your progress update.
By the end of this project, you'll have:
- A searchable problem library that filters challenges by difficulty, category, status, and architecture topic.
- An offline-first design editor that preserves guest work locally and synchronises signed-in drafts through the cloud.
- A protected cloud judge that runs visible and hidden scenarios before recording verdicts, scores, and progress.
- Secret Mission: Add an admin challenge authoring workflow with a rubric, visible scenarios, and hidden judge cases.
Are there any prerequisites?
You should be comfortable with JavaScript, React components, arrays, objects, and asynchronous functions.
Install Node.js 20.19+ or 22.12+, and create a free Supabase account. The completed learner project stays within the verified free-plan allowances.
Before We Start
Before We Start...
Before the hands-on work begins, lock in the practice experience you are building and why server-side judging matters. This gives every later interface and security decision a clear purpose.
Set Up the React Flow Project
Set Up the Full-Stack Workspace
The practice platform needs one workspace for its visual editor and cloud client. A stable foundation prevents dependency problems from masking later authentication or judging issues.
A version-pinned React workspace keeps the local guest experience predictable. The Supabase client prepares the same interface for authenticated cloud features.
You will verify Node.js, scaffold with Vite, then install React Flow with the Supabase JavaScript client. The final browser check proves the frontend toolchain works before the backend exists.
In this step, get ready to:
- Verify that Node.js meets Vite's compatibility requirement.
- Create a pinned React workspace with React Flow and the Supabase client.
- Run the local starter application in your browser.
Check Node.js compatibility and scaffold the project
Vite requires Node.js 20.19+ or 22.12+. Checking now prevents dependency errors after the project files exist.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
- Type Terminal (macOS) or PowerShell (Windows), then press Enter.
- Check your installed Node.js version by running:
node --version
What does this command do?
The --version option prints the Node.js release currently available in your terminal. Use that number to choose the matching path below.
✔️ I see a supported version
Your version satisfies Vite's 20.19+ or 22.12+ requirement. Your runtime is ready for the project.
ⓧ I see an older version
Your current Node.js release cannot run this pinned Vite workspace. Upgrade it before creating the project.
macOS
- Open the official Node.js download page.
- Choose an LTS release that satisfies 22.12+.
- Download the macOS installer.
- Run the downloaded installer.
- Quit Terminal after the installer finishes.
- Reopen Terminal through system search.
- Verify the updated version by running:
node --version
You should now see a supported Node.js release.
Windows
- Open the official Node.js download page.
- Choose an LTS release that satisfies 22.12+.
- Download the Windows installer.
- Run the downloaded installer.
- Close PowerShell after the installer finishes.
- Reopen PowerShell through system search.
- Verify the updated version by running:
node --version
You should now see a supported Node.js release.
ⓧ Command not found
Your terminal cannot find Node.js. Install an LTS release before continuing.
macOS
- Open the official Node.js download page.
- Download an LTS macOS installer that satisfies 22.12+.
- Run the downloaded installer.
- Reopen Terminal after installation.
- Confirm Node.js is available by running:
node --version
You should now see the installed Node.js version.
Windows
- Open the official Node.js download page.
- Download an LTS Windows installer that satisfies 22.12+.
- Run the downloaded installer.
- Reopen PowerShell after installation.
- Confirm Node.js is available by running:
node --version
You should now see the installed Node.js version.
The scaffolder opens an interactive wizard. You'll enter the folder name before choosing React with JavaScript. npm may also ask for permission to download the scaffolder.
- Move to your Desktop by running this command:
cd ~/Desktop
Why start on the Desktop?
This command makes the Desktop your terminal's current location. Vite creates the architecture-arena folder there so you can find it easily.
- Start the official Vite scaffolder by running:
npm create vite@latest
What does this command do?
This command downloads the official Vite scaffolder. The wizard creates a starter project from your choices.
- Approve the temporary scaffolder download if npm asks for permission.
- Enter architecture-arena for the project name.
- Choose React as the framework.
- Choose JavaScript as the variant.
The scaffold is ready when Terminal confirms that the project files were created inside architecture-arena.
Did the scaffolder stop?
Check whether Terminal is waiting for a project choice. Use the arrow keys to highlight an option before pressing Enter.
If the command reports that Node.js is unsupported, return to the version tabs above. Install a compatible release before trying again.
help me diagnose my Vite scaffolding problem
Pin the workspace and add React Flow
The generated project uses whichever package versions are current at scaffold time. Replacing its manifest locks every learner to the versions used by this project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
- Type VS Code and press Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the architecture-arena folder on your Desktop.
- Click Open.
- Approve the folder if VS Code displays a trust prompt.
You should see the generated project files in the Explorer sidebar. The list includes package.json with the src folder.
- Click package.json in the Explorer sidebar.
- Press Cmd+A (macOS) or Ctrl+A (Windows) to select its current contents.
- Replace the selected content by pasting this file:
{
"name": "architecture-arena",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.118.0",
"@xyflow/react": "12.12.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.5"
}
}
What does this file control?
- The scripts section defines how Vite starts the development server or creates a build.
- The dependencies section pins React, React Flow, and the Supabase browser client.
- The devDependencies section pins the Vite tools used during development.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Click Terminal in the top menu bar.
- Click New Terminal.
- Install the pinned dependencies by running:
npm install
What does this command do?
The command reads package.json before downloading its exact dependencies. This makes @xyflow/react available to the application.
You should see npm finish without a dependency installation failure.
Did the installation fail?
Confirm that the VS Code terminal is inside the architecture-arena folder. Its prompt should end with that folder name.
Compare your package.json with the pinned file below if npm reports a package or JSON problem.
help me fix my npm installation
Why React Flow?
React Flow exposes the nodes with edges that the simulator needs as graph input. Excalidraw supports free-form drawing that would require extra interpretation before the judge could inspect a request path.
The browser document needs a predictable mount point for React. Its title also gives the project a clear name in Safari.
- Click index.html in the Explorer sidebar.
- Press Cmd+A (macOS) or Ctrl+A (Windows) to select its current contents.
- Replace the selected content by pasting this file:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Architecture Arena</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What does this file do?
- The root element gives React a place to render the application.
- The module script loads src/main.jsx as the browser entry point.
- The title labels the Safari tab as Architecture Arena.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
React Flow hooks need a provider above the application. Its stylesheet supplies the base canvas layout that later steps build on.
- Click src/main.jsx in the Explorer sidebar.
- Press Cmd+A (macOS) or Ctrl+A (Windows) to select its current contents.
- Replace the selected content by pasting this file:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ReactFlowProvider } from '@xyflow/react'
import App from './App.jsx'
import './index.css'
import '@xyflow/react/dist/style.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ReactFlowProvider>
<App />
</ReactFlowProvider>
</StrictMode>,
)
What does this code do?
- The imports load React's rendering tools with the generated App component.
- The ReactFlowProvider wrapper makes shared flow state available to the future canvas.
- The React Flow stylesheet provides the library's required base styles.
- The final expression renders the application inside the root element from index.html.
- Save src/main.jsx by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Confirm that package.json with index.html and src/main.jsx are saved.
Keep the generated src/App.jsx with src/index.css unchanged for the starter-page check.
ⓧ I'd like to double check the full code
Compare your pinned manifest with this complete package.json file:
{
"name": "architecture-arena",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "2.118.0",
"@xyflow/react": "12.12.0",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"vite": "8.3.5"
}
}
Compare your browser document with this complete index.html file:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Architecture Arena</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Compare your React entry point with this complete src/main.jsx file:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ReactFlowProvider } from '@xyflow/react'
import App from './App.jsx'
import './index.css'
import '@xyflow/react/dist/style.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ReactFlowProvider>
<App />
</ReactFlowProvider>
</StrictMode>,
)
The generated vite.config.js should match this complete file:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
})
Leave the generated src/App.jsx with src/index.css unchanged. They provide the starter interface used for this step's browser check.
Run the starter application
The development server compiles the imports you just configured. A successful browser load proves that the pinned packages can work together.
Before you run the server, what do you expect Safari to show if every dependency resolves correctly?
- Start the Vite development server from the VS Code terminal by running:
npm run dev
What does this command do?
The command runs the dev script from package.json. Vite starts a local server while keeping this terminal process active.
You should see Terminal display a local server address without a compilation failure.
- Keep the Vite process running in the VS Code terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open system search.
- Type the name of your browser and press Enter.
- Enter http://localhost:5173 in the browser address bar.
- Press Enter to load the local application.
You should see the Vite React starter page. The browser tab should show Architecture Arena.
That's the foundation in place. Your pinned React workspace now loads with React Flow available to the application.
Does the page fail to load?
Confirm that the Vite process is still running in the VS Code terminal. Closing that process stops the local server.
If Terminal reports an import problem, compare package.json with src/main.jsx against the full-code tab above.
help me fix my Vite starter page
Your local toolchain is ready with both graph and cloud clients installed. Next, you will create the database, authentication flow, and access policies that protect each learner's work.
Load the Multi-Problem Workspace
Create the Secure Cloud Backend
Your pinned workspace now runs with the graph and cloud packages installed. The next layer gives each learner an identity plus a protected place for drafts, submissions, and progress.
The browser needs permission to read public problems without gaining permission to inspect another learner's work. Supabase combines authentication with a PostgreSQL database so those boundaries can live beside the data.
You will create the backend, apply Row Level Security, and connect the React app with a publishable browser key. The final check confirms a real authenticated identity exists.
In this step, get ready to:
- Create a Supabase project and connect the React workspace.
- Create the platform tables with Row Level Security policies.
- Verify an authenticated cloud identity.
Create the Supabase project
A Supabase project provides the database, authentication service, and Edge Function environment used later. The free project can pause after one week without activity, so a short wake-up delay is normal when you return.
- Open the Supabase Dashboard in your browser.
- Click New project.
- Enter architecture-arena in the Project name field.
- Create a strong database password in the Database password field.
- Store the database password in your password manager.
- Select the region closest to you in the Region field.
- Click Create new project.
Provisioning usually takes a couple of minutes. The dashboard is ready when the project overview replaces the progress screen.
- Open the project's Connect dialog.
- Copy the Project URL.
- Copy the publishable key.
- Create .env.local inside architecture-arena with the VS Code Explorer new-file control.
- Add the connection settings by pasting this file:
# Connect the browser client to your Supabase project.
VITE_SUPABASE_URL=[[SUPABASE_URL="https://your-project.supabase.co"]]
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
What belongs in the environment file?
The Vite prefix exposes these values to browser code. The publishable key is designed for client use, while Row Level Security decides which rows the session can access.
Never place the database password or a Supabase secret key in this file.
- Replace the project URL placeholder with the copied Project URL.
- Replace the publishable-key placeholder with the copied publishable key.
- Save .env.local.
- Confirm .env.local appears beside package.json.
Environment values missing?
Confirm the filename begins with a dot and ends with .local.
Help me diagnose missing Supabase environment values.
Create the database schema
The platform stores public challenge data separately from private learner data. Each private row carries a user_id that the database compares with the authenticated session.
- Open SQL Editor in the Supabase dashboard sidebar.
- Click New query.
- Create the profiles, problems, and drafts tables by running:
-- Store public learner identities.
create table public.profiles (
id uuid primary key references auth.users on delete cascade,
username text unique not null,
created_at timestamptz not null default now()
);
-- Store public system design challenges.
create table public.problems (
id text primary key,
title text not null,
difficulty text not null,
category text not null,
tags text[] not null default '{}',
prompt text not null,
constraints jsonb not null default '[]',
visible_cases jsonb not null default '[]',
published boolean not null default false
);
-- Store one private draft per learner and challenge.
create table public.drafts (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users on delete cascade,
problem_id text not null references public.problems on delete cascade,
graph jsonb not null default '{"nodes":[],"edges":[]}',
updated_at timestamptz not null default now(),
unique (user_id, problem_id)
);
What does this schema establish?
- The public problems table holds prompts and visible checks.
- The private drafts table keeps the canvas graph as JSON.
- The uniqueness rule gives each learner one resumable draft per challenge.
- Confirm the SQL Editor reports a successful query.
- Create the submission and progress tables by running:
-- Keep immutable judge results.
create table public.submissions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users on delete cascade,
problem_id text not null references public.problems on delete cascade,
graph jsonb not null,
verdict text not null,
score integer not null check (score between 0 and 100),
metrics jsonb not null default '{}',
duration_seconds integer,
created_at timestamptz not null default now()
);
-- Keep the latest progress summary.
create table public.progress (
user_id uuid not null references auth.users on delete cascade,
problem_id text not null references public.problems on delete cascade,
status text not null default 'attempted',
best_score integer not null default 0,
attempts integer not null default 0,
updated_at timestamptz not null default now(),
primary key (user_id, problem_id)
);
Why keep two result tables?
A submission never changes after judging, which preserves an audit trail. The progress row stores the best result for fast dashboards without deleting earlier attempts.
- Confirm the SQL Editor reports a successful query.
- Enable Row Level Security and grant the minimum operations by running:
-- Lock every exposed table before adding policies.
alter table public.profiles enable row level security;
alter table public.problems enable row level security;
alter table public.drafts enable row level security;
alter table public.submissions enable row level security;
alter table public.progress enable row level security;
revoke all on public.profiles, public.problems, public.drafts,
public.submissions, public.progress from anon, authenticated;
grant select on public.profiles, public.problems to anon, authenticated;
grant update on public.profiles to authenticated;
grant select, insert, update, delete on public.drafts to authenticated;
-- Judge-owned result tables are read-only in the browser.
grant select on public.submissions, public.progress to authenticated;
Why use grants and policies?
Grants decide which operations a role may attempt. Policies decide which rows each allowed operation can reach.
- Confirm the SQL Editor reports a successful query.
- Add public-read and owner-only policies by running:
-- Publish approved challenges and profile names.
create policy "Published problems are public" on public.problems
for select to anon, authenticated using (published = true);
create policy "Profiles are public" on public.profiles
for select to anon, authenticated using (true);
create policy "Users update their profile" on public.profiles
for update to authenticated using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
-- Keep drafts private to their owner.
create policy "Users read drafts" on public.drafts
for select to authenticated using ((select auth.uid()) = user_id);
create policy "Users create drafts" on public.drafts
for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "Users update drafts" on public.drafts
for update to authenticated using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users delete drafts" on public.drafts
for delete to authenticated using ((select auth.uid()) = user_id);
How does ownership work?
Each private policy compares the row owner with auth.uid(). A signed-in learner therefore sees only their own drafts through the browser API.
- Confirm the SQL Editor reports a successful query.
- Protect submissions and progress by running:
-- Let learners read only their trusted judge results.
create policy "Users read submissions" on public.submissions
for select to authenticated using ((select auth.uid()) = user_id);
create policy "Users read progress" on public.progress
for select to authenticated using ((select auth.uid()) = user_id);
-- Inserts and updates stay unavailable to browser roles.
-- The authenticated Edge Function records both result shapes.
- Confirm the SQL Editor reports a successful query.
- Open Table Editor.
- Confirm all five new tables are listed.
SQL query failed?
A permission failure often means the grant or matching policy is missing. Check the operation named in the error before widening access.
Help me diagnose the database migration.
Connect and verify the browser session
The browser client uses the publishable key to reach Supabase. Authentication later supplies a JWT that lets the database evaluate the owner-only policies.
- Create src/lib inside architecture-arena with the VS Code Explorer new-folder control.
- Create src/lib/supabase.js.
- Initialize the browser client by pasting this file:
import { createClient } from '@supabase/supabase-js'
// Reuse one browser client for auth, data, functions, and realtime.
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
)
What does this client do?
- createClient() reads the two Vite environment values.
- The exported client carries the current session token on later requests.
- Save src/lib/supabase.js.
- Restart the Vite development server.
- Open the browser console and confirm no missing-URL error appears.
Client cannot connect?
Confirm both environment values are saved before restarting Vite. A server that stayed open during the file change still holds the old environment.
Help me fix the Supabase client connection.
Before the final check, do you expect an unauthenticated browser to read a private draft row?
- Open Authentication in the Supabase dashboard.
- Open Users.
- Click Add user.
- Enter an email address you control.
- Enter a temporary password in the password field.
- Click Create user.
- Confirm the new account appears in the user list.
You now have a real authenticated identity, while signed-out requests still receive no private draft rows. That separation is the security foundation for cloud sync and judging.
Your backend now separates public challenges from private learner activity. Next, you will turn the catalog into a searchable problem library backed by this schema.
Build Architectures by Dragging and Wiring Components
Build the Problem Library
Your backend now separates public challenges from private learner activity. The first feature to use that boundary is a library that anyone can browse before signing in.
A useful practice platform helps learners choose deliberately instead of opening random prompts. Search, difficulty, category, tags, and completion state turn a flat challenge list into a study tool.
You will seed public problem records, load them through the Supabase Data API, and keep a local fallback for guest resilience. The result follows the familiar discover-open-practise loop without copying another platform's content or branding.
In this step, get ready to:
- Seed a public system design problem catalog.
- Load cloud problems with a local fallback.
- Filter and open challenges from a searchable library.
Seed the public problem catalog
Each challenge record describes the learner-facing prompt and visible scenarios. Hidden judge scenarios stay outside this public table and arrive with the server-side judge.
- Open SQL Editor in the Supabase dashboard.
- Click New query.
- Add three original system design challenges by running:
-- Seed original public practice challenges.
insert into public.problems
(id, title, difficulty, category, tags, prompt, constraints, visible_cases, published)
values
('url-shortener', 'Design a URL Shortener', 'Medium', 'Read-heavy systems',
array['cache', 'database', 'latency'],
'Design redirects for a read-heavy workload.',
'["Fast redirects", "Durable mappings", "No direct database access"]',
'[{"name":"baseline","rps":18000,"failed":[]}]', true),
('realtime-chat', 'Design Real-Time Chat', 'Hard', 'Real-time systems',
array['websocket', 'queue', 'availability'],
'Design durable delivery over persistent connections.',
'["Persistent connections", "Queued delivery", "Message history"]',
'[{"name":"baseline","rps":14000,"failed":[]}]', true),
('video-streaming', 'Design Video Streaming', 'Hard', 'Content delivery',
array['cdn', 'object-storage', 'throughput'],
'Design high-volume media delivery with a protected origin.',
'["Edge delivery", "Durable media", "Controlled origin load"]',
'[{"name":"baseline","rps":25000,"failed":[]}]', true)
on conflict (id) do update set
title = excluded.title,
difficulty = excluded.difficulty,
category = excluded.category,
tags = excluded.tags,
prompt = excluded.prompt,
constraints = excluded.constraints,
visible_cases = excluded.visible_cases,
published = excluded.published;
What does each record control?
- Each stable id connects future drafts, submissions, and progress to one challenge.
- The tags array powers topic filters.
- The public visible_cases list gives learners checks they may inspect before submission.
- Confirm the SQL Editor reports a successful query.
- Open Table Editor.
- Select the problems table.
- Confirm all three challenge rows show true in the published column.
Seed query failed?
If the query reports invalid JSON, check that the constraints and visible-case values use double quotes inside their SQL strings.
Help me fix the problem seed query.
Add the offline-first data source
Cloud data keeps the catalog current, while a bundled fallback keeps guest practice available during a network outage. The browser chooses cloud rows when the query succeeds.
- Create src/problemData.js with the VS Code Explorer new-file control.
- Add the local fallback records by pasting this file:
// Keep guest browsing available when the cloud cannot respond.
export const FALLBACK_PROBLEMS = [
{
id: 'url-shortener',
title: 'Design a URL Shortener',
difficulty: 'Medium',
category: 'Read-heavy systems',
tags: ['cache', 'database', 'latency'],
prompt: 'Design redirects for a read-heavy workload.',
},
{
id: 'realtime-chat',
title: 'Design Real-Time Chat',
difficulty: 'Hard',
category: 'Real-time systems',
tags: ['websocket', 'queue', 'availability'],
prompt: 'Design durable delivery over persistent connections.',
},
{
id: 'video-streaming',
title: 'Design Video Streaming',
difficulty: 'Hard',
category: 'Content delivery',
tags: ['cdn', 'object-storage', 'throughput'],
prompt: 'Design high-volume media delivery with a protected origin.',
},
]
Why mirror the cloud shape?
The fallback keeps the same field names as the database response. Components can render either source without branching on data shape.
- Save src/problemData.js.
- Create src/useProblems.js.
- Add the cloud-first loading hook by pasting this file:
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabase.js'
import { FALLBACK_PROBLEMS } from './problemData.js'
export function useProblems() {
// Start with local data so the library renders immediately.
const [problems, setProblems] = useState(FALLBACK_PROBLEMS)
const [source, setSource] = useState('Local fallback')
useEffect(() => {
async function loadProblems() {
const { data, error } = await supabase
.from('problems')
.select('*')
.eq('published', true)
.order('title')
if (!error && data?.length) {
setProblems(data)
setSource('Cloud catalog')
}
}
loadProblems()
}, [])
return { problems, source }
}
How does the fallback work?
- The hook renders the local records before the network responds.
- The Supabase query returns only rows allowed by the public problem policy.
- source reveals which data path the learner currently sees.
- Save src/useProblems.js.
- Refresh the browser.
- Confirm the console remains free of table-permission errors.
Cloud problems not loading?
Check that the problems table has a select grant plus the published-problems policy. A successful WebSocket connection does not replace Data API permission.
Help me debug the cloud problem query.
Build and test the searchable library
The library derives its visible cards from the current filters. The underlying problem array remains unchanged, which makes clearing filters predictable.
- Create src/ProblemLibrary.jsx.
- Add the search and filter component by pasting this file:
import { useMemo, useState } from 'react'
export default function ProblemLibrary({ problems, source, onOpen }) {
const [query, setQuery] = useState('')
const [difficulty, setDifficulty] = useState('All')
// Recompute cards only when data or filters change.
const visible = useMemo(() => problems.filter((problem) => {
const text = `${problem.title} ${problem.category} ${problem.tags.join(' ')}`.toLowerCase()
return text.includes(query.toLowerCase())
&& (difficulty === 'All' || problem.difficulty === difficulty)
}), [difficulty, problems, query])
return (
<section className="library">
<header><div><p>Problem library</p><h1>System design practice</h1></div><span>{source}</span></header>
<div className="filters">
<input aria-label="Search problems" placeholder="Search title, category, or tag" value={query} onChange={(event) => setQuery(event.target.value)} />
<select aria-label="Difficulty" value={difficulty} onChange={(event) => setDifficulty(event.target.value)}>
<option>All</option><option>Medium</option><option>Hard</option>
</select>
</div>
<p>{visible.length} challenges</p>
<div className="problem-grid">{visible.map((problem) => (
<button key={problem.id} onClick={() => onOpen(problem)}>
<span>{problem.difficulty}</span><h2>{problem.title}</h2><p>{problem.prompt}</p><small>{problem.tags.join(' · ')}</small>
</button>
))}</div>
</section>
)
}
How do the filters work?
- The search index combines each card's title, category, and tags.
- The difficulty selection narrows the same array without changing cloud data.
- onOpen hands the complete chosen challenge to the parent application.
- Save src/ProblemLibrary.jsx.
- Replace src/App.jsx with the library shell below:
import { useState } from 'react'
import ProblemLibrary from './ProblemLibrary.jsx'
import { useProblems } from './useProblems.js'
export default function App() {
const { problems, source } = useProblems()
const [selected, setSelected] = useState(null)
// Keep routing lightweight until the editor arrives.
if (selected) {
return (
<main className="problem-preview">
<button onClick={() => setSelected(null)}>Back to library</button>
<p>{selected.difficulty} · {selected.category}</p>
<h1>{selected.title}</h1>
<p>{selected.prompt}</p>
<strong>Editor arrives in the next step.</strong>
</main>
)
}
return <ProblemLibrary problems={problems} source={source} onOpen={setSelected} />
}
What does the shell control?
The app keeps one selected problem in state. Choosing a card replaces the library with a focused preview, while the back button restores discovery.
- Save src/App.jsx.
- Open src/index.css in the Explorer sidebar.
- Replace its generated styles with the shared library and editor styles below:
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, system-ui, sans-serif; color: #e5e7eb; background: #0f172a; }
button, input, select { font: inherit; }
button { cursor: pointer; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.library, .problem-preview, .editor { min-height: 100vh; padding: 2rem; }
.filters { display: flex; gap: 0.75rem; margin: 1.5rem 0; }
.filters input, .filters select { padding: 0.75rem; border: 1px solid #475569; border-radius: 0.5rem; }
.filters input { flex: 1; }
.problem-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; }
.problem-grid button { padding: 1.25rem; color: inherit; text-align: left; background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; }
.palette { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0; }
.palette button { padding: 0.6rem 0.8rem; background: #e2e8f0; border: 0; border-radius: 0.4rem; }
.canvas { width: 100%; height: calc(100vh - 12rem); min-height: 32rem; background: #f8fafc; border-radius: 0.75rem; overflow: hidden; }
- Save src/index.css.
- Refresh the browser.
Before testing the filters, which cards do you expect to remain when difficulty is set to Hard and the search contains cdn?
- Enter cdn in the search field.
- Select Hard in the difficulty field.
- Confirm only Design Video Streaming remains.
- Clear the search field.
- Select Design Real-Time Chat.
- Confirm the focused challenge preview opens.
The library now turns public database rows into a deliberate practice workflow. Search and difficulty state produce an observable, repeatable result.
Library not filtering?
Check that every record has a tags array. Calling join() on a missing tag list stops the filter render.
Help me debug the problem library.
Your searchable library can now discover and open cloud-backed challenges. Next, you will replace the preview with an offline-first architecture editor.
Simulate Traffic and Inject Failures
Build the Offline-First Editor
Your problem library now turns public rows into searchable practice choices. The next goal is to make each opened challenge editable without sacrificing the guest experience.
A system design editor must preserve two kinds of information: layout and structure. React Flow keeps node positions and directional edges as serialisable graph data.
You will add a component palette, create movable nodes, wire edges, delete selections, and restore drafts. Guest work stays in the browser, while authenticated sessions can upsert the same graph to Supabase.
In this step, get ready to:
- Build the infrastructure component palette and canvas.
- Add graph controls for wiring, deletion, zoom, and reset.
- Persist drafts locally with an authenticated cloud-sync path.
Create the component palette
The palette keeps infrastructure metadata separate from the editor. Every node receives a stable type, readable label, and colour from this catalog.
- Create src/componentLibrary.js with the VS Code Explorer new-file control.
- Add the reusable infrastructure catalog by pasting this file:
// Define the reusable infrastructure choices shown in the editor.
export const COMPONENTS = {
client: { label: 'Client', color: '#60a5fa' },
cdn: { label: 'CDN', color: '#a78bfa' },
'load-balancer': { label: 'Load Balancer', color: '#22d3ee' },
'api-gateway': { label: 'API Gateway', color: '#38bdf8' },
'app-server': { label: 'App Server', color: '#34d399' },
cache: { label: 'Cache', color: '#fbbf24' },
queue: { label: 'Message Queue', color: '#fb923c' },
database: { label: 'Database', color: '#f472b6' },
storage: { label: 'Object Storage', color: '#c084fc' },
}
Why keep scoring out of the palette?
These records describe presentation and identity only. Capacity and hidden scoring rules remain server-side so browser edits cannot change the judge.
- Save src/componentLibrary.js.
- Create src/ArchitectureCanvas.jsx.
- Add the interactive graph surface by pasting this file:
import { useCallback } from 'react'
import { Background, Controls, MiniMap, ReactFlow, addEdge, useReactFlow } from '@xyflow/react'
import { COMPONENTS } from './componentLibrary.js'
export default function ArchitectureCanvas({ nodes, edges, setNodes, setEdges, onNodesChange, onEdgesChange }) {
const { screenToFlowPosition } = useReactFlow()
const onConnect = useCallback((connection) => {
setEdges((current) => addEdge({ ...connection, animated: true }, current))
}, [setEdges])
function onDragOver(event) {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}
function onDrop(event) {
event.preventDefault()
const type = event.dataTransfer.getData('application/architecture-component')
const component = COMPONENTS[type]
if (!component) return
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
const node = { id: crypto.randomUUID(), position, data: { type, label: component.label }, style: { borderColor: component.color } }
setNodes((current) => [...current, node])
}
return <div className="canvas" onDragOver={onDragOver} onDrop={onDrop}>
<ReactFlow nodes={nodes} edges={edges} onConnect={onConnect} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} fitView>
<Background /><Controls /><MiniMap />
</ReactFlow>
</div>
}
How does the canvas stay editable?
- The drop handler converts screen coordinates into canvas coordinates before creating a node.
- addEdge() stores each new directional connection.
- Controls and MiniMap provide zoom, fit-view, and navigation controls.
- Save src/ArchitectureCanvas.jsx.
- Confirm the file appears beside ProblemLibrary.jsx.
Canvas file showing errors?
Check that ArchitectureCanvas.jsx imports from @xyflow/react and ./componentLibrary.js.
Help me debug the canvas component.
Assemble the architecture editor
The editor owns the graph arrays and passes interactions down to the canvas. This keeps one serialisable source of truth for reset, save, and submission.
- Create src/draftStore.js with the VS Code Explorer new-file control.
- Add guest draft recovery by pasting this file:
const emptyGraph = { nodes: [], edges: [] }
export function loadDraft(problemId) {
// Isolate each guest draft by challenge ID.
const raw = localStorage.getItem(`architecture-draft:${problemId}`)
if (!raw) return emptyGraph
try {
return JSON.parse(raw)
} catch {
return emptyGraph
}
}
export function saveDraft(problemId, graph) {
// Preserve the complete serialisable graph in the browser.
localStorage.setItem(
`architecture-draft:${problemId}`,
JSON.stringify(graph),
)
return { location: 'local' }
}
Why create the store now?
The editor imports these helpers as soon as it loads. Creating the file first keeps the next browser check free from missing-module errors.
- Create src/ArchitectureEditor.jsx.
- Add the graph state and palette by pasting this file:
import { useEffect } from 'react'
import { useEdgesState, useNodesState } from '@xyflow/react'
import ArchitectureCanvas from './ArchitectureCanvas.jsx'
import { COMPONENTS } from './componentLibrary.js'
import { loadDraft, saveDraft } from './draftStore.js'
export default function ArchitectureEditor({ problem, onBack }) {
const saved = loadDraft(problem.id)
const [nodes, setNodes, onNodesChange] = useNodesState(saved.nodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(saved.edges)
// Persist every coherent graph change for guest recovery.
useEffect(() => {
const timer = setTimeout(() => saveDraft(problem.id, { nodes, edges }), 400)
return () => clearTimeout(timer)
}, [edges, nodes, problem.id])
function startDrag(event, type) {
event.dataTransfer.setData('application/architecture-component', type)
event.dataTransfer.effectAllowed = 'copy'
}
function reset() {
setNodes([])
setEdges([])
}
return <main className="editor"><header><button onClick={onBack}>Back to library</button><h1>{problem.title}</h1><button onClick={reset}>Reset</button></header>
<aside className="palette">{Object.entries(COMPONENTS).map(([type, item]) => <button draggable key={type} onDragStart={(event) => startDrag(event, type)}>{item.label}</button>)}</aside>
<ArchitectureCanvas nodes={nodes} edges={edges} setNodes={setNodes} setEdges={setEdges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} />
</main>
}
What does the editor own?
- useNodesState and useEdgesState keep movement, deletion, and connection changes controlled.
- A short save delay avoids writing to storage on every pointer movement.
- Reset clears the current challenge graph without affecting another challenge's draft.
- Save src/ArchitectureEditor.jsx.
- Open src/App.jsx in the Explorer sidebar.
- Replace its contents with the editor-aware application shell below:
import { useState } from 'react'
import ArchitectureEditor from './ArchitectureEditor.jsx'
import ProblemLibrary from './ProblemLibrary.jsx'
import { useProblems } from './useProblems.js'
export default function App() {
const { problems, source } = useProblems()
const [selected, setSelected] = useState(null)
// Switch between discovery and the active challenge.
if (selected) {
return (
<ArchitectureEditor
problem={selected}
onBack={() => setSelected(null)}
/>
)
}
return (
<ProblemLibrary
problems={problems}
source={source}
onOpen={setSelected}
/>
)
}
- Save src/App.jsx.
- Refresh the browser.
- Open any challenge from the problem library.
You should see the selected challenge title, a draggable infrastructure palette, and an empty dotted canvas with zoom controls.
Editor does not open?
Confirm the app still imports ProblemLibrary for the unselected state. Render ArchitectureEditor only when the selected problem exists.
Help me fix the library-to-editor flow.
Persist and verify drafts
Guest persistence belongs in localStorage because it works without an account or network. The same graph can be upserted to Supabase after authentication.
- Open src/draftStore.js in the VS Code Explorer.
- Replace the local-only helper with local recovery plus authenticated cloud sync by pasting this file:
import { supabase } from './lib/supabase.js'
const emptyGraph = { nodes: [], edges: [] }
export function loadDraft(problemId) {
// Isolate each guest draft by challenge ID.
const raw = localStorage.getItem(`architecture-draft:${problemId}`)
if (!raw) return emptyGraph
try { return JSON.parse(raw) } catch { return emptyGraph }
}
export async function saveDraft(problemId, graph) {
localStorage.setItem(`architecture-draft:${problemId}`, JSON.stringify(graph))
// Mirror the draft only when an authenticated owner exists.
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { location: 'local' }
const { error } = await supabase.from('drafts').upsert({
user_id: user.id,
problem_id: problemId,
graph,
updated_at: new Date().toISOString(),
}, { onConflict: 'user_id,problem_id' })
if (error) throw error
return { location: 'cloud' }
}
Why save locally first?
Every save writes locally first, so a network error does not erase guest work. A signed-in session then mirrors the same graph into the owner-protected drafts table.
- Save src/draftStore.js.
- Open Design a URL Shortener from the library.
- Drag Client onto the canvas.
- Drag App Server onto the canvas.
- Drag Database onto the canvas.
- Connect Client to App Server.
- Connect App Server to Database.
- Move each node to a different position.
Before reloading, do you expect the graph structure and positions to return from browser storage?
- Reload the browser page.
- Open Design a URL Shortener again if the library is visible.
- Confirm all three nodes return in their saved positions.
- Confirm both edges remain attached.
- Select one node.
- Press Delete to remove it.
- Use the canvas controls to zoom and fit the remaining graph.
That is the offline-first editing loop working. Your graph survives reloads, while React Flow still handles movement, selection, deletion, and navigation.
Draft did not return?
Check the browser console for a JSON parse error. If an older malformed draft exists, clear the matching architecture-draft: key and rebuild the graph.
Help me debug draft recovery.
Your editor now produces a durable graph that the browser and database can store. Next, you will send that graph to a protected server-side judge with visible and hidden scenarios.
Save Drafts and Submission History
Build the Protected Cloud Judge
Your editor now produces a durable graph that survives reloads. The next step moves evaluation away from the browser so learners cannot change the rubric or hidden scenarios.
A protected judge treats the browser as an untrusted client. A Supabase Edge Function validates the bearer token, loads server-owned cases, evaluates the submitted graph, and records the result.
You will expose visible scenario feedback while keeping hidden checks inside the function. The learner receives a verdict, score, metrics, and improvement guidance without receiving the hidden rubric.
In this step, get ready to:
- Build the protected server-side judge.
- Authenticate browser submissions with the current bearer token.
- Run visible and hidden scenarios before recording the result.
Create the Edge Function judge
The judge receives a problem ID plus graph JSON. Its hidden cases live only in the deployed function, while public problem rows continue to describe visible scenarios.
- Open Edge Functions in the Supabase dashboard sidebar.
- Click Deploy a new function.
- Choose the dashboard editor path.
- Enter judge-submission as the function name.
- Open the generated index.ts file.
- Replace the starter code with the imports, case data, and evaluator below:
import { withSupabase } from 'npm:@supabase/server@1'
// Hidden cases never enter the public problems table.
const hiddenCases = {
'url-shortener': ['load-balancer', 'cache', 'database'],
'realtime-chat': ['queue', 'database'],
'video-streaming': ['cdn', 'storage'],
}
function evaluateGraph(problemId: string, graph: any) {
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []
const edges = Array.isArray(graph?.edges) ? graph.edges : []
const types = new Set(nodes.map((node: any) => node.data?.type))
const required = hiddenCases[problemId as keyof typeof hiddenCases] ?? []
const hiddenPassed = required.filter((type) => types.has(type)).length
const connectedIds = new Set(edges.flatMap((edge: any) => [edge.source, edge.target]))
const connectedNodes = nodes.filter((node: any) => connectedIds.has(node.id)).length
const connectivity = nodes.length ? connectedNodes / nodes.length : 0
const score = Math.round((hiddenPassed / Math.max(required.length, 1)) * 70 + connectivity * 30)
return {
verdict: score >= 70 ? 'Accepted' : 'Needs work',
score,
metrics: { nodeCount: nodes.length, edgeCount: edges.length, connectivity },
hiddenPassed,
hiddenTotal: required.length,
}
}
What does the evaluator measure?
- The hidden requirement list stays inside the function bundle.
- Connectivity checks whether submitted nodes participate in at least one edge.
- The score combines hidden component coverage with graph connectivity.
- Add the result-recording helper directly below evaluateGraph():
async function recordResult(admin: any, userId: string, problemId: string, graph: any, result: any) {
// The admin client is used only after the wrapper authenticates the caller.
const { error: submissionError } = await admin.from('submissions').insert({
user_id: userId,
problem_id: problemId,
graph,
verdict: result.verdict,
score: result.score,
metrics: result.metrics,
})
if (submissionError) throw submissionError
const { data: current } = await admin.from('progress')
.select('best_score, attempts')
.eq('user_id', userId)
.eq('problem_id', problemId)
.maybeSingle()
const { error: progressError } = await admin.from('progress').upsert({
user_id: userId,
problem_id: problemId,
status: result.verdict === 'Accepted' ? 'completed' : 'attempted',
best_score: Math.max(current?.best_score ?? 0, result.score),
attempts: (current?.attempts ?? 0) + 1,
updated_at: new Date().toISOString(),
})
if (progressError) throw progressError
}
Why record both result shapes?
The immutable submission preserves every attempt. The progress upsert keeps the best score and attempt count ready for the dashboard.
- Add the authenticated request handler directly below recordResult():
export default {
fetch: withSupabase({ auth: 'user' }, async (request, ctx) => {
try {
const userId = ctx.userClaims?.id
if (!userId) return new Response('Invalid session', { status: 401 })
const { problemId, graph } = await request.json()
if (!problemId || !graph) {
return new Response('Invalid submission', { status: 400 })
}
const result = evaluateGraph(problemId, graph)
await recordResult(ctx.supabaseAdmin, userId, problemId, graph, result)
return Response.json({
...result,
feedback: result.verdict === 'Accepted'
? ['The graph passed the current judge rubric.']
: ['Connect more nodes and cover the required architecture roles.'],
})
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : 'Judge failed' },
{ status: 500 },
)
}
}),
}
How is the judge protected?
- The auth: 'user' wrapper verifies the session before the handler runs.
- The verified user ID is copied from the server-side request context.
- The admin client records judge-owned results only after authentication succeeds.
- The browser keeps read-only access to its own submissions and progress rows.
- Click Deploy function in the dashboard editor.
- Wait for the function status to show a successful deployment.
- Open the function details page.
- Confirm JWT verification remains enabled.
Deployment can take a minute while Supabase bundles the function. The details page is ready when the deployment status changes to active.
Function failed to deploy?
Check the line number in the deployment log. TypeScript errors usually come from a missing closing brace between the evaluator, recorder, and request handler.
Help me debug the Edge Function deployment.
Authenticate and invoke the judge
The browser SDK automatically sends the current session's access token when it invokes the function. The UI still needs a clear sign-in path before submission.
- Create src/AuthPanel.jsx with the VS Code Explorer new-file control.
- Add the email and password sign-in form by pasting this file:
import { useState } from 'react'
import { supabase } from './lib/supabase.js'
export default function AuthPanel({ onSignedIn }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('Sign in to submit and sync drafts.')
async function signIn(event) {
event.preventDefault()
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
if (error) return setMessage(error.message)
setMessage(`Signed in as ${data.user.email}`)
onSignedIn(data.user)
}
return <form className="auth-panel" onSubmit={signIn}>
<label>Email<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
<button type="submit">Sign in</button>
<p>{message}</p>
</form>
}
What does sign-in unlock?
signInWithPassword() creates the browser session used by drafts, Edge Functions, and Row Level Security. The password stays inside the authentication request and is never stored in your app data.
- Save src/AuthPanel.jsx.
- Create src/judge.js.
- Add the browser invocation helper by pasting this file:
import { supabase } from './lib/supabase.js'
export async function submitToJudge(problemId, graph) {
// Supabase attaches the current access token to the function request.
const { data, error } = await supabase.functions.invoke('judge-submission', {
body: { problemId, graph },
})
if (error) throw error
return data
}
What crosses the trust boundary?
The browser sends only the problem ID plus current graph. The server chooses the rubric and records the authenticated result.
- Save src/judge.js.
- Create src/JudgePanel.jsx with the VS Code Explorer new-file control.
- Add session-aware submission and result rendering by pasting this file:
import { useEffect, useState } from 'react'
import AuthPanel from './AuthPanel.jsx'
import { submitToJudge } from './judge.js'
import { supabase } from './lib/supabase.js'
export default function JudgePanel({ problemId, graph }) {
const [user, setUser] = useState(null)
const [result, setResult] = useState(null)
const [status, setStatus] = useState('Ready to submit.')
useEffect(() => {
// Keep the panel aligned with the active browser session.
supabase.auth.getUser().then(({ data }) => setUser(data.user))
const { data } = supabase.auth.onAuthStateChange((_event, session) => setUser(session?.user ?? null))
return () => data.subscription.unsubscribe()
}, [])
async function submit() {
setStatus('Judging...')
submitToJudge(problemId, graph)
.then((next) => { setResult(next); setStatus('Result recorded.') })
.catch((error) => setStatus(error.message))
}
if (!user) return <AuthPanel onSignedIn={setUser} />
return <section className="judge-panel">
<button onClick={submit}>Submit design</button><p>{status}</p>
{result && <pre>{JSON.stringify(result, null, 2)}</pre>}
</section>
}
What does the judge panel manage?
- The session listener handles sign-in changes without a page reload.
- The submit handler sends only the current problem ID and graph.
- The result panel renders the verdict, score, metrics, hidden pass count, and feedback returned by the judge.
- Save src/JudgePanel.jsx.
- Open src/ArchitectureEditor.jsx.
- Find the existing import group at the top of the file.
// Existing editor imports.
import ArchitectureCanvas from './ArchitectureCanvas.jsx'
import { COMPONENTS } from './componentLibrary.js'
import { loadDraft, saveDraft } from './draftStore.js'
- Add the judge panel import below the canvas import so the group now includes:
// Editor dependencies plus the protected submission panel.
import ArchitectureCanvas from './ArchitectureCanvas.jsx'
import JudgePanel from './JudgePanel.jsx'
import { COMPONENTS } from './componentLibrary.js'
import { loadDraft, saveDraft } from './draftStore.js'
- Find the canvas line near the bottom of src/ArchitectureEditor.jsx.
{/* Existing editor surface. */}
<ArchitectureCanvas nodes={nodes} edges={edges} setNodes={setNodes} setEdges={setEdges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} />
- Add JudgePanel directly below the canvas so the end of the component now reads:
{/* Keep judge inputs tied to the current controlled graph. */}
<ArchitectureCanvas nodes={nodes} edges={edges} setNodes={setNodes} setEdges={setEdges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} />
<JudgePanel problemId={problem.id} graph={{ nodes, edges }} />
- Save src/ArchitectureEditor.jsx.
- Refresh the browser.
- Open any challenge from the problem library.
You should now see a sign-in form plus a submission control. The editor remains usable before authentication, while judge submission has an explicit account boundary.
Judge request rejected?
Confirm the same browser tab contains the active Supabase session before submission. A 401 response means the function rejected a missing or expired bearer token.
Help me debug authenticated judge submission.
Run visible and hidden scenarios
The first submission deliberately omits useful architecture roles. Running the weaker graph first proves that the server owns the verdict before you improve the design.
- Sign in with the account you created in the Supabase dashboard.
- Open Design a URL Shortener.
- Reset the editor.
- Drag Client onto the canvas.
- Drag Database onto the canvas.
- Connect Client to Database.
Before submitting this minimal graph, do you expect it to satisfy the hidden architecture coverage checks?
- Click Submit design.
You should see Needs work with a score below 70. The response shows the hidden pass count without exposing the required hidden roles.
- Drag Load Balancer onto the canvas.
- Drag Cache onto the canvas.
- Drag App Server onto the canvas.
- Connect Client to Load Balancer.
- Connect Load Balancer to Cache.
- Connect Cache to App Server.
- Connect App Server to Database.
Before the second submission, do you expect the new roles and complete path to cross the acceptance threshold?
- Click Submit design.
You should see Accepted with a higher score. The metrics show five nodes, four edges, and full connectivity.
That is the protected judge working end to end. The server validates identity, evaluates hidden cases, records the immutable attempt, and updates the learner's progress summary.
Improved graph still rejected?
If the second graph still needs work, inspect the submitted node data.type values. The hidden evaluator matches stable component keys, not displayed labels.
Help me compare the submitted graph with the judge inputs.
Your platform now records trusted submissions instead of browser-calculated scores. Next, you will turn those records into submission history, filters, and a progress dashboard.
Track Submissions and Progress
Your protected judge now stores trusted attempts plus a progress summary. Those records become much more useful when learners can compare outcomes and choose what to retry.
Submission history answers what happened on each attempt. The progress dashboard answers where to focus next across the whole problem library.
You will load owner-protected submissions, filter them by verdict, sort them by score or date, and summarise completion state. A retry action carries the saved graph back into the editor.
In this step, get ready to:
- Load and filter authenticated submission history.
- Build progress cards from best scores and attempt counts.
- Retry an earlier graph from the editor.
Load submission history
Row Level Security already limits the query to the current learner. The component can therefore focus on sorting and filtering the rows it receives.
- Create src/useSubmissions.js with the VS Code Explorer new-file control.
- Add the owner-protected submission query by pasting this file:
import { useCallback, useEffect, useState } from 'react'
import { supabase } from './lib/supabase.js'
export function useSubmissions() {
const [submissions, setSubmissions] = useState([])
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
setLoading(true)
const { data, error } = await supabase
.from('submissions')
.select('id, problem_id, verdict, score, metrics, graph, created_at')
.order('created_at', { ascending: false })
if (error) throw error
setSubmissions(data ?? [])
setLoading(false)
}, [])
useEffect(() => {
refresh()
}, [refresh])
return { submissions, loading, refresh }
}
What does the history query return?
The hook requests only the columns needed by history and retry. Its newest-first order matches the learner's most recent work.
- Save src/useSubmissions.js.
- Create src/SubmissionHistory.jsx.
- Add verdict filters, score sorting, and retry controls by pasting this file:
import { useMemo, useState } from 'react'
export default function SubmissionHistory({ submissions, onRetry }) {
const [verdict, setVerdict] = useState('All')
const [sort, setSort] = useState('Newest')
// Keep server rows immutable while deriving the selected view.
const visible = useMemo(() => submissions
.filter((item) => verdict === 'All' || item.verdict === verdict)
.toSorted((a, b) => sort === 'Highest score'
? b.score - a.score
: new Date(b.created_at) - new Date(a.created_at)), [sort, submissions, verdict])
return <section className="history">
<header><h2>Submission history</h2><span>{visible.length} attempts</span></header>
<div className="filters">
<select value={verdict} onChange={(event) => setVerdict(event.target.value)}>
<option>All</option><option>Accepted</option><option>Needs work</option>
</select>
<select value={sort} onChange={(event) => setSort(event.target.value)}>
<option>Newest</option><option>Highest score</option>
</select>
</div>
<div>{visible.map((item) => <article key={item.id}>
<strong>{item.problem_id}</strong><span>{item.verdict} · {item.score}/100</span>
<time>{new Date(item.created_at).toLocaleString()}</time>
<button onClick={() => onRetry(item)}>Retry this graph</button>
</article>)}</div>
</section>
}
How does history stay predictable?
- The verdict selector narrows the current learner's rows.
- toSorted() creates a sorted copy instead of mutating fetched state.
- Retry passes the complete submission row back to the parent workflow.
- Save src/SubmissionHistory.jsx.
- Open src/App.jsx in the Explorer sidebar.
- Replace the application shell with the library, history, and retry flow below:
import { useState } from 'react'
import ArchitectureEditor from './ArchitectureEditor.jsx'
import ProblemLibrary from './ProblemLibrary.jsx'
import SubmissionHistory from './SubmissionHistory.jsx'
import { useProblems } from './useProblems.js'
import { useSubmissions } from './useSubmissions.js'
export default function App() {
const { problems, source } = useProblems()
const { submissions, loading } = useSubmissions()
const [selected, setSelected] = useState(null)
const [view, setView] = useState('library')
// Restore the exact historical graph before opening its problem.
function retry(item) {
localStorage.setItem(`architecture-draft:${item.problem_id}`, JSON.stringify(item.graph))
setSelected(problems.find((problem) => problem.id === item.problem_id))
}
if (selected) return <ArchitectureEditor problem={selected} onBack={() => setSelected(null)} />
if (view === 'history') return <main><button onClick={() => setView('library')}>Back to library</button>{loading ? <p>Loading submissions...</p> : <SubmissionHistory submissions={submissions} onRetry={retry} />}</main>
return <main>
<nav><button onClick={() => setView('history')}>Submission history</button></nav>
<ProblemLibrary problems={problems} source={source} onOpen={setSelected} />
</main>
}
- Save src/App.jsx.
- Refresh the browser.
- Click Submission history above the problem library.
You should see the earlier weak and accepted URL shortener attempts. Changing the verdict filter should update the attempt count immediately.
No submissions visible?
If the list is empty, confirm you are signed in with the same account that invoked the judge. Row Level Security intentionally hides another account's submissions.
Help me debug missing submission rows.
Build the progress dashboard
Progress rows compress many immutable attempts into the learner's latest status. Joining them with public problem data produces cards that remain useful before every challenge has an attempt.
- Create src/ProgressDashboard.jsx.
- Add the summary calculations by pasting this file:
export default function ProgressDashboard({ problems, progress }) {
// Pair every public problem with an optional private progress row.
const cards = problems.map((problem) => ({
...problem,
progress: progress.find((row) => row.problem_id === problem.id),
}))
const completed = cards.filter((card) => card.progress?.status === 'completed').length
const attempts = progress.reduce((total, row) => total + row.attempts, 0)
const best = progress.reduce((score, row) => Math.max(score, row.best_score), 0)
return <section className="dashboard">
<header><h2>Your progress</h2><div>
<strong>{completed}/{cards.length} completed</strong>
<span>{attempts} attempts</span><span>{best} best score</span>
</div></header>
<div className="progress-grid">{cards.map((card) => <article key={card.id}>
<span>{card.progress?.status ?? 'not started'}</span>
<h3>{card.title}</h3>
<p>{card.progress?.best_score ?? 0}/100 best score</p>
<small>{card.progress?.attempts ?? 0} attempts</small>
</article>)}</div>
</section>
}
How are empty states represented?
The dashboard includes every public problem, even when no progress row exists. That makes not-started challenges visible beside attempted and completed work.
- Save src/ProgressDashboard.jsx.
- Create src/useProgress.js with the VS Code Explorer new-file control.
- Add the authenticated progress query by pasting this file:
import { useCallback, useEffect, useState } from 'react'
import { supabase } from './lib/supabase.js'
export function useProgress() {
const [progress, setProgress] = useState([])
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
// Read only the signed-in learner's RLS-protected summary rows.
setLoading(true)
const { data, error } = await supabase.from('progress')
.select('problem_id, status, best_score, attempts, updated_at')
.order('updated_at', { ascending: false })
if (error) throw error
setProgress(data ?? [])
setLoading(false)
}, [])
useEffect(() => {
refresh()
}, [refresh])
return { progress, loading, refresh }
}
- Save src/useProgress.js.
- Create src/ProgressView.jsx.
- Connect public problem rows to private progress rows by pasting this component:
import ProgressDashboard from './ProgressDashboard.jsx'
import { useProblems } from './useProblems.js'
import { useProgress } from './useProgress.js'
export default function ProgressView({ onBack }) {
const { problems } = useProblems()
const { progress, loading } = useProgress()
// Keep the dashboard's loading state visible to the learner.
return <main>
<button onClick={onBack}>Back to library</button>
{loading ? <p>Loading progress...</p> : <ProgressDashboard problems={problems} progress={progress} />}
</main>
}
- Save src/ProgressView.jsx.
- Open src/App.jsx.
- Add this import below the ProblemLibrary import:
// Add the authenticated progress screen to the app shell.
import ProgressView from './ProgressView.jsx'
- Add the progress branch directly below the existing history branch:
// Render the selected authenticated dashboard.
if (view === 'progress') {
return <ProgressView onBack={() => setView('library')} />
}
- Add a Your progress button beside Submission history in the navigation:
{/* Expose both authenticated record views. */}
<nav>
<button onClick={() => setView('history')}>Submission history</button>
<button onClick={() => setView('progress')}>Your progress</button>
</nav>
- Save src/App.jsx.
- Refresh the browser.
- Click Your progress.
You should see one completed URL shortener card, total attempts from both submissions, and not-started cards for untouched challenges.
Progress stays at zero?
Check that the progress query runs after authentication. A query made before session restoration can return an empty owner-protected result.
Help me debug the progress summary.
Retry an earlier architecture
A retry should begin from the chosen historical graph, not from whichever draft happens to be open. The retry handler copies that graph into the editor state and leaves the immutable submission untouched.
- Open src/App.jsx.
- Locate the retry(item) function added to the application shell.
- Confirm it writes item.graph to the matching architecture-draft: key.
- Confirm it selects the problem whose ID matches item.problem_id.
- Leave the original submission row unchanged.
Before choosing a retry, do you expect the accepted graph or the earlier weak graph to appear when you select the weaker attempt?
- Set the verdict filter to Needs work.
- Click Retry this graph on the earlier weak attempt.
- Confirm the editor opens with the two-node graph.
- Add the missing infrastructure roles.
- Submit the improved graph to the judge.
- Return to submission history.
- Confirm a new attempt appears while the earlier weak attempt remains.
The full learning loop now preserves evidence instead of overwriting it. Learners can inspect an old result, reopen its exact graph, improve it, and compare the new outcome.
Retry opens an empty graph?
Write the retry graph to the same problem-specific key used by loadDraft(). A different key opens an empty editor even though the submission row is correct.
Help me debug the retry handoff.
Your platform now turns judge results into a repeatable improvement loop. Next, you will add a timed interview session with autosave and automatic submission.
Run a Timed Interview Session
Your progress dashboard can now reopen any earlier graph and preserve every attempt. The next challenge is to practise under the same time pressure that makes interviews feel different from open-ended study.
A timed session needs explicit lifecycle state: start time, deadline, autosave checkpoints, and final submission. The server stores the session record so the timer cannot be reset by refreshing the page.
You will add a 30-minute interview mode with autosave, a visible countdown, and automatic submission at expiry. Exiting early records an abandoned session instead of silently deleting the attempt.
In this step, get ready to:
- Store timed interview session records.
- Build a countdown with periodic autosave.
- Submit automatically when time expires.
Create interview session records
The session row anchors the deadline to the server database. A browser refresh can restore the remaining time from ends_at instead of starting a fresh countdown.
- Open SQL Editor in the Supabase dashboard.
- Click New query.
- Create the interview session table and owner policies by running:
-- Store one timed practice lifecycle per interview attempt.
create table public.interview_sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users on delete cascade,
problem_id text not null references public.problems on delete cascade,
status text not null default 'active',
started_at timestamptz not null default now(),
ends_at timestamptz not null,
submitted_at timestamptz,
last_autosave_at timestamptz,
graph jsonb not null default '{"nodes":[],"edges":[]}'
);
alter table public.interview_sessions enable row level security;
grant select, insert, update on public.interview_sessions to authenticated;
create policy "Users read interview sessions" on public.interview_sessions
for select to authenticated using ((select auth.uid()) = user_id);
create policy "Users create interview sessions" on public.interview_sessions
for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "Users update interview sessions" on public.interview_sessions
for update to authenticated using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
What does a session record preserve?
- ends_at preserves the original deadline.
- last_autosave_at proves when the latest graph checkpoint reached the cloud.
- status distinguishes active, submitted, expired, and abandoned sessions.
- Confirm the SQL Editor reports a successful query.
- Open Table Editor.
- Confirm interview_sessions appears in the table list.
Session migration failed?
If policy creation fails, confirm the table exists before the policy statements run. A partially completed query can leave the table present while later statements still need to be rerun.
Help me debug the timed-session migration.
Build the countdown and autosave hook
The countdown derives remaining seconds from a fixed deadline. This avoids timer drift and keeps refresh recovery aligned with the server record.
- Create src/useInterviewTimer.js with the VS Code Explorer new-file control.
- Add the deadline-driven timer by pasting this file:
import { useEffect, useRef, useState } from 'react'
export function useInterviewTimer(endsAt, onExpire) {
const [secondsLeft, setSecondsLeft] = useState(0)
const expireRef = useRef(onExpire)
const expiredRef = useRef(false)
expireRef.current = onExpire
useEffect(() => {
// Permit one expiry callback for each server deadline.
expiredRef.current = false
function tick() {
const remaining = Math.max(0, Math.ceil((new Date(endsAt) - Date.now()) / 1000))
setSecondsLeft(remaining)
if (remaining === 0 && !expiredRef.current) {
expiredRef.current = true
expireRef.current()
}
}
tick()
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
}, [endsAt])
const minutes = Math.floor(secondsLeft / 60).toString().padStart(2, '0')
const seconds = (secondsLeft % 60).toString().padStart(2, '0')
return { secondsLeft, label: `${minutes}:${seconds}` }
}
Why use a fixed deadline?
The hook recalculates from endsAt every second. Background-tab delays therefore correct themselves on the next tick.
- Save src/useInterviewTimer.js.
- Create src/interviewService.js.
- Add session creation and autosave by pasting this file:
import { supabase } from './lib/supabase.js'
export async function startInterview(problemId) {
const { data: { user } } = await supabase.auth.getUser()
const now = new Date().toISOString()
const { data: active } = await supabase.from('interview_sessions').select('*')
.eq('user_id', user.id).eq('problem_id', problemId).eq('status', 'active')
.gt('ends_at', now).order('started_at', { ascending: false }).limit(1).maybeSingle()
if (active) return active
const endsAt = new Date(Date.now() + 30 * 60 * 1000).toISOString()
const { data, error } = await supabase.from('interview_sessions')
.insert({ user_id: user.id, problem_id: problemId, ends_at: endsAt }).select().single()
if (error) throw error
return data
}
export async function autosaveInterview(sessionId, graph) {
const { error } = await supabase.from('interview_sessions').update({
graph, last_autosave_at: new Date().toISOString(),
}).eq('id', sessionId)
if (error) throw error
}
export async function closeInterview(sessionId, status) {
const { error } = await supabase.from('interview_sessions').update({
status, submitted_at: status === 'abandoned' ? null : new Date().toISOString(),
}).eq('id', sessionId)
if (error) throw error
}
How does the session lifecycle work?
Starting a session fixes a 30-minute deadline. Autosave updates the graph without changing that deadline, while closure records how the session ended.
- Save src/interviewService.js.
- Confirm both timer files appear in the src folder.
Session cannot be saved?
Check that the user is authenticated before startInterview() runs. The table requires an owner ID that matches the active session.
Help me debug interview-session persistence.
Run and expire an interview session
Interview mode wraps the existing editor instead of creating a second graph implementation. It adds a timer, periodic cloud checkpoints, and lifecycle controls around the same nodes and edges.
- Create src/ActiveInterview.jsx with the VS Code Explorer new-file control.
- Add guarded expiry, autosave, and final submission by pasting this file:
import { useEffect, useRef, useState } from 'react'
import ArchitectureEditor from './ArchitectureEditor.jsx'
import { autosaveInterview, closeInterview } from './interviewService.js'
import { submitToJudge } from './judge.js'
import { useInterviewTimer } from './useInterviewTimer.js'
export default function ActiveInterview({ session, problem, onExit }) {
const [graph, setGraph] = useState(session.graph)
const graphRef = useRef(graph)
const finishing = useRef(false)
graphRef.current = graph
useEffect(() => {
// Save the latest graph every 15 seconds without resetting the interval.
const timer = setInterval(() => autosaveInterview(session.id, graphRef.current), 15000)
return () => clearInterval(timer)
}, [session.id])
async function finish(status = 'submitted') {
if (finishing.current) return
finishing.current = true
await autosaveInterview(session.id, graphRef.current)
await submitToJudge(problem.id, graphRef.current)
await closeInterview(session.id, status)
onExit()
}
const timer = useInterviewTimer(session.ends_at, () => finish('expired'))
return <section className="interview-mode"><header><strong>{timer.label}</strong><button onClick={() => finish()}>Finish interview</button></header><ArchitectureEditor problem={problem} initialGraph={session.graph} onGraphChange={setGraph} showJudge={false} onBack={async () => { if (finishing.current) return; finishing.current = true; await closeInterview(session.id, 'abandoned'); onExit() }} /></section>
}
How does interview mode end?
- The wrapper creates one server session when interview mode opens.
- The timer calls the same finish path automatically at zero.
- Leaving early marks the session abandoned without creating a judge submission.
- Save src/ActiveInterview.jsx.
- Create src/InterviewMode.jsx.
- Resume or create a server-backed session by pasting this wrapper:
import { useEffect, useState } from 'react'
import ActiveInterview from './ActiveInterview.jsx'
import { startInterview } from './interviewService.js'
export default function InterviewMode({ problem, onExit }) {
const [session, setSession] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
// Reuse the active row so refreshes preserve its graph and deadline.
startInterview(problem.id)
.then(setSession)
.catch((reason) => setError(reason.message))
}, [problem.id])
if (error) return <p>{error}</p>
if (!session) return <p>Starting interview session...</p>
return <ActiveInterview session={session} problem={problem} onExit={onExit} />
}
- Save src/InterviewMode.jsx.
- Open src/ArchitectureEditor.jsx.
- Find the component signature at the top of the file:
// Current editor signature.
export default function ArchitectureEditor({ problem, onBack }) {
- Replace the current signature with optional interview inputs and judge visibility:
// Accept restored graph state while hiding open-practice submission controls.
export default function ArchitectureEditor({ problem, onBack, initialGraph, onGraphChange, showJudge = true }) {
- Find the line that loads the saved graph.
// Current draft initialisation.
const saved = loadDraft(problem.id)
- Prefer the server-restored interview graph when one exists:
// Interview state takes priority over the normal local draft.
const saved = initialGraph ?? loadDraft(problem.id)
- Add the parent callback inside the existing graph save effect:
// Persist normal drafts and expose the controlled graph to interview mode.
useEffect(() => {
const graph = { nodes, edges }
const timer = setTimeout(() => saveDraft(problem.id, graph), 400)
onGraphChange?.(graph)
return () => clearTimeout(timer)
}, [edges, nodes, onGraphChange, problem.id])
- Find the existing JudgePanel line near the bottom of the editor.
// Current open-practice submission control.
<JudgePanel problemId={problem.id} graph={{ nodes, edges }} />
- Render the judge only when the parent practice mode enables it:
{/* Timed mode submits through its guarded lifecycle instead. */}
{showJudge && <JudgePanel problemId={problem.id} graph={{ nodes, edges }} />}
- Save src/ArchitectureEditor.jsx.
- Open src/ProblemLibrary.jsx.
- Add onInterview to the component props.
// Give each selected challenge a timed-mode callback.
export default function ProblemLibrary({ problems, source, onOpen, onInterview }) {
- Add the timed action beside the existing editor action:
{/* Offer open practice and timed practice for the selected challenge. */}
<button onClick={() => onOpen(selected)}>Open editor</button>
<button onClick={() => onInterview(selected)}>Start timed interview</button>
- Save src/ProblemLibrary.jsx.
- Open src/App.jsx.
- Import InterviewMode.
// Add timed practice to the app shell.
import InterviewMode from './InterviewMode.jsx'
- Add interview selection state beside the existing view state:
// Track the challenge currently running under a deadline.
const [interview, setInterview] = useState(null)
- Render interview mode before the normal selected-editor branch:
// Keep timed sessions separate from open-ended editor sessions.
if (interview) {
return <InterviewMode problem={interview} onExit={() => setInterview(null)} />
}
- Pass the timed-mode callback to the problem library:
{/* Connect both practice modes to the problem library. */}
<ProblemLibrary
problems={problems}
source={source}
onOpen={setSelected}
onInterview={setInterview}
/>
- Save src/App.jsx.
- Refresh the browser.
- Select Design Real-Time Chat.
- Click Start timed interview.
You should see a countdown beginning close to 30:00 above the same architecture editor. Refreshing the page should restore the deadline from the active session instead of granting another 30 minutes.
Waiting 30 minutes would slow this project unnecessarily. For the expiry check, change the temporary duration expression from 30 * 60 * 1000 to 30 * 1000 before starting a new test session.
- Click Back to library in the active interview editor to mark the 30-minute session abandoned.
- Change the duration expression in src/interviewService.js to 30 * 1000.
- Save src/interviewService.js.
- Select Design Video Streaming from the library.
- Click Start timed interview to create a separate 30-second test session.
- Add two connected nodes before the timer reaches zero.
- Watch the countdown reach 00:00.
When the countdown reaches 00:00, the guarded finish path saves the latest graph and submits it once. You should return to the library and see a new attempt in submission history.
- Restore the duration expression to 30 * 60 * 1000 after the expiry test.
- Save src/interviewService.js.
You now have a timed practice flow that survives refreshes and submits at a real deadline. Autosave protects the graph without turning the timer into a client-controlled value.
Timer behaves unexpectedly?
If expiry fires more than once, confirm the timer interval is cleared when the component unmounts. Also disable the finish action after the first submission starts.
Help me debug timed interview expiry.
Your timed mode now records realistic interview attempts with expiry behavior. Next, you will aggregate accepted results into a live leaderboard without exposing private drafts.
Build the Realtime Leaderboard
Your timed mode now produces authenticated judge results with a fixed deadline. The final platform feature turns completed work into a shared ranking without exposing private drafts or graph payloads.
A leaderboard needs a safe public projection of private activity. The database will publish only a display name, problem ID, best score, attempt count, and update time.
You will maintain that projection from trusted progress updates, subscribe to changes through Supabase Realtime, and calculate stable ranks in the browser. The first page stays bounded to protect load as usage grows.
In this step, get ready to:
- Create the public leaderboard projection.
- Subscribe to score updates in realtime.
- Rank top learners by problem and overall score.
Create the public leaderboard projection
The leaderboard table contains no graph JSON and no email address. A database trigger refreshes it whenever the trusted progress summary changes.
- Open SQL Editor in the Supabase dashboard.
- Click New query.
- Create public ranking rows by running:
-- Publish only safe ranking fields.
create table public.leaderboard_entries (
user_id uuid not null references auth.users on delete cascade,
problem_id text not null references public.problems on delete cascade,
display_name text not null,
best_score integer not null default 0,
attempts integer not null default 0,
updated_at timestamptz not null default now(),
primary key (user_id, problem_id)
);
alter table public.leaderboard_entries enable row level security;
grant select on public.leaderboard_entries to anon, authenticated;
create policy "Leaderboard entries are public" on public.leaderboard_entries
for select to anon, authenticated using (true);
create index leaderboard_problem_score_idx
on public.leaderboard_entries (problem_id, best_score desc, updated_at asc);
Why use a projection table?
The composite key keeps one best-score row per learner and challenge. The index supports bounded top-score queries without scanning submission history.
- Confirm the SQL Editor reports a successful query.
- Create the profile trigger that assigns a private default display name by running:
-- Create a profile whenever Supabase Auth creates a user.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, username)
values (new.id, 'learner-' || left(new.id::text, 8))
on conflict (id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Backfill the account created earlier.
insert into public.profiles (id, username)
select id, 'learner-' || left(id::text, 8) from auth.users
on conflict (id) do nothing;
Why generate a display name?
The default name avoids publishing email addresses. Learners can later change their own username through the profile update policy.
- Confirm the SQL Editor reports a successful query.
- Create the progress-to-leaderboard trigger by running:
-- Refresh the public projection from trusted progress rows.
create or replace function public.sync_leaderboard_entry()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.leaderboard_entries
(user_id, problem_id, display_name, best_score, attempts, updated_at)
select new.user_id, new.problem_id, profiles.username,
new.best_score, new.attempts, new.updated_at
from public.profiles where profiles.id = new.user_id
on conflict (user_id, problem_id) do update set
display_name = excluded.display_name,
best_score = excluded.best_score,
attempts = excluded.attempts,
updated_at = excluded.updated_at;
return new;
end;
$$;
create trigger on_progress_changed
after insert or update on public.progress
for each row execute procedure public.sync_leaderboard_entry();
alter publication supabase_realtime add table public.leaderboard_entries;
How does a score reach Realtime?
The trigger runs after the judge updates progress. Realtime then broadcasts changes from the safe projection table instead of the private submissions table.
- Confirm the SQL Editor reports a successful query.
- Submit one accepted design again to fire the trigger.
- Open Table Editor.
- Confirm one row appears in leaderboard_entries.
Leaderboard row missing?
If the projection remains empty, confirm a matching profile row exists for the authenticated user. The trigger intentionally skips rows that lack a public display name.
Help me debug the leaderboard projection.
Subscribe to ranking updates
The initial query loads a bounded top page. The channel then refreshes that page whenever an insert, update, or delete changes the projection.
- Create src/useLeaderboard.js with the VS Code Explorer new-file control.
- Add the bounded query and Realtime subscription by pasting this file:
import { useCallback, useEffect, useState } from 'react'
import { supabase } from './lib/supabase.js'
export function useLeaderboard(problemId) {
const [entries, setEntries] = useState([])
const refresh = useCallback(async () => {
let query = supabase.from('leaderboard_entries')
.select('user_id, problem_id, display_name, best_score, attempts, updated_at')
.order('best_score', { ascending: false })
.order('updated_at', { ascending: true })
.range(0, 49)
if (problemId !== 'overall') query = query.eq('problem_id', problemId)
const { data, error } = await query
if (error) throw error
setEntries(data ?? [])
}, [problemId])
useEffect(() => {
refresh()
const channel = supabase.channel(`leaderboard:${problemId}`)
.on('postgres_changes', { event: '*', schema: 'public', table: 'leaderboard_entries' }, refresh)
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [problemId, refresh])
return entries
}
How does the subscription stay bounded?
- range(0, 49) caps the first page at 50 rows.
- A problem selection adds an indexed equality filter.
- Cleanup removes the old channel when the selected leaderboard changes.
- Save src/useLeaderboard.js.
- Create src/Leaderboard.jsx.
- Add per-problem and overall ranking by pasting this file:
import { useMemo, useState } from 'react'
import { useLeaderboard } from './useLeaderboard.js'
export default function Leaderboard({ problems }) {
const [scope, setScope] = useState('overall')
const entries = useLeaderboard(scope)
// Overall scope sums each learner's best score across problems.
const ranked = useMemo(() => {
const rows = scope === 'overall'
? Object.values(entries.reduce((map, row) => {
map[row.user_id] ??= { ...row, best_score: 0, attempts: 0 }
map[row.user_id].best_score += row.best_score
map[row.user_id].attempts += row.attempts
return map
}, {}))
: entries
return rows.toSorted((a, b) => b.best_score - a.best_score || a.attempts - b.attempts)
}, [entries, scope])
return <section className="leaderboard"><header><h2>Leaderboard</h2>
<select value={scope} onChange={(event) => setScope(event.target.value)}>
<option value="overall">Overall</option>{problems.map((problem) => <option key={problem.id} value={problem.id}>{problem.title}</option>)}
</select></header>
<ol>{ranked.map((entry) => <li key={`${entry.user_id}:${entry.problem_id}`}>
<strong>{entry.display_name}</strong><span>{entry.best_score} points</span><small>{entry.attempts} attempts</small>
</li>)}</ol>
</section>
}
How are ranks calculated?
Per-problem mode ranks one row per learner. Overall mode groups the bounded result set by learner, then sums best scores and uses fewer attempts as the tie-breaker.
- Save src/Leaderboard.jsx.
- Create src/LeaderboardView.jsx with the VS Code Explorer new-file control.
- Connect the public problem list to the ranking component by pasting this file:
import Leaderboard from './Leaderboard.jsx'
import { useProblems } from './useProblems.js'
export default function LeaderboardView({ onBack }) {
const { problems } = useProblems()
// Supply the public challenge labels used by the scope selector.
return <main>
<button onClick={onBack}>Back to library</button>
<Leaderboard problems={problems} />
</main>
}
- Save src/LeaderboardView.jsx.
- Open src/App.jsx.
- Import the new leaderboard view:
// Add the public ranking screen to the app shell.
import LeaderboardView from './LeaderboardView.jsx'
- Add the leaderboard branch beside the history and progress branches:
// Render the public ranking screen from the shared view state.
if (view === 'leaderboard') {
return <LeaderboardView onBack={() => setView('library')} />
}
- Add a Leaderboard button to the existing navigation:
{/* Keep the three record views in one navigation row. */}
<nav>
<button onClick={() => setView('history')}>Submission history</button>
<button onClick={() => setView('progress')}>Your progress</button>
<button onClick={() => setView('leaderboard')}>Leaderboard</button>
</nav>
- Save src/App.jsx.
- Refresh the browser.
- Click Leaderboard.
You should see your generated display name, current score, and attempt count. Switching from Overall to the URL shortener scope should keep the same row with the problem-specific score.
Leaderboard does not update?
Confirm leaderboard_entries belongs to the supabase_realtime publication. Also check that the component removes its old channel during cleanup.
Help me debug Realtime leaderboard updates.
Verify ranking changes live
A genuine realtime check needs two views of the same public data. One tab stays on the leaderboard while the other creates a new trusted score.
- Open the platform in a second browser tab.
- Keep the first tab on the URL shortener leaderboard.
- Open the URL shortener editor in the second tab.
- Improve the graph enough to beat your current best score.
- Submit the improved graph in the second tab.
Before returning to the first tab, do you expect the ranking row to require a manual refresh?
- Return to the first browser tab.
- Confirm the score and attempt count update without a page refresh.
- Switch to the Overall leaderboard.
- Confirm the overall total reflects the new best score.
Your full platform now closes the loop from discovery to public achievement. Private graphs remain behind owner policies, while trusted best scores flow into a bounded live ranking.
Ranking row is stale?
If the score changes but the attempt count does not, inspect the progress upsert in the judge. The projection copies both fields from the same trusted progress row.
Help me trace a stale leaderboard row.
Secret mission
Build the Admin Challenge Authoring Workflow
Build an admin-only challenge authoring workflow with public prompts, rubric weights, visible scenarios, and private hidden judge cases.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep the platform running, pause local development, or delete every resource. The completed workflow fits within the verified Supabase free-plan allowances, and the local Vite server has no ongoing cost.
Resources you used:
- The local architecture-arena folder contains the React source and installed npm packages.
- The browser stores guest drafts under problem-specific architecture-draft: keys.
- The Supabase project contains authentication users, database tables, Edge Functions, Realtime configuration, drafts, submissions, progress, interview sessions, and leaderboard entries.
Keep everything running
No action is needed. Choose this if you are still practising, improving the judge, or adding challenges.
- Keep the architecture-arena folder on your computer.
- Keep the Supabase project active.
- Keep browser storage for guest draft recovery.
- Restart local development later with the existing npm workflow.
Pause - I'll come back to this later
Stop the running development process to free local resources. Your source files, browser drafts, and Supabase data remain available for your next session.
- Return to the terminal running the Vite server.
- Press Ctrl+C to stop the process.
- Confirm the terminal prompt returns.
- Keep the architecture-arena folder.
- Leave the Supabase project unchanged.
Free Supabase projects may pause after one week without activity. Opening the project again can take a short time while it resumes.
Delete - I don't want to use this again
Deleting the project removes authentication users, database rows, Edge Functions, and Realtime data. Export anything you want to keep before continuing.
- Return to the terminal running the Vite server.
- Press Ctrl+C to stop the process.
- Open the local platform in your browser before closing its tab.
- Open the browser developer console.
- Clear the local site's guest drafts by running:
// Remove only this project's guest drafts.
Object.keys(localStorage)
.filter((key) => key.startsWith('architecture-draft:'))
.forEach((key) => localStorage.removeItem(key))
This removes only the platform's guest draft keys from the current development origin. Cloud drafts and submissions remain until you delete the Supabase project.
- Delete the local project folder with the command for your operating system:
macOS
- Remove the project from your Desktop by running:
rm -rf ~/Desktop/architecture-arena
Run ls ~/Desktop and confirm architecture-arena is absent.
Windows
- Remove the project from your Desktop by running:
Remove-Item -Recurse -Force "$HOME\Desktop\architecture-arena"
Open File Explorer and confirm architecture-arena is absent from your Desktop.
- Open the Supabase Dashboard.
- Select the architecture-arena project.
- Open Project Settings in the sidebar.
- Select General.
- Click Delete project in the project deletion section.
- Complete the confirmation prompt shown by the dashboard.
- Confirm the project no longer appears in your Supabase organisation.
Nice Work!
Nice Work!
You did it! You built a full-stack system design practice platform with offline editing, authenticated cloud judging, timed sessions, progress tracking, and live rankings.
You've learned how to:
- Build an offline-first architecture editor where guests can drag components, wire request paths, recover drafts, and sync after authentication.
- Protect a server-side judge with bearer-token validation, hidden scenarios, immutable submissions, and Row Level Security.
- Turn trusted results into timed sessions, progress dashboards, retry workflows, and realtime leaderboards.
- Secret Mission: Build an admin-only challenge authoring workflow with public prompts, rubric weights, visible scenarios, and private hidden cases.
Ready to quiz yourself?