Skip to content

Repository files navigation

San Liuk's Task Organizer

Task Organizer in use

Docker build

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 a git pull: if the schema changed, re-run supabase/schema.sql in 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 codes 42P01/42703 and says so explicitly).


📸 A look at it

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.

Tasks inside a macro theme

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.

Projects and macro themes in the sidebar

Quick captureCtrl/Cmd+K from anywhere, or just n. Only the title and the destination are required, so in a hurry you type and press Enter. Where is a combobox you type into, not a menu you scroll.

The quick add modal

Analytics — created against completed, all-time progress, weekly velocity, streaks, lead time, tracked time and a six-month heatmap. The project selector at the top narrows the whole panel at once.

The analytics panel

Quick notes — anything that isn't a task and shouldn't become one. Text, or a sketch drawn straight into the panel when a shape says it faster than a sentence. Pin the ones that should stay on top.

The quick notes panel

Appointments — a date has things that happen at a time rather than things to get done, and they do not belong in a task list. They surface at the top of the Today view on the day itself.

The appointments calendar

🚀 Setup from scratch

Everything below is done once, and takes about ten minutes.

1. Create a Supabase project

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.

2. Create the schema

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.

3. Enable Google sign-in

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

4. Point the app at the project

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.

5. Run it

npm install
npm run dev

6. Deploy

Two 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 --build

The 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_id column, 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 makes is_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.


🗺️ Project map (read this to get oriented fast)

This section exists so you get an immediate map of the code without having to re-explore every file.

Stack

  • 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 single index.css with CSS variables (dark/light theme)

Folder tree

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 / macrotemi survives as an identifier (type names, the macrotemi table, column macrotema_id) because it is the database schema. Everything the user sees says "macro theme". Renaming the identifiers would mean a schema migration.

The main files, in detail

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

Data model

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:

  1. The arithmetic closes. created − completed = still open. No unexplained gap, nothing to interpret.
  2. 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 original created_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 rebuild created_tasks from the other tables. A created-and-deleted task exists in neither tasks nor completed_tasks; its creation event lives only in created_tasks. Rebuilding from those two sources erases the discard history and silently resets the count to zero. If created_tasks already exceeds tasks + 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 waiting status are unrelated. The timer measures effort; waiting parks 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 expanded
  • macrotema_order → an array of ids defining the custom macro theme order (drag & drop)

Authentication

  • Login via Google OAuth only (Auth.tsx).
  • App.tsx gates the interface on VITE_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 is public.is_owner() in supabase/schema.sql, and the two must hold the same address.
  • NoteBox, Calendar and Analytics pull user_id from the Supabase session to scope data per user.

Key flows and behaviours

  • 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_date is a YYYY-MM-DD string with no time, compared as a string against the local today. Do not route it through new Date('2026-07-14'): that is midnight UTC and shifts the day. Use todayISO/fromISODate/isOverdue from utils.ts.
  • The waiting status: 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+K anywhere, or n when 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 whole Project › Macro theme label. 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. See matchesDueFilter in utils.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 (confirmDialog state in App.tsx, requestConfirm helper, passed down to MacrotemaCard/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: SearchBar filters 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 MacrotemaPicker combobox 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_tasks and completed from completed_tasks to 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, where created excludes 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_id is 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_entries was 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.

Technical notes / known quirks

  • The build script is just vite build (no tsc): type-checking is not part of the pipeline. Run npx tsc --noEmit by hand before committing — it currently passes clean.
  • Schema changes are not automatic. supabase/schema.sql is 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 codes 42P01/42703 and 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.sql drops the older policy names explicitly before creating its own.

🚀 Running the project locally

Prerequisites

  • Node.js (version 18 or later recommended)
  • npm or yarn as the package manager

Installing dependencies

First time you run the project:

npm install

Environment variables

Create 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".

Starting the dev server

npm run dev

The server starts on http://localhost:3000 and opens the browser automatically.

Stopping the server

  • Press Ctrl + C in the terminal running npm run dev.

📦 Other available commands

Production build

npm run build

Preview the production build

npm run preview

Type-check (not part of the build)

npx tsc --noEmit

🎨 Colour palette (sanliuk.com)

The 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

📝 Notes

  • The app requires authentication via Google OAuth.
  • Data lives in Supabase; theme and layout preferences live in the browser's localStorage.

☕ Support

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.

About

A self-hosted, single-user task manager with a cross-project Today view, a work timer, and analytics that don't lie. React, TypeScript, Vite and Supabase

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages