back to portfolio
case study

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

LayerChoiceReasoning
FrameworkNext.js 15 App RouterServer components reduce client bundle. API routes collocate server logic with the UI that uses it
LanguageTypeScriptShared types between API responses and UI state — the source of truth lives in one place
AuthClerkProduction-grade auth without building session management. JWT templates allow Clerk tokens to authenticate Supabase requests
DatabaseSupabase (PostgreSQL)Row-level security scopes all queries to the authenticated user. JSONB column stores session snapshots efficiently
AIOpenAI GPT-4o-miniBest price-to-quality ratio for structured JSON generation and conversational responses
DeploymentVercelZero-config CI/CD. Preview deployments per PR

Data Flow

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.

typescript
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.

typescript
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.

typescript
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

SYMPTOM / PROBLEM

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.

FIX / LESSON

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

SYMPTOM / PROBLEM

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.

FIX / LESSON

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

SYMPTOM / PROBLEM

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.

FIX / LESSON

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

SYMPTOM / PROBLEM

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.

FIX / LESSON

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.

typescript
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.

typescript
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

<100ms
Clerk JWT → Supabase auth
1 query
Full state restoration
1–3s
AI plan generation
O(1)
Undo/redo push/pop
287kb
First load JS
instant
Local fallback response

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.

back to portfolio