An honest engineering breakdown of building Planow — the decisions, tradeoffs, and lessons that mattered.
Syed and I had been circling around the idea of building Planow for roughly four years. Not continuously — it surfaced the way most side projects do: in moments of frustration, when existing tools felt unnecessarily complex or slow.
We didn’t want another bloated productivity tool.
We wanted something that answers one question instantly:
“What should I do right now?”
That naturally led us to the Eisenhower Matrix.
The idea was simple:
- Categorize tasks by urgency and importance
- Make prioritization visual and immediate
But building something that feels instant and intuitive turned out to be the real challenge.
Planow is built on a simple idea: your tasks should be fast, private, and available the moment you think of them — not after a network request.
The Stack — Choosing the Future (With Tradeoffs)
I made a deliberate decision early: Build on where the ecosystem is going, not where it is.
- Next.js 16 (Canary) + React 19
- TypeScript
- Tailwind CSS v4
- Zustand
- Supabase
- @hello-pangea/dnd
Next.js 16 (Canary) + React 19: Both pre-stable when I started. I made this call deliberately. My reasoning: we were building something we intended to use long-term, and building on the current stable release meant I’d be doing a framework migration in six months regardless. React 19’s improvements to concurrent rendering and transitions were directly relevant to the drag-and-drop experience I had in mind. I accepted the tradeoff — occasional canary instability, fewer Stack Overflow answers — in exchange for building on where the ecosystem was actually heading.
TypeScript throughout: Not a stylistic preference. The core Task interface flows through the UI layer, the Zustand store, the Supabase integration, and the drag-and-drop logic. Without TypeScript keeping the shape of that data honest across all four, you’re just hoping nothing breaks at runtime.
Tailwind CSS v4 — also pre-stable. The removal of the tailwind.config.js requirement and improved CSS variable integration in v4 were what pushed me toward it. I use Tailwind for layout utilities, spacing, and responsive breakpoints only. All color and typography tokens live in theme.css as CSS custom properties. That architecture decision mattered more than the Tailwind version.
Zustand for state. Redux felt excessive for the scope. Context didn’t scale cleanly. Zustand hit the right balance. Zustand was minimal and powerful. Zustand worked because of its simple API, centralized state, easy async handling and built-in persistence support. It became the backbone for state, persistence, and optimistic updates.
Supabase for backend, auth, and real-time. PostgreSQL, row-level security, Google OAuth, and WebSocket subscriptions in one managed service. I’m not in the business of running database infrastructure. Supabase let me stay focused on the product.
@hello-pangea/dnd for drag-and-drop. The community-maintained fork of react-beautiful-dnd, updated for React 18+ compatibility. react-beautiful-dnd is no longer actively maintained by Atlassian — using the fork was the safer long-term call.
The Data Architecture — Getting Flat Right
Early in the build I had to make a foundational decision about the data shape. The obvious approach for a Kanban-style board is a map keyed by column name:
// The tempting approach
mainData: {
Do: Task[],
Schedule: Task[],
Delegate: Task[],
Limit: Task[],
Later: Task[]
}
This makes rendering easy. It makes drag-and-drop operations locally easy. It makes syncing to a relational database a nightmare. Every operation that touches the database requires you to reconstruct which array a task lives in, and your data shape in the client diverges from your data shape in the DB.
I went with a flat array instead, with each task carrying its own boardName (and later board_id). The rendering layer groups by quadrant. Every database operation is a straightforward row operation.
// Flat array - what I actually used
mainData: Task[] // each task has boardName: 'Do' | 'Schedule' | etc.
This decision paid off when I connected to Supabase. No transformation layer needed. Fetch from DB, set in store, render.
The Drag-and-Drop Reorder Problem
This is where most Kanban implementations quietly take shortcuts.
The naive approach is to sort by createdAt timestamp and treat position as implicit. This works until someone drags a card, at which point your timestamp is semantically wrong. You need an explicit position integer column.
My drag handler:
function handleOnDragEnd(result: any) {
const { source, destination } = result;
if (!destination) return;
const sourceItems = mainData
.filter((task) => task.boardName === source.droppableId)
.sort((a, b) => a.position - b.position);
const destItems = mainData
.filter((task) => task.boardName === destination.droppableId)
.sort((a, b) => a.position - b.position);
if (source.droppableId === destination.droppableId) {
const reordered = [...sourceItems];
const [removed] = reordered.splice(source.index, 1);
reordered.splice(destination.index, 0, removed);
const updated = reordered.map((task, i) => ({ ...task, position: i }));
updateData(updated);
return;
}
const newSource = [...sourceItems];
const [removed] = newSource.splice(source.index, 1);
const newDest = [...destItems];
newDest.splice(destination.index, 0, removed);
const updatedSource = newSource.map((task, i) => ({ ...task, position: i }));
const updatedDest = newDest.map((task, i) => ({
...task,
boardName: destination.droppableId,
position: i,
}));
const untouched = mainData.filter(
(t) =>
t.boardName !== source.droppableId &&
t.boardName !== destination.droppableId,
);
updateData([...untouched, ...updatedSource, ...updatedDest]);
}
On the database side, I needed atomic bulk updates for position changes. A single UPDATE per task would mean N round trips per drag operation. I wrote a Supabase RPC function instead:
create
or replace function reorder_tasks(payload jsonb) returns setof tasks as $$ begin perform pg_advisory_xact_lock(123456);
return query
with
rows as (
select
(elem ->> 'id'):: uuid as id,
(elem ->> 'position'):: int as position,
nullif(elem ->> 'board_id', ''):: int as board_id,
(elem ->> 'updated_at'):: timestamptz as updated_at
from
jsonb_array_elements(payload) elem
)
update
tasks t
set
position = r.position,
board_id = coalesce(r.board_id, t.board_id),
updated_at = r.updated_at
from
rows r
where
t.id = r.id
returning
t.*;
end;
$$ language plpgsql;
The pg_advisory_xact_lock is important. Without serializing reorder operations at the transaction level, two concurrent drags from two devices produce a race condition where both writes succeed but the resulting order is wrong. The advisory lock is session-scoped and cheap — it doesn’t block reads, only concurrent writes to the same logical lock ID.
The Local-First Architecture
This is the part I’m most satisfied with, and also the part that took the most iteration to get right.
Planow is designed as a local-first system — tasks are stored locally and work instantly. Cloud sync is optional and enabled only when users choose to log in.
If a user signs in, their locally-created tasks sync to the cloud. If they’re already a cloud user, we fetch from the DB on login. Conflict resolution happens automatically.
The Zustand pattern I landed on:
addTask: async (task) => {
const previousData = get().mainData;
const userData = get().userData;
// Optimistic update - UI responds immediately
const tempTask = {
...task,
id: crypto.randomUUID(),
completed: false,
source: userData ? "remote" : "local",
updated_at: new Date().toISOString(),
};
set((state) => ({ mainData: [...state.mainData, tempTask] }));
if (userData) {
const { data, error } = await supabase
.from("tasks")
.insert(task)
.select()
.single();
if (error || !data) {
// Rollback on failure
set({
mainData: previousData,
snackbar: { show: true, content: error?.message, type: "error" },
});
return;
}
// Replace temp record with real DB record (correct ID)
set((state) => ({
mainData: state.mainData.map((t) => (t.id === tempTask.id ? data : t)),
}));
}
// If no userData: stays in local state with source: 'local'
};
The source: ‘local’ marker is how the sync layer knows what to push on login. We only sync tasks carrying that marker — not everything. This prevents unnecessary writes and handles the common case of a user editing tasks while logged out and then logging back in.
The sync RPC handles conflict resolution via updated_at comparison:
create
or replace function sync_tasks(payload jsonb) returns setof tasks as $$ begin return query insert into tasks (
id, board_id, completed, position,
title, updated_at
)
select
(t ->> 'id'):: uuid,
(t ->> 'board_id'):: int,
(t ->> 'completed'):: boolean,
(t ->> 'position'):: int,
t ->> 'title',
(t ->> 'updated_at'):: timestamptz
from
jsonb_array_elements(payload) t on conflict (id) do
update
set
board_id = excluded.board_id,
completed = excluded.completed,
position = excluded.position,
title = excluded.title,
updated_at = excluded.updated_at
where
excluded.updated_at > tasks.updated_at
returning
*;
end;
$$ language plpgsql;
Last-write-wins by timestamp. Simple, predictable, and correct for a single-user task manager.
The Privacy Philosophy — Your Data Stays Yours
One principle guided many of our decisions:
Your data belongs to you — not the platform.
Planow is built to work without requiring an account. Tasks are stored locally by default, meaning:
- No forced sign-ups
- No automatic cloud sync
- No hidden data collection
If a user chooses to log in, only then is data synced to the cloud for cross-device access.
This flips the typical model:
Most apps start with the cloud and store data locally as a cache.
Planow starts local — and uses the cloud only when needed.
The Design System — Syed’s Domain, My Implementation
I should be honest here: the design system architecture was a collaborative decision, but Syed drove the visual language. What I implemented was a full CSS custom property token system where every single color in the application is a variable reference. Not one hardcoded hex value in any component.
:root[data-theme="light"] {
- primary: #5A67FA;
- background: #F7F7FF;
- quadrant-do-bg: color-mix(in srgb, #CDFDDD 40%, transparent);
- quadrant-do-header: #0D834C;
- quadrant-do-border: #0D834C;
}
:root[data-theme="dark"] {
- primary: #5A67FA;
- background: #06060F;
- quadrant-do-bg: color-mix(in srgb, #0D834C 20%, transparent);
}
The practical consequence: dark mode isn’t a pile of dark: Tailwind overrides. It’s a complete, independently tuned set of token values. Switching themes means flipping the data-theme attribute on . Everything responds. We built out a full opacity ladder for each quadrant color (4%, 8%, 10%, 16%, 32%, 48%, 64%, 80%, 92%) for state layers. Over 400 variables total across both themes. It sounds like overkill for a side project, and maybe it is — but we never had a “this doesn’t look right in dark mode” bug.
What I Got Right
- Flat data model → simplified everything
- RPC-based bulk updates → performance win
- Optimistic UI → better UX
- CSS token system → scalable theming
- Zustand → minimal and effective
What Was Harder Than Expected
Drag reorder logic
- Same column
- Cross column
- Position recalculation
Database sync consistency
- Order remains correct
- No race conditions
What Planow Is Today
- A fast, visual task manager
- Built around the Eisenhower Matrix
- Real-time synced (when logged in)
- Optimistic and responsive
- Clean, minimal UI
- Dark/light theme supported
What’s Next
- Collaboration features (data model ready)
- Reports/analytics UI
- Improved sync strategies
- Better onboarding & landing experience
Privacy-focused sync alternatives:
- Import / Export tasks between devices
- Manual data transfer without requiring login
- Full user control over where data lives
The goal is simple:
Users should be able to move their data across devices without being forced into an account system.
The Real Lesson
None of this was “hard” individually.
But:
- Every small decision compounded
- Every edge case mattered
- Every shortcut showed up later
The biggest wins came from:
aligning product decisions with technical architecture early
The Honest Part
Four years from “we should build this” to shipped MVP. The idea was simple. The engineering was not hard exactly — but it was full of small decisions that compounded, and getting each one right took longer than the individual decision seemed to warrant.
The local-first sync architecture, the drag-and-drop position management, the conflict resolution — each of these problems is well-understood in the abstract. Actually implementing them in a way that handles real edge cases (concurrent drags, multi-device sync, auth state changes mid-session) requires you to think through every failure mode and decide how to handle it.
Syed handled product and design. I handled engineering. The best decisions came from the gap between those two functions — the conversations about what the right tradeoff was between implementation complexity and correct behavior.
That’s worth saying plainly:
the most important engineering decisions on this project weren’t purely technical. They were answers to product questions that happened to have technical consequences.
Not a developer? Want to understand what Planow is actually for and why the Eisenhower Matrix might change the way you work? Read the story here: I Stopped Drowning in To-Do Lists the Day I Asked Myself Two Questions
Written by Yogesh Raj · Built with Syed Shahab
Try 👉 https://planow.app/ and let me know your honest feedback
Top comments (0)