Server-authoritative timed tasks — A web application for conducting fair, tamper-proof timed assessments where all timing logic runs on the server.
- ⏱️ Server-Authoritative Timing — All time calculations happen on the server, preventing client-side manipulation
- 🔄 Session Resumption — Users can close the browser and return; their timer continues
- 📊 Admin Dashboard — View all submissions with status, timing, and proof URLs
- 🎨 Modern UI — Dark theme with glass-morphism effects and smooth animations
- ♿ Accessible — ARIA labels, keyboard navigation, reduced motion support
- Framework: Next.js 16 (App Router)
- Database: Supabase (PostgreSQL)
- Styling: Tailwind CSS 4
- Language: TypeScript
git clone <your-repo-url>
cd fairtask
npm installFollow the Supabase Setup Guide below to get your environment variables.
Create a .env.local file in the project root:
NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_keyCopy and run the SQL schema in your Supabase SQL Editor. See Database Schema below.
npm run devOpen http://localhost:3000 to see the app.
- Go to supabase.com and sign in (or create an account)
- Click "New Project"
- Fill in the details:
- Name:
fairtime(or any name you prefer) - Database Password: Generate a strong password (save this!)
- Region: Choose the closest to your users
- Name:
- Click "Create new project" and wait for it to initialize (~2 minutes)
Once your project is ready:
- Click "Project Settings" (gear icon) in the left sidebar
- Click "API" under Configuration
You'll see a page with your API credentials:
- Look for "Project URL"
- Copy the URL (looks like
https://xxxxx.supabase.co)
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
- Look for "Project API keys"
- Copy the
anonpublickey (the shorter one, safe for browsers)
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
- On the same page, find the
service_rolekey ⚠️ Click "Reveal" to see it⚠️ NEVER expose this in client-side code or commit to git!
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Create a file named .env.local in your project root with all three variables:
# Supabase Configuration
# Get these from: Supabase Dashboard → Project Settings → API
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...your-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...your-service-role-keyNote: The
.env.localfile is already in.gitignoreand won't be committed.
Run this SQL in your Supabase Dashboard:
- Go to SQL Editor (left sidebar)
- Click "New query"
- Paste the following SQL and click "Run"
-- =============================================
-- FairTime Database Schema
-- =============================================
-- Enable UUID extension (usually already enabled)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- =============================================
-- Tasks Table
-- =============================================
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
time_limit_seconds INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- =============================================
-- User Sessions Table
-- =============================================
CREATE TABLE IF NOT EXISTS user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL,
task_id TEXT NOT NULL REFERENCES tasks(id),
started_at TIMESTAMP WITH TIME ZONE,
deadline_at TIMESTAMP WITH TIME ZONE,
submitted_at TIMESTAMP WITH TIME ZONE,
proof_url TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Ensure one session per email per task
CONSTRAINT unique_email_task UNIQUE(email, task_id),
-- Validate status values
CONSTRAINT valid_status CHECK (status IN ('pending', 'in_progress', 'on_time', 'late', 'not_submitted'))
);
-- =============================================
-- Auto-update updated_at Trigger
-- =============================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_user_sessions_updated_at
BEFORE UPDATE ON user_sessions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =============================================
-- Indexes for Performance
-- =============================================
CREATE INDEX IF NOT EXISTS idx_user_sessions_email ON user_sessions(email);
CREATE INDEX IF NOT EXISTS idx_user_sessions_task_id ON user_sessions(task_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_status ON user_sessions(status);
-- =============================================
-- Seed MVP Task (30 minutes = 1800 seconds)
-- =============================================
INSERT INTO tasks (id, title, description, time_limit_seconds)
VALUES (
'mvp-task-1',
'FairTime MVP Task',
'Complete the assigned task within the time limit. Your timer started when you entered your email. Submit a link to your completed work before the deadline.',
1800
)
ON CONFLICT (id) DO NOTHING;fairtask/
├── app/
│ ├── page.tsx # Landing page with email form
│ ├── layout.tsx # Root layout with fonts
│ ├── globals.css # Global styles and CSS variables
│ ├── admin/
│ │ └── page.tsx # Admin dashboard
│ ├── task/
│ │ └── [sessionId]/
│ │ ├── page.tsx # Task display (server component)
│ │ ├── TaskClient.tsx # Task UI (client component)
│ │ ├── loading.tsx # Loading skeleton
│ │ ├── error.tsx # Error boundary
│ │ └── not-found.tsx # 404 page
│ └── api/
│ ├── health/route.ts # Health check endpoint
│ ├── session/
│ │ ├── start/route.ts # POST: Create/resume session
│ │ └── [id]/
│ │ ├── route.ts # GET: Session state
│ │ └── submit/route.ts # POST: Submit proof
│ └── admin/
│ └── submissions/route.ts # GET: All submissions
├── components/
│ ├── Timer.tsx # Reusable timer with polling
│ ├── ProofSubmission.tsx # Submission form + confirmation
│ ├── LoadingSkeleton.tsx # Loading states
│ └── ErrorBoundary.tsx # Error handling
├── lib/
│ ├── supabase/
│ │ ├── server.ts # Server-side Supabase client
│ │ └── client.ts # Browser-side Supabase client
│ ├── types.ts # TypeScript interfaces
│ ├── time.ts # Time calculation utilities
│ └── retry.ts # Retry with exponential backoff
└── .env.local # Environment variables (not in git)
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/session/start |
Create or resume a session |
GET |
/api/session/[id] |
Get session state with timing |
POST |
/api/session/[id]/submit |
Submit proof URL |
GET |
/api/admin/submissions |
List all submissions |
GET |
/api/health |
Health check |
| Route | Description |
|---|---|
/ |
Landing page with email input |
/task/[sessionId] |
Task page with timer and submission form |
/admin |
Admin dashboard with all submissions |
To change the task content or time limit, update the seed data in Supabase:
UPDATE tasks
SET
title = 'Your New Task Title',
description = 'Your task description here...',
time_limit_seconds = 3600 -- 1 hour
WHERE id = 'mvp-task-1';Or insert a new task:
INSERT INTO tasks (id, title, description, time_limit_seconds)
VALUES (
'custom-task-1',
'Custom Task',
'Description of what participants need to do...',
2700 -- 45 minutes
);- Push your code to GitHub
- Go to vercel.com and import your repository
- Add environment variables in Vercel dashboard:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY
- Deploy!
The app runs anywhere that supports Next.js:
- Railway
- Render
- DigitalOcean App Platform
- Self-hosted with
npm run build && npm start
- ⏱️ Server-Authoritative: All time calculations happen on the server
- 🔒 Service Role Key: Never expose in client-side code
- 📝 Immutable Submissions: Once submitted,
submitted_atcannot be changed - 🚫 No Auth (MVP): Admin dashboard is unprotected; add authentication for production
MIT
Built with ❤️ using Next.js and Supabase