@specfork/todo
Personal task manager with projects, subtasks, dates, priorities, labels, repeats, reminders, list/kanban/calendar views, filters, quick add, import and export.
Todo
One person keeps their tasks in nested projects, plans them with dates, priorities, labels and repeats, gets reminded on time, and looks at them as a list, a table, a kanban board, a calendar or a cross-project agenda. Everything can be exported in one archive and imported again.
- Server-based, single user per account. The server is the source of truth; each account
(
@specfork/auth) owns one private data set. No teams, sharing or collaboration. - Export all my data (
@specfork/privacy) returns every project, view, bucket, task (trash included), label, reminder, relation, saved filter and setting (DATA-1). - Reminders and the overdue digest arrive through
@specfork/notifications(REM).
Behaviour derived from Vikunja's open-source code (AGPL-3.0-or-later), written fresh and stack-free. Features Vikunja unlocks only with a paid licence key (admin panel, time tracking, audit logs, user invites) were not used. Not affiliated with or endorsed by Vikunja or its contributors. The name is used only to credit the source.
| Area | Prefix | Covers |
|---|---|---|
| settings.md | SET | Server model, data ownership, user settings, local time, day arithmetic |
| projects.md | PRJ | Projects, nesting, inbox, archive, delete, duplicate, favorites |
| tasks.md | TASK | Task fields, priorities, dates, done and undo, trash, bulk, labels |
| subtasks.md | REL | Subtasks and other task relations |
| recurrence.md | REP | Repeat modes and how completing a repeating task moves its dates |
| reminders.md | REM | Absolute and relative reminders, notification types, overdue digest |
| views.md | VIEW | List, table, kanban and calendar views, buckets, positions, agenda |
| query.md | QRY | Filter language, search, sorting, paging, saved filters, favorites |
| quick-add.md | QADD | Parsing one line of text into a task |
| data.md | DATA | Export contents, import rules, CSV import, delete-all, inventory |
All instants are stored in UTC; API names are snake_case.
Errors are named codes: invalid, not_found, taken, conflict, cycle, archived,
bucket_full, limit_reached, busy, gone, filter_too_complex.
Data
Status: Draft · Prefix: DATA
Purpose
What todo's export holds, its import and CSV import rules, and what delete-all and the inventory cover.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| DATA-1 | Export (@specfork/privacy:PRV-1), format @specfork/todo, version 1, one JSON Lines file per entity: settings, projects (archived too), views, buckets, tasks (trashed too, with deleted_at; label_ids, per-view positions and bucket_ids), labels, reminders, relations, saved_filters. Every field of this spec; instants UTC. Notifications are not exported. |
export → every task id once in tasks.jsonl |
| DATA-2 | Native import (@specfork/privacy:PRV-5): an export ZIP of ≤ 256 MB and ≤ 100,000 items (projects, views, buckets, tasks, labels, reminders, relations, saved filters), else invalid before anything is written. Runs in the background, one at a time per user (busy); ends with import_finished or import_failed (REM-8). |
100,001 tasks → invalid, no data added |
| DATA-3 | Import fixes conflicts instead of failing: a label with the same title and color as an existing own label is reused; a clashing project identifier is cleared; tasks with empty titles are skipped; relations to missing tasks are dropped; archived projects are archived after their tasks are created. |
import twice → projects doubled, labels not; import HOME when HOME exists → identifier empty |
| DATA-4 | CSV import, step 1: a UTF-8 file of ≤ 100,000 rows with a header row; the delimiter (, ; tab |) is detected; the server returns headers, a suggested mapping and the first 5 rows. Step 2: the user maps each column to title, description, due_date, start_date, end_date, done, priority, labels, project, reminder or ignore and may preview the first 5 tasks. A mapping without title → invalid. All or nothing, like DATA-2. |
;-separated file → delimiter ; |
| DATA-5 | CSV values: dates read in the chosen format in time_zone; done true for true, yes, 1, done, completed (any case); priority 0–5 clamped or the words low/medium/high/urgent → 1/2/3/4; labels comma-separated; reminder an absolute time. Everything goes under one new top-level project Imported from CSV, one child per distinct project value. |
row done=Yes → task done |
| DATA-6 | Delete all my data (@specfork/privacy:PRV-6) and account deletion (@specfork/privacy:PRV-7) remove the whole SET-2 data set, trash included; a new empty Inbox becomes the default (PRJ-6). |
delete all → no projects but a new empty Inbox |
| DATA-7 | Inventory (@specfork/privacy:PRV-9): projects, views, tasks, labels, reminders, relations, saved filters and settings, kept until deleted; trashed tasks 30 days (TASK-11); notifications per @specfork/notifications:NTFY-12. No third parties. |
manual: inventory lists each entity of DATA-1 |
Out of scope
- Importers for other products' accounts or files; scheduled or continuous sync; whole-instance backup and restore.
- Attachments and backgrounds in the archive (not part of this spec).
Decisions
- Packaging, delivery, limits and re-authentication come from
@specfork/privacy; todo lists only its entities and import fixes. The source used a product-named version file. - Imports are all or nothing; the source could stop part-way.
- CSV priority words map to this spec's scale; the source's CSV mapping disagreed with its own priority labels.
Projects
Status: Draft · Prefix: PRJ
Purpose
Projects hold tasks. They nest into a tree, can be archived, duplicated, favorited and deleted.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| PRJ-1 | Project: {id, title (1–250 chars, trimmed), description (rich text, ≤ 65,535 chars), identifier? (0–10 chars of [A-Z0-9], stored upper-case), hex_color?, parent_id?, position (number), is_archived (default false), is_favorite (default false), created, updated}. |
create with title " " → invalid |
| PRJ-2 | identifier is unique among the owner's projects, ignoring case → taken; empty means none. |
home when HOME exists → taken |
| PRJ-3 | hex_color (projects, tasks, labels, views): 6 hex digits; one leading # is stripped; stored lower-case; anything else → invalid; empty clears it. |
#FF8800 → ff8800 |
| PRJ-4 | Nesting has no depth limit. parent_id must be an own existing project; self → invalid; a parent inside the project's own subtree → cycle. Moving a project moves its whole subtree. |
A ⊃ B ⊃ C, set A's parent to C → cycle |
| PRJ-5 | Siblings are ordered by position ascending, then id. A project created without position goes after the last sibling (last + 65,536). When two neighbours end up less than 0.01 apart, all siblings are renumbered evenly, keeping their order. |
create 3 projects → positions ascending in creation order |
| PRJ-6 | Sign-up creates the project Inbox and sets it as default_project_id. The default must be an own, non-archived project, else invalid. A user always has a default: deleting it makes the oldest remaining non-archived top-level project the default, or a new Inbox if none is left. |
delete Inbox, no other project → new Inbox is default |
| PRJ-7 | Archiving sets is_archived on the project and every descendant. An archived project and its tasks, views and buckets are read-only: any write → archived, except unarchiving. Unarchiving clears is_archived on the project and every descendant. Unarchiving a project whose parent is archived → archived. Creating a project under, or moving one into, an archived parent → archived. |
archived project, rename task → archived; unarchive child of archived parent → archived |
| PRJ-8 | The default project cannot be archived, directly or by archiving an ancestor → invalid. |
archive parent of Inbox → invalid |
| PRJ-9 | Project lists leave out archived projects unless include_archived=true. Tasks of archived projects are left out of saved filters, the agenda, the favorites list, reminders and the digest; they stay readable inside their project. |
archived project, overdue task → not in digest |
| PRJ-10 | Delete is permanent and covers the project, all descendants, their tasks (trashed ones too), views, buckets and positions; labels stay. A preview call returns {project_count, task_count} that the delete would remove. |
delete project with 2 children and 9 tasks → preview {3, 9} |
| PRJ-11 | Duplicate copies title + (copy), description, color, parent, views, buckets, tasks (fields, labels, reminders, positions, buckets), and relations between copied tasks; identifier is cleared, is_favorite false, child projects are not copied. Each copied task gets a copied_from relation (REL-1) to its original. |
duplicate → copy has no identifier, same task count |
| PRJ-12 | is_favorite marks a project; favorite non-archived projects are listed apart, ordered as in PRJ-5. |
favorite then archive → not in favorites list |
Out of scope
- Project backgrounds and stock-photo search, project sharing and links, per-project webhooks, subscriptions.
Decisions
- Identifiers are unique per owner, not per instance: data sets are private (SET-2).
- A default project always exists; the source could leave none after the owner deleted it.
- Archiving an ancestor of the default project is refused; the source archived it anyway.
- A duplicated project's title gets
(copy); the source used a different suffix.
Query
Status: Draft · Prefix: QRY
Purpose
How tasks are filtered, searched, sorted and paged, plus saved filters and the favorites list.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| QRY-1 | Filter: conditions field op value joined by && and ||, && binding tighter, parentheses for grouping. Ops: =, !=, >, >=, <, <=, like, in, not in. Values may be quoted with ' or "; in lists are comma-separated. Unknown op or field → invalid. |
priority ~ 3 → invalid |
| QRY-2 | Fields: id, index, title, description, done, done_at, due_date, start_date, end_date, priority, percent_done, hex_color, repeat_after, created, updated, project (id), labels (id), reminders, bucket_id. |
labels in 3, 7 → tasks with label 3 or 7 |
| QRY-3 | labels and reminders match when any item matches; with != or not in, when no item matches. like is a case-insensitive substring match. |
labels != 3 → tasks without label 3 |
| QRY-4 | Date values: RFC 3339; YYYY-MM-DD (local midnight); YYYY-MM-DD HH:MM (local); or date math now followed by any of +N/-N with unit s m h d w M y and rounding /d /w /M /y to the start of the local unit (weeks start on week_start). Other text → invalid. Local means filter_time_zone when the request gives one (unknown → invalid), else the user's time_zone. |
due_date < now/d+1d → due before tomorrow 00:00 local; zone Tokyo, now/d → Tokyo midnight |
| QRY-5 | include_nulls=true: every condition also matches tasks whose field is empty (null, 0 for numbers, no labels or reminders). |
due_date < now, include nulls → undated tasks too |
| QRY-6 | A filter over 16 KB or nested deeper than 100 parentheses → filter_too_complex; unbalanced parentheses → invalid. |
101 nested groups → filter_too_complex |
| QRY-7 | Search s: trimmed; case-insensitive substring of title or description; #N also matches tasks with index N. Combined with the filter by AND. |
s=#12 → task with index 12 |
| QRY-8 | Sort: sort_by (one or more of the QRY-2 scalar fields, or position within a view) with order_by asc (default) | desc; empty values last; id ascending breaks ties. Default: position in a view, else id. |
sort due_date → undated tasks last |
| QRY-9 | Paging: page from 1, per_page default 50, maximum 50 (larger values capped), below 1 → invalid; responses carry total and total_pages. |
per_page=500 → 50 items |
| QRY-10 | Saved filter: {id, title (1–250 chars), description?, filter, sort?, include_nulls (default false), is_favorite, created, updated}; the filter is checked on save. Its tasks come from all own non-archived projects. |
save done = false && priority >= 4 → urgent open tasks |
| QRY-11 | Saved filters are listed with projects as read-only pseudo-projects (kind: saved_filter) with the VIEW-2 views, the list view without the done = false filter. Creating a task in one → invalid. In its manual kanban views, tasks that newly match are added to the default bucket (date-based filters re-checked at least every minute). |
create task in saved filter → invalid |
| QRY-12 | Favorites pseudo-project (kind: favorites): listed only while at least one non-trashed task is favorite; holds those tasks; has list (filter done = false), table and calendar views and no kanban; read-only. |
unfavorite last task → pseudo-project gone |
Out of scope
- Full-text ranking or fuzzy search; filter autocomplete; searching comments or attachments.
Decisions
&&binds tighter than\|\|; the source applied joins left to right.- Date math defaults to the user's zone everywhere; the source used the server zone outside saved filters.
- Assignee and creator fields are gone: one user owns everything.
Quick add
Status: Draft · Prefix: QADD
Purpose
Turn one line of text into a task with labels, project, priority, due date and repeat.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| QADD-1 | quick_add {text, project_id?} parses text by quick_add_mode: symbols → *label, +project, !priority; alt → @label, #project, !priority; off → no parsing. A token runs to the next space, or is a quoted value right after the prefix. Matched tokens are removed and spaces collapsed. |
Buy milk *shop !3 → title Buy milk, label shop, priority 3 |
| QADD-2 | Priority tokens count only for 0–5; others stay in the title. Only the first project token counts, matched case-insensitively by title, then by identifier; no match → the token stays in the title. The task goes to the matched project, else project_id, else default_project_id. |
+nowhere x → title +nowhere x in Inbox |
| QADD-3 | Labels match own labels by title, ignoring case; unknown ones are created with a random hex_color; repeated label tokens count once. |
*new *new → one new label |
| QADD-4 | Date words: today, tonight (21:00), tomorrow, next week (+7 days), next month (1st of next month), end of month, this weekend (coming Saturday, today if Saturday), weekday names full or 3-letter with optional next (next such day after today), in N hours|days|weeks|months, numeric YYYY-MM-DD, YYYY/MM/DD, M/D[/YYYY], D.M[.YYYY], <month name> D or D <month name>, optionally followed by at H[:MM][am|pm]. Days are in time_zone. |
call mom fri at 6pm on Mon → due Fri 18:00 local |
| QADD-5 | The first date phrase becomes due_date and is removed; without a time it takes default_due_time; a date without a year that has passed this year goes to next year. |
Dec 1 on Dec 5 → next year Dec 1 09:00 |
| QADD-6 | Repeat phrases: every [N] hour(s)|day(s)|week(s)|month(s)|year(s) (N as digits or one…ten), hourly, daily, weekly, monthly, yearly. Hours, days and weeks set interval mode; months set monthly with repeat_months N; years set monthly with 12 × N. No date given → due today at default_due_time. |
water plants every 3 days → repeat_after 259,200, due today |
| QADD-7 | Text wrapped wholly in quotes is taken literally without the quotes; text that is only tokens is taken literally as the title. A created task with due_date and no reminders gets one relative reminder per quick_add_reminders offset (REM-3). |
"*literal" → title *literal |
Out of scope
- Assigning people; natural-language parsing in other languages.
Decisions
- Mode names are generic (
symbols,alt); the source named its second mode after another product. - Parsing may run on client or server, but results must match these rules.
Recurrence
Status: Draft · Prefix: REP
Purpose
Repeating tasks: one task that reopens with new dates each time it is completed.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| REP-1 | Fields: repeat_mode: interval (default) | monthly | from_completion; repeat_after: seconds, 0–315,360,000 (10 years), default 0; repeat_months: 1–120, default 1, used only by monthly. Out of range → invalid. |
repeat_after: -5 → invalid |
| REP-2 | A task repeats when repeat_mode is monthly, or when it is interval/from_completion with repeat_after > 0. Completing a repeating task (by done flag or done bucket) keeps done false, sets done_at to now, moves dates and reminders per REP-3..6, unticks every ticked checkbox in description, and puts the task in each kanban view's default bucket. |
daily task completed → done false, due moved |
| REP-3 | interval: each set date (due_date, start_date, end_date) and each absolute reminder moves on its own by the smallest whole number k ≥ 1 of repeat_after steps that puts it strictly after now. Unset dates stay unset. Steps follow SET-8. |
daily, due 3 days ago 09:00, now 10:00 → due tomorrow 09:00 |
| REP-4 | monthly: each set date and absolute reminder moves forward repeat_months calendar months in time_zone, once, keeping local time; a day past the month's end becomes its last day. If start and end are both set, end = new start + old (end − start). |
due Jan 31 09:00, 1 month → Feb 28 09:00 (2027) |
| REP-5 | from_completion: new due_date = now + repeat_after (SET-8); start_date and end_date keep their old offsets from due_date. Without a due date: start = now + step and end keeps its offset from start; with only an end date, end = now + step. |
weekly, completed Wed 18:00 → due next Wed 18:00 |
| REP-6 | from_completion reminders: the earliest absolute reminder becomes now + repeat_after; the others keep their offsets from it. |
reminders 09:00 and 10:00 → new pair 1 h apart |
| REP-7 | After any move, relative reminders are recomputed from the new dates (REM-3) and every reminder's sent_at is cleared. |
relative −1 h to due, due moved a day → reminder moved a day, unsent |
| REP-8 | Setting repeat_after to 0 in interval or from_completion mode stops repeating; the next completion is final. |
repeat removed, complete → done true |
Out of scope
- Rule-based repeats (every 2nd Tuesday, weekdays only), end dates or counts for a series, per-occurrence history.
Decisions
repeat_monthsadded: the source's monthly mode was fixed at one month, and its quick-add turned other month and year counts into no repeat.intervalandmonthlykeep their source meaning:intervalcatches up past now,monthlymoves once even if still in the past.
Reminders
Status: Draft · Prefix: REM
Purpose
Reminders on tasks, when they fire, the notification types todo sends, and the daily overdue digest.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| REM-1 | Reminder: {id, task_id, remind_at? (UTC instant), relative_to?: due_date|start_date|end_date, relative_period (signed seconds, default 0), sent_at?}; at most 20 per task, else invalid. |
21st reminder → invalid |
| REM-2 | Absolute reminder: relative_to null and remind_at given. A non-zero relative_period without relative_to → invalid. |
{relative_period: -3600} → invalid |
| REM-3 | Relative reminder: remind_at = the relative_to date + relative_period (negative = before), using SET-8; recomputed on every task save. If that date is unset, remind_at is null and the reminder never fires but is kept. |
due 10:00Z, −3,600 → 09:00Z; clear due → remind_at null |
| REM-4 | Reminders with the same remind_at (to the second) on one task merge into one; reads return them by remind_at ascending. Any change of remind_at clears sent_at. |
two at 09:00 → one stored |
| REM-5 | A scheduler runs at least once a minute. A reminder fires when remind_at ≤ now, sent_at is null, the task is not done, not trashed and not in an archived project. Firing sets sent_at, so each reminder fires once. |
done task, reminder time passes → nothing sent |
| REM-6 | Missed runs: a reminder found up to 24 h late is still delivered, marked late; one found more than 24 h late gets sent_at without delivery. |
server down 2 h → reminder delivered on restart |
| REM-7 | remind_at is an absolute instant: it fires at the same moment whatever time_zone says, and changing time_zone does not move it (SET-5). Messages show times in the user's current time_zone. |
reminder 07:00Z, zone Berlin → fires 07:00Z, text says 09:00 |
| REM-8 | Notification types (@specfork/notifications:NTFY-1), recipient always the owner: task_reminder (subject: task; in-app required, email default off), overdue_digest (subject: the local date; in-app on, email default instant), import_finished and import_failed (subject: the import; in-app on, email off). |
user turns task_reminder email on → reminders also mailed |
| REM-9 | A firing reminder creates or bumps (@specfork/notifications:NTFY-2) the task's task_reminder: title = task title, body = its due time in the user's time_zone, marked late under REM-6. Reminders of one task firing in the same run count once. Notifications of a trashed task are hidden until restore; purge deletes them (@specfork/notifications:NTFY-13). |
2 reminders same minute → 1 notification, count 1 |
| REM-10 | Overdue digest: once per local day at the user's digest_time (@specfork/notifications:NTFY-8; SET-6 applies), when tasks exist that are not done, not trashed, not in archived projects, with due_date before that moment, one overdue_digest is created; else nothing. Lines sorted by due_date ascending, each: title, identifier, project title, whole hours overdue; past the 2,000-char body limit the list ends at a whole line with +N more. A missed digest is created later the same local day; at most one per local date. |
2 overdue tasks at 08:00 → one digest with 2 lines; server down 08:00–11:00 → digest at 11:00 |
Out of scope
- Channels and inbox features beyond
@specfork/notifications. - Daily summary of tasks due today (the digest lists overdue tasks only).
Decisions
- At most 20 reminders per task; the source had no limit.
sent_atwith a 24 h catch-up (REM-5, REM-6); the source fired only inside the current minute and silently lost reminders while down.- Archived projects send no reminders, matching the digest; the source still fired them.
- The digest uses the user's local date; the source used the server's date, so far-off zones could miss it.
- Inbox, read state, snooze, email delivery and preferences come from
@specfork/notifications; todo only defines the types, when they fire and their text. The digest is one notification per day, so it lists every overdue task, not only new ones. - Recipient is always the owner: no assignees or subscribers in a personal app.
Settings
Status: Draft · Prefix: SET
Purpose
Where the data lives, who owns it, the per-user settings, and the local-time rules every other area uses.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SET-1 | Server-based: the server stores all data and is the only source of truth. Clients read and write through its API and need a connection; there is no offline queue and no client-side merge. | server unreachable → write fails, nothing stored on the client |
| SET-2 | Every account owns one private data set: projects, views, buckets, tasks, labels, saved filters, reminders, notifications, settings. Every record carries owner_id; there is no sharing. Any read or write of another owner's record → not_found, same as a missing id. |
user B reads user A's task id → not_found |
| SET-3 | Settings and defaults: default_project_id, default_due_time (HH:MM, default 09:00), quick_add_mode (symbols|alt|off, default symbols), quick_add_reminders (list of signed second offsets, ≤ 5, default empty), overview_filter_id?. time_zone and week_start are the user's (@specfork/auth:AUTH-1). |
new user → default_due_time 09:00 |
| SET-4 | Validation: HH:MM also accepts H:MM, range 00:00–23:59, else invalid; overview_filter_id must be an own saved filter, else invalid. |
default_due_time: 24:00 → invalid |
| SET-5 | Instants (task dates, reminders, done_at, created, updated) are stored in UTC and returned as RFC 3339 with Z. Local-time rules (date-only input, "today", date math, digest time, day steps) use the user's time_zone (@specfork/auth:AUTH-1) at the moment they are evaluated. Changing time_zone never rewrites stored instants; only later local-time evaluations change. |
due 2026-05-04T07:00:00Z, zone Berlin → shown 09:00 local; due 09:00 Berlin, switch zone to Lisbon → same instant, shown 08:00 |
| SET-6 | A local wall-clock time that does not exist (DST gap) resolves to the first valid minute after it; one that occurs twice (DST overlap) resolves to the first occurrence. | Berlin 2026-03-29 02:30 → 03:00 CEST (01:00Z) |
| SET-7 | Date-only input YYYY-MM-DD for a task date means that local day at default_due_time in time_zone (SET-6 applies). |
2026-06-01, Berlin, default 09:00 → 2026-06-01T07:00:00Z |
| SET-8 | Day arithmetic: a duration that is a whole multiple of 86,400 s is applied as that many calendar days in time_zone, keeping the local wall-clock time (SET-6 applies); any other duration is added as exact seconds. Used by REP and REM. |
09:00 Berlin on 2026-03-28 + 86,400 s → 09:00 on 2026-03-29 (23 h later) |
Out of scope
- Offline-first clients, sync between devices without the server, end-to-end encryption.
- Several people sharing one data set; teams; link sharing.
- Interface preferences (theme, date display, sound on completion): client-side only.
Decisions
- Server-based, as the source; a personal app still gets accounts so one instance can host several isolated users.
default_due_timedefaults to 09:00; the source's client picked the next of 9/12/15/18/21 o'clock.- Day steps keep wall-clock time (SET-8); the source added exact seconds, so daily items drifted an hour at DST changes.
time_zoneandweek_start(default Monday; the source defaulted to Sunday) come from@specfork/auth:AUTH-1.- Works in either
@specfork/auth:AUTH-18mode:single_ownerfor one person,multifor several isolated users.
Subtasks
Status: Draft · Prefix: REL
Purpose
Links between tasks: parent and subtask, plus related, duplicate, blocking, order and copy links.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| REL-1 | Relation {task_id, other_task_id, kind}. Kinds and their inverses: subtask ↔ parent, related ↔ related, duplicate_of ↔ duplicates, blocking ↔ blocked_by, precedes ↔ follows, copied_from ↔ copied_to. Other kinds → invalid. |
kind child → invalid |
| REL-2 | Creating a relation also stores its inverse; deleting either side deletes both. | A subtask B → B shows A as parent |
| REL-3 | A task related to itself → invalid. The same (task_id, other_task_id, kind) twice, or its inverse, → conflict. One pair may hold several different kinds. |
A subtask B exists, add B parent A → conflict |
| REL-4 | subtask/parent links must not form a cycle of any length → cycle. Other kinds may form cycles. |
A ⊃ B ⊃ C, make A a subtask of C → cycle |
| REL-5 | Both tasks must belong to the owner; they may be in different projects. A trashed task's relations are hidden and come back on restore; purge deletes them. | link tasks in Inbox and Work → allowed |
| REL-6 | A task read includes related_tasks grouped by kind, each {id, identifier, title, done, project_id}. |
read A → related_tasks.subtask lists B |
| REL-7 | Listing with nest_subtasks=true returns only tasks without a parent in the result, each with its matching subtasks nested at any depth; nested tasks do not count toward per_page. |
A ⊃ B, nested list → one top item A containing B |
Out of scope
- Dependency scheduling (blocked tasks cannot be completed, auto-moving followers).
- Checklists as records; description checkboxes are plain rich text (REP-2 clears them).
Decisions
- No automatic roll-up: completing a parent or all subtasks changes nothing else (TASK-8), as in the source.
Tasks
Status: Draft · Prefix: TASK
Purpose
The task record, its numbering, completion and undo, trash, bulk operations, and labels.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| TASK-1 | Task: {id, project_id, index, identifier, title (1–250 chars, trimmed), description (rich text, ≤ 65,535 chars), done (default false), done_at?, due_date?, start_date?, end_date?, priority, percent_done, hex_color?, repeat_after, repeat_mode, repeat_months, is_favorite, labels[], reminders[], related_tasks{}, created, updated, deleted_at?}. |
create {title: "Pay rent"} → done false, priority 0 |
| TASK-2 | priority: 0 none (default), 1 low, 2 medium, 3 high, 4 urgent, 5 do_now; other values → invalid. percent_done: number 0–1 (default 0), else invalid. |
priority: 6 → invalid; percent_done: 1.5 → invalid |
| TASK-3 | due_date, start_date, end_date are optional instants (date-only input per SET-7). end_date before start_date when both are set → invalid; due_date is independent of both. |
start 10th, end 9th → invalid |
| TASK-4 | index comes from a per-project counter starting at 1 that never goes back, even after deletes. identifier = project identifier + - + index, or # + index when the project has none. A task can be read by (project_id, index). |
3rd task in HOME → HOME-3 |
| TASK-5 | Create without project_id uses default_project_id. A new task lands in every view of its project (VIEW-6, VIEW-10). |
create without project → in Inbox |
| TASK-6 | Update is partial: only sent fields change; null clears an optional field. Clients cannot set index, identifier, done_at, created, updated, deleted_at. |
send {priority: 3} → title unchanged |
| TASK-7 | Moving to another own project (change project_id) gives the next index there; the old (project_id, index) still resolves to the task. The task goes into each target kanban view's done bucket if done, else its default bucket. |
move HOME-3 to WORK → WORK-8; HOME-3 still opens it |
| TASK-8 | Done: false → true sets done_at to the server's now; true → false clears it. Repeating tasks follow REP-2 instead. Completing a parent does not complete subtasks and vice versa. |
mark done → done_at set; reopen → done_at null |
| TASK-9 | Every done change returns undo_token, single-use, valid 60 s. undo(undo_token) restores done, done_at, dates, reminders, buckets and positions to their state before the change. Used or expired → gone. |
complete repeating task, undo within 60 s → old due date back |
| TASK-10 | Delete moves a task to trash: deleted_at = now; it disappears from projects, views, filters, search, reminders and the digest. The trash lists trashed tasks newest first. |
delete → task not in list view |
| TASK-11 | Restore from trash clears deleted_at; the task returns to its project (or default_project_id if that project is gone or archived), at the top of each view and in each kanban default bucket. Permanent removal happens on request (purge) or 30 days after deleted_at (checked at least hourly) and removes its label links, relations, reminders and notifications. |
trashed 31 days → gone; restore → not_found |
| TASK-12 | Duplicate copies all fields except done (false), done_at (null) and index (new); copies labels and reminders, places the copy at the top, and adds copied_from to the original (REL-1). |
duplicate done task → copy not done |
| TASK-13 | Bulk update {task_ids (1–100, duplicates ignored), fields}: fields may set title, description, done, due_date, start_date, end_date, priority, percent_done, hex_color, repeat_after, repeat_mode, repeat_months, project_id, bucket_id, is_favorite, labels, reminders; any other key → invalid. All or nothing: one unknown or archived task → nothing changes. |
3 ids, one foreign → not_found, none changed |
| TASK-14 | Bulk create: 1–100 tasks in one project, all or nothing; a failing item → invalid naming its 0-based position. |
101 tasks → invalid |
| TASK-15 | Label: {id, title (1–250 chars), description?, hex_color?, created, updated}; titles need not be unique. |
two labels home → both stored |
| TASK-16 | Adding a label already on the task → conflict; removing one not on it → not_found. Setting labels replaces the whole set. Any label change updates the task's updated. Deleting a label removes it from every task; the tasks stay. |
add home twice → conflict; delete label → tasks keep other labels |
Out of scope
- Assignees, comments, mentions, reactions, attachments and cover images, time tracking, task subscriptions, read/unread markers.
- Task identifiers across instances; CalDAV access.
Decisions
- Title capped at 250 chars and descriptions at 65,535; the source left both unbounded.
priority,percent_doneand date order are validated; the source accepted any number and any order.- Update is partial; the source cleared every field the client left out.
- Trash with restore (TASK-11); the source kept deleted tasks 30 days but offered no restore.
- Undo is a server token (TASK-9); the source's client re-sent the opposite done value, which re-advanced repeating tasks.
Views
Status: Draft · Prefix: VIEW
Purpose
Ways to look at a project's tasks (list, table, kanban, calendar), kanban buckets, manual order, and the cross-project agenda.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| VIEW-1 | View: {id, project_id, title (1–250 chars), kind: list|table|kanban|calendar, position, filter? ([QRY-1](#QRY-1)), bucket_mode: none|manual|filter, bucket_filters: [{title, filter}] (≤ 50), default_bucket_id?, done_bucket_id?}; a project's views are listed by position. |
kind gantt → invalid |
| VIEW-2 | A new project gets four views: List (list, 100, filter done = false), Calendar (calendar, 200), Table (table, 300), Board (kanban, 400, manual) with buckets To do (100, default bucket), Doing (200) and Done (300, done bucket). |
new project → 4 views, board has 3 buckets |
| VIEW-3 | Non-kanban views always have bucket_mode none; a kanban view sent with none gets manual. In filter mode every bucket_filters item needs a valid filter; filters are checked on save → invalid. |
list view with manual → stored none |
| VIEW-4 | A view shows its project's tasks matching the view filter AND the request filter. Deleting a view deletes its buckets and positions, not tasks. | delete board → tasks unchanged |
| VIEW-5 | Bucket: {id, view_id, title (1–250 chars), limit (integer ≥ 0, 0 = none), position, count}, listed by position; count = tasks in it matching the view filter. A view holds at most 100 buckets → limit_reached. |
101st bucket → limit_reached |
| VIEW-6 | In a manual kanban view every task of the project sits in exactly one bucket. A new or moved-in task goes to the requested bucket, else default_bucket_id, else the lowest-position bucket (ties: lowest id); a done task goes to the done bucket when set. |
create task, no bucket given → To do |
| VIEW-7 | Moving a task into a different bucket whose count ≥ limit (limit > 0) → bucket_full; reordering inside a bucket is always allowed. |
bucket limit 2 holding 2, move third in → bucket_full |
| VIEW-8 | Moving a task into the done bucket marks it done (TASK-8, REP-2) and moves it to the done bucket of every other manual kanban view of the project; moving it out marks it not done. Creating a task in the done bucket creates it done. Marking a task done elsewhere moves it to each view's done bucket, if set; marking it not done moves it from a done bucket to that view's default bucket and leaves other buckets alone. | drag to Done → done true, done_at set; reopen task in Done → back in To do |
| VIEW-9 | default_bucket_id and done_bucket_id must be buckets of the same view → invalid. Deleting a view's last bucket → invalid. Deleting a bucket moves its tasks to the default bucket (or the lowest remaining one if it was the default) and clears default_bucket_id/done_bucket_id that pointed to it. |
delete Doing with 3 tasks → 3 tasks in To do |
| VIEW-10 | Manual order: each (task, view) pair has a numeric position; lists and buckets sort by it ascending, then id. New tasks go above the current top. A move sets the midpoint of its new neighbours; when a gap falls below 0.01 the view's positions are renumbered evenly across 0–2³², keeping order. |
drop task between positions 1 and 2 → 1.5 |
| VIEW-11 | filter bucket mode: buckets are computed, one per bucket_filters item in order, each showing tasks matching the view filter AND its own filter; a task may appear in several; moving tasks between them → invalid. |
filters priority >= 3, priority < 3 → split by priority |
| VIEW-12 | Calendar view: from/to (default: the current local month in time_zone); a task appears when its span [start_date or due_date, end_date or due_date] meets [from, to); tasks without any date are left out unless show_undated=true. Sorted by span start, then id. |
task Jun 3–5, window June → shown on 3, 4, 5 |
| VIEW-13 | Agenda (across all own non-archived projects): not-done tasks with due_date in [from, to), defaults now and now + 7 days; show_overdue (default false) adds due_date < from; show_undated (default false) adds tasks without due_date. Sorted by due_date ascending, then id descending. Home: the agenda without a date range (all not-done tasks), or the saved filter overview_filter_id when set. |
overdue task, default agenda → hidden; set overview filter → home shows its tasks |
Out of scope
- Visual layout: table columns, card fields, month/week grids, drag preview, timeline bars.
- Per-view sharing or public view links.
Decisions
- The source's Gantt timeline is specified as the
calendarkind: same date-window rules, default window is the current month instead of −15/+55 days. - Kanban default view is named
Board; bucket titles are generic.
From Specfork/todo at c6b831d, licence CC-BY-4.0.