A web app for managing tasks organised as Projects → Macro Themes → Tasks → Comments, built with React, TypeScript, Vite and Supabase. It includes a cross-project "Today" view, due dates, recurring tasks, checklists, a work timer, quick notes, hand-drawn sketches, a mini appointment calendar and an analytics dashboard (heatmap, streak, velocity, completion rate, tracked time).
It is single-user, and you host it: the app runs on your own domain and the data lives in a Supabase project that belongs to you — their cloud, or your own instance from Supabase's Docker distribution. There is no account to create with me and no server of mine anywhere in the path. Setting it up takes one SQL script and three environment values — see Setup from scratch.
⚠️ After agit pull: if the schema changed, re-runsupabase/schema.sqlin the Supabase dashboard (SQL Editor). It is idempotent, so running it again on an existing database is safe. If the app shows a data-loading error, an out-of-date schema is the first thing to suspect (the app detects Postgres codes42P01/42703and says so explicitly).
Tasks inside a macro theme. Each row carries its priority, and the action bar holds everything you do to a task: start the timer, park it as waiting on someone else, move it, edit it, complete it, delete it. The two counters are the checklist and the comments.
Projects and macro themes in the sidebar, each with its open-task count, and the priority
and due-date filters above them. Today at the top cuts across every project and answers
what do I do right now.
Everything below is done once, and takes about ten minutes.
supabase.com → New project. The free plan is enough. Pick a region near you; the database password is only needed for direct SQL access, the app never uses it.
Open SQL Editor → New query, and paste the whole of supabase/schema.sql.
Before running it, edit one line — the email in public.is_owner(), near the top:
create or replace function public.is_owner() returns boolean
language sql stable as $$
select auth.role() = 'authenticated'
and auth.email() = 'YOUR_EMAIL@example.com' -- 👈 your Google account
$$;That address is the only account that will be able to read or write anything. Then hit Run. The script creates ten tables, their indexes and the Row Level Security policies.
Authentication → Providers → Google: enable it and fill in the client id and secret from
a Google Cloud OAuth client. Then, under
Authentication → URL Configuration, add your redirect URLs — your deployed domain, plus
http://localhost:3000 for local development.
Google is the only login method wired up (Auth.tsx).
Project Settings → API, then create a .env in the repository root (see .env.example):
VITE_SUPABASE_URL=https://xxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOi...
VITE_OWNER_EMAIL=your.email@gmail.com # the same address you put in is_owner()The anon key is public by design: it ships inside the JavaScript bundle of your deployed
site, and anyone can read it from there. That is expected and is not a leak. What protects
your data is Row Level Security, which is why step 2 matters and why VITE_OWNER_EMAIL alone
would protect nothing — it only keeps the wrong account out of the interface.
npm install
npm run devTwo ways, and neither needs a server-side runtime: the build is a folder of static files and the backend is Supabase.
With Docker. Copy .env.example to .env, fill in the three values, then:
docker compose up -d --buildThe app is on http://localhost:8080 (change the host port in docker-compose.yml). The
image is a multi-stage build: Node compiles the bundle, nginx serves it, and nothing else
ships. Put your own reverse proxy in front if you terminate TLS elsewhere.
Note that the three values are build args, not runtime environment. Vite writes them into
the bundle, so after editing .env you rebuild — docker compose up -d --build — rather than
restart. That is also why there is no prebuilt image to pull: every installation compiles its
own Supabase URL in.
On any static host. npm run build produces dist/, which Vercel, Netlify, Cloudflare
Pages or your own web server can serve as-is. Set the same three variables in the host's
environment.
Either way, add the deployed URL to the Supabase redirect URLs from step 3.
Why single-user? The tables holding the actual work — projects, macro themes, tasks, comments, checklists — carry no
user_idcolumn, so there is nothing to separate one person's rows from another's. That is a design choice, not an omission: one person, one Supabase project, one deployment. It is what makesis_owner()a single line instead of a permission system. If you want a second user, give them their own installation.
On the name
macrotemi: it is Italian for "macro themes", and it is the one non-English identifier in the codebase. It is the database schema itself, so renaming it would be a migration rather than a translation. Everything else — code, comments, UI — is in English.
This section exists so you get an immediate map of the code without having to re-explore every file.
- React 18 + TypeScript (function components with hooks)
- Vite 4 as build tool and dev server (port 3000, opens the browser automatically)
- Supabase as the backend: Postgres database + authentication (Google OAuth)
- react-icons (the
Fi, Feather, set) for icons - No state-management or UI library: state lives in
useState/useRef, styling in a singleindex.csswith CSS variables (dark/light theme)
TaskManager/
├── index.html # HTML entry point, mounts #root
├── vite.config.ts # Vite config (port 3000, react plugin)
├── tsconfig.json # TypeScript config
├── package.json # scripts: dev / build / preview
├── .env # VITE_SUPABASE_URL / _ANON_KEY / VITE_OWNER_EMAIL (do NOT commit)
├── .env.example # the same three variables, without values
├── Dockerfile # multi-stage: node builds the bundle, nginx serves it
├── docker-compose.yml # reads .env, exposes the app on port 8080
├── .dockerignore # keeps .env and node_modules out of the build context
├── docker/
│ └── nginx.conf # SPA fallback to index.html + cache headers
├── .github/workflows/
│ └── docker.yml # CI: builds the image, starts it, checks it serves
├── supabase/
│ └── schema.sql # ⭐ the whole database: tables, indexes, RLS. Run by hand.
└── src/
├── main.tsx # React bootstrap (createRoot → <App/> in StrictMode)
├── App.tsx # ⭐ the heart of the app: global state, layout, CRUD, modals
├── types.ts # shared types (Task, Subtask, Comment, Project, Macrotema, ...)
├── supabaseClient.ts # Supabase client, initialised from .env
├── supabaseApi.ts # ⭐ DB queries (projects/macrotemi/tasks/subtasks/comments/notes/appointments)
├── analyticsUtils.ts # creation & completion events + time sessions
├── utils.ts # date/due-date/recurrence/duration helpers, search, localStorage
├── index.css # every style + dark/light theme variables
├── hooks/
│ └── useModalForm.ts # reusable hook for the forms inside modals
└── components/
├── Auth.tsx # login screen (Google OAuth button)
├── SearchBar.tsx # search input with a 300ms debounce
├── TodayView.tsx # ⭐ the "Today" view: overdue, due, appointments, waiting
├── QuickAdd.tsx # quick task capture (Ctrl/Cmd+K, or "n")
├── TaskMetaFields.tsx # shared fields: priority + due date + recurrence
├── MacrotemaPicker.tsx # destination combobox (quick add + move task)
├── MacrotemaCard.tsx # a macro theme card + "add task" form + TaskItem list
├── TaskItem.tsx # a single task: actions, badges, timer, checklist, comments
├── Subtasks.tsx # a task's checklist (add/tick/delete)
├── Modal.tsx # generic modal (focus trap, closes on Esc/outside click)
├── NoteBox.tsx # quick notes panel (text + sketches, pin, edit, delete)
├── DrawingCanvas.tsx # canvas for hand-drawn sketches (colours, eraser, hi-dpi)
├── Calendar.tsx # mini appointment calendar, by date
└── Analytics.tsx # charts: created/done bars, heatmap, streak, velocity, time
Naming note:
macrotema/macrotemisurvives as an identifier (type names, themacrotemitable, columnmacrotema_id) because it is the database schema. Everything the user sees says "macro theme". Renaming the identifiers would mean a schema migration.
| File | Responsibility |
|---|---|
App.tsx |
Holds nearly all application state and the CRUD logic. Manages: session/login gate, data loading (loadAllData), the Projects→Macro Themes sidebar tree, the Today view, selection, drag & drop reordering, every modal (add/edit macro theme, task, project, move, quick add) and the confirmation dialog for destructive actions. |
supabaseApi.ts |
The single entry point to the database. One function per operation (fetchTasks, addTask, deleteTask, fetchSubtasks, addProject, fetchNotes, addAppointment, ...). If you change the DB schema, start here. |
types.ts |
Shared interfaces: Task, Subtask, Comment, Project, Macrotema, Priority, TaskStatus, Recurrence, SearchResult. |
analyticsUtils.ts |
Creation events (created_tasks), completion events (completed_tasks) and timed sessions (time_entries). The backbone of the analytics data. |
utils.ts |
Due-date maths (todayISO, isOverdue, dueLabel), recurrence (advanceDueDate), the due filter (matchesDueFilter), durations (formatDuration). |
Logical hierarchy: Project (1) → Macrotema (N) → Task (N) → Comment (N).
A macro theme can also be Unassigned (no project).
Supabase tables:
| Table | Main columns |
|---|---|
projects |
id, title, created_at |
macrotemi |
id, title, project_id (nullable), created_at |
tasks |
id, macrotema_id, title, description, priority (high/medium/low), created_at, due_date, status (open/waiting), recurrence (daily/weekly/monthly/yearly), recurrence_anchor_day, timer_started_at |
subtasks |
id, task_id, text, done, position, created_at |
comments |
id, task_id, text, created_at |
notes |
id, user_id, text, type (text/drawing), image, pinned, created_at, updated_at |
appointments |
id, user_id, date, text, created_at, updated_at |
created_tasks |
id, user_id, created_at, + the origin columns below |
completed_tasks |
id, user_id, task_created_at, completed_at, + the origin columns below |
time_entries |
id, user_id, task_id (nullable), task_title, seconds, started_at, ended_at, + the origin columns below |
The three analytics tables share the same origin columns: macrotema_id (nullable), macrotema_title, project_id (nullable), project_title. Ids for filtering, titles so a row stays readable after the macro theme or the project is deleted.
About "Done": completing a task deletes it from the tasks table and only records a row in completed_tasks (carrying the original created_at, so lead time can be computed). There is therefore no archive of completed tasks: title, description and comments are gone. This is deliberate — the confirmation dialogs prevent accidental deletion, and a content archive isn't needed.
A deleted task counts as NEVER CREATED. This is the rule that governs every number in Analytics, and it is a deliberate product decision, not an accident.
People delete tasks for reasons that carry no signal: it was a test, the plan changed, the idea was too vague to keep. Leaving those in "created" inflates the bars and drags every ratio down for reasons that say nothing about the work. So the "created" series is built from the tasks that turned out to be real:
created = live tasks + completed tasks (with the completed ones bucketed
by their ORIGINAL created_at)
Two consequences, both intended:
- The arithmetic closes.
created − completed = still open. No unexplained gap, nothing to interpret. - Deleting a task lowers a past bar. History is not immutable here. That is the point: removing noise retroactively sharpens the chart rather than corrupting it.
So what is created_tasks for now? One thing only: it is the sole record of how much work you invented and then binned. discarded = created_tasks − (tasks + completed_tasks). It is shown in Analytics as a quiet footnote, outside every equation and every rate — a deletion is too ambiguous to be a metric, and folding it into a percentage would force you to interpret it on every glance. If that footnote proves useless, the table and logCreatedEvent can be dropped without touching anything else.
⚠️ Filtered by project, all three terms are cut at the same date. The subtraction only means something if its terms describe the same set of tasks. Only events written after the origin columns existed can be attributed to a project, while the live tasks of that project include ones opened long before — subtracting all of them made the count go negative and show up as zero. So with a project selected the count starts at the first attributed creation event, and the completions are cut by their originalcreated_at, not by when they were closed: a task opened before the cut and completed after it has an attributed completion but no matching creation event, and would eat a discard that never happened.
⚠️ Never rebuildcreated_tasksfrom the other tables. A created-and-deleted task exists in neithertasksnorcompleted_tasks; its creation event lives only increated_tasks. Rebuilding from those two sources erases the discard history and silently resets the count to zero. Ifcreated_tasksalready exceedstasks + completed_tasks, that surplus is the discard history — the one thing in there that cannot be reconstructed from anywhere else.
Recurrence and recurrence_anchor_day: completing a recurring task immediately spawns a new one with the next due date (and the checklist copied over, reset). The anchor is the intended day of month (1–31): it is needed because a "the 31st" due date gets clamped to the 28th in February, and without an anchor it would restart from the 28th forever, drifting. With the anchor, March goes back to the 31st. See advanceDueDate in utils.ts.
Tracked time: timer_started_at lives on the task (so the stopwatch survives a reload and follows you across devices); on stop, a row is written to time_entries. task_title is denormalized on purpose and the FK is ON DELETE SET NULL: that keeps the session readable even after the task has been completed or deleted. One timer at a time.
The timer has no Pause, on purpose. Stop already closes the session; starting again opens a new one, and Analytics sums the sessions — so Stop + Start is a pause. A separate Pause button would duplicate Stop.
The timer and the
waitingstatus are unrelated. The timer measures effort;waitingparks a task on a third party. They sit in the same button row, so the waiting toggle deliberately does not use a pause icon (it used to, and it read as "pause the timer" — see.task-actions-divider).
State kept in localStorage:
theme→'dark'|'light'expanded_projects→ a{ [projectId]: boolean }map of which projects are expandedmacrotema_order→ an array of ids defining the custom macro theme order (drag & drop)
- Login via Google OAuth only (
Auth.tsx). App.tsxgates the interface onVITE_OWNER_EMAIL: any other authenticated user sees "Access Denied". This is convenience, not security — it stops the wrong account from seeing the UI, but it does nothing to the database. The real gate ispublic.is_owner()insupabase/schema.sql, and the two must hold the same address.NoteBox,CalendarandAnalyticspulluser_idfrom the Supabase session to scope data per user.
- The "Today" view (the default on open): cuts across projects and macro themes and answers what do I do right now. Sections: today's appointments, overdue, due today, next 7 days, high priority with no due date, waiting on someone else. Reached from the first sidebar entry, which shows the count (red if anything is overdue).
- Due dates:
due_dateis aYYYY-MM-DDstring with no time, compared as a string against the local today. Do not route it throughnew Date('2026-07-14'): that is midnight UTC and shifts the day. UsetodayISO/fromISODate/isOverduefromutils.ts. - The
waitingstatus: a task blocked on someone else (client, supplier, a reply) isn't actionable. It stays visible but dimmed and grouped at the bottom, and it does not feed the "Today" counts. - Quick capture:
Ctrl/Cmd+Kanywhere, ornwhen you aren't already typing in a field. It has the same fields as the in-macro-theme form (description, due date, recurrence): all optional, so in a hurry you type a title and press Enter. The "Where" field is a combobox (MacrotemaPicker.tsx), not a<select>: you type into it and the list below filters as you go, matching the wholeProject › Macro themelabel. A native menu cannot contain a text field — a filter placed above one fights with it, because clicking the filter closes the menu. Typing highlights the first result, so three letters and Enter are enough; opening it highlights the destination you already have, so Enter never moves the task somewhere else by surprise. Esc closes the list without closing the modal around it. After saving, the app takes you to the macro theme the task landed in and clears the filters — otherwise an active filter could hide the very task you just created, and it would look like the save had failed. - Filters (sidebar): priority and due date combine with AND. The due filter offers
late(overdue),today,7d(within 7 days, overdue and today included),no date. SeematchesDueFilterinutils.ts. - Keyboard on a selected task: select a task (click it, or Tab to it — the focus ring is the selection) and press Del to delete it or Enter to complete it; a comment takes Del too. Both open the same confirmation dialog as the buttons, where Enter confirms, so a shortcut never destroys anything on its own. Inside a text field they stay a plain character and a plain newline: the handler only fires when the task or the comment itself holds the focus. Auto-repeat is ignored (
e.repeat, here and in the dialog's own Enter handler) — otherwise holding Enter would open the dialog and confirm it in the same breath. - Confirmation on destructive actions: deleting a task / macro theme / project / comment, and marking a task "Done", always go through a confirmation dialog (
confirmDialogstate inApp.tsx,requestConfirmhelper, passed down toMacrotemaCard/TaskItem). On desktop Enter confirms and Esc cancels; on mobile you use the buttons. Nothing is deleted until you confirm. - Guard rail before the confirmation: a macro theme that still has tasks in it, or a project that still has macro themes in it, cannot be deleted (blocking message, no confirmation dialog).
- Search:
SearchBarfilters macro themes/tasks/descriptions (300ms debounce). While a search or filter is active the sidebar shows a flat list; otherwise the Projects→Macro Themes tree. - Moving a task: the destination uses the same
MacrotemaPickercombobox as the quick add, minus the macro theme the task is already in — moving a task onto itself isn't a move. The cursor lands in the search field, so the modal answers to typing straight away. - Drag & drop: macro themes are reordered by dragging the ⠿ handle, only within the same project group; the order is persisted in
localStorage. - Analytics: reads created from
created_tasksand completed fromcompleted_tasksto draw bar charts (week/month/year with rolling windows), a 26-week heatmap, current/best streak, weekly velocity, lead time and tracked time (window total + the 5 tasks that ate the most time).⚠️ Lead Time ≠ effort. It is the calendar time between creation and completion: a task opened in March and closed in July reads "4 months" even if it took you an hour. Real effort is Tracked time (the timer).- Per-window stat is
Net(created − completed), not a rate. A rate here used to divide completed-in-window by created-in-window, which compares two different cohorts: the tasks you close this week were mostly created weeks ago. It returned 500% on a good week and 0% on a week where you created nothing (division by zero). What a window can honestly answer is whether the backlog grew or shrank. Negative is good. - All-time rate is
Progress = done / created, wherecreatedexcludes deleted tasks (see the data-model note above). It stays under 100% while you carry a backlog, and that is correct: open tasks are pending, not failed. - A project selector at the top narrows the whole panel — chart, heatmap, streak, velocity, lead time, tracked time. The filter is applied once, to the four source lists, so every formula (including
created − completed = still open) keeps holding inside the selected project.- Events store both levels — macro theme and project — and the macro theme wins on read. If the macro theme still exists, the event is attributed to its current project: move a macro theme into another project and its history moves with it, the same way a deleted task retroactively lowers a past bar. The stored
project_idis the fallback for when the macro theme has been deleted and there is nothing left to ask. A macro theme with no project resolves to Unassigned, which is a real bucket, not missing data. ⚠️ Events recorded before the origin columns existed carry no macro theme, and never will. "Done" deletes the task, so the link is gone before anyone could ask for it — there is nothing to backfill from (time_entrieswas the one partial exception: the sessions of tasks that were still alive could be recovered). Those events are counted under "All projects" and disappear from every other view; the panel states this explicitly when a project is selected, rather than looking like it lost data. On a fresh installation this does not apply: every event is attributed from the first one.
- Events store both levels — macro theme and project — and the macro theme wins on read. If the macro theme still exists, the event is attributed to its current project: move a macro theme into another project and its history moves with it, the same way a deleted task retroactively lowers a past bar. The stored
- The
buildscript is justvite build(notsc): type-checking is not part of the pipeline. Runnpx tsc --noEmitby hand before committing — it currently passes clean. - Schema changes are not automatic.
supabase/schema.sqlis run by hand in the Supabase dashboard. It is idempotent —create table if not exists,drop policy if exists— so it is safe both on an empty database and on a live one. If the app shows a data-loading error, an out-of-date schema is the first thing to suspect (the app detects Postgres codes42P01/42703and says so explicitly). - RLS policies combine with OR. Adding a strict policy does not tighten anything while a permissive one is still in place on the same table, which is why
schema.sqldrops the older policy names explicitly before creating its own.
- Node.js (version 18 or later recommended)
- npm or yarn as the package manager
First time you run the project:
npm installCreate a .env file in the root (see Setup from scratch for where the
values come from):
VITE_SUPABASE_URL=...
VITE_SUPABASE_ANON_KEY=...
VITE_OWNER_EMAIL=...Without the first two, supabaseClient.ts throws on startup. Without the third, every account
— yours included — lands on "Access Denied".
npm run devThe server starts on http://localhost:3000 and opens the browser automatically.
- Press
Ctrl + Cin the terminal runningnpm run dev.
npm run buildnpm run previewnpx tsc --noEmitThe main design-system colours are taken from sanliuk.com:
| Colour | Hex | Usage |
|---|---|---|
| Cyan / Teal | #00d9c0 |
Primary in dark mode, accent in light mode |
| Pink / Neon Pink | #ff4365 |
Primary in light mode, accent in dark mode |
- The app requires authentication via Google OAuth.
- Data lives in Supabase; theme and layout preferences live in the browser's
localStorage.
Task Organizer is free and MIT-licensed, and it stays that way — there is no paid tier and nothing is held back. If it earned a place in your workflow, you can buy me a coffee on Ko-fi.






