NeuroSpace
AI Productivity Platform
Most productivity apps are passive storage systems. They hold tasks and wait. NeuroSpace is different — it thinks alongside you. This is the engineering story behind building it.
The Problem
Most productivity apps are passive storage systems. They hold tasks and wait. Users still have to decide what to work on, estimate how long things take, notice when they are falling behind, and figure out why they keep missing deadlines. The cognitive overhead of managing a productivity system often exceeds the benefit.
The question I wanted to answer: what would a productivity tool look like if it actually thought alongside you?
Technical Architecture
| Layer | Choice | Reasoning |
|---|---|---|
| Framework | Next.js 15 App Router | Server components reduce client bundle. API routes collocate server logic with the UI that uses it |
| Language | TypeScript | Shared types between API responses and UI state — the source of truth lives in one place |
| Auth | Clerk | Production-grade auth without building session management. JWT templates allow Clerk tokens to authenticate Supabase requests |
| Database | Supabase (PostgreSQL) | Row-level security scopes all queries to the authenticated user. JSONB column stores session snapshots efficiently |
| AI | OpenAI GPT-4o-mini | Best price-to-quality ratio for structured JSON generation and conversational responses |
| Deployment | Vercel | Zero-config CI/CD. Preview deployments per PR |
Data Flow
User Action
↓
Optimistic UI Update (instant)
↓
API Route (authenticated via Clerk)
↓
Supabase Admin Client (service role, bypasses RLS)
↓
PostgreSQL Write
↓
Snapshot Save (full state as JSONB)Every mutation follows this pattern. The optimistic update makes the UI feel instant. The snapshot ensures state survives refresh.
Engineering Decisions
Why Session Snapshots?
The naive approach: on every page load, fetch all tasks and reconstruct state. The problem: undo/redo history is lost, column order is lost, and archived tasks disappear. My approach: save the complete state as a JSONB snapshot after every mutation — one row per user, always current. This also makes undo/redo persistent across refreshes.
async function syncToDb(restored: Task[], previous: Task[]) {
const prevIds = new Set(previous.map((t) => t.id));
const restoredIds = new Set(restored.map((t) => t.id));
// Re-insert tasks that undo brought back
for (const task of restored.filter((t) => !prevIds.has(t.id))) {
await fetch("/api/tasks", { method: "POST", body: JSON.stringify(task) });
}
// Delete tasks that redo removed
for (const task of previous.filter((t) => !restoredIds.has(t.id))) {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
}
}Why useRef for Undo/Redo Stacks?
Undo/redo stacks do not cause re-renders — they only matter at the moment of undo. Using useState would trigger unnecessary renders on every mutation registration. structuredClone creates a deep copy that handles Date objects correctly.
export function useUndoRedo<T>() {
const past = useRef<T[]>([]);
const future = useRef<T[]>([]);
function register(prevState: T) {
past.current.push(structuredClone(prevState));
future.current = []; // new action clears redo
}
}Why Midnight Archiving Instead of 24 Hours?
A task completed at 11pm would still appear in the Done column the next morning under a 24-hour rule. The correct boundary is midnight in local time — not UTC, which would shift the boundary by the user's timezone offset.
function isReadyToArchive(task: Task): boolean {
if (task.status !== "done" || !task.completed_at) return false;
const midnight = new Date();
midnight.setHours(0, 0, 0, 0); // local midnight — not UTC
return new Date(task.completed_at) < midnight;
}The Bugs That Taught Me the Most
1. Silent Persistence Failure
Tasks were saving locally and appearing on the board, but disappeared on refresh. No errors in the console. Every POST /api/history returned 400 silently.
The API was validating Array.isArray(body.snapshot). The provider was sending an object, not an array — the shape had changed when I added archive support but the API never got updated. Lesson: when two systems share a data contract, define the type once and import it in both places.
2. Undo Across Refresh
Undo worked perfectly in the same session. After refresh, the undone delete came back. The task had been physically deleted from Supabase — the snapshot saved the restored state but the tasks table did not have the row.
After every undo/redo, sync the actual DB state to match the restored state. Re-insert deleted tasks, delete tasks that redo removed. The database must match memory at all times.
3. Hydration Mismatch
Math.random() was called at module level to generate a unique tab identifier. Server rendered one value, client hydrated with a different value. React threw a hydration error.
Move random generation inside a useRef that only initialises on the client — check typeof window !== 'undefined' before assigning the value.
4. Case Sensitivity on Linux
App worked perfectly on Windows locally. Vercel build failed: Module not found: Can't resolve './FocusMode'. The file was saved as focusMode.tsx — Windows is case-insensitive, Linux (Vercel) is not.
git mv to rename the file to match the import exactly. Always match import casing to file casing. Build and test on Linux from day one, or add a CI linting step that catches this before it reaches production.
AI Integration Design
The planner adapts to the user's energy level before calling GPT. A mood multiplier adjusts session lengths so GPT's time allocations match actual capacity.
function adaptToMood(mood: Mood, learning: Learning) {
const mul =
mood.energy === "low" ? 0.6 :
mood.energy === "high" ? 1.3 : 1;
return {
average: Math.max(5, Math.round(learning.average * mul)),
deep: Math.max(5, Math.round(learning.deep * mul)),
};
}Graceful Degradation
Every AI endpoint has a local fallback. The app works without OpenAI credits. It gets smarter when you have them.
try {
const response = await openai.chat.completions.create({ ... });
return NextResponse.json(parseResponse(response));
} catch (aiErr: any) {
// 429, 401, network error — all handled the same way
return NextResponse.json(localFallback(task, learning, mood));
}Results
What I Would Do Differently
Define API contracts first. I built the provider and the API routes in parallel and they drifted. In a real team you would write the OpenAPI spec first and generate types from it.
Test the error paths, not just the happy path. The silent persistence failure would have been caught immediately by a test that asserted the snapshot shape.
Use a type-safe API client. Raw fetch with manual JSON parsing requires manual type assertions everywhere. tRPC or a generated SDK would have made the shape mismatch impossible.
Never assume case-insensitivity. Build and test on Linux from day one.