Meghnaad
Taskwarrior 3 in your browser and on your phone. Meghnaad is another replica of your
Taskwarrior data: the task CLI keeps syncing to your bucket as it does today, and Meghnaad reads and writes the same
bucket. A task added here shows up on your laptop after task sync, and the other way round. Behaviour follows
Taskwarrior 3.5.0, command for command, and where it does not, that is a bug.
It is console-first: type task commands, with tables, forms, boards and charts beside them. Both ways of using it
run the same Rust command engine, so anything you can click you can type.
Two ways to use it
The web app
The engine runs in the page, as WebAssembly in a Web Worker, with your tasks saved in the browser. A small Worker on Cloudflare sits between the page and your bucket: it checks Cloudflare Access (the login), holds the encryption secret, and opens and seals the versions that pass through; it never parses a task. Your existing S3 or R2 bucket is the sync store. You deploy it to your own Cloudflare account, and the basic features run on Cloudflare’s free tier.
Start with Deploying, which has a Deploy to Cloudflare button, then read Using Meghnaad. It adapts to a phone’s screen too, with a menu for the pages and tasks as cards.
The Android app
A native app in Kotlin and Compose, with the same engine running on the phone. It works offline, keeps your keys in the Android Keystore, and syncs straight with your bucket over S3 (R2, AWS S3, MinIO, …) or with a TaskChampion sync server, so it needs no Worker and no Cloudflare Access. It does what the web app does, with a layout made for a phone, and two things a browser tab cannot: reminders that come with the app closed, and home-screen widgets (a task list for any report, a burndown, a calendar and a card of a board).
Download the APK from the latest release and read The Android app.
Both together
They are two replicas of the same bucket, so they stay in step with each other and with your task CLI. Settings and
hooks can be kept in step through the bucket as well (never the secrets).
Where to start
| Using Meghnaad | Pages, console, confirmations, boards, hooks, recurrence, urgency, time tracking |
| Deploying | Setup, GitHub Actions deploy, Access, connecting task, troubleshooting |
| The Android app | Install, connect, the widgets, sharing settings and hooks, building it |
| taskrc support | Every taskrc option: done, partial, not done |
| Architecture | The engine, sync, platform limits, how correctness is kept |
The source, issues and releases are on GitHub.
Using Meghnaad
The app has a normal interface and a console, and both talk to the same command engine, so anything you can click
you can type, and the other way round. Behaviour follows Taskwarrior 3.5.0; where it differs from the desktop,
this guide says so. How the engine works is in Architecture; every supported taskrc setting is in
taskrc support.
| Chapter | What it covers |
|---|---|
| The pages | Every page of the app, and its address |
| The console | Commands, chains, completion, and the questions Taskwarrior asks |
| Tasks | Adding, editing and acting on tasks, bulk actions, views |
| Boards | Saved boards of charts, tables and numbers |
| Settings | Your taskrc, colours, commands, tasksh review and urgency |
| Recurring tasks | Templates, instances and how they are made |
| Hooks | Lua hooks, and the same script with the real task |
| Time tracking and reminders | Start, stop, and reminders |
| On a phone | The phone layout and the app on the home screen |
| Different from the desktop | Where the web app is not the task you know |
The pages
| Page | What it shows |
|---|---|
| Tasks | Any report as a table, with filters. Click a row for the task’s detail |
| Projects | Projects and sub-projects with their tasks, and a filter box |
| Tags | Every tag with its tasks and how many are overdue, and a filter box |
| Summary | How far along each project is |
| Calendar | Months, with what is due or scheduled on each day |
| Burndown | Pending, started and done over time |
| Boards | Pages of your own: numbers, tables and charts, each with its own filter |
| Console | Type commands; reports and answers print here |
Addresses
Every page has an address. You can share a link with anyone who can sign in to the same deployment, open it in a new tab, and the browser’s back button goes where you were.
| Address | Page |
|---|---|
/ | Your home page (Settings → General): a report or a board, next if you haven’t chosen |
/report/<name> | Tasks, as that report: /report/next, /report/list, and any report of your own by name |
/projects /tags /summary /calendar /burndown /console | those pages |
/boards, /board/<slug> | the list of boards, and one board (see Boards) |
/settings, /settings/<tab> | Settings, and one tab: general taskrc tasksh commands hooks urgency |
/settings/taskrc/<path> | one file of the taskrc folder (/settings/taskrc/holidays/en-US.rc) |
/task/<8 characters> | a task’s detail over the page behind it (the first 8 characters of its uuid) |
An address for a report or board that doesn’t exist says so and opens your home page. A filter you typed isn’t part of the address.
Filters and the prompt
Summary, Calendar, Burndown, Projects and Tags have the same filter box as Tasks: Tab completes, filters become chips, and the filter helpers sit below. It is focused when the page opens (not on a phone, where that would raise the keyboard). Every page except the Console has the prompt in a bar at the bottom. The header also has the running timer, the bell (reminders) and Settings.
The last command appears above the prompt, including what a click ran. Pick a report or sort a column and you can see the exact command; press ↑ to edit and re-run it.
Projects and Tags
Both start with your open tasks, waiting ones included. That is three chips in the filter box: status.not:completed,
status.not:deleted and status.not:recurring. They are filters like any other. Take the completed or deleted chip
away and those tasks come in too, tinted green or red, so a project or tag stays listed after its last task is done.
Any other filter works (project:Home, due.before:eow, +work).
Long reports
A report of hundreds of tasks takes a while to lay out, so the table draws the first 200 at once and 300 more each time you scroll near the end, or press Show more or Show all. The report is still whole: select-all, the count and the sort cover every task, drawn or not.
Tag chips
In the Tasks table, clicking a tag chip toggles +tag in that report’s filter, as typing it would. A pressed chip is in
the filter; click again to remove it. A second tag joins the first with or (( +a or +b ), tasks with either), and a
third goes into the same parentheses. Tags you typed side by side, which all have to be present, get the next one added
the same way.
Anywhere else (a report printed in the Console, a task’s detail, the Projects page, the Tags page itself) a chip opens that tag’s entry on the Tags page, expanded and highlighted. A tag no pending task has still gets an entry, so a chip from a report of finished tasks works too. Open in Tasks on an entry filters the Tasks page by that tag.
The console
Commands follow Taskwarrior: [filter] command [modifications]. The leading task is optional.
next # a report (built-in, or defined in your taskrc)
project:Home +errand due.before:eow list
add Pay rent project:Home due:eom +bills
3 modify due:2026-12-25T08:30 priority:H
3 start 3 stop 3 done 3 delete undo
3 annotate called the landlord
estimate:big count # UDAs work like any attribute
rc.report.next.sort:due-,project+ next # per-command overrides, as in Taskwarrior
calc 2 days + 3 hours
help
Keys: / focuses the prompt · Tab completes (and cycles a menu; Shift+Tab goes back) · ↑/↓ the last 20
commands · Esc closes a menu · Ctrl+L clears the console.
Where a report prints. Typed in the Console, a report (list, next, …) prints there. Typed in the bar under
another page, it opens in the Tasks view, where it can be sorted and filtered. History, the timesheet, show and
config always answer in the Console, so typing them elsewhere switches to it.
Commands
| Write | add log duplicate import purge modify done delete start stop annotate denotate append prepend undo |
| Read | info (or information) count projects tags summary calendar burndown.daily .weekly .monthly .annual history.* ghistory.* (same four periods) timesheet stats udas columns reports context show config export ids uuids calc commands _get _projects _tags, and the lists Taskwarrior makes for shell completion: _commands _aliases _columns _config _context _ids _show _udas _unique <attribute> _urgency _uuids _version _zshids _zshuuids _zshcommands _zshattributes. ids squeezes runs into ranges (1 3-5); uuids is one line |
commands lists every command and report with its category, whether it writes, and what it takes. It shows what this
app understands, which is a little less than task; your own reports are there too. _get prints the value of each
DOM reference it is given: _get 1.due 1.project rc.bulk tw.version. A reference with no value prints nothing, a word
that isn’t a reference is an error, and credentials are never among them.
Abbreviations and aliases. A command, report or attribute name can be shortened to abbreviation.minimum
characters (default 2): proj:Home, ann 3 text. alias.<name>=<words> in the taskrc makes a typed word stand for
others. Taskwarrior’s own rm (delete) and burndown (burndown.weekly) work without one. Only what you type is
expanded, never what the buttons send.
Your own commands. A word defined under Settings → Commands (fail for done outcome:fail) works at this prompt
too, with or without task, and after a filter. See Commands.
Filters
attr:value with modifiers (.is .not .has .startswith .before .after .by .none .any …), or compared as due < eom,
urgency>5, project != Home, description ~ Pay (= == != !== < <= > >= ~ !~, with or without spaces).
- Tags and words:
+tag/-tag, virtual tags (+OVERDUE +DUETODAY +READY +ACTIVE +BLOCKED …), plain words and/pattern/. - Ids:
3,1-4,7and uuid prefixes. Several side by side (1 3, or two uuids) pick all of them, and the rest of the filter narrows that group. - Logic:
and,or,xor, parentheses, and!(…)to reverse a group. - Arithmetic: a comparison may have arithmetic on either side (see below).
- Priority goes H, M, L, and a task with none is below them all. If the taskrc’s
uda.priority.valuessays otherwise it follows that: withH,M,,La task with none sits between M and L, and with no blank one it is above them all.
Arithmetic in a comparison is worked out by calc’s evaluator for each task: due < now + 3d, urgency * 2 > 10,
now - entry < 1h, ( urgency + 1 ) * 2 > 5 (glued too: urgency*2>10). An attribute stands for what the task holds,
and a task with no date is on neither side of a comparison with one. What can’t be worked out (due - now with no due)
stops the command with Taskwarrior’s message.
As in Taskwarrior, and so the same here:
tagsis one string (sorted, joined by commas):tags:ais the start of it andtags.is:a,ball of it. Ask for a tag with+a.- A value that begins with
^,*or%is an arithmetic operator to Taskwarrior’s lexer, sodescription ~ ^Payfails. Usedescription.startswith:Pay. notis a word to find. A!with no parenthesis takes only the value next to it:! project:Workis false for every task,project:Work !isproject:false,+a !the tagfalse, andproject !the tasks with no project. After a group or a task number it reverses it.- A word is broken at every parenthesis (
not(+a)isnotand(+a)).=~is not an operator.
Dates: today tomorrow eow som eoy monday 3d 2w, 2026-12-25, 2026-12-25T08:30, now+2h, and anything your
dateformat describes (12/25/2026 with m/d/Y). Also as Taskwarrior reads them: the start and end of the previous,
this and next day, week, work week, month, quarter and year (sopw eonm soq eoww), later and someday, month names
(jan), any three letters of a day, 22nd, 10am, 14:30, ISO weeks (2026-W44), days of the year (2026-300), and
easter goodfriday eastermonday ascension pentecost midsommar. A date in a filter may be an expression too:
due.before:(now+2d), due.after:eow-1d+1h.
Text is matched as regular expressions, as in Taskwarrior (regex=1): plain words, /pattern/, .has,
.startswith, .endswith, .word, and the /from/to/ substitution in modify. A pattern is searched for anywhere in
the text, and for the description the annotations too. Quote one with spaces ('/buy.*milk/'). Taskwarrior reads these
with C++ std::regex (ECMAScript, byte by byte) and so does this app, so \w, \d and case-insensitive matching are
ASCII only. Lookahead, backreferences and syntax ECMAScript lacks ((?i)) are refused with a message. regex=off goes
back to plain text.
Changing tasks
modify and add take tags: and status: too. tags:a,b makes those the task’s tags and no others (tags:
none). status:completed or status:deleted finishes the task with an end of now, and status:pending takes it back.
end: on a pending task is refused (modify) or left out (add), as in Taskwarrior. rtype:, mask:, imask:,
uuid:, id: and urgency: are refused. A dependency you take away (depends:-3) must be one the task has. A tag is
kept under any name (+URGENT, +a:b, +Mixed; case-sensitive), and one that is also a virtual tag such as +PENDING
is stored, but a filter reads the virtual one. An empty description is refused.
Changes beside a command. What follows done, delete, start, stop, annotate, append and prepend can
carry changes: 3 done end:-2h (finished two hours ago), 3 start due:eow +urgent, 3 annotate called her due:friday.
Attributes, tags and substitutions are applied to the task, and the plain words left over are the annotation (or, for
annotate, append and prepend, the text). The buttons send their text literally, so a note that starts with due:
stays a note.
log and duplicate. log Paid the invoice project:Home records a task that is already finished: its end is the
moment you enter it, and recur: and wait: are refused. 3 duplicate copies task 3 as a new pending task, keeping
everything but its identity, start, end and entry. What follows is applied to the copy (3 duplicate due:eow +again), with plain words becoming a note. Copying a recurring instance gives an ordinary task; copying a template
gives another template. Both ask first at bulk tasks, like any write.
purge removes deleted tasks for good: purge status:deleted, or purge project:Old. A task has to be deleted first;
anything else is skipped with a hint. It asks about each task (“Permanently remove task 3 ‘x’?”), and with no filter it
asks as other commands do, or refuses under allow.empty.filter=off. Tasks that waited on a purged task stop waiting. A
recurring template can be purged only once its instances are deleted, and takes them with it, after a question that
follows recurrence.confirmation (no refuses). undo brings a purged task back with all it had; the Console
remembers only the last few commands, and not across a restart.
import reads tasks from a file, such as one export wrote (or task export on your desktop).
- Typing it shows Choose a file…. The browser reads the file and the server checks it, then says how many tasks are new, how many would change and how many are already there, and lists them.
- Nothing is written until you press Import. Then all of it is, or none, as one
undostep. - Tasks are matched by
uuid. New ones are added, a different one takes the file’s attributes whole (what the file leaves out is removed, as in Taskwarrior), and an identical one is left alone, so importing a file twice changes nothing.
A file with a bad task (an invalid date or uuid, an unknown status, malformed annotations) is refused with the task’s
number and the reason. A file may be one task, an array, or a task per line. The limit is about 3 MB (a few thousand
tasks) or 5,000 tasks at a time, so split a bigger one. Missing entry, end and modified are filled in,
default.project and UDA defaults apply to what lacks them, and hooks run for each task. import-v2 isn’t in the app.
Reading and reports
export gives you a file. It prints a summary and a Download button. The file is Taskwarrior’s own JSON, as
task export writes it (ids and urgency included), so task import reads it. It takes a filter (project:Home export)
and a report’s name (export next uses that report’s filter and sort), and json.array=off makes it one task per line.
History and the timesheet. history.monthly (also .daily, .weekly, .annual) counts the tasks added, completed
and deleted in each period, with the net change and averages. ghistory.* draws the same as a bar of + (added), X
(completed) and - (deleted), 80 columns wide. A recurring template isn’t counted as added, and a filter narrows them
(project:Home history.monthly). timesheet lists what was completed and started, by week and day; with no filter it
covers the last four weeks, and report.timesheet.filter changes that.
stats counts the tasks that match a filter (all, with none): by status, annotations, tags, projects, blocked and
blocking, the age of the oldest, and how often tasks are added and finished. The two rows about undo and the sync backlog
describe this page: undo is what it remembers, and the backlog is normally 0, since every write is pushed at once (it
grows while you’re offline).
projects and tags list what is pending, with a project’s count including its sub-projects. list.all.projects
and list.all.tags add finished tasks, and complete.all.tags does the same for tag completion.
calc is Taskwarrior’s calculator: numbers, durations, dates, strings, and/or, comparisons, and references such
as 1.due - now, 1.tags.x, rc.bulk or system.version. expressions=postfix makes it read 2 days 3 hours +.
Numbers and dates are read as Taskwarrior’s lexer reads them (1e3, 5., 2023-W01-3, 2023-100, 2023-10; a whole
number from 315532800 to the year 9999 is an epoch time). Anything else that starts like a number (3x, 1,5) is a
word, printed as typed, and so are integers joined by - or , (5-3, 1,2) and / 10 /. rc.<setting> and _get rc.<setting> answer with Taskwarrior’s default when the taskrc is silent.
show and config read and change the settings the app keeps, and work only in the Console.
showlists every setting, defaults included, with your changes highlighted and the default beneath.show weekstartnarrows it to names containing that text.config weekstart mondaysets one (several words become one value;config name ""blanks it).config weekstartwith no value removes it, bringing the default back. Withconfirmationon it asks first.- The change is saved in your bucket like an imported taskrc, and is the list the taskrc tab shows. Both follow the
same rules as an import: sync and credential-like names are refused and never echoed, and
a name the app doesn’t read, or a value it can’t accept (
weekstart someday), is refused with the reason.
Contexts
context define work +work or project:Work saves a context. context work makes it active, context none turns it
off, context delete work removes it, context (or context list) lists them, and context show says which is in
force. They edit the same saved settings as the taskrc tab and config, so the pages follow at once. Defining asks first
when its filter matches no pending task, and deleting asks when confirmation is on. Tab completes the subcommands and
your context names, and rc.context: the same way.
A filter that can’t also serve as what new tasks get (one with or, a -tag or a modifier such as .before) makes a
read-only context, and says so. Put the whole filter in quotes (context define this_week "+WEEK or (due.after:sow and due.before:eow)") to read it as one filter, as Taskwarrior does, unless it starts with a name:value, which takes all of it.
The active context is the console’s. It sits above the prompt (context work, with a ✕ that runs context none),
and every command runs in it. While one is active, the pill in the top bar, the Run button and the prompt are amber.
The prompt names it (task[work]>) when there’s room; on a phone it stays task> and a long press shows it.
Pages. Tasks, Summary, Calendar, Burndown, Projects and Tags have a Context menu: same as console (default),
none, or any context. The choice is for that page only (kept on this device, not synced), so context work in the
console doesn’t change a page that chose none. It is sent with the page’s commands as rc.context=<name>.
Boards. A board has no menu: it follows the console’s context unless its filter names one. Put
rc.context=this_week in the board’s filter to make that its default (a card that ignores the board’s filter ignores that
too), or in a card’s filter for that card alone. A card’s Information names the context it ran in. The Filter
above a board (collapsed until you open it) holds the board’s own filter; what you type there replaces it while you look,
nothing is saved, and Reset brings the board’s own back.
New tasks (the + and add) still take the active context’s write rule.
The prompt
Colours
The command is bold, name:value attributes are purple, +tag green, ids orange, and/or and
brackets red, rc. settings grey italic, and quoted text its own colour. After add and log the rest is a
description, so and there is only a word. A long command wraps, so all of it can be read.
Suggestions
Suggestions open as you type, under the filter boxes and above the prompt; Tab or a tap takes one. They know where
the word is: a command is said once, so with add, modify or a report already in the line only attributes are offered
(not calendar, summary or another report), and a bare number is a task id only in front of the command, not in a
description that follows it.
Several commands on one line
Like a shell, the console runs a && b (b only if a worked), a || b (b only if a failed) and a ; b (always),
left to right with no precedence, so a && b || c is (a && b) || c.
- Each answer is drawn in the console under one heading, the line as you typed it, and the prompt returns after the last.
- A command that asks a question (a delete, say) waits for the answer before the line goes on, and “no” counts as a failure. Clearing the console ends the line. An error is a failure, and so is a command that couldn’t be reached.
- This is the page’s doing, as it is a shell’s: the engine is sent one command at a time, so these are not Taskwarrior
syntax. An unquoted operator always separates commands, in a filter too, so write
orandandthere, or quote it ('||',\|\|).add 'Fish & chips; or pizza'keeps its;. reviewhas to be on a line of its own. Your task commands may use these too.
Questions Taskwarrior asks
The same questions as on the desktop, from the same settings, answered on the page.
deleteandundoask (confirmation, on by default).- A change to
bulktasks or more asks (default 3), whatever the command. - A command with no filter asks before it changes every task, finished and deleted ones included
(
allow.empty.filter). - Finishing or deleting a task in the middle of a dependency chain offers to repair the chain
(
dependency.confirmation): what waited on it then waits on what it waited on. - Editing, appending to, prepending to, annotating or deleting a task of a recurring series asks whether to do the
same to the rest of the series (
recurrence.confirmation).
Taskwarrior asks yes / no / all / quit about each task in turn. Here the questions arrive together as a table with a tick for each task the command would change: tick all, none, or the ones you want, then go ahead. Only tasks the command would actually change are listed. A single task is a plain yes/no. The delete buttons ask by turning into “sure?” on the first click.
Tasks
Reports
Pick any report, including your own, and add filters by typing (Tab completes) or from the helpers. Filters show as removable chips, and the report’s own filter and default sort are shown above the table.
Reports whose names start with _ (such as _reviewed, which the tasksh tab defines) are Taskwarrior’s hidden helpers,
so the sidebar and report box leave them out. When there are any, an eye button beside Reports shows them, and the
app remembers your choice. A hidden report you opened by name stays listed while you are on it, and typing its name at
the prompt always works.
Empty columns are left out, as Taskwarrior leaves them: a column with nothing in it for any row isn’t drawn, in the
web table and the Android one. print.empty.columns=yes keeps them (rc.print.empty.columns:yes for one command).
Sorting and grouping
Sorting
Click a column header: again to reverse, a third time to reset, Shift-click to add a tie-breaker. It is
applied as rc.report.<name>.sort:…, so it matches what you would type.
Grouping
A / on a sort key (project+/) puts a gap in the table wherever that column’s value changes, as in
Taskwarrior. Here each group also gets a header saying what it has in common (Project: Home, or Project: Home · Outcome: fail for two columns). Hover a column header and click Group to group by it (again to stop); on a phone
use the Group picker beside Sort. Underneath it is the same command: grouping by project adds
rc.report.next.sort:project+/,urgency- to the filter. It works with UDAs, and minimal starts grouped by project.
Reading the table
- Finished tasks are tinted, not struck through: green for completed, red for deleted.
- IDs are bold, and hovering one shows the uuid with a Copy button.
- Project names are split at the dots, each part in its own colour. Tags are pills.
- Urgency is coloured by level (under 5, to 10, to 15, from 15).
- The
start.active,tags.indicatoranddepends.indicatorcolumns followactive.indicator,tag.indicatoranddependency.indicator. - Dates follow your taskrc:
dateformat,dateformat.report(tables),dateformat.info(detail),dateformat.annotation(notes) and a report’s owndateformat, with Taskwarrior’s tokens (Y y M m D d H h N n S s A a B b V v J j w). Without one, dates show as2026-12-25, plus the time when there is one.
The detail view
Click a row. It shows all fields, annotations, dependencies you can follow, a coloured status pill, the work sessions
(with journal.time) and a collapsed History of what changed and when (journal.info, on by default, worded like
task info). History comes from the task’s operation log, so changes before the last snapshot aren’t in it. It is
fetched when you open the section (the hidden _history <uuid>), so info, the pages and exports never carry it.
Buttons: done, start/stop, edit, delete.
Links between tasks
A task named in Deps (or as the parent of a recurring instance) shows as its short id. Point at it, or focus it with the keyboard, for a card with its status, description, project, priority, due date and tags, and Open task. With a mouse, clicking the id opens the task; on a touch screen the first tap shows the card.
Dependencies
Depends on lists the tasks this one waits for. Tap a line for a card of that task. A task others wait on shows a
Blocking line; Show the tasks waiting on this looks them up when you ask (( status:pending or status:waiting ) depends.has:<uuid>).
Projects
The project is clickable, part by part (Home, then Kitchen). It opens that project on the Projects page, expanded
and highlighted, as a tag chip does on the Tags page.
Task addresses
Every task has an address, /task/<first 8 characters of its uuid>, which opens its drawer. Back closes it, and a reload
or pasted link opens it again. The link button in the drawer copies it.
Whoever you send it to must be able to sign in to the same deployment, so it suits your own devices and people your
Cloudflare Access policy lets in. It uses the uuid, not the number task 3 uses, because numbers differ between replicas
and change as tasks finish. A longer prefix works; one that matches no task, or several, says so.
Adding tasks
The round + button at the bottom right of every page (desktop, phone and the Android app) opens a one-line box typed
as in the console: Buy milk due:tomorrow +home project:Home priority:H. The words are the description; project:,
due:, priority:, recur:, a UDA and +tag set the rest. Put a word in quotes to keep it out of the fields
('read project:x').
As you type, a card under the line shows the task as it will be made, read by the engine’s own add, so what it shows is
what is added. A part that is wrong (a date that isn’t one, a priority that isn’t allowed, a repeat with no due date) is
named under the card, and Add waits until it is fixed. Enter adds.
More fields opens the full form with the line already broken into fields: project, priority, due, wait, scheduled, until, repeat, tags, dependencies, UDAs, “start now” and a first note.
- Each date field opens a panel. Type a date as Taskwarrior reads one (
tomorrow,eow,eow-2d,2026-10-01; the date words complete with Tab or a tap), pick a keyword, or choose a day and optional time from the calendar. - Project and tags suggest every name you have, not just those on screen: ↑/↓ to pick, Enter or Tab to take it, Esc to close the list. You can always type something new; that is how a new project or tag is made, and the field says so.
- Annotations and string UDAs can span lines. In an annotation box Enter saves and Shift+Enter starts a new line. In a UDA field Enter starts a new line and Ctrl/Cmd+Enter saves.
Notes and Markdown
Notes and string UDAs are drawn as Markdown in the detail view and in reports (tables and, on Android, cards):
paragraphs, # headings, **bold**, *italic*, ~~strike~~, `code` and fenced code, [links](https://…) and bare
web addresses, nested bullet and numbered lists, > quotes and ---. Not tables, images, footnotes or HTML; those show
as typed, and links are made only for http, https and mailto. Showing text never changes what is stored.
Checklists (- [ ] milk, - [x] eggs) have boxes you can click in a string UDA. The app changes that box and saves
the whole text with an ordinary modify <uda>:<text>, so the CLI, a hook and the history see a normal edit. Boxes in a
note are only shown, because Taskwarrior has no way to change a note in place.
How a table shows annotations is up to the report’s description column, as in Taskwarrior 3 (description,
description.oneline, description.count, description.desc).
Bulk actions
Tick rows in the Tasks table (the header box ticks all). With two or more ticked, a bar appears:
- Complete and Delete act on every ticked task. Delete asks once, as a row’s button does, unless
confirmationis off. - Modify… opens lines of field and value (project, priority, due, wait, scheduled, until, repeat, depends, your UDAs,
and tags to add or remove). It says what it will do and applies every line to every ticked task as one
modify. An empty value clears the field. - CLI puts the ticked tasks at the prompt (their numbers as a list like
1,3-5, and the full uuid of any without one) and leaves you to type the rest, such asannotate called them. - Clear drops the selection.
A selection lasts only while it is in the table: another filter, or leaving the page, ends it. A long selection goes in
batches of 120 tasks, so undo takes it back a batch at a time.
Summary
Each project’s tasks remaining, their average age, how much is complete and a bar, with sub-projects indented. A
project’s name opens its tasks in the Tasks view. summary.all.projects=1 includes finished projects.
Calendar
Months with week numbers, today, weekends, holidays, and the days with something due (green; today in purple; overdue
in red) or scheduled. It takes Taskwarrior’s arguments (calendar y, calendar due, calendar 3 2027, calendar march 2027) in its box, and has buttons for earlier, later, today, a year and “from the first due date”. A filter-shaped word
(project:Work) narrows which tasks colour the days. Click a day to see what is due in the list report.
Holidays are defined in the taskrc as holiday.<id>.name and .date (or .start and .end), in the taskrc itself
or a file it includes (include holidays/en-US.rc). Settings → taskrc → Add holidays makes that file for you. With
calendar.holidays=full (which Add holidays turns on), holidays are also listed under the months, hovering shows the
name, and on a phone a tap shows it (tap again to hide). sparse only colours the days, as task calendar does. On a
holiday that is also due or scheduled, the first tap names the holiday and the second opens the tasks.
A due or scheduled date on a holiday is flagged wherever you set it, as long as the taskrc defines holidays. The date
is worked out first, so eod, som or eow-2d are checked as the day they come to.
- In the task form the Due or Scheduled field gets a yellow outline and a warning icon. Hover or tap it for the reason (“due (eod) is 2026-12-25, a holiday: Christmas Day”).
- In the console (and Android prompt) the date word turns yellow when a command that sets it (
add,log,modify,duplicate) has it. Hover it, or put the cursor in it, to see the reason. - The quick add card flags it the same way. A filter such as
list due:2026-12-25is not flagged, since it only asks which tasks are due then.
It is only a warning: nothing is changed or refused.
Burndown
Pending, started and done over time, with the net fix rate and an estimated completion date, by day, week, month or
year. It counts as Taskwarrior does, quirks included (checked bar by bar). burndown.cumulative=0 stops carrying
finished tasks forward.
Boards
A board is a page of your own made of sections, each a number, a table or a chart over the tasks its filter picks. Open Boards, type a name and press New board. Edit lets you change the layout and sections, and Save keeps them.
Address
Every board has a slug, the last part of its address (/board/week): one lower-case word of letters,
digits, - and _. It is made from the name when the board is created, and you can change it under Edit →
Address. It must be unique, and if the board is your home page, changing it moves the home page too.
Save as a copy
(under Edit, so it isn’t pressed by accident) saves what is on the page as a new board named
“<name> copy” and shows it. The board you opened stays as last saved, and the home page stays where it was.
Where it’s kept
In the app’s own settings.json (key boards), not your taskrc, so it follows you to every
device that signs in to the same deployment.
Not a report
A board is built from what <filter> export prints, so it sees every attribute of a task, UDAs
included, and runs nothing of its own.
Sections
A section has a title, a filter, and one thing to show. Charts are drawn by Chart.js on the web (loaded the first time one is drawn) and by Koala Plot on the phone, in the page’s colours. Hover a bar, point or slice for its figure; on a phone, tap. Axis labels are thinned so none is cut off. The table is the app’s own.
| Shows | What it is |
|---|---|
| Number | One figure: the count of the tasks, or the sum, average, smallest or largest of a field |
| Bar chart | The same figure for each value of an attribute (project, tags, priority, a UDA) or each period of a date. Category bars lie down; date bars stand up |
| Line chart | The same as a line, meant for a date (end per week shows what you finish) |
| Area chart | A line with the area under it filled |
| Pie chart, Donut chart | The share of each value in the whole. One series; its slices are the values |
| Radar chart | The values around a circle, a shape per series. Good for comparing two or three series over a few values |
| Polar area chart | A pie whose slices differ in how far they reach rather than their angle |
| Heatmap | A grid of columns, rows and cell strength; see Heatmaps |
| Table | Any report (next, list, one of yours) over the section’s filter, drawn as on the Tasks page |
Heatmaps
A heatmap is like GitHub’s grid of commits. It has three parts:
- Columns (Group by): each week, say, or each project.
- Rows (Rows): a part of a date that comes round again (the weekday, hour, day of the month or month of a date, which may be a different date than the columns’) or the value of an attribute (project, a UDA). Weekdays begin where your week does.
- Strength of each cell: the measure (tasks, a sum, an average).
A new heatmap has a column per week the tasks were added and a row per weekday. Cells are squares and the grid sits in the middle of its card. Hover or tap a cell for its row, column and value; a cell with nothing to measure is faint.
A series is a colour, not a row. Each series is its own grid in its own colour, one above the other. Stacked adds them into one grid (named “Success / Failed”) whose cells blend the series’ colours in the proportions of what each had; hover or tap one for the total and each series’ value.
With Rows set to A row for each series, the series are the rows of one grid, each in its own colour, and Stacked adds them into one row: a heatmap along the columns only.
Filters
The filter is the one the Tasks page takes (Tab completes).
- Board filter. A board can have its own, which every section adds to as
( board filter ) ( section filter ), so anorin one can’t spill into the other. - Ignore the board’s filter. Tick it in a section’s settings (off by default) for a section that should ask about its own tasks only.
Date ranges
A board can have any number of named ranges, each a start and an end (both required), set in the board’s
settings. They sit above the board next to its filter. Filters on the board, a section or a series use them as
$name.start and $name.end, so one change moves every card. For a range named daterange (sow to eow):
(end.after:$daterange.start and end.before:$daterange.end) or (scheduled.after:$daterange.start and scheduled.before:$daterange.end)
Dates
A date is anything Taskwarrior reads: sow, eod, som, 2026-10-01, 2026-10-01T09:00, eow-2d, in your time
zone with weeks starting on weekstart. A bare date is the start of that day. Each date is a button opening a panel
with a field (date words complete with Tab or a tap), buttons for common words, and a calendar with time. Dates are
written into the filter as the moment they mean, so a value with a space can’t break it.
Names
A name is lower-case letters, digits and _, starting with a letter, and shown as a label (sprint_week is
“Sprint week”). An unknown name, an unreadable date or an end before its start is an error on the cards that use it,
and nothing runs.
Changing them
Dates typed above a board last only while the page is open (Reset brings the board’s own back). The board’s own are changed in its settings.
Counts and the largest group
A range also knows its length: $name.minute_count, .hour_count, .day_count, .week_count, .month_count,
.quarter_count and .year_count are how many of that period it touches. Use them in a chart’s Keep: a chart per day
over sow to eow with Keep $daterange.day_count keeps 7 days. Periods are counted as a chart cuts them, in your
time zone, and the end isn’t in the range (a range ending at midnight stops before it). Monday 5 to Sunday 11 October is
7 days and 1 week; Wednesday 7 to Tuesday 13 is 7 days and 2 weeks. Keep takes only a number or one of these counts.
$name.lg_group.name is the largest group of the range: the step (day, week, month or year) a chart over it
is best cut into, the largest that still gives at least two whole periods. A week is days; two weeks or a month are
weeks; two months or a year are months; two years are years; under two days is days. $name.lg_group.count is how many
of those the range touches (a week is 7 days; a month is usually 5 weeks). A chart’s Per offers Largest group
(currently week), one per range, so the chart regroups as the range changes, and Keep takes $name.lg_group.count
to show exactly the range. The keyword buttons in the date panel say in a tooltip what each means and what it is now.
A range as the axis
A chart grouped by a date normally runs from the first period any task falls in to the last. Over makes the axis a range instead: the board’s date range (by name) or dates of its own (a From and To, written like a range’s dates).
- The axis is then every period of the range, Per day, week, month and so on, whether or not a task falls in it. A task outside the range has no place on it, so the section’s filter needn’t repeat the range.
- A burndown over a week per day has its seven days. A series that counts what is open at the end of each period
(Counts) stops at today, leaving later periods empty, as
task burndownstops. - The periods are the range’s, so the chart moves with the dates typed above the board, and a chart of the largest group of the same range gives exactly its whole periods.
Measuring and grouping
Measuring
Measuring works on numbers (urgency, a numeric UDA) and durations (a duration UDA such as 2h or PT2H). A
duration figure is shown as Taskwarrior writes one (2h, 3d). A task with no value for the field is left out of a sum,
average or comparison, and a group where none has one shows –, not zero. Anything else is refused.
Grouping
Grouping by an attribute makes a bar per value, and a task with none is counted under (none). A task with several
tags counts once under each, so bars can add up to more than the tasks (the figure above them doesn’t).
Date periods
A date is cut into days, weeks, months, quarters or years in your time zone, with weeks starting on weekstart.
An empty period is still drawn, so lines run on and charts have no gaps. At most 2000 periods.
Order and number
A chart of values puts the largest first; Order changes that (smallest first, or by name),
and Keep shows only the first few. A date chart is in time order, and there Keep shows the latest periods:
per day with Keep 7 is the last week, ending today.
Series
A chart can have several series, like Chart.js datasets. Each has a label and a filter (added to the board’s and the
section’s) and is drawn as its own line or bars on the same axis, with a legend. A pie, donut or polar chart has one
series, whose slices are the values. All series of a chart must measure the same kind of thing (numbers or durations),
and they share the grouping, so a period where one has nothing still has a place for it (zero, or – for an average).
Stacking
Stacked piles bars on each other or adds lines up. The first series is at the bottom. Move up and Move down change the order, and a series with no colour of its own keeps its colour when it moves. Add series puts a new one at the end and Remove series deletes one.
Colour
Choose one of Taskwarrior’s basic colours by name (red, bright blue, …), drawn in the page’s palette so
it follows a light or dark page and your theme, or any #rrggbb from the system picker. With none, series take the
palette’s blue, red, green, yellow, magenta and cyan in turn.
A measure of its own
A series can have its own measure, set in the file; otherwise it uses the section’s.
Per period, not pending
Unlike task burndown, which counts what is pending at each moment, a chart counts per
period by the date you pick (end, entry, due). So “finished per week, by outcome” is a stacked bar chart with a
series Succeeded (outcome:success or outcome:, which takes a task with no outcome as a success) and Failed
(outcome:failed).
Colours of a UDA’s values
A chart grouped by a UDA’s value (priority, or your own) draws each value in the colour your taskrc gives it,
color.uda.<name>.<value> (color.uda.priority.H=red; color.uda.<name>.none for tasks with no value). That applies
to the slices of a pie, donut or polar chart and the bars of a bar chart of one series, on the web, on the phone and in
the widget. A value with no colour keeps the chart’s own, and a colour too faint for the page is darkened or lightened
until it shows. A rule for every value (color.uda.<name>) and colours of projects and tags are not used, and several
series and charts over time keep their series’ colours.
Burndown-style series
A series can count what is open at the end of each period instead of what happened in it, which is how task burndown counts. In the series’ Counts menu pick:
- Pending: entered, not yet finished, and not started.
- Started: started, not yet finished.
- Done: finished by then, so it adds up.
In the file, give a series a span: { "from": "entry", "to": "end", "unless": "start" } is pending, { "from": "start", "to": "end" } started and { "from": "end" } done. Any other dates work (open from scheduled until end).
The chart must be grouped by a date, and its periods run from the first task to the current one. Stack the three to get
Taskwarrior’s burndown, and split any with its filter (Done into outcome:success or outcome: and outcome:failed). It
counts the same as burndown.weekly for tasks started after they were entered (checked week by week), except that
Taskwarrior counts only a task still pending as started, and here a task started and then finished is started between
the two.
The info button
Every card has an ⓘ in its corner. It opens a panel with:
- what the card asks (its measure and grouping, or its report),
- the filter each series ran, with Open in Tasks to look at those tasks,
- the figure or, for a chart, each value on its axis with the number of tasks behind it and the total over all tasks,
- the tasks the filter got (the first 200, with the columns it measures and groups by).
It is how you check where a number came from.
Laying out the board
The page is 12 columns wide. In Edit:
- Drag a section by its move handle (top right) to move it, and its bottom-right corner to resize. It snaps to the grid. Whatever it lands on is pushed down, and everything floats up to the first free row, so there are no gaps or overlaps. No section is narrower than two columns.
- Without a mouse, each handle takes the arrow keys (move or resize) and says where the section now is.
- Add puts a new section in the first free place. Settings (the pencil) slides in a panel with its filter, measure and grouping (full width on a phone). The bin removes a section. Cancel drops everything since Edit.
- Duplicate (two overlapping squares) copies a section, with its settings and the title “
<title> copy”, into the first free place and selects it. - A card too narrow for its tools (about 280 px) shows one ⋯ with Information, Move, Settings, Duplicate and Remove.
On a phone the sections keep their reading order (top to bottom, left to right) on a grid of 6 columns, or 4 on a small phone. A number is as wide as its share of the row: four numbers of three columns sit side by side on 4 columns, three to a row on 6. A table or chart would be cramped in a quarter of a phone, so it stacks as Bootstrap’s columns do: a whole row if it is half the grid or wider, half a row if narrower. Two of the 12 columns is the narrowest, which is one phone column, so a table or chart has just those two sizes and the corner drag jumps between them. Edit there has the same corner drag (the width is saved in the 12, so it holds on a wide screen), up and down buttons to move a section earlier or later, and the same side panel.
The file format
You never have to write it by hand, but you can. A board or section that isn’t valid is named at the top of the page and
left out (the rest of settings.json is read as usual). Saving writes only the valid ones, so fix the file first if you
want to keep what was left out.
{
"boardsVersion": 1,
"boards": [
{
"id": "week",
"name": "My week",
"description": "What I got done",
"filter": "project.not:Someday",
"ranges": [{ "name": "daterange", "start": "sow", "end": "eow" }],
"sections": [
{ "id": "open", "title": "Open", "x": 0, "y": 0, "w": 3, "h": 2, "type": "number",
"filter": "status:pending" },
{ "id": "done", "title": "Done per week", "x": 3, "y": 0, "w": 9, "h": 4, "type": "line",
"filter": "status:completed", "by": { "time": { "field": "end", "step": "week" } } },
{ "id": "outcome", "title": "Finished per week, by outcome", "x": 0, "y": 4, "w": 12, "h": 4, "type": "bar",
"stacked": true, "filter": "status:completed", "by": { "time": { "field": "end", "step": "week" } },
"series": [
{ "label": "Succeeded", "filter": "outcome:success or outcome:", "color": "green" },
{ "label": "Failed", "filter": "outcome:failed", "color": "red" }
] },
{ "id": "effort", "title": "Estimate by project", "x": 0, "y": 8, "w": 12, "h": 4, "type": "bar",
"measure": { "fn": "sum", "field": "estimate" }, "by": { "field": "project" }, "limit": 10 },
{ "id": "days", "title": "Done per day, this range", "x": 0, "y": 12, "w": 12, "h": 4, "type": "bar",
"filter": "status:completed",
"by": { "time": { "field": "end", "step": "$daterange.lg_group.name", "range": "daterange" } } },
{ "id": "own", "title": "Done in October, per week", "x": 0, "y": 16, "w": 12, "h": 4, "type": "bar",
"filter": "status:completed",
"by": { "time": { "field": "end", "step": "week", "range": { "start": "2026-10-01", "end": "2026-10-31T23:59" } } } }
]
}
]
}
x and w are columns (0 to 11, and 2 to 12); y and h are rows. measure is { "fn": "count" } unless it says
otherwise, and estimate above is a duration UDA of yours.
Settings
Settings in the header has a tab each for General, the taskrc, tasksh, Commands, Hooks and
Urgency. Every tab has its own Apply (Save, on Hooks), and applying one doesn’t touch the others. A tab with
unapplied edits has a dot, and the edits stay while you look at another tab or page. If the saved settings change
elsewhere (the Console’s config, another tab), a tab with no edits of its own follows them.
General sets the home page, the page the app opens on: any report (including your own) or any of your boards.
The default is next. It is kept in the app’s own settings.json ("homepage": { "report": "list" } or { "board": "week" }), not your taskrc, so it is the same on every device that signs in to the same deployment. A link to a task
still opens that task, and a report or board that is gone opens next. (The taskrc’s default.command is different: it
is the command a line runs when it names none.)
(The screenshot predates the list of files beside the text, described under Your taskrc.)
Your taskrc
UDAs, custom reports, contexts, holidays and your other settings live in ~/.taskrc, not in your synced data, so the app
keeps its own copy: a folder of files, kept as you wrote them. Open the taskrc tab. The files are on the left
(the main one, taskrc, first); choose one to edit it on the right, then Apply. Only an allowlist of settings is
read; the full list is in taskrc support.
The editor
The editor numbers the lines and colours each part: a setting’s name, its value, a comment, include and its file, and
the [unsupported] the app writes where it commented a line out. A line that is no setting at all is underlined (it is
commented out when you Apply), and a credential-like name is struck through (it is removed). A line the engine has a
warning for, such as an include that points nowhere, is marked in the line numbers. On the web it is CodeMirror
(Tab indents, Ctrl+Z undoes within one file); on the phone it is a text box with the same colours. The engine says what
each piece is, so both agree.
What is kept and what is not
Text is kept as written: comments, blank lines, order and spacing. Two things change on the way in:
- A line the app can’t use is commented out (
# [unsupported] some.key=1) rather than dropped, and the result says which lines and why. - Sync settings and anything credential-like are removed (
sync.*,taskd.*, names containingsecret,password,token, …), commented-out ones included. They are never stored, logged, shown or synced, and only the names of what was removed are reported.
A UDA that exists on a task but isn’t defined in your taskrc is shown but read-only.
include
It works as in Taskwarrior: include holidays/en-US.rc reads that file where the line stands, so later lines win. Write
the path from the file that has the line (include ../taskrc from inside a folder); includes nest up to ten levels. An
include that points nowhere, outside the folder or at an absolute path is reported with its file and line, and the rest
is still read. include dark-256.theme (Taskwarrior’s own themes) becomes that theme’s lines when you apply. A file no
include reaches is kept but marked unused.
Files
Add a file
Type its name (holidays/en-US.rc) and press Add, or use Add files to upload several.
Import folder
Uploads a whole folder (your dotfiles checkout). .taskrc or taskrc at its top becomes the main
taskrc. Hidden files and folders (.git), binary files and anything over 256 KB are left out and listed, and up to 64
files or 1 MB are taken. It adds to what is here; tick Replace what is here to make the folder the whole of it (it
needs a taskrc). One import is one step, so Restore previous undoes all of it.
Download
Keeps the file shown (under its own name) and Download all (.zip) every file, keeping folders. They hold what is saved, not unapplied edits, and never a credential. Import folder takes an unzipped copy back. On a phone the system’s save dialog asks where to keep it.
Include this file in…
On any file but the main one, this adds include <path> to the file you choose and shows it;
Apply keeps it. Delete file removes one (the main taskrc can only be emptied).
config and the settings pages
They write to the main taskrc, as task config does: a line starting with name=
changes in place and keeps its comment, a new setting goes on the end, and removing one deletes those lines. A setting
an included file defines isn’t edited there: the new line overrides it, or you edit that file to remove it.
Restore previous
Undoes the last save, import or delete (all files together).
Import from a link
Keep your dotfiles repository as the source: paste the address of the file (a GitHub or GitLab file page works, as does a raw link or a gist’s) and choose Import.
- It brings the main taskrc only. Included files aren’t fetched, so add them (they show as not found until you do).
- The Worker fetches it, so the site’s CORS rules don’t matter, and the file never reaches the browser. It keeps what a pasted file would keep.
- It saves straight away (Restore previous undoes it) and doesn’t remember the link, so import again to refresh.
- Only
https://links to public sites are followed, with no addresses, ports or user names in the link (the same for any redirect), up to 256 KB. The request carries no login, so the link must work without one (a secret gist’s does). A web page rather than the file is refused.
Add holidays
This makes a file of a country’s public holidays from one of three sources, and shows the list it found before anything is saved:
Holidata
From holidata.net, the data behind Taskwarrior’s own holiday files: 42 locales in 34
countries, with the language where a country has several. Saved as holidays/<locale>.rc (holidays/en-US.rc).
Nager.Date
From date.nager.at: about 200 countries, saved as holidays/nager-<code>.rc. For
some countries it lists only holidays that fall on the same date every year, so ones that follow the moon (Eid) can be
missing: look the list over.
Calendar link (.ics)
A public feed such as Google’s holiday calendars
(https://calendar.google.com/calendar/ical/en.bd%23holiday%40group.v.calendar.google.com/public/basic.ics;
webcal:// works too). The Worker fetches it under the same rules as the taskrc link import (https, a public host, up
to 1 MB). All-day events become holidays, a multi-day one a stretch (.start and .end), and a plain yearly repeat is
carried over the years. A Keep list has every name; what the calendar calls an observance (Valentine’s Day) starts
unticked. The file is named after the calendar unless you give a name.
Pick the country (or paste the link), the years (last year to two years ahead to begin with) and Find holidays. Holidays of one region only (Massachusetts’ Patriots’ Day, a Swiss canton’s) are left out unless you tick the region.
Save writes the file and, unless they are there already, adds include holidays/<file>.rc and
calendar.holidays=full to the main taskrc (you can untick them). If the main taskrc has unapplied edits, the lines go
into its box and Apply keeps them. A file made this way records where it came from, so Update holidays on that file
fetches it again for the years now with the same regions and names (a year with no data is named). A calendar’s link is
remembered only if it has no ?, since such a link can hold a secret. Saving again replaces the file, and Restore
previous undoes it.
Holidata and Nager.Date are asked from your browser (or phone) and learn your IP address and the country and years you asked for, nothing else. A calendar’s host learns the Worker’s (or the phone’s) address and the link.
Contexts
A context (context.<name>.read / .write) filters what you see and tags what you add. It can also carry settings,
context.<name>.rc.<key>, in force while it is active. As in Taskwarrior they win over everything, including a
command-line rc. override.
Colours
Rows are coloured by Taskwarrior’s colour rules, and the same colours reach the calendar, burndown, summary bar and history graph. With nothing set you get Meghnaad’s own theme:
- the active task on soft green, overdue in coral, due today in bold amber (due later this week is plain white, so today’s tasks stand out),
- blocked tasks dimmed, recurring ones in orchid,
+nextmarked, and no zebra, - priority coloured on its own cell (
color.uda.priority.H,.M,.L: coral, amber, green), whatever colour the rest of the row has. Taskwarrior’s rules colour a whole row; colouring one field is the app’s addition, from the same settings.
Colours don’t blend (rule.color.merge=no): a row takes the colour of the first rule in rule.precedence.color that
applies, so a keyword or tag rule you add replaces that colour instead of mixing. Put rule.color.merge=yes in your
taskrc to blend as Taskwarrior does. Your color.* lines work as on the desktop:
color.active=bold on sage # a rule: `sage` is a softer name for green
color.tag.work=sky # a tag, `project.Home`, `keyword.milk`, `uda.estimate.big` likewise
color.overdue=bold rgb500
rule.precedence.color=overdue,active,tag. # which rule wins first; with `rule.color.merge=yes` the rules blend instead
color.alternate=on gray2 # shade every other row
The taskrc tab has a Colour theme picker (Meghnaad’s own, Taskwarrior’s default and the 16 standard themes such as
dark-256 and solarized-light-256) and a Colours switch. Choosing a theme replaces the colour lines in the box, and
Apply keeps them. A pasted include dark-256.theme is read as that theme. color=off (or rc.color:off) turns every
colour off, and a task tagged nocolor is never coloured.
colors in the Console draws the palette, colors bold coral on sky a sample, and colors legend every colour in force,
each in its own colour. Colours are Taskwarrior’s palette (rgb440, gray12, color214, on bright blue); the basic
ones are drawn softly for the page, and a colour that wouldn’t show on it is nudged toward readable. The friendly names
are ink, coral/rose, sage/moss, amber/sand, sky/ocean, orchid/plum, teal/aqua, snow/pearl and
slate. taskrc support lists every colour setting.
Commands
A command is a word that stands for a whole command line. Define it under Settings → Commands: a name, what it stands for, and optionally an icon. Any Lucide icon works, picked by sight, by name, or by what it is for (“cancel” finds the circled cross). The app carries only the few icons it uses itself; any other is fetched from the jsdelivr CDN when first shown and remembered by your browser (see Icons from a CDN in the README’s security notes). With no network, an icon you haven’t used shows as a terminal, and the picker lets you type its name instead.
fail for done outcome:fail works like this:
fail → done outcome:fail
task fail → the same: the leading `task` is optional
3 fail → 3 done outcome:fail
project:Home +work fail → project:Home +work done outcome:fail
What comes before the command word selects the tasks, as it always does (and the active context applies). What comes
after is added to what the command stands for. A command may carry a filter of its own: late for +OVERDUE list makes
project:Home late read project:Home +OVERDUE list. Only the command word can be one, so add fail the test still
adds a task called “fail the test”, and a command used inside another is written out too. A name is written out once on a
line, which lets done stand for done outcome:ok and still reach Taskwarrior’s done.
A command wins over a Taskwarrior command, a report or a taskrc alias.* of the same name. It differs from a taskrc
alias in two ways: it is yours alone (kept in this app’s settings.json, not the taskrc that mirrors your desktop one),
and it reads the line as Taskwarrior does, so it applies only where the command goes.
Several commands. wrap for done outcome:fail && modify +failed runs the second only if the first worked; || and
; work as on the console line. The filter before the name goes in front of every one of them (3 wrap is 3 done outcome:fail && 3 modify +failed, so use a uuid rather than a number when an earlier step changes what the number
means), and what follows the name goes after the last (3 wrap +x ends with 3 modify +failed +x). A button runs each
on the task it is for, by uuid, and stops where && would.
Buttons. Every command is also a button: the ⋯ menu on a task (in the table and the drawer) runs it on that task, and the bar over selected tasks runs it on all of them, a batch at a time. The menu isn’t there while you have no commands. Where a table is wider than its box (a narrow board card, say), each row’s buttons (Done, Start or Stop, Edit, Delete, then the commands) fold into that menu, and open out again when there’s room.
Names are one word that can’t be taken for a filter: no spaces, no + - /, no : or =, not a number.
The tab shows the settings.json it will save. Other keys in the file are kept, and entries it can’t use are listed and
dropped when you apply. The file is at most 64 KB.
tasksh
Review is tasksh’s review: it walks the tasks you haven’t looked at lately, one at a time, and you mark each as
reviewed, skip, edit, change, finish or delete it. Start it with Review in the top bar (a card with buttons) or by
typing review in the Console. review 10 stops after ten (a limited review is done from the Console), and rev is
enough, as in tasksh. As in tasksh the menu is keybindings, not a shell: a key is the answer, with no Enter after it, and
the same choices are buttons.
What it needs
Two things in the taskrc, which the tasksh tab prepares: a reviewed attribute (a date UDA recording when you last
looked at a task) and a _reviewed report (one uuid column) listing the tasks due for review. If they’re missing, the
first review asks, in tasksh’s words, whether to define them, and does so with the lines below.
The tab says whether each is defined, defined differently (showing what is there now, since applying changes it) or
missing, and lists the exact config lines Apply would write, the ones tasksh writes the first time it runs. Nothing
else in the taskrc is touched. You can adjust the report’s filter (by default never reviewed, or not for 6 days, and
still pending or waiting) and its order; Use tasksh’s defaults puts them back.
Reviewing
Each task is shown with [3 of 12] and everything info shows, then the menu:
| Key | |
|---|---|
| Enter | Mark it reviewed (modify reviewed:now) |
s | Skip it: on to the next, not counted as reviewed |
e | Edit it in the app’s editor; closing the editor marks it reviewed and shows it again |
m | Type what to change (+tag -tag /teh/the/ project:X), then Enter; Escape cancels. The same task is shown again |
c / d | Complete or delete it; both count as reviewed |
q | Stop. What is done stays done, and you can start again where the list left off |
At the end: End of review. 3 out of 12 tasks reviewed.
In the Console the prompt is off while the review asks (it reads review>), a bar of buttons stands over it, and Escape
quits; the only thing you type is the change after m. Open as card in that bar carries on in the card from the same
place. In the card the Reviewed button has the focus, so Enter marks it, as in tasksh, and the same letters work as keys.
Questions Taskwarrior asks mid-step are asked here too, with Yes and No (or y and n). For a recurring task: “This is a
recurring task. Do you want to modify all pending recurrences of this same task?” Yes marks the whole series, No only
this one. (rc.confirmation:no doesn’t silence it, since recurrence.confirmation governs it, so tasksh shows it too.)
Every step is an ordinary command (task <uuid> modify reviewed:now, done, delete) sent with rc.confirmation:no, so
hooks run, undo works and the Tasks table follows. Review steps aren’t added to the command history.
Urgency
A task’s urgency is the sum of Taskwarrior’s urgency.* terms, with its built-in coefficients unless your taskrc changes
them. The Urgency tab shows every coefficient next to its default. Edit one, add your own, or send one back to its
default, then Apply. Edits are saved with your imported settings (as urgency.* lines in the taskrc tab). Your desktop
~/.taskrc isn’t touched, so copy any change you want there.
It follows Taskwarrior’s rules, so nothing is inherited that Taskwarrior doesn’t inherit:
- Coefficients add up: a task gets every matching coefficient, not just the most specific.
urgency.user.project.Home.coefficientapplies toHomeandHome.Kitchen, but notHomework.- Tags match user and virtual tags (
urgency.user.tag.OVERDUE.coefficient). Keywords match the description, case-sensitively.urgency.uda.<name>.coefficientmatches any value of a UDA, and….<value>.coefficientone value. urgency.inheritis off, as in Taskwarrior. When on, a task that blocks others takes the highest urgency of what it blocks, through the whole chain, plus 0.01.
rc.urgency.due.coefficient:0 next and rc.urgency.inherit:1 also work for a single command.
Recurring tasks
add Water plants recur:weekly due:friday
add Pay rent recur:monthly due:eom until:2027-12-31
3 modify recur:2w # change the period of a recurring task
Periods are Taskwarrior’s: daily weekdays weekly biweekly monthly quarterly yearly, counts such as 3d
2w 6mo, and ISO durations like P1M. A repeating task shows a repeat icon, and its detail lists its instances, each
linking back. The recurring task itself is a template: you can edit it, but not complete or start it.
How instances are made
Recurrence is on by default (recurrence=1). Before the commands that Taskwarrior flags Recur (reports, count,
export, ids, projects, the history, ghistory and burndown charts, and the page’s own reads) the app creates
the instances that are due and expires those past their until. It does not do this before a command that changes a
task. recurrence.limit=N keeps N upcoming instances (default 1).
Taskwarrior advises that when several clients sync, one is primary and the others set recurrence=0, because two that
each make an instance before syncing make two. This app’s own clients (the page, other tabs, the phone) avoid that: an
instance’s uuid comes from its template and its index, so they all make the same task and the replicas merge it. A real
task on the same data doesn’t know the trick, so if you also run the CLI, set recurrence=0 in its taskrc, or turn it
off here with recurrence=off. With it off, the app still shows and edits recurring tasks and reads instances made
elsewhere.
Editing and deleting a series
- Editing follows
recurrence.confirmation:prompt(default) asks whether to change all pending recurrences or just this task,yeschanges the whole series,noonly the task you edited. - From an instance,
modify,append,prependandannotatego to the template and every pending instance. Amodifycarries dates andrecurtoo, somodify due:fridayon one instance moves them all. From a template onlymodifyreaches the instances.denotate,start,stopanddonenever do. - Deleting a template or an instance asks the same way.
yesdeletes the template and its pending instances (from an instance, the whole series),noonly the task you named. - A template’s mask has a letter per instance:
-pending,+done,Xdeleted,Wwaiting. It followsmodify,start,doneanddeleteof an instance, not a note or anappend.
Details
- A date can be given relative to another date of the same task, read left to right:
add Review due:friday wait:due-1d recur:weekly, ormodify scheduled:due-2h. A date the task doesn’t have yet, or that comes later on the line, can’t be used. - The filters
imask,maskandrtypework as in Taskwarrior (imask:0,rtype:periodic,mask.any:). A task with noimaskcounts as 0. recurrence.indicator(defaultR) is what therecur.indicatorcolumn shows.
Only periodic recurrence is acted on
A template’s rtype is periodic (every period from the due date, and all that the app and task 3.5.0 create) or
chained (the next starts when the last ends). Taskwarrior 3.5.0 knows the word chained but never acts on it, and
doesn’t let you set it, so neither does this app. A template that arrives with rtype:chained (an import, or another
client) is kept and exported as it was, and its instances are made on the periodic schedule, as task makes them.
Hooks
Taskwarrior runs your scripts when a command starts, when a task is added or changed, and when it ends. A browser can’t
run ~/.task/hooks/on-add.*, so here a hook is a function in a Lua script you write in Settings → Hooks.
The script lives in your bucket (as web/hooks.lua, beside your settings), not in the app, so a change is used by the
next command with no rebuild or deploy. It runs on the server behind your sign-in, so it can hold secrets that never
reach the page. Your desktop’s own hooks are separate: they fire only for commands run there, unless you run this same
script on the desktop too (below).
A command waits for each hook in turn, as task waits for its scripts (add asks for on_launch, on_add and
on_exit). A new task doesn’t hold the page up for that: + and the full form wait a second, so a refusal comes back
to the form it was typed in, then they close and a Creating… notice stays until the task is made. If a hook refuses
it then, the one-line add opens again with what you typed.
The four hooks
Define any of them, each at most once:
| Hook | Runs | It can |
|---|---|---|
on_launch(command) | before a command does anything; command is the line | refuse it |
on_add(task) | for each new task, before it is saved | change the task, or refuse |
on_modify(old, new) | for each task modify, done, delete, start, stop, annotate… changes, before it is saved | change new, or refuse |
on_exit(changed) | after the command, with the tasks it changed | only talk |
-- Anything in project Work (or below it) is also tagged +office.
function on_add(task)
if task:in_project("Work") then
task.tags:add("office")
say("Tagged +office.")
end
end
-- A task can't be finished while it is blocked, and a due date is never left in the past.
function on_modify(old, new)
if old.status == "pending" and new.status == "completed" and old.blocked then
refuse("Finish what it depends on first.")
end
if new.due ~= nil and new.due < today() then
new.due = tomorrow()
end
end
Tasks, tags and dates
A task is a table: description, project, priority, status, uuid, blocked, blocking, recur,
annotations (a list of {entry, text}), the dates, and your UDAs as plain fields (task.estimate = "big"; set one to
nil to remove it). Change the task you are given, or return one. A hook may change what modify may change: uuid,
status, annotations and the recurrence fields can be read but not changed, and trying is an error. The change joins
the command’s undo step, and a refused command saves nothing. In on_modify, old is read-only.
Tags (and task.depends, a set of task ids as text) are a set: task.tags:has("work"), :add("a", "b"),
:remove("x"), :has_any("a", "b"), :has_all(…), :list(), #task.tags, and for t in task.tags:each() do … end.
Dates (entry, modified, start, due, wait, scheduled, and finished and expires for end and until,
which are Lua keywords) can be calculated with:
- Make one:
date("friday"),date("2026-12-25"),date("eom"),date("now+3d"),now(),today(),tomorrow(). - Add or subtract a duration:
days(2),hours(3),minutes(30),weeks(1),duration("3d"). - Compare (
d1 < d2) or subtract (d2 - d1is a duration, with:in_days(),:in_hours()and so on). - Read parts:
d.year,.month,.day,.hour,.min,.sec,.weekday(0 is Sunday). - Format with
d:format("Y-M-D")(Taskwarrior’s letters) or round down withd:start_of("week")(day,week,month,year).
They follow your time zone and dateformat.
Helpers and messages
task:in_project("Work")(also true forWork.Mail),:is_pending(),:is_completed(),:is_deleted(),:is_recurring(),:is_waiting(),:is_overdue().say("…"),warn("…")andprintshow under the command’s result in the Console, or as a toast elsewhere.refuse("why"), or any error in the script, stops the command with that message. An error inon_exitis shown but stops nothing.
The network and secrets
A script can reach other services, to hosts it declares at the top, over https only (http:// only for localhost, to
test):
allow_hosts{"acme.atlassian.net"}
function on_add(task)
local res, err = http.post("https://acme.atlassian.net/rest/api/3/issue", {
headers = { Authorization = "Basic " .. secret("jira") },
json = { fields = { summary = task.description } },
})
if res == nil then
warn("Jira unreachable: " .. err) -- the network failed: carry on without it
return
end
task.jira = res.json.key -- a UDA named jira
say("Created " .. res.json.key)
end
http.get, post, put, patch and delete (or http.request(method, url, opts)) take opts of headers = { Name = "value" }, json = <table> or body = "text", and timeout = seconds (up to 10). The answer is { status, ok, headers, body, json } (json when the body is JSON; header names are lowercase), or nil and a reason when the request couldn’t
be made or answered, the one failure a script is expected to handle. A host not declared, a plain http:// address, or a
sixth request in one call is an error that ends the hook. http.urlencode(text) makes text safe for an address. A slow
service slows the command, so give it a short timeout.
secret("name") is a value kept apart from the script. Set secrets in Settings → Hooks → Secrets: each is sealed in
your bucket with the same key as your tasks, can be replaced but never shown again, and is replaced by *** in anything
a hook prints, fails with or returns. Or set a Worker secret called HOOK_SECRET_<NAME> (wrangler secret put HOOK_SECRET_JIRA, the dashboard, or your deploy workflow): it is read the same way, wins over one kept in the app, and
never touches the bucket. Each call is logged as hook http METHOD host -> status, never the path, which may hold a
token. A missing secret fails the hook with a message that says so.
The editor and the sandbox
The editor highlights Lua and marks a mistake with its line as you type, using the same Lua that runs the script. A
script that doesn’t load can’t be saved, so a saved script always runs, and the one before it is kept. The language is
Lua 5.4 with string, table and math only: no files, no clock but the command’s. A script that runs too long (a few
million steps) or uses too much memory is stopped with a message.
Good to know
hooks=off(orrc.hooks:offfor one command, orconfig hooks off) turns them all off.undodoesn’t run hooks, as in Taskwarrior.- The housekeeping before a report runs hooks as there:
on_addfor each recurring instance it makes, andon_modifyfor the parent whose mask changed and for a task it expires. If a hook refuses one of these, the command stops with its message and writes nothing, where Taskwarrior can leave the instances made before the refusal. - A command that asks a question first runs
on_launchonce per round. - Only the hooks your script defines are called, so a command with none is as fast as before. If the server can’t be
reached, the hook is skipped with a warning and the command carries on (your own
refuseor error still stops it).
A fuller example: rules by project
This is a real script, the one its author keeps in their dotfiles. It tags a new task from where it belongs, so the tags
don’t have to be typed. It is a table of rules, each a match function and the metadata to apply when it matches, so
adding a rule is adding a table entry, not code.
-- True if any period-separated segment of project matches any of segments (a string or a list of strings)
-- literally. With partial, a candidate may match anywhere inside a project segment (chore matches chorexxx, never
-- chor); otherwise it must equal the whole project segment. A nil project (a task with no project) never matches.
local function segment_matches(project, segments, case_insensitive, partial)
if project == nil then
return false
end
if type(segments) == "string" then
segments = { segments }
end
if case_insensitive then
project = project:lower()
end
for part in project:gmatch("[^.]+") do
for _, segment in ipairs(segments) do
if case_insensitive then
segment = segment:lower()
end
if partial then
if part:find(segment, 1, true) then
return true
end
elseif part == segment then
return true
end
end
end
return false
end
-- The project's nth segment (1-based), or nil if there are fewer or no project: nth_segment("FUL.Billing", 2) is
-- "Billing".
local function nth_segment(project, n)
if project == nil then
return nil
end
local i = 0
for segment in project:gmatch("[^.]+") do
i = i + 1
if i == n then
return segment
end
end
end
-- Rules run by on_add, in order. Each entry has a match function (takes the task, returns true if the rule applies) and
-- a metadata table to apply to the task when it does (the keys are listed at apply_metadata).
local on_add_autometa = {
{
match = function(task)
return segment_matches(task.project, { "chore", "chores" }, true, false)
end,
metadata = {
tags = { "chore" },
},
},
{
match = function(task)
return nth_segment(task.project, 1) == "FUL"
or task.tags:has("jira")
or (task.jira ~= nil and task.jira ~= "")
end,
metadata = {
tags = { "work" },
},
},
}
local function as_list(value)
return type(value) == "string" and { value } or value
end
local function is_unset(value)
return value == nil or value == ""
end
-- Applies one metadata table to a task. A value may be a function of the task, for something computed. Keys:
-- tags, depends add to the set (a list, or one string)
-- remove_tags take out of tags
-- anything else a field (project, priority, due, wait, scheduled, until, a UDA...) set only if the task has no value
-- for it yet, so what was typed on the command line wins. Dates take "tomorrow", "now+3d", a date().
local function apply_metadata(task, metadata)
for key, value in pairs(metadata) do
if type(value) == "function" then
value = value(task)
end
if key == "tags" or key == "depends" then
for _, item in ipairs(as_list(value)) do
task[key]:add(item)
end
elseif key == "remove_tags" then
for _, tag in ipairs(as_list(value)) do
task.tags:remove(tag)
end
elseif value ~= nil and is_unset(task[key]) then
task[key] = value
end
end
end
function on_add(task)
for _, entry in ipairs(on_add_autometa) do
if entry.match(task) then
apply_metadata(task, entry.metadata)
end
end
end
How it works
segment_matches(project, segments, case_insensitive, partial)is true when any dot-separated part of the project equals (or, withpartial, contains) any of the given words. A task with no project never matches.nth_segment(project, n)is the project’s nth part, sonth_segment("FUL.Billing", 2)isBilling.on_add_autometais the list of rules. Here anything under achoreorchoresproject gets+chore, and anything under theFULproject, or tagged+jira, or with ajiraUDA set, gets+work.apply_metadataapplies one rule.tagsanddependsare added to;remove_tagstakes tags out; any other key is a field set only if the task has no value for it yet, so what you typed on the command line wins. A value can be a function of the task, for something computed (due = "tomorrow"anddue = function(task) … endboth work).on_addruns every rule in order.
To make it yours, change the two match functions and their metadata. A rule that sets a default can use any field
(priority = "L", wait = "now+3d"), and a rule with remove_tags = { "inbox" } cleans up after another.
Try it without task, with the program from below: meghnaad-hook check hooks.lua says which hooks it defines. To see it run, link it as on-add.meghnaad (see the next section), then
task add Take out bins project:Home.Chores and task 1 info shows +chore.
The author keeps the script in a dotfiles repository managed with dotr and runs meghnaad-hook install as a post action of that package, so a new machine gets the hooks linked in one step.
The same script with the real task
meghnaad-hook runs the script for the task on your computer, with the same Lua, library and http, so a script
written in Settings → Hooks works there unchanged, and the other way round. task runs an executable per hook from
<data>/hooks/; meghnaad-hook is that executable, linked under the names task looks for.
Get the program once, any of three ways (none needs a clone of this repository):
Homebrew
For macOS and Linux: brew install uroybd/tap/meghnaad-hook, and brew upgrade for a newer one. The links
install makes point at Homebrew’s opt path, which survives an upgrade, so nothing needs installing again.
Prebuilt
No Rust needed: download meghnaad-hook-<version>-<platform>.tar.gz from the
latest release (Linux or macOS, x86_64 or arm64) and put
meghnaad-hook where you keep programs, for example ~/.local/bin. On macOS, if it is blocked for coming from the
internet, run xattr -d com.apple.quarantine ~/.local/bin/meghnaad-hook (or fetch it with curl).
From source
Needs Rust: cargo install --git https://github.com/uroybd/Meghnaad tc-hook --locked, which puts it in
~/.cargo/bin. Add --tag v<version> for a release instead of the latest commit.
meghnaad-hook check ~/hooks.lua # which hooks the script defines, or the line that is wrong
meghnaad-hook install ~/hooks.lua # ~/.task/hooks.lua -> your script; ~/.task/hooks/on-add.meghnaad -> the program, …
To try it, save the first example above (the one that tags Work tasks) as ~/hooks.lua, run the two commands, then:
$ task add Write report project:Work
Created task 1.
Tagged +office.
The project 'Work' has changed. Project 'Work' is 0% complete (1 task remaining).
$ task diag | grep -A4 Hooks # Active: on-add.meghnaad (executable) (symlink)
From then on, edit the script and the next task command uses it. Run install again only when the script starts or
stops defining a hook, or after you move the program or build a new one somewhere else (a Homebrew upgrade isn’t one).
Where things are
The data directory is --data DIR, else $TASKDATA, else ~/.task. The script is
$MEGHNAAD_HOOKS, else <data>/hooks.lua; install SCRIPT makes that a link to SCRIPT, so the script can live in a
dotfiles repository. install links only the hooks the script defines (each hook file costs a process per task
command) and removes the links of ones it no longer defines.
Removing and other places
meghnaad-hook uninstall removes the *.meghnaad links, and only those; your own hook
executables and hooks.lua stay. Links go in <data>/hooks. The taskrc’s hooks.location is not read (it may be in an
included file), so if you set one, pass that directory to install and uninstall as --hooks DIR.
meghnaad-hook check with no argument checks the script it would use. If there is no script, a hook prints
meghnaad-hook: there is no script at … and the command carries on.
What it does
on-add and on-modify give the script the task and print it back. What the script can’t change
(uuid, status, annotations, recurrence) is refused as in the web app, and anything it didn’t touch, id and urgency
included, goes back as task wrote it. refuse("why"), an error, or a script that doesn’t load stops the command and
shows the message (task exits 4). say and warn print under the command’s result; a warn starts with
Warning:. on-exit can only talk. on-launch gets the command line without task. hooks=off in the taskrc turns
them off.
Dates
Dates use this computer’s time zone and your taskrc’s dateformat, date.iso and weekstart. rc. overrides on
the command line and included rc files are not seen.
The network
The network works as on the server: allow_hosts{…} at the top, https only (http://localhost for testing), at
most five requests a call and ten seconds each; following redirects is the script’s job.
Secrets
Secrets are environment variables: secret("JIRA_TOKEN") is the variable JIRA_TOKEN, by that exact name (nothing
is prefixed or guessed, because the environment is shared with every program). A missing one is an error that stops
the hook, and its value is replaced by *** in anything the script prints, fails with or returns. Set them where
task starts from (your shell profile, or launchctl setenv for a GUI). The Worker’s and the app’s secrets are separate.
Differences
task.blocked and task.blocking are always false (task doesn’t send them, and a hook can’t ask,
since task holds the data). A date UDA reads and writes as seconds, as everywhere in the script.
Time tracking and reminders
Time tracking. Add journal.time=on to your taskrc. start and stop then record “Started task” and “Stopped
task” (the texts are configurable), and a task’s detail shows its sessions and a running total. A running task shows a
timer in the header and in the tab title.
Reminders. The bell in the header turns them on. You get:
- a heads-up before a due date that has a time,
- a morning reminder for dates without one,
- a note when a task comes off
wait, - one digest a day of what is overdue.
In the browser the tab has to be open, because a page can’t wake up in the background. System notifications also need
HTTPS (or localhost) and your permission. In the Android app reminders work with the app closed; the digest comes at
the hour you chose for dates without a time (Menu → Reminders).
On a phone
Below about 760 px (a phone, or a narrow window) the layout changes:
- The menu (☰) holds what the top bar has on a desktop: Tasks (it opens the list of reports), Projects, Tags, Summary, Calendar, Burndown, Console, Review, Reminders and Settings. The bar keeps what is live: the running task and the active context. A floating + adds a task from any page.
- Tasks are cards. The description comes first, with a tick box and the number, then small marks: priority, project, tags (tap one to filter), due date (red once past), urgency and the report’s other columns. A started task is tinted. A card has a Done button and a ⋯ menu with start or stop, edit, delete and your task commands.
- Pull down at the top of a page to sync and redraw it.
- Sorting is a Sort picker, since there are no column headers to tap. Forms and a task’s detail go full screen.
- Complete (⇥) and Previous command (⌃) stand in for keys a phone lacks. They sit beside Edit and Copy on the line that shows the last command. The prompt grows to show a long command, and suggestions can be tapped.
You can add the web app to your home screen from the browser menu, or use the Android app: it keeps the tasks on the phone, works offline, syncs straight with your bucket and has widgets.
Different from the desktop
Where this app is not the task you know:
Numeric ids are this app’s own
Pending tasks are numbered by creation time, so they won’t match your laptop’s. Use uuid prefixes where it matters.
undo
Covers the last few commands made while this tab is open. Closing or reloading the tab forgets them.
Hooks are Lua
, not scripts. A browser can’t run local programs, so hooks are functions in a script you edit in Settings. See Hooks.
Task commands and settings.json are the app’s own
A desktop task knows nothing of them, though your taskrc’s
alias.* still works there. See Commands.
&&, || and ; belong to the console
, as in a shell: the page splits the line and sends one command at a
time. Taskwarrior’s own filter also reads && and || but evaluates them wrongly (+a || +b finds only +b), so
an unquoted operator here always ends a command. In a filter, write and and or. See
the console.
tasksh is only review
The rest of the shell (!, exec, diagnostics, its prompt and history) means nothing
in a browser, and e opens the app’s editor, not $EDITOR. The reviewed attribute and _reviewed report the tasksh
tab defines are the lines tasksh adds to a desktop taskrc, so you can copy them there.
edit and import-v2 are missing
The detail view edits a task, and a browser can’t read Taskwarrior 2’s data
files. purge works, with one difference: it removes the task from your current tasks, but older versions in the
bucket still hold it until they are cleaned up. The app does that as the CLI does: after about one push in twenty,
once they are over 180 days old and a snapshot covers them.
Storage, sync and offline
Where your tasks live
The engine runs in the page, with a copy saved in the browser (IndexedDB), so the next visit pulls only what changed. A new device, or one whose copy was thrown away, downloads the newest snapshot, which takes a moment for a long history.
Syncing
A change applies at once and is pushed in the background. The page pulls what other devices did when you return to the tab and every 30 seconds while you look at it, so a change from your laptop can take that long to show. Tabs of the same browser tell each other.
Offline
If the server can’t be reached, a banner says so. Changes keep working, are kept in the browser, and go up with the next sync that works (Retry tries now). The page itself must be loaded from the server, so it can’t start with no connection. Start over from the server, in the same banner, discards this browser’s copy (including changes that never went up) and loads the tasks again. Use it when the copy and the bucket disagree, say after a replaced bucket.
What it needs
Web Workers and WebAssembly, which every current browser has. If the engine can’t start, the page says why and runs no command: nothing on the server can run one instead.
Deploying to Cloudflare
What you need
| Plan | Why | |
|---|---|---|
| Workers | Free is fine | Workers Free allows 10 ms of CPU per request. The Worker only checks Access and opens and seals versions (the engine runs in your browser), but the first request after an idle spell derives the encryption key, which can be cut off with error 1102. Cloudflare tolerates an occasional overrun and the app retries. Workers Paid ($5/month) lifts the limit. See Limits of the platform |
| R2 | Free is fine | The bucket your task CLI syncs to |
| Access | Free is fine | Sign-in (one-time PIN works with no setup) |
On your machine you need Node.js 20+ for every route. Routes A and C also need Rust when you deploy
from a clone of the source: the build compiles the Worker and the in-browser engine to WASM, and installs the
wasm32-unknown-unknown target, worker-build and wasm-bindgen (at the version in Cargo.lock) if missing. The first
build takes a few minutes. Route 0 needs no Rust: it deploys a prebuilt release. Route B builds on Cloudflare, and
route D on GitHub.
Pick one route to start. Route D is for updating after A, C or 0 has done the first deploy.
0. Prebuilt: no Rust, no build
Every release is built once by CI and published ready to deploy: to the release branch and, for a tagged version, as a
.tar.gz on the releases page. Nothing about your hooks, secrets or
settings is in the build (they live in your bucket), so everyone deploys the same files.
With the button (nothing to install):
Choose your R2 bucket (your existing sync bucket, or a new one) and give the encryption secret (the same value as
sync.encryption_secret). Then open the address it gives you and follow the setup page to connect Access
(Connecting Access).
From a terminal (Node.js 20+ only):
git clone --branch release --depth 1 https://github.com/uroybd/Meghnaad.git meghnaad
cd meghnaad
npm install
npm run setup # the same guided questions as route A, without the build
To update, pull the release branch (or download the new release) and run npm run deploy. Your answers are kept in
.deploy.vars, which git pull never touches because it isn’t tracked.
A. Guided: npm run setup (recommended)
npm install
npm run setup
It checks your build tools (offering to install what’s missing), logs you in with wrangler login, then asks:
| Question | Notes |
|---|---|
| R2 bucket | Your existing sync bucket is used as is; a name that doesn’t exist yet is created. Nothing in the bucket is changed |
| Custom domain | Optional, e.g. tasks.example.com. The domain must already be a zone on your Cloudflare account. Empty means the free *.workers.dev address. With a custom domain the workers.dev address is switched off |
| Encryption secret | The same value as sync.encryption_secret in your taskrc (for a new bucket, invent a strong passphrase and use it for the CLI too). Stored as a Worker secret |
| Cloudflare Access | It tells you exactly what to click (below), asks for the team domain and AUD tag, then deploys again |
Your answers are saved in .deploy.vars (git-ignored). Run npm run deploy to update, or npm run setup again to
change something; both are safe to repeat. npm run deploy -- --check prints the exact config that would be deployed.
Any key can be an environment variable instead (handy in CI).
You can stop after the first deploy. Until Access is connected, the app refuses every request and shows a setup page with your hostname filled in, explaining what’s left. Nothing about your tasks is reachable.
Connecting Access
This is the one part that needs the dashboard, because the AUD tag only exists once the application does:
- Cloudflare dashboard → Access controls → Applications → Add an application → Self-hosted.
- Destination: your hostname (the custom domain if you set one, otherwise the
workers.devaddress). - Add an Allow policy with the selector Emails and your address. One-time PIN login works without setup.
- Copy the application’s Application Audience (AUD) tag. Your team domain,
https://<team>.cloudflareaccess.com, is under Access controls → Settings.
Access signs people in; the Worker then re-verifies the signed token on every API call (signature, issuer, audience, expiry), so your data stays protected even if someone finds a way around Access. Make the Access application cover the whole hostname: the static app shell (HTML and JavaScript, nothing private) is served without running the Worker, so Access is what guards it.
B. From the dashboard (no terminal)
Fork this repo, then in the Cloudflare dashboard go to Workers & Pages → Create → Import a repository. Set the build
command to node scripts/build.mjs and leave the deploy command as npx wrangler deploy. Before the first build, edit
wrangler.jsonc in your fork if you need a different bucket (bucket_name) or a custom domain (routes).
Then open the deployed address. The setup page walks you through Access and the two Text variables (TEAM_DOMAIN,
POLICY_AUD) to add under Settings → Variables and Secrets; add TC_ENCRYPTION_SECRET there as a Secret.
(keep_vars is on, so redeploys don’t remove what you added.)
The Deploy to Cloudflare button is route 0, which uses the prebuilt release branch and needs no Rust. This route is
for building on Cloudflare from your own fork, for example to carry your own changes.
C. By hand
npx wrangler login
npx wrangler r2 bucket create <name> # skip if the bucket exists
echo "R2_BUCKET=<name>" > .deploy.vars # skip if it is the default, taskwarrior-sync
npm run deploy # builds, then prints the URL (Access can't be connected before this)
npx wrangler secret put TC_ENCRYPTION_SECRET # the same value as sync.encryption_secret
# create the Access application, add TEAM_DOMAIN=... and POLICY_AUD=... to .deploy.vars, then:
npm run deploy
.deploy.vars takes R2_BUCKET, CUSTOM_DOMAIN, TEAM_DOMAIN, POLICY_AUD and WORKER_NAME (to run a second copy),
one KEY=value per line. The tracked wrangler.jsonc never needs editing: npm run deploy overlays these values into a
git-ignored wrangler.deploy.jsonc.
Heads-up: after
wrangler r2 bucket create, wrangler may offer to add the bucket towrangler.jsoncas a new binding (named after the bucket, with"remote": true). Answer no, or delete it afterwards. The app uses exactly one binding,TASKS, andwrangler devrefuses to start with an extra remote binding unless you’ve registered a workers.dev subdomain. (npm run devruns locally, so it ignores remote bindings either way.)
D. From GitHub Actions (manual)
The repository has a workflow, .github/workflows/deploy.yml,
that builds and deploys the Worker when you start it: Actions → Deploy → Run workflow. It never runs on a push. Do
route 0, A or C once first, since the bucket and Access have to exist and the first deploy is where you find out they
work. After that, updating is one click. In your fork:
- Create an API token at Cloudflare → My Profile → API Tokens, from the Edit Cloudflare Workers template (it needs Workers Scripts: Edit and, for a custom domain, Workers Routes / Zone access). Note your Account ID (Workers & Pages overview, right-hand side).
- Settings → Environments → New environment →
production. Add required reviewers if you want an approval click before each deploy. Put the secrets and variables below on this environment (or on the repository). - Add these:
| Where | Name | Value |
|---|---|---|
| Secret | CLOUDFLARE_API_TOKEN | the token from step 1 |
| Secret | CLOUDFLARE_ACCOUNT_ID | your account id |
| Secret, optional | TC_ENCRYPTION_SECRET | the same value as sync.encryption_secret. If unset, the Worker keeps the secret it already has from route 0, A or C. Set it only if you want the workflow to be the single source of truth |
| Variable | DEPLOY_VARS | your deployment choices: the contents of your .deploy.vars, one KEY=value per line. Optional; without it the defaults apply |
DEPLOY_VARS is a multi-line variable. Paste what .deploy.vars holds, for example:
# Same choices as `npm run setup` saved on your machine.
R2_BUCKET=taskwarrior-sync
CUSTOM_DOMAIN=tasks.example.com
TEAM_DOMAIN=https://myteam.cloudflareaccess.com
POLICY_AUD=<the 64-character Application Audience tag>
WORKER_NAME=taskwarrior-web
All five are optional, # comments and blank lines are fine, and each value is checked as npm run deploy checks it (a
typo fails the run before anything is built). The workflow writes the variable out as .deploy.vars, so it works exactly
like a deploy from your machine. Use the same values you deployed with: leaving out TEAM_DOMAIN or POLICY_AUD
later removes nothing (keep_vars), but a different R2_BUCKET or WORKER_NAME points at a different bucket or Worker.
It is a variable, not a secret, because none of it is secret (a team domain and an AUD tag are in every request’s token). The encryption secret and the API token are, and only the steps that need them receive them.
The workflow’s one option is Dry run: it only prints the Worker config your settings would produce, the quickest way
to check your variables, and builds and deploys nothing. It doesn’t run the tests, because the
CI workflow already runs them on every push and
pull request, so deploy a commit whose CI is green. Run it from the branch you want to deploy; for your own hooks that is
your my-hooks branch (see Hooks). Runs are serialised, so two clicks never deploy at once.
Last step, whichever way: open the URL, sign in, and import your taskrc (Settings → taskrc) to get your UDAs and
custom reports.
Making a release (for maintainers)
The version is the one in the root package.json. The web app shows it at the bottom of Settings, the Android app
takes it as its version name (and a code that rises with it), and the prebuilt package carries it. To release:
# 1. set "version" in package.json (for example 0.2.0), commit it, push, and let CI pass
git tag v0.2.0 && git push origin v0.2.0
Pushing the tag runs the Release workflow, which refuses a tag that isn’t v plus the version in package.json. It
builds once and publishes:
- a new commit on the
releasebranch holding the built Worker (worker/), the web app and engine (app/), the scripts and awrangler.jsoncwithout a build step (the branch only gains commits; nothing is force-pushed), - a GitHub release titled with the version, holding the same files as
meghnaad-<version>.tar.gz, - the Android APK, added to that release when its build finishes (signing it).
A Homebrew tap. The release can also keep the meghnaad-hook formula up to date in a tap, so people install it with
brew install <owner>/tap/meghnaad-hook. It is off until you set the repository variable HOMEBREW_TAP to the tap
(owner/homebrew-tap) and add the secret HOMEBREW_TAP_TOKEN: a fine-grained token with Contents: read and write
on that one repository (the workflow’s own token can’t push to another). For a release version (not 1.2.3-rc.1) the
tap job waits for the four hook builds, writes Formula/meghnaad-hook.rb (scripts/homebrew-formula.mjs, with the URL
and sha256 of each platform’s archive) and pushes it as “Update meghnaad-hook to v<version>”. If a platform’s build
fails, no formula is written; re-run the failed jobs and it follows. Without the token it only warns.
Trying a build first. Actions → Release → Run workflow builds the same things as <version>-dev.<commit> and only
updates the release branch (the APK stays as the run’s artifact). node scripts/build.mjs && node scripts/package-release.mjs produces the same package in release/ on your machine. The workflow doesn’t run the tests,
so release a commit CI has passed.
The documentation site. These docs are an mdBook (docs/book.toml) published to GitHub Pages by the Docs
workflow on a push to main that touches docs/. Set the custom domain in the repository’s Pages settings, and the
repository variable DOCS_DOMAIN so the sitemap uses it.
The Android app
It doesn’t use the Worker or Access: it syncs straight with the bucket, so deploying the web app isn’t a step toward it. Install the APK from the latest release and give it the same bucket and encryption secret (the Android app). With the web app deployed, the two can also share settings and hooks through the bucket (Settings → Sync on the phone).
Connect your task CLI
Skip this if your CLI already syncs to the bucket. Otherwise create an R2 API token (R2 → Manage R2 API Tokens → Object Read & Write, scoped to your bucket), note your account ID, then:
task config sync.encryption_secret '<the same value as TC_ENCRYPTION_SECRET>'
task config sync.aws.region auto
task config sync.aws.bucket <your bucket name>
task config sync.aws.access_key_id <R2 access key id>
task config sync.aws.secret_access_key <R2 secret access key>
export AWS_ENDPOINT_URL=https://<ACCOUNT_ID>.r2.cloudflarestorage.com # add to your shell profile
task sync
Released versions of Taskwarrior have no config key for a custom S3 endpoint, but the AWS SDK it uses reads
AWS_ENDPOINT_URL from the environment, which is what points it at R2. Set it everywhere task sync runs (your
shell, cron, hooks, GUI launchers), or task quietly talks to real AWS and fails with a PermanentRedirect error. Newer,
unreleased Taskwarrior builds add sync.aws.endpoint_url.
Configuration reference
| Name | Kind | Purpose |
|---|---|---|
TASKS | R2 binding (wrangler.jsonc) | The bucket your task CLI syncs to |
TC_ENCRYPTION_SECRET | Worker secret | Same value as the CLI’s sync.encryption_secret |
TEAM_DOMAIN | var (not in wrangler.jsonc) | https://<team>.cloudflareaccess.com. Set by npm run setup (via .deploy.vars) or as a Text variable in the dashboard |
POLICY_AUD | var (not in wrangler.jsonc) | The Access application’s AUD tag; set the same two ways |
R2_BUCKET, CUSTOM_DOMAIN, WORKER_NAME | .deploy.vars | Optional deploy choices |
DEV_AUTH_BYPASS | .dev.vars only | Skips the Access check for local development. Honoured only for loopback hosts (localhost, 127.x.x.x, ::1); anywhere else it is ignored and logged. Never set it on a deployed Worker |
Troubleshooting a deployment
| Symptom | Likely cause |
|---|---|
| The app shows an “Almost there” page | Access isn’t connected yet: follow the page, or run npm run setup |
403 from /api/* after deploying | TEAM_DOMAIN or POLICY_AUD is unset or wrong (the token’s iss and aud must match exactly), or you’re not signed in through Access. The Worker log says why (auth rejected: …) |
| Cloudflare error 1102 (“Worker exceeded resource limits”), usually on the first request after a while | The CPU limit (10 ms on Workers Free), passed by the one request that does the most work. The app retries read-only requests once, and the retry meets a warm instance. If it keeps happening, say with a very large task list, Workers Paid lifts the limit. In the dashboard, “Exceeded CPU Time Limits” vs “Exceeded Memory” says which; npx wrangler tail shows it live |
A stack trace in wrangler tail shows only wasm-function[1234] | The build leaves out debug function names to save about 70 KB. Build and deploy once with KEEP_WASM_NAMES=1 npm run deploy when you need to read one |
| “task storage error” (502) | Wrong TC_ENCRYPTION_SECRET or the wrong bucket. The cached state resets on the next request |
| Task command icons show as a terminal, and the icon picker says the list could not be fetched | The browser can’t reach cdn.jsdelivr.net: it is offline or blocked, or a proxy in front of the app sets a Content-Security-Policy without it (web/public/_headers allows it with connect-src 'self' https://cdn.jsdelivr.net https://holidata.net https://date.nager.at). Typing an icon’s name in the picker still works |
| Every command fails with “The engine could not start in this browser” | The in-page WebAssembly engine was blocked or failed: a proxy sets a Content-Security-Policy without 'wasm-unsafe-eval' (web/public/_headers has script-src 'self' 'wasm-unsafe-eval'), or the browser has no Web Workers or WebAssembly. The reason follows the message; nothing on the Worker can run commands instead |
| A banner says “Couldn’t sync with the server” | The page couldn’t reach /api/tc/*: you’re offline, Cloudflare Access signed you out (reload to sign in again), or the Worker failed. Your changes are kept in the browser and go up with the next sync that works. Start over from the server discards the browser’s copy, for a replaced bucket or a changed secret |
wrangler dev says the assets directory is missing | Run npm --prefix web run build once |
wrangler dev exits with “register a workers.dev subdomain” | wrangler.jsonc has an extra "remote": true R2 binding (added when you ran wrangler r2 bucket create). Delete it, keeping only TASKS |
task sync fails with PermanentRedirect | AWS_ENDPOINT_URL isn’t set in that environment, so it’s talking to AWS |
| “the server is busy; try again” | Many commands arrived at once and waited too long for the shared replica; retry |
The Android app
A native app in Kotlin and Compose, with the engine as a native library and widgets on the home screen. It keeps your tasks on the phone and syncs them straight with your bucket over S3, or with a TaskChampion sync server. No Worker and no Cloudflare Access are involved, and your keys stay on the phone. It does what the web app does, with a layout made for a phone, plus two things a browser tab can’t: reminders that come with the app closed, and widgets.
Status: it builds. Its Rust half is tested (signing, sync against a bucket that enforces conditional writes, settings, hooks) and its Kotlin half has unit tests (the screens’ logic, and commands, review and import through the real engine). It has been tried on an Android 16 emulator: connecting, the pages, editing, review, reminders, widgets, import and export. It hasn’t been run on a physical phone yet, so expect rough edges.
What you get
- Everything in the web app: the console, the pages, review, urgency, recurrence, hooks and settings, in a layout
made for a phone: a menu for the pages and tasks as cards (On a phone). Review is a
card with buttons, and
reviewin the console opens it too. - Works offline. The engine and tasks are on the phone; changes sync when there is a network.
- Pull down to sync. At the top of any page but the console, pulling down syncs with the bucket and redraws.
- Any S3-compatible store: Cloudflare R2, AWS S3, MinIO, Backblaze B2. The store must accept conditional writes
(
If-MatchandIf-None-Matchon upload), which R2, MinIO and current AWS S3 do. - Export and import use the system’s file pickers:
exportoffers the file to keep, andimportreads one and says what it would do before it writes anything.
Tablets, foldables and keyboards
On a tablet, a foldable or a phone held wide (over 760 dp), a report is the web’s table instead of cards:
- The report’s own columns, with headers that sort (a long press adds a tie-breaker, like the web’s Shift-click) and a button to group by the column, a tick box per row, and the row’s buttons at the end. Columns take the width of what they hold and the description takes the rest. A report with more columns than fit scrolls sideways, with the tick box and buttons kept in place.
- Summary is a table, the calendar shows its months side by side, and the burndown grows to fill the width.
- A task’s details open as a drawer on the right (560 dp, like the web’s), the task form and review as a card in the middle, and Settings is no wider than the web’s 820 dp.
- The menu isn’t over the page: the list of reports is an always-open sidebar and the pages are tabs along the top (their names drop away below 1100 dp, and those of Review and Settings below 1400 dp). The floating + is the same.
With a keyboard attached, the web’s keys work: / focuses the prompt with its line selected (not while you’re typing in
a field), Tab completes, ↑ and ↓ go through the last commands, Ctrl+L clears the console and Esc leaves the prompt. In
the Hooks editor Ctrl+S saves and Tab indents. In the task form Enter saves, and Ctrl+Enter saves from a multi-line field.
Reminders and links
Reminders
Reminders have the same options as the web app (Menu → Reminders), and work with the app closed. The phone wakes the
app when something is due or comes off wait, and the notification opens the task. Allow notifications when asked. The
once-a-day digest of what is overdue comes at the hour you chose for dates with no time.
Task links
The link button in a task’s details copies meghnaad://task/1a2b3c4d (the first 8 characters of its
uuid, like the web’s /task/1a2b3c4d). Opening it from a note or another app opens the task, on the phone that has the
task: the link names it and doesn’t carry it.
Page links
Every page has a meghnaad:// link mirroring the web’s addresses: meghnaad://report/<name>,
meghnaad://board/<slug>, meghnaad://boards, meghnaad://settings/<tab> (and meghnaad://settings/taskrc/<path> for
a taskrc file), the pages (projects, tags, summary, calendar, burndown, console) and
meghnaad://task/<8 characters>. A board has a button that copies its link. A link to a report or board that isn’t
there says so and opens the home page.
Boards
The boards you make on the web are in the menu under Boards, on 4 columns (6 on a wide phone), each card as wide as
its share of the row, so several numbers sit side by side. The same engine code works them out, so the numbers agree:
counts, sums and averages of numbers and durations, per value of an attribute or per day, week, month, quarter or year,
several series on one chart (stacked or side by side), and series that count pending, started and done at the end of
each period as task burndown does.
Boards are in settings.json, which syncs with the bucket, so a board saved on one device appears on the others after
the next sync. Settings → General picks the page the app opens on: any report or board. ⓘ on every card shows its
filter (with Open in Tasks), the tasks that filter got and the values on its axis.
Charts
Bars, lines, areas, pies, donuts and radar are drawn by Koala Plot (a polar area chart is drawn as a pie). A heatmap, which Koala Plot has no plot for, is a grid the app lays out itself: one per series, or one for all when stacked. A tap on a cell says its row, column and value.
Editing a board
The pencil changes a board on the phone as the web does: its name, description, filter and address, and for each card its
title, type, filter, measure, grouping, order and series (colours, and which task burndown state they count). The board
is drawn as it will look.
- Tap a card to pick it, drag its corner to resize it in the phone’s columns, and move it earlier or later with the arrows above.
- A card’s settings open full screen. The engine says whether the draft is valid, and Save waits until it is.
Widgets
Four, each with its own settings (long-press the widget, then its pencil; the same screen opens when you add one). They redraw after any change, from the app or the widget, and sync by themselves about every half hour.
Tasks
The tasks of any report (built-in or yours), narrowed by a filter (project:Home +work), as many as you
choose, with a title of your own (left empty, it is named after the report: Most urgent tasks for next). Tap a
circle to mark a task done, the text to open that task, + to add a task typed as in the console (Buy milk due:tomorrow +home), and the sync button to pull now. Under each task it shows the fields you tick: project, due,
priority, tags, urgency, age, scheduled, wait, recurrence, task number, notes.
Burndown
Daily, weekly or monthly bars of the tasks a filter picks, with finished at the bottom, then started, then pending, and how long it would take to clear them at this pace.
Board card
One card of a board, chosen in the widget’s settings: a number drawn large, or a chart (bars, lines, areas, pies, donuts, radar, heatmaps) from the same numbers as the board, in the widget’s light or dark look. A tap opens the board. A table card isn’t drawn (it says so).
Calendar
A month with today circled and a dot on days with something due (red overdue, amber today, blue after), for the tasks a filter picks. The arrows go to other months, and the month’s name goes back to this one. A tap on a day selects it (a ring marks it) and, when the widget has room, shows that day’s due or scheduled tasks: beside the month when wide, under it when tall, and not at all when small, so resizing is how you choose. Two tasks are listed, each with its time (or “scheduled”) and project, and a tap opens the task. A Show all line opens the whole day in the app, as the date above them does.
Building it
You need the Android SDK and NDK (Android Studio installs both), Node.js 20+, Rust and cargo install cargo-ndk. Then:
npm run build:android # the engine for the phone, then the APK
adb install -r app/androidApp/build/outputs/apk/debug/androidApp-debug.apk
The script finds the SDK in ANDROID_HOME or ~/Library/Android/sdk, uses the JDK 21 that comes with Android Studio, adds
the Rust targets it needs, and builds for arm64-v8a (phones). A debug build also has x86_64 (the emulator on an Intel
machine), and ANDROID_ABIS chooses. It doesn’t build for 32-bit ARM.
Size. The library has its symbols stripped (profile.android in Cargo.toml) and is packed compressed, a release is
arm64 only, and R8 cuts unused code and resources (app/androidApp/proguard-rules.pro keeps what is looked up by name:
the engine’s functions). If you add a class that something finds by name, add it there, and try the release build, not
only the debug one. Open app/ in Android Studio to run it from there; app/README.md says how to see the screens live
on a computer. The launcher icon is the web app’s logo: node scripts/android-icons.mjs makes it again from
web/public/logo.png (it needs ImageMagick).
Releases and signing
The Release workflow (a v* tag, or Actions → Release → Run workflow) also builds the APK, with the web app’s
version (Making a release): attached to the GitHub release for a tag, or
kept as the run’s artifact (android-apk) otherwise. An installed app can only be updated by an APK signed with the
same key, so make one key and keep it:
keytool -genkeypair -keystore meghnaad.keystore -alias meghnaad -keyalg RSA -keysize 2048 -validity 10000
base64 < meghnaad.keystore | tr -d '\n' # the value of the first secret below
Add these repository secrets: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS (meghnaad)
and ANDROID_KEY_PASSWORD. Without them the workflow still builds, but the APK is debug-signed: it installs, and the next
one can’t update it in place. Don’t commit the keystore. Locally, npm run build:android -- --release signs with the same
variables in the environment (ANDROID_KEYSTORE_FILE is the path).
Updates
The app checks for a newer version itself: when it opens (at most once a day) it asks GitHub for the latest release of the repository that built it, and says “Meghnaad 0.6.36 is out” once per new version. Settings has an Updates section at the bottom of every tab:
- Check for updates works any time.
- Download the APK appears when a newer one is out. It opens the release’s APK in the phone’s browser, which downloads it and offers to install it over this app (Android asks once to let the browser install apps).
- What’s new opens the release page.
Your tasks and connection stay, as long as the APK is signed with the same key. Only https addresses from the release are
opened, and nothing is sent to GitHub but the request for the release.
A build knows where its releases are only if it was told: the Release workflow passes ANDROID_UPDATE_REPO (its own
repository, so a fork’s APK looks at the fork’s releases). A hand-made build leaves it empty and shows no Updates
section; set ANDROID_UPDATE_REPO=owner/name when building to try it.
First run
The app asks where your tasks sync: an S3 bucket or a sync server.
A bucket. It asks for the S3 endpoint (https://<account>.r2.cloudflarestorage.com for R2), the bucket, the region
(auto for R2, the real one for AWS), an access key and its secret, and the encryption secret (the same as
sync.encryption_secret in your taskrc). It tries them with a first sync and says what is wrong if they fail. Create the
access key as in Connect your task CLI: an R2 token with Object Read & Write,
scoped to the bucket.
The keys are kept encrypted with a key in the Android Keystore. No screen can read them: the engine takes them to sync and nothing else does.
An example
| Field | Cloudflare R2 | AWS S3 | MinIO behind HTTPS |
|---|---|---|---|
| S3 endpoint | https://0123456789abcdef.r2.cloudflarestorage.com | https://s3.eu-west-1.amazonaws.com | https://minio.example.com |
| Bucket | taskwarrior-sync | my-task-sync | tasks |
| Region | auto | eu-west-1 | us-east-1 |
| Access key ID (the client ID) | 4f1c… (32 characters) | AKIA… | your MinIO user |
| Secret access key (the client secret) | shown once when the token is made | shown once | your MinIO password |
| Encryption secret | the same as sync.encryption_secret in your taskrc | same | same |
These are the values your task CLI already has: sync.aws.region, sync.aws.bucket, sync.aws.access_key_id,
sync.aws.secret_access_key and sync.encryption_secret, plus the endpoint in AWS_ENDPOINT_URL. For R2 the endpoint is
https://<account id>.r2.cloudflarestorage.com (the account id is in the dashboard’s right-hand column), and the key pair
is an R2 API token with Object Read & Write on the bucket.
The endpoint must be https://; plain http:// is accepted only for localhost and 127.0.0.1 (for trying it out).
A sync server
Choose Sync server to use a TaskChampion sync server (taskchampion-sync-server, or anything speaking its protocol)
instead of a bucket. It takes three things, the values your task CLI already has:
| Field | In your taskrc |
|---|---|
Server address, such as https://tasks.example.com | sync.server.url |
| Client ID, a UUID the same on every device that shares the tasks (Generate makes one for a new set) | sync.server.client_id |
| Encryption secret | sync.encryption_secret |
The address must be https:// (http:// only for localhost and 127.0.0.1). Tasks are sealed on the phone before they
are sent, so the server holds only what it can’t read, and the client ID is kept in the Keystore like the keys.
The web app and the Worker sync with the bucket only, so tasks that sync to a sync server don’t reach the web app. A sync server doesn’t hold the app’s settings and hooks either. A bucket for those files is optional: tick Also keep settings and hooks in a bucket and give it a bucket (a different one from the web app’s tasks is fine), and the two switches under Settings → Sync keep them in step, as below. Without one they stay on the phone.
Syncing somewhere else
A phone remembers where its tasks were last synced. If you point it at a different place (another server or bucket, or from a bucket to a server), the new place doesn’t have the version the phone stands on, so the app asks first:
- Keep them and add them there: the phone’s tasks are added to what the new place holds (the same tasks aren’t duplicated), and everything is then in both.
- Start from what is there: the phone’s tasks are let go, and only what the new place holds is kept.
- Cancel changes nothing.
Nothing on the phone changes until the new place has answered, so a wrong address or secret leaves it as it was. A phone with no tasks moves without asking.
Different from the web app
Settings live on the phone
Unless you turn sharing on, the taskrc subset, the app’s settings, the hooks script and the hook secrets are in the
app’s private storage. Under Settings → Sync (when connected) you can keep the
settings (the taskrc files under web/rc/, web/config.json, web/settings.json) and/or the hooks
(web/hooks.lua) in step with the bucket. They are the files the web app keeps there, so an edit on either side reaches
the other at the next sync.
- What changed since the last sync wins. When both changed, the newer wins and the other is kept as the web app’s restore
point (
config.prev.json,hooks.prev.lua; for the taskrc files,rc.prev.json, all together). - A taskrc file deleted on one side and untouched on the other is deleted on both.
- A file that won’t load, or a settings or taskrc file holding a credential, is not taken from the bucket.
- The hook secrets are never synced. The two switches are this phone’s own choice, kept on the phone and not synced.
Without sharing
Import your taskrc again in Settings: paste it, import it from a link, or choose files or a
whole folder (Add files, Import folder). Download and Download all (.zip) keep a file or the whole
folder through the save dialog. Add holidays (same tab) fetches a country’s holidays from holidata.net or
date.nager.at, or a calendar (.ics) from a link, with a plain HTTPS request from the phone (a calendar’s link and every
redirect are held to the rules the Worker applies to a taskrc link), and saves them as a file in the taskrc folder.
Settings → Sync
This shows where the phone syncs (the endpoint, bucket, region and a hint of the access key’s name, or the sync server and a hint of the client ID; never a secret) and changes it. Blank fields keep their value, so you can correct the address without typing the keys again. The change is tried with a sync first, and one that doesn’t work isn’t kept.
Hooks run on the phone
They run in the same Lua sandbox with the same http and secret(), from the script you edit in
Settings → Hooks. The phone makes their network calls, and the secrets are sealed with the Keystore key.
No Cloudflare Access, no Worker
Anyone with the phone unlocked has the app: lock the phone.
How it fits together
screens (Compose) app/shared: Store, one file per page
│ JSON in and out Engine.kt (mirrors web/src/lib/api.ts)
▼
LocalEngine (Kotlin) ── AppEngine: one engine for the screens, the widgets, the reminder alarm and the sync worker
│ JNI NativeEngine.kt (package me.utsob.meghnaad)
▼
crates/android (Rust): tc-core + the S3 store + the settings routes
│ S3 over HTTPS, signed (SigV4)
▼
your bucket
crates/android/src/engine.rsis the engine andjni.rsthe thin layer Kotlin calls. Every call answers in JSON.crates/tc-core/src/request.rsturns a request into a command and its answer for this engine and the browser’s, so the two can’t differ.webapi.rsanswers the settings routes (/api/config,/api/settings, …) for any store: the Worker keeps them in the bucket, the app in a folder, asked of the engine’sapicall.s3.rsis the bucket as tc-core’sObjectStore, andsigv4.rssigns requests (tested against Amazon’s own examples).syncserver.rsis a TaskChampion sync server as ataskchampion::Server(/v1/client/…,X-Client-Id, the key derived with the client id as the salt).tests/syncserver.rsruns the engine against a server kept in the test and, with--ignoredandTC_SYNC_SERVER, against the realtaskchampion-sync-serverand thetaskCLI.engine.rschooses between the sync server and the bucket for the tasks, keeps a bucket of its own for the shared files, and remembers where the tasks were last synced. Another place makessyncfail withbackend-changeduntil it is asked again withswitch(mergeorreplace), and the new place’s replica is made aside and swapped in only once it has synced.- The replica is saved to a file in the app’s storage after every change. The screens, widgets and reminder alarm share one engine, so what a widget changes shows in the app and the other way round.
- Reminders:
Notify.ktholds the web’s rules (notify.ts). After every change the next moment worth a reminder is worked out andAlarmManageris set for it; its receiver shows what is due and sets the next one (also after the phone starts). The background sync (WorkManager, every half hour) runs while a widget is placed or reminders are on.
Tests: cargo test -p tc-android (an in-process bucket stands in for S3), and in app/
./gradlew :shared:desktopTest :shared:testDebugUnitTest :androidApp:testDebugUnitTest (build the engine for this
machine first with cargo build --profile android -p tc-android; the tests that run the real engine skip without it).
taskrc support
Which of Taskwarrior’s taskrc options Meghnaad handles, and which it doesn’t. The list of options comes from the
taskrc(5) man page of Taskwarrior 3.5.0, and the status of each comes from reading this repository’s code.
How the taskrc is used. You paste or choose your ~/.taskrc (or a folder of files, with include followed). The text
is kept as written in your bucket (web/rc/…), only an allowlist of settings is read from it (and what they add up to is
kept in web/config.json), and your desktop file is never touched. Lines that can’t be used are commented out; sync and
credential lines are removed. See Your taskrc. The app’s own settings, such as task
commands, aren’t taskrc options: they live in a settings.json of their own (Commands).
Scope. This tracks Taskwarrior 3.5.0 and newer only. Settings removed or deprecated before 3.5.0 aren’t listed and aren’t supported, even if an older taskrc contains them.
show and config (Console only) read and edit the same saved settings under the same rules; see
the console.
Status key
| Status | Meaning |
|---|---|
| Done | Read and behaves like Taskwarrior |
| Partial | Read, with a difference. The remark says what |
| Not done | Meaningful for a web app but not implemented. Imported as “other settings not used” (harmless) |
| N/A | Terminal, desktop or local-file behaviour with no equivalent here |
| Blocked | Refused on purpose, for safety |
At a glance
Each row below counts once, even where a row covers several related options.
| Area | Done | Partial | Not done | N/A | Blocked |
|---|---|---|---|---|---|
| Files, hooks and environment | 3 | 1 | 1 | 5 | 0 |
| Terminal | 1 | 0 | 0 | 2 | 0 |
| Commands, aliases and matching | 4 | 1 | 0 | 0 | 0 |
| Confirmations and safety | 3 | 0 | 0 | 0 | 0 |
| Recurrence | 4 | 0 | 0 | 0 | 0 |
| Lists, indicators and charts | 6 | 0 | 0 | 0 | 0 |
| Output and debugging | 0 | 1 | 0 | 5 | 0 |
| Dates and calendar | 16 | 0 | 0 | 1 | 0 |
| Journal | 4 | 0 | 0 | 0 | 0 |
| Dependencies | 1 | 0 | 0 | 1 | 0 |
| Colour | 7 | 1 | 0 | 1 | 0 |
| Urgency | 17 | 0 | 0 | 0 | 0 |
| Defaults | 5 | 0 | 0 | 0 | 0 |
| Reports | 10 | 0 | 0 | 0 | 0 |
| User defined attributes | 6 | 0 | 0 | 0 | 0 |
| Context | 4 | 0 | 0 | 0 | 0 |
| Sync | 0 | 0 | 0 | 0 | 1 |
| Total | 91 | 4 | 1 | 15 | 1 |
Files, hooks and environment
| Option | Status | Remark |
|---|---|---|
rc.<key>:<value> on a command line | Done | Typed in the console, for one command. Held to the same allowlist as the taskrc, so rc.sync.* and credential-like keys are refused. Harmless unknown ones (rc.verbose) are accepted and ignored, as task does |
rc.report.<name>.<attr>:<value> | Done | Changes one attribute of a report for one command. The table’s header sort uses it |
include <file> | Done | Followed in the app’s folder of files (Settings → taskrc): the path is relative to the file with the line, later lines win, and files nest to ten levels. A missing file or one outside the folder is reported with its file and line (Taskwarrior stops with an error; the app reports and goes on). Absolute paths, ~ and $VAR aren’t followed, since there is no file system to read. Taskwarrior’s *.theme files are expanded when you apply |
purge.on-sync | Not done | The app purges only when you run purge, never by itself on a sync. Leave that to your CLI |
data.location, TASKDATA | N/A | Data lives in the R2 bucket |
TASKRC, XDG_CONFIG_HOME | N/A | There is no local taskrc; its place is a folder of files in the bucket (web/rc/), the main one being taskrc |
hooks | Partial | The master switch (hooks=off, or rc.hooks:off for one command). A browser can’t run scripts, so hooks are functions in a Lua script kept in the bucket and edited in Settings: on_launch, on_add, on_modify, on_exit. The same script runs with the real task through meghnaad-hook, and hooks=off turns it off there too. See Hooks |
hooks.location, debug.hooks | N/A | There is no hooks directory and no scripts to trace |
gc | N/A | Working-set ids are computed by the app, not stored |
exit.on.missing.db | N/A | There is no local database |
Terminal
| Option | Status | Remark |
|---|---|---|
limit | Done | The default number of tasks a report shows (limit=25; none or 0 for all). It applies to reports whose own filter and command line have no limit: word, as rc.limit does; rc.limit:5 works for one command. page means “as many as fit on screen”, which a browser doesn’t have, so it shows everything, and the built-in next carries limit:page so it is never cut. export, count and info are never cut. A cut report says “showing N of M”. Unset shows everything, as Taskwarrior does |
detection, defaultwidth, defaultheight, avoidlastcolumn, hyphenate, reserved.lines | N/A | Terminal size and wrapping. The browser lays tables out itself |
editor | N/A | Tasks are edited in the app’s editor form |
Commands, aliases and matching
How a typed command line is read.
| Option | Status | Remark |
|---|---|---|
search.case.sensitive | Done | |
abbreviation.minimum | Done | The shortest abbreviation understood, default 2, for commands, report names, attribute and UDA names (proj:, desc:) and the words calendar takes. Shorter than that, a word is just a word, as in Taskwarrior (checked against task 3.5.0 at 1 to 4). Modifiers (.has, .before) are never abbreviated. Differences: an abbreviation that fits two commands is reported as ambiguous here, where Taskwarrior treats it as plain text; day and month names in dates stay at three letters, which Taskwarrior doesn’t tie to this setting |
regex | Partial | On by default, as in Taskwarrior. Plain words, /pattern/, .has, .hasnt, .startswith, .endswith, .word, .noword and the /from/to/ substitution in modify are regular expressions, and for the description the annotations are searched too. The syntax is ECMAScript read byte by byte, as C++ std::regex does: . is one byte, and \w, \d, \s, \b and case folding are ASCII only. Not supported: lookahead ((?=…), (?!…)) and backreferences (\1), which ECMAScript has, and syntax it lacks ((?i), lookbehind, named groups); a pattern using them is refused with a message. Also different: . matches \r here and not there, a substitution edits the description only (not annotations), and a replacement that would cut a character in half is skipped. regex=off matches plain text, with a leading ^ or trailing $ as an anchor |
expressions | Done | expressions=postfix makes calc read 1 2 + instead of 1 + 2; anything else is infix. calc is a port of Taskwarrior’s evaluator (same grammar, operator table and per-type rules, so 7 / 2 is 3, 1y is 365 days and 2 ^ -1 can’t be evaluated) over integers, reals, booleans, strings, dates and durations, with Taskwarrior’s messages. DOM references work: 1.due, <uuid>.project (a uuid may be cut to eight characters or more), 1.due.year and the other date parts, 1.tags.x, 1.annotations.1.description, rc.<setting> (the default when the taskrc is silent), tw.version, system.version. Attribute names must be exact (1.desc is just a word). ~ and !~ are refused with Taskwarrior’s message, since calc has no task to match. Filters are always infix |
alias.<name> | Done | A typed word that is an alias stands for the words it is set to (alias.rm=delete), up to ten rounds so aliases can use each other and a loop ends. Taskwarrior’s own rm (delete), burndown (burndown.weekly), history and ghistory exist without a taskrc, and the taskrc can change or empty them. Only typed lines expand: button arguments and everything after -- are taken as they are. history.* and ghistory.* commands don’t exist here. For a word standing for a whole command line (with its own filter, icon and button), see Commands: they live in settings.json, not the taskrc |
Confirmations and safety
The questions Taskwarrior asks before it changes things. Taskwarrior asks yes / no / all / quit about each task in turn; here the same questions arrive together as a table with a tick per task.
| Option | Status | Remark |
|---|---|---|
confirmation | Done | On by default. delete and purge ask (“Delete task 1 ‘x’?”, “Permanently remove task 1 ‘x’?”), and so does undo when there is something to undo. Off: no question for those, though bulk, an empty filter and a recurring series still ask. The questions arrive as a table with a tick per task (all ticked is “all”, none is “quit”), listing only tasks the command would actually change. The delete buttons take the second click (“sure?”) as the answer, or delete at once when the setting is off. duplicate asks only at bulk tasks. edit is not in the app |
bulk | Done | Default 3: a change to that many tasks or more asks first, for any command that changes tasks, whether confirmation is on or off. 0 never asks because of the count. Text that isn’t a number counts as 0, as in Taskwarrior |
allow.empty.filter | Done | On by default. A command that changes tasks, given no filter, asks first (“This command has no filter, and will modify all (including completed and deleted) tasks”), and after a yes the bulk question still follows. Off: refused with Taskwarrior’s message. With confirmation off and this on it is still refused (“Command prevented from running.”), as in Taskwarrior. An active context counts as a filter |
Recurrence
See Recurring tasks for how it behaves.
| Option | Status | Remark |
|---|---|---|
recurrence | Done | On by default; recurrence=off (or 0, no) turns it off. Before the commands Taskwarrior flags Recur (reports, count, export, …) the app creates the instances that are due and expires tasks past until. Taskwarrior advises one primary client with recurrence=1 and recurrence=0 on the others, because two that each make an instance before syncing make two. Between this app’s own clients that can’t happen: an instance’s uuid derives from its template and index, so every client makes the same task and replicas merge it. A real task on the same data still makes its own copy, so turn it off there |
recurrence.confirmation | Done | Unset or prompt asks, a true-ish value is yes, anything else no. It governs modify, append, prepend, annotate and delete on a task of a series, and purge of a template |
recurrence.indicator | Done | |
recurrence.limit | Done |
Lists, indicators and charts
| Option | Status | Remark |
|---|---|---|
uda.<name>.indicator | Done | Shown by the indicator column format |
list.all.projects | Done | projects also counts finished tasks (never deleted ones), and _projects lists their projects too (deleted ones’ as well, as in Taskwarrior). projects follows Taskwarrior’s layout: a project’s count includes its sub-projects, sub-projects are indented, and a line gives the number of projects and tasks |
summary.all.projects | Done | On, summary lists projects whose tasks are all finished too (their bar is full). Off by default |
complete.all.tags, list.all.tags | Done | list.all.tags makes tags count finished and deleted tasks’ tags too (the footer counts tasks before the filter, as Taskwarrior does). complete.all.tags makes _tags list them, along with the built-in tags, and the app offers them for completion |
active.indicator, tag.indicator, dependency.indicator | Done | What the start.active, tags.indicator and depends.indicator columns show (defaults *, +, D). Headers follow Taskwarrior: A, and as many letters of Tags or Depends as the indicator is long. The dependency columns count only open dependencies, so a finished task no longer shows D |
burndown.cumulative | Done | On by default, as in Taskwarrior: a finished task stays counted as done on every later bar. Off counts it only on the bar of the day it was finished. Used by burndown.daily, .weekly, .monthly and .annual |
Output and debugging
Terminal output, and tools for debugging the command line client.
| Option | Status | Remark |
|---|---|---|
verbose | N/A | Accepted and ignored |
nag | N/A | The reminder printed after a command in a terminal |
annotation.info | Partial | In Taskwarrior it decides whether task info shows annotations. The detail view here always shows them, so the setting isn’t read |
indent.annotation, indent.report, row.padding, column.padding | N/A | Terminal layout |
print.empty.columns | Done | As in Taskwarrior, a report leaves out a column that is empty for every row (default no); yes keeps them (rc.print.empty.columns:yes for one command). The engine says which columns are empty, and the web and Android tables do the leaving out. A report with no rows keeps its columns |
xterm.title, _forcecolor | N/A | Terminal behaviour |
debug, debug.parser, obfuscate | N/A | Debugging aids for the CLI |
Dates and calendar
The calendar command takes Taskwarrior’s arguments (calendar, calendar y, calendar due, calendar 2027, calendar 3 2027, calendar march 2027). Its layout, week numbers and which days are coloured how were checked against the real
task calendar. The colours are the app’s own theme, not color.calendar.*.
| Option | Status | Remark |
|---|---|---|
dateformat | Done | Taskwarrior’s format letters (Y M D H N S and the rest), in tables, info and the detail view |
dateformat.report | Done | |
dateformat.info | Done | |
dateformat.annotation | Done | |
report.<name>.dateformat | Done | |
weekstart | Done | Sunday (the default, as in Taskwarrior) or Monday. It decides how the calendar’s weeks start, which week numbers it shows, and how an ISO week such as 2026-W44 is counted (from Monday, ISO weeks; from Sunday, Taskwarrior’s older count). sow, eow and the other week dates count from Monday whatever it says, because Taskwarrior’s code does (checked against task 3.5.0 on a Sunday with weekstart=Sunday); a board’s date range is the exception and follows the page’s week. rc.weekstart:monday works for one command |
due | Done | How many days ahead counts as due (+DUE). Default 7 |
dateformat.edit | N/A | There is no task edit text file; the editor form has date pickers |
dateformat.holiday, holiday.<name>.name, .date, .start, .end | Done | A holiday is one date, or a stretch from start to end. Dates are read in dateformat.holiday (default YMD; the numeric parts Y y M m D d and separators work). easter, goodfriday, eastermonday, ascension and pentecost work too and, as in Taskwarrior, mean the next one from today. Taskwarrior’s own ids (holiday.en-US1.name, with a dash) are read. include holidays.en-US.rc works: add that file to the folder and include it from the main taskrc, or use Add holidays, which fetches the current years from Holidata, Nager.Date or a public .ics calendar and writes the file. (The holidays.*.rc files that ship with Taskwarrior hold only 2021 and 2022) |
calendar.details | Done | sparse (default) colours the days that have something due or scheduled; full also lists what is due in the months shown, using calendar.details.report; none turns the colouring off |
calendar.details.report | Done | The report that list uses (default list) |
calendar.holidays | Done | none (default), sparse (colour the holiday days) or full (also list them; here a day’s tooltip, or a tap, names its holidays too). Whatever the setting, a due or scheduled date set on a holiday is flagged in the form and the console |
calendar.legend | Done | On by default |
calendar.monthsperline | Done | How many months a bare calendar shows. A browser has no width in characters, so the default is 3 and the months wrap to fit |
calendar.offset, calendar.offset.value | Done | Moves the first month shown by value months (default -1) when offset is on |
displayweeknumber | Done | Week numbers beside each week, on by default. They follow weekstart: ISO weeks for Monday, and weeks counted from the first Sunday for Sunday |
date.iso | Done | On by default. Off, an ISO date typed alone (2026-12-25) is understood only if it matches dateformat (checked against task 3.5.0 for each combination); a date with a time (2026-12-25T10:00) or words (tomorrow) always is. Typed dates are read in dateformat first, like Taskwarrior (dateformat=m/d/Y accepts 12/25/2026). ISO week and day-of-year dates (2026-W52, 2026-359) and year-month (2026-12) are read as Taskwarrior reads them and follow date.iso too. Difference: 20261225 is accepted here though Taskwarrior refuses it |
Journal
| Option | Status | Remark |
|---|---|---|
journal.time | Done | start and stop write the annotations; the detail view turns them into a sessions table and hides the markers |
journal.time.start.annotation | Done | |
journal.time.stop.annotation | Done | |
journal.info | Done | The detail view and info (collapsed, read only when opened) list what changed and when, built from the replica’s operation log and worded like Taskwarrior’s info journal: changes within a second share a row, modified is never listed, and a stopped start reports its duration (measured to end when done end: is given). Difference: a start removed with no matching start in the history (a snapshot dropped it) says Start deleted. rather than a time since 1970. History before a snapshot isn’t available, as with the CLI |
Dependencies
| Option | Status | Remark |
|---|---|---|
dependency.confirmation | Done | On by default. Finishing or deleting a task that waits on something and has others waiting on it breaks the chain; the app asks “Would you like the dependency chain fixed?” and, on yes, makes those tasks wait on what it waited on (checked against task 3.5.0, task by task in the order they are finished). Off repairs without asking. For several tasks the questions arrive together as a table, after the permission one. A repair that only becomes necessary because of an earlier “no” isn’t offered |
dependency.reminder | N/A | The reminder printed to a terminal after done, delete and start. Dependencies themselves (depends:, +BLOCKED, +BLOCKING) work |
Colour
Taskwarrior’s colour rules work, drawn on a web page instead of a terminal. A colour is a palette index underneath, as in
a terminal (rgb440, gray12, color214 are the exact xterm colours). The basic sixteen are drawn in a softer palette
that follows the light or dark page, a background in them is tinted into the page, and a foreground that wouldn’t show is
moved toward readable. Besides Taskwarrior’s names (red, cyan, …) the basic colours have friendlier ones: ink,
coral/rose, sage/moss, amber/sand, sky/ocean, orchid/plum, teal/aqua, snow/pearl, and slate
for bright black. bold coral on sky is bold red on blue.
| Option | Status | Remark |
|---|---|---|
color | Done | On unless the taskrc says color=off (or rc.color:off for one command); then nothing is coloured: rows, charts, the history graph |
color.active, .blocked, .blocking, .completed, .deleted, .due, .due.today, .overdue, .recurring, .scheduled, .tagged, .until, .project.none, .tag.none | Done | Colour the task’s whole row. Ported from Taskwarrior’s rules.cpp, each checked against the colour the real task gives a task |
color.tag.<name>, color.project.<name>, color.keyword.<word>, color.uda.<name>, color.uda.<name>.<value> | Done | A tag (virtual ones too), a project or one inside it, a word in the description or a note (case as search.case.sensitive), a UDA with a value or this value (none for no value) |
rule.precedence.color, rule.color.merge | Done | The order the rules apply in, as Taskwarrior reads it (a word completes to every rule it begins: tag., keyword.), and whether colours blend or the highest rule wins. The nocolor tag exempts a task |
color.alternate | Done | Shades every other row of a report, with the rules laid over it |
color.calendar.*, color.burndown.*, color.summary.*, color.history.* | Done | The calendar’s days, the burndown bars, the summary bar and the history graph (counts inside coloured bars, as Taskwarrior draws it in colour) |
color.header, .label, .label.sort, .footnote, .warning, .error, .debug, color.sync.*, color.undo.* | Partial | Read, checked, and listed by show and colors legend, but not drawn: the app has no table header, footnote or sync output to colour |
fontunderline | N/A | The table header underline of a terminal |
| Themes | Done | web/public/themes/ has the 16 standard Taskwarrior themes and the app’s own. The taskrc tab’s Colour theme picker puts a theme’s lines into the box, and a pasted include dark-256.theme is expanded to them. The files are static assets, not part of the Worker |
Where the app differs from the desktop: with no color.* lines the rows follow Meghnaad’s own theme
(meghnaad.theme: soft colours, no zebra), not Taskwarrior’s defaults, which are the default theme in the picker. And a
# starts a comment anywhere in a taskrc line (bulk=5 # five), as in Taskwarrior, which several themes rely on.
Urgency
All of these can also be edited in the app (Settings → Urgency). See Urgency for the inheritance rules, which follow Taskwarrior’s source.
| Option | Status | Remark |
|---|---|---|
urgency.project.coefficient | Done | Default 1.0 |
urgency.active.coefficient | Done | Default 4.0 |
urgency.scheduled.coefficient | Done | Default 5.0 |
urgency.waiting.coefficient | Done | Default -3.0 |
urgency.blocked.coefficient | Done | Default -5.0 |
urgency.blocking.coefficient | Done | Default 8.0 |
urgency.annotations.coefficient | Done | Default 1.0 |
urgency.tags.coefficient | Done | Default 1.0 |
urgency.due.coefficient | Done | Default 12.0 |
urgency.age.coefficient | Done | Default 2.0 |
urgency.age.max | Done | Default 365 |
urgency.user.project.<project>.coefficient | Done | Applies to the project and its sub-projects, never a lookalike (Work doesn’t reach Workshop). Coefficients add up |
urgency.user.tag.<tag>.coefficient | Done | Matches user tags and virtual tags such as OVERDUE. next is 15.0 by default |
urgency.user.keyword.<keyword>.coefficient | Done | Case-sensitive match on the description |
urgency.uda.<name>.coefficient | Done | Any value of the UDA |
urgency.uda.<name>.<value>.coefficient | Done | One value. The priority defaults (H 6.0, M 3.9, L 1.8) are built in |
urgency.inherit | Done | Off by default, as in Taskwarrior. On, a blocking task takes the highest urgency of what it blocks, through the whole chain, plus 0.01 |
Defaults
| Option | Status | Remark |
|---|---|---|
default.command | Done | Used when a command line has no command. Falls back to next |
uda.<name>.default | Done | Applied by add when the UDA isn’t given |
default.project | Done | Applied by add when the task has no project of its own, after the active context’s write rule. Not applied by modify |
default.due | Done | Applied by add when no due is given. A duration (3d) is counted from now, and a date word (eow, 2030-01-01) is read like the same word typed as due:. A recurring task still needs its own due, as in Taskwarrior. A value that can’t be read is dropped with a warning on import |
default.scheduled | Done | Same as default.due, for the scheduled date |
Reports
| Option | Status | Remark |
|---|---|---|
report.<name>.description | Done | |
report.<name>.columns | Done | With .format variants, UDAs included |
report.<name>.labels | Done | |
report.<name>.sort | Done | Ported from sort.cpp, quirks included. The table header sort uses the same spec |
report.<name>.filter | Done | |
report.<name>.context | Done | |
report.<name>.dateformat | Done | |
json.array | Done | Whether export wraps its tasks in a JSON array (the default: [, one task per line, ]) or prints one task per line. rc.json.array:off works for one command |
report.timesheet.filter | Done | What timesheet shows with no filter. Taskwarrior’s default is the pending tasks started, and the completed tasks ended, in the last four weeks |
report.timesheet.context | Done | Whether the active context applies to timesheet. As in Taskwarrior it doesn’t, unless this is on |
| Built-in reports | Done | next, list, long, ls, all, completed, waiting, newest, oldest, overdue, active, ready, recurring, blocked, unblocked, blocking, minimal, with Taskwarrior 3.5.0’s columns, labels, filters, sorts and descriptions. A test compares each with task show report. on a fresh install, and the interop script checks that each picks and orders the same tasks as the real CLI. Any can be overridden one attribute at a time from the taskrc |
User defined attributes
| Option | Status | Remark |
|---|---|---|
uda.<name>.type | Done | string, numeric, date, duration, uuid |
uda.<name>.label | Done | |
uda.<name>.values | Done | Also drives sorting. A trailing blank value is allowed |
uda.<name>.default | Done | See Defaults |
uda.<name>.indicator | Done | |
uda.priority.* | Done | priority is a string UDA Taskwarrior defines itself (label Priority, values H,M,L,), so a taskrc names only what it changes: uda.priority.values=Critical,Important, alone is enough. The values are what a task may be given (anything else is refused with “The ‘priority’ attribute does not allow a value of …”), the sort order and the order for priority.above:, .below: and <, and what the editors, bulk Modify and completion offer. The blank stands where the list puts it (H,M,,L: between M and L); a list with no blank puts a task with no priority above every level. Urgency is separate: only urgency.uda.priority.<value>.coefficient counts, so a renamed level adds nothing until it has one. udas lists it, and show prints the default as H,M,L, |
A UDA that exists on a task but isn’t defined in the taskrc is shown but read-only.
Context
| Option | Status | Remark |
|---|---|---|
context | Done | The active context. context <name> and context none change it |
context.<name>.read | Done | context define writes it |
context.<name>.write | Done | Applied to new tasks (add, log). context define writes it too, unless the filter can’t be one (or, -tag, a modifier) |
context.<name>.rc.<key> | Done | Settings in force while that context is active: default.command, limit, a report’s filter or sort (context.work.rc.report.next.filter), urgency coefficients, and any other setting this app reads. As in Taskwarrior they win over everything, a command-line rc. override included, and rc.context:home for one command brings in home’s settings. They are checked like any taskrc line: credential-like keys are refused by name, a setting the app has no use for is only named, and a value that can’t be used is dropped with a warning. A context can’t set context itself |
Sync
| Option | Status | Remark |
|---|---|---|
sync.*, taskd.*, and any name containing secret, password, token, credential and similar | Blocked | Never stored, logged or shown; only the names of what was dropped are reported. The Worker syncs through its own R2 binding and secret, and the Android app takes its bucket or sync server (sync.server.url, sync.server.client_id) on the phone, into its Keystore |
The bucket side of sync isn’t configured from a taskrc, but it behaves like the CLI’s. The Worker syncs before and after
every command, writes a snapshot on about one push in ten (as task sync does), and picks the newest snapshot when it
starts cold. It also tidies the bucket as the CLI does: after about one push in twenty it deletes versions that lost a
race, older snapshots, and versions over 180 days old that a snapshot covers.
What is left
One “Not done” row remains, and it can’t change in a browser: purge.on-sync. The app purges only when you run purge,
never by itself on a sync; that is left to the CLI. Beyond the taskrc, the commands edit and import-v2 aren’t in the
app.
Architecture
How Meghnaad is put together, and why. For what it does, see Using Meghnaad; for the settings it reads, taskrc support.
The idea
Taskwarrior 3 keeps its data in TaskChampion, which syncs through a server: a place where every client pushes its
changes (as numbered “versions”) and pulls everyone else’s. With sync.server.type=aws that place is a bucket, and the
clients agree on its layout. Meghnaad is one more client of that bucket, written for the browser.
A replica, not a copy
It reads and writes the same bucket in TaskChampion’s own format and encryption, so task sync and the web app see each
other’s changes.
The replica lives in your browser
The engine (Rust compiled to WebAssembly, in a Web Worker in the page) holds your tasks in memory, runs Taskwarrior commands against them, and keeps a saved copy in IndexedDB. A small Cloudflare Worker sits between the page and the bucket: it checks Cloudflare Access, holds the encryption secret, and opens and seals the versions that pass through. It never parses a task.
One library for Taskwarrior behaviour
Filters, reports, urgency, recurrence, confirmations and dates live in one Rust library, tc-core. The Svelte app draws
what the engine returns and has no Taskwarrior logic of its own. What it does own is the shell level around task: task
commands, && || ; chains, bulk actions and review, which only send ordinary commands to the engine.
Why the engine is in the page
It started the other way round: the Worker held the replica and ran every command, so every request spent the free plan’s 10 ms of CPU. Moving the engine into the page took that cost off the Worker and made reads and writes instantaneous. Every current browser has what it needs (Web Workers and WebAssembly), so the Worker no longer runs it.
flowchart TB
subgraph desktop["Desktop: Taskwarrior 3"]
direction TB
sh["shell aliases<br/>alias fail='task done outcome:fail'"]
tsh["tasksh<br/>review"]
cli["task CLI (C++)"]
tcd["TaskChampion (Rust)"]
db[("SQLite replica<br/>~/.task")]
rc["~/.taskrc"]
sh -.-> cli
tsh -.-> cli
cli --> tcd --> db
rc -.-> cli
end
subgraph web["Your browser: Meghnaad"]
direction TB
cmds["task commands<br/>(settings.json)"]
rev["tasksh review"]
spa["Svelte app<br/>(draws results)"]
eng["Engine in a Web Worker (Rust → WASM)<br/>command engine in tc-core"]
tcw["TaskChampion (Rust)"]
mem[("Replica in memory<br/>saved copy in IndexedDB")]
cmds -.-> spa
rev -.-> spa
spa -- "what you type or click" --> eng --> tcw --> mem
end
subgraph phone["Your phone: Meghnaad for Android"]
direction TB
wg["Widgets<br/>tasks · burndown · calendar"]
app["The app<br/>Kotlin and Compose"]
nat["Engine (Rust, native)<br/>tc-core, hooks (Lua)"]
rep[("Replica in a file<br/>settings and keys on the phone")]
wg --> nat
app --> nat --> rep
end
subgraph edge["Cloudflare"]
direction TB
access{{"Cloudflare Access<br/>sign-in and token check"}}
assets["Static assets<br/>the app and the engine (.wasm)"]
wk["Worker (Rust → WASM)<br/>opens and seals versions<br/>runs your hooks (Lua)<br/>key in a Worker secret"]
access --> assets
access --> wk
end
bucket[("R2 bucket<br/>salt · latest<br/>v-parent-child · s-version<br/>web/config.json · web/settings.json")]
tcd <-- "task sync (when you run it)<br/>S3 API, key on your machine" --> bucket
nat <-- "S3 API, signed on the phone<br/>any S3-compatible store" --> bucket
tcw <-- "after every write, when you return,<br/>every 30 s: /api/tc/*, plain versions" --> access
spa -. "taskrc settings, settings.json<br/>/api/config, /api/settings" .-> access
eng -. "a hook call: /api/run" .-> access
wk <-- "R2 binding" --> bucket
assets -. "loaded once, then cached" .-> spa
sh <-. "counterpart" .-> cmds
tsh <-. "counterpart" .-> rev
classDef ext fill:transparent,stroke:#999,color:#999,stroke-dasharray:4 3
class sh,tsh,cmds,rev ext
The dashed grey boxes aren’t Taskwarrior itself. They are what people add around it: shell aliases and tasksh on the
desktop, and their counterparts here, task commands (settings.json, expanded in the browser) and tasksh’s
review (the Settings page sets up the reviewed attribute and _reviewed report it needs). Both only send ordinary
commands to the engine, so it knows nothing about them.
Where things run
In the page
web/src/engine/ and crates/browser: the engine, in a dedicated Web Worker so a big report never blocks the page. The
Svelte app sends it what you typed or clicked ({ "line": "…" } or { "args": […] }, with your time zone and the time)
and gets back the JSON the app reads. One entry point means one place where Taskwarrior semantics live. A file to import
goes the same way, as its text, and tc-core’s import module parses and checks it. The engine answers one message at a
time, in order.
Pure helpers. tc_core::helpers holds what needs nothing from the replica, so the page doesn’t ask the Worker for
it: a date in the user’s dateformat, a command line split and quoted as the engine reads it, a duration in one unit,
what a header click does to a report’s sort, what Tab completes and how a line is coloured, how an urgency coefficient is
named, what each cell of a report shows, the numbers a board section shows and where its sections sit on the grid, what a
quick-add line would make (preview), and the Markdown of a note. helpers::call runs those with structured arguments
by name, as JSON.
web/src/engine/helpers.ts loads the same WebAssembly module again on the page’s own thread (one download, since the
browser has the bytes) and calls them at once, because a screen asks for a date while it draws and a message is
asynchronous. main.ts loads them before it imports the app, so the first paint waits for the engine’s module, which the
engine needs a moment later anyway. The phone calls the same functions through JNI.
On the Worker
crates/worker, behind Cloudflare Access. There is no engine here, so nothing on the Worker runs a command; only your
hooks run there (a task in and a task out, never the replica). If the page can’t start the engine (it needs Web Workers,
WebAssembly and a Content-Security-Policy that allows 'wasm-unsafe-eval'), it says so instead of asking the server.
- Static files (the app shell and the engine’s
.wasm) are served by Cloudflare without running the Worker. Only/api/*runs it. auth.rschecks the Cloudflare Access token on every/api/*request: RS256 signature against your team’s published keys, issuer, audience and expiry. Misconfiguration means403, never open./api/tc/*is the sync protocol between the page’s replica and the bucket, in plain form over HTTPS (see Sync):GET pull,POST push,GET/POST snapshot. The Worker opens and seals each version with the key it derives from the secret (session.rskeeps that key between requests), so the secret never leaves it.- Settings have routes of their own, because they live in the bucket and not in a replica (below).
- Hooks (below).
GET /api/healthsays whether the Worker can reach the bucket and open it with the secret. It holds no tasks.
Settings routes
/api/configand/api/config/*serve the taskrc and urgency, andGET/PUT /api/settingsservesettings.json.PUT /api/config/enginesaves what aconfigcommand changed. The engine worked it out in the page; the Worker refuses anything sync- or credential-like and keeps the previous version for a restore.- The taskrc is a folder of files, one object each under
web/rc/(text as written, less sync and credential lines), served byGET /api/config/filesandGET/PUT/DELETE /api/config/files/<path>, withPOST /api/config/files/importtaking several at once.web/config.jsonis what they add up to, rebuilt on every change, andweb/rc.prev.jsonholds the files as they were before the last change. - All of these go to
tc_core::webapi::WebSettings, the same code the phone runs. - Two routes fetch from elsewhere and are the Worker’s alone:
POST /api/config/taskrc/url(a taskrc, saved as a pasted one is) andPOST /api/config/holidays/calendar(an.icscalendar for Add holidays, returned as text and saved by nothing). Both follow the strict link rules ofrc_url.
Hooks routes
The user’s Lua script is its own object in the bucket (web/hooks.lua, with the previous one beside it), not part of
settings.json, since the page reads that in full and a script may hold secrets.
POST /api/runcalls one hook ({ fn, args, now, tz, … }) and answers{ ok, value, say }, or{ ok: false, error, say }for a refusal or failure.GET /api/hooks/definedsays which of the four hooks the script defines, so the engine asks only for those.GET/PUT /api/hooksread and save the script (for the editor alone; a script that doesn’t load is refused), andPOST /api/hooks/checksays what is wrong with a script and where, as you type.- The Lua VM is built once per Worker instance and reused, and rebuilt when the script changes (or given its own when busy waiting on the network for another request).
- The Worker is the host that gives a script the network and secrets (
hooknet.rs:fetchwith a timeout, no automatic redirect, a 1 MB cap on the answer, a log line per call). A secret is looked up as a Worker secretHOOK_SECRET_<NAME>first, then inweb/hook-secrets.enc, a map sealed with the key derived fromTC_ENCRYPTION_SECRET.GET/PUT/DELETE /api/secrets[/<name>]manage that one, and names only ever come back.
Hooks, in order
For a command the engine runs on_launch (if defined) before anything. on_add and on_modify run for each task as the
command builds the change, just before it is committed: each is a request that returns the task, which diff turns into
changes in the same batch (a refusal ends the command and writes nothing). on_exit runs last. A hook that can’t be
reached is skipped with a warning, and the command goes on.
The engine’s HookHost is async for this. RemoteHooks (crates/browser) calls the Worker, and LuaHooks runs a
script in-process, which is how a host with no Worker (the Android app) uses the same script and runner. A script’s cold
start is 3-10 ms and a warm call under a millisecond (measured under Node’s V8, not on a Worker), which is why the VM is
kept, and why a script is bounded by an instruction budget (5 million) and 32 MB.
http and secret are async functions of the Lua runtime (omnilua’s async feature: a script’s coroutine is
suspended while the Rust future runs), supplied by a Net the host passes to LuaRunner::load_with. A host with none (a
test, a bare runner) gives a script an error. The runner enforces the rules whichever host it is:
- the script declares the hosts it may call with
allow_hostswhile it loads, and can’t widen them later; - only
https://(orhttp://to this machine), at most five requests and 256 KB of body per call, and a timeout of at most 10 seconds; - the value of every secret it read is replaced by
***in what it printed, failed with or returned; - a failed request comes back to the script as
niland a reason, because an error from an async function ends the call and can’t be caught withpcall.
On Android
crates/android and app/. There is no Worker: the engine is tc-core as a native library, called from Kotlin over JNI.
It syncs straight with the bucket, signing S3 requests itself (CloudServer over an S3Store, the same server code the
Worker runs over R2), or with a TaskChampion sync server (syncserver.rs, the same sealed versions over HTTP, keyed by
the client id). See the Android app.
The app draws that engine natively, not in a WebView. shared holds the engine’s JSON types and API (mirroring
web/src/lib/types.ts and api.ts), a Store that mirrors the web’s, and the screens. androidApp holds the activity,
the reminder alarm’s receiver and the widgets. Taskwarrior behaviour stays in tc-core, reached through the same JNI
library; only the web app’s own view logic is ported. Reminders are the web’s rules (Notify.kt mirrors notify.ts) plus
an alarm: after every change the next moment worth a reminder is worked out and AlarmManager wakes the app then, so they
work with the app closed. See app/README.md.
With the real task
crates/hook-shim is the program meghnaad-hook: the same Lua script runs for the CLI on a computer. task runs an
executable per hook from <data>/hooks/; install links this one program there as on-add.meghnaad and so on, for the
hooks the script defines, and it picks the hook to run from the name it was called by.
- It speaks
task’s hook protocol: a line of JSON in, a line out, feedback lines, and a non-zero exit to refuse. convert.rsturnstask’s JSON into the engine’s task, so the script gets exactly whattask_to_jsongives the Worker. What comes back is turned intotc_core::hooks::diff’s changes and applied to the JSONtasksent, with the same checks, so a field the script may not touch can’t be changed here either.- It has a
LuaRunnerand aNetof its own (net.rs:ureq, the script’s timeout, no redirects, a 1 MB cap;secret(name)is the environment variable of that exact name). The clock is the computer’s zone and the taskrc’sdateformat,date.isoandweekstart. - Nothing about Taskwarrior is decided here.
tc-core: the engine
Pure Rust with no I/O of its own, tested natively. Its hosts are the browser engine, the Node harness
(scripts/engine-cli.mjs) and the tests. The Worker uses its settings, report and protocol code but runs no commands.
| Area | Modules | What they do |
|---|---|---|
| Protocol | cloud, crypto, names, store, wire | TaskChampion’s cloud server over an object store; PBKDF2 → ChaCha20-Poly1305; object names; the storage trait the Worker implements over R2; and wire, the plain protocol between the page’s replica and the Worker (a batched pull, a push, snapshots), with both halves so a test can run a whole sync through them |
| Replica storage | live | The in-memory database. TaskChampion’s own copies every task on the first write of each transaction; this one changes its data in place and keeps a short journal to undo a dropped transaction. A test runs random transactions against both and compares them. It can be saved to bytes and loaded again (save, load), which is what the browser keeps in IndexedDB |
| Tasks | model, filter, modify, dates, rx, recur | A plain view of a task; filter expressions; planning modify and add; Taskwarrior’s date and duration parsing and regular expressions; recurring tasks |
| Commands | cli | Parsing a command line (aliases, abbreviations, contexts, rc. overrides), the write path with its confirmations, undo, the commands listing, and dispatch to everything below |
| Reports | report, run, urgency, history | Built-in and custom reports, running one (filter, sort, limit, columns), urgency, and a task’s change history |
| Colour | color | Colour specifications and how they blend, the rules that colour a task, the colors command and the history graph’s colours; checked against the real task’s escape codes |
| Views | summary, calendar, burndown, activity, stats, calc, export, import | Each a port of its Taskwarrior counterpart: summary, calendar, burndown.*, history.*/ghistory.*/timesheet (activity), stats, calc (and _get, which reads the same DOM), export (Taskwarrior’s own JSON) and import (a port of CmdImport, checking the whole file before writing). The app’s pages use a separate hidden _rows, and the detail view’s History a hidden _history <uuid> |
| Hooks | hooks, lua_hooks (+ lua/prelude.lua) | hooks runs user code at Taskwarrior’s four hook points through an async HookHost or plain Hooks, with what they print returned to the Console, the task as JSON (task_to_json / task_from_json) and diff turning what came back into changes. lua_hooks (the lua feature) runs a script in a sandbox (LuaRunner: JSON in, JSON out) and as in-process hooks (LuaHooks); the prelude is the Lua library a script gets |
| Boards | board (see below) | The saved boards of settings.json and what a board section shows, for every front end |
| Add preview | preview | What add <line> would make without making it: the line is split as add reads it and run through parse_mods and plan (same default project, UDA defaults, context write rule, date and priority checks and repeat rules). A bad attribute is left out and named, so the rest still shows, which lets the quick add draw a card while you type. It needs only the config and the time, so the page and Android call it through helpers::call |
| Taskrc highlight | rc_highlight | The pieces of a taskrc file as the parser cuts each line: a setting’s name, =, its value, a comment, the [unsupported] marker, include and its path, a line that is no setting (bad) and a credential-like name (secret). Shape only, so a half-typed file is coloured too. The web’s CodeMirror and the phone’s text field draw them |
| Holiday clash | clash (with calendar::holidays_on) | Whether a due or scheduled date falls on a holiday. The date is resolved as add and modify read it (so eod, som, eow-2d, abbreviations and the user’s zone mean what they mean there), then looked up in the holiday.* settings whatever calendar.holidays says. value answers for one form field, and line finds the date words of a command line in the commands that set attributes. A pure function of the config and the time |
| Holidays | holidays, holidata, nager, ics | The holiday file Add holidays makes, from three sources, as holiday.<id>.name / .date (or .start and .end) lines under a header recording the source, regions, left-out names and (when it holds no secret) the link, so settings_of can read it back. All pure; each front end does the fetching (the page’s connect-src allows https://holidata.net and https://date.nager.at; a calendar goes through the Worker’s POST /api/config/holidays/calendar; the phone uses HttpURLConnection with rc_url’s checks on every redirect) and saves the result through the folder’s routes |
| Settings | taskrc, rcfiles, rc_url, settings, appsettings | The allowlisted subset of a taskrc (what is accepted, what is refused, a verdict per line) and the typed Config. rcfiles is the taskrc as a folder: path rules, sanitize (removes sync and credential lines, comments out what can’t be used), resolve (follows include as Taskwarrior does) and the text edits config makes. rc_url is which links the Worker may fetch (HTTPS to a public host, redirects included) and the GitHub/GitLab page-to-raw rewrite. show, config and context list and edit it under the same rules. appsettings checks that settings.json is a JSON object of a sensible size |
Boards
board is pure, with no replica; the page asks for it through helpers::call, as an Android screen does. Sorting and
keeping the first few groups is each screen’s job.
normalizekeeps the valid boards and sections, fits them to the 12-column grid and says what it dropped.aggregatereads anexport(Taskwarrior’s JSON:20261007T123000Zdates, ISO durations, numbers) and counts, sums, averages or compares it as a whole and per value of an attribute or per day, week, month, quarter or year of a date. A period with nothing in it is kept;weekstartand the zone come from the caller. A series may carry aspan(from,to,unless: dates of a task), andchartthen counts the tasks in that state as of the end of each period, which is howtask burndowncounts pending, started and done.chartlays several series (each its ownexportand measure) on one axis in Chart.js’s shape: labels, and a dataset of values per series, with every period from the first to the last so a series with nothing in one still has a place.rangesworks out a board’s named date ranges (a start and end, each a date as Taskwarrior reads one, in the caller’s zone and week) and writes$name.start,$name.endand the counts of periods it touches ($name.day_count, for a chart’slimit, resolved byboard_limit).$name.lg_group.nameis the step a chart over the range is cut into (the largest of day, week, month and year with two whole periods in it); aby.time.stepmay be written as it, kept asTimeBy.varuntil the page has worked it out. Aby.time.range(a range’s name, or two dates of the chart’s own) makes the axis every period of that range, with a state series stopping at today:board_charttakes the board’s ranges as a fourth argument and works the range intoTimeBy.window, which is never saved. A name that is no range, or a date that can’t be read, is refused.layoutplaces, moves and resizes a section so that what it lands on is pushed down and everything floats up, and orders sections for a narrow screen.heatmakes a heatmap’s grids (reached asboard_heatmap). The columns arechart‘s; the rows are the value of an attribute or a part of a date (weekday, hour, day of the month, month). Each series’ tasks are cut into the rows and each row is laid on one axis bychart, so the columns agree. It makes a grid per series, or one with them added up.flowlays sections out on a phone’s 4 or 6 columns (reading order kept, a number as wide as its share of the row, a table or chart half a row or a whole one, none under two of the 12).resize_flowandreorderchange the saved layout from a phone’s steps.
How a command runs
execute does the same things in the same order every time:
- Apply the command line’s
rc.<key>:<value>overrides to a copy of the config, then the active context’s own settings (which win, as in Taskwarrior). - Read the first day of the week,
date.isoanddateformatinto the clock, and expand aliases (typed lines only). - Parse into a filter, a command and modifications. A word shorter than
abbreviation.minimumis just a word. - Run the
on_launchhook, which can refuse the command (show,configandcontextskip it: they are about settings). - Do Taskwarrior’s housekeeping first: create the recurring instances that are due and expire tasks past
until. - Load the tasks as plain “facts”, number the pending ones, and run the command.
on_addandon_modifyfire inside it, just before a write is committed. - Run the
on_exithook, and return what the hooks printed with the result (feedback) for the Console to show.
Writes
Writes go through one path (write_selected), so the safety rules are in one place.
Questions are data, not prompts. A write that needs an answer returns a Confirm result and changes nothing. There
are three kinds: plain (yes/no: undo, a command with no filter), permission (one row per task the command would
change: Taskwarrior’s yes/no/all/quit becomes ticks in a table) and extras (follow-ups that arise only once those are
answered: repair a dependency chain, carry a change to a recurring series). The client re-sends the command with the
answers (Options { confirmed, approved, extras }).
Hooks run inside this path, before the commit: on_add and on_modify see the task as the command leaves it, and what
they hand back is diffed and applied into the same batch, or the command is refused and nothing is saved.
One batch, one undo step. Everything the command changes is built as one batch of operations and committed once. TaskChampion’s own undo only covers unsynced changes, and the page syncs after every write, so the inverse operations are committed as new changes instead. The undo stack lives in the page’s memory.
Sync
CloudServer is TaskChampion’s server interface over the bucket, wire-compatible with the CLI’s own AWS/GCP backends
(salt, latest, v-<parent>-<child>, s-<version>; encrypted with a key from your secret and the bucket’s salt). The
Worker runs it. Because the page’s engine is a replica, it never needs the CLI’s cooperation.
Cheap when idle
One GET latest. Catching up lists the versions once and fetches them in batches of 32, rather than one at a time.
Conditional writes
latest is swapped with its ETag, so two clients pushing at once can’t both win; the loser re-syncs and retries.
Snapshots, like the CLI
After a push the Worker occasionally (about one push in ten) writes a snapshot and removes the ones it supersedes, so a cold start restores one snapshot plus a few versions instead of replaying history. A failed snapshot is ignored on purpose: failing the sync would make the replica send an already-sent version again.
Tidying up, like the CLI
About one push in twenty is followed by a cleanup, as in TaskChampion’s own server. It deletes versions that lost a race
(but not one a writer may be about to make latest), every snapshot but the newest, and versions older than about 180
days that the newest snapshot covers.
It uses each object’s upload time from R2’s listing and deletes up to 1,000 objects per cleanup in one request, so a
cleanup costs a handful of requests however much it removes; the next one carries on. The oldest versions go first, so
what is left is always one unbroken chain up to latest: a replica that is behind finds the next version or none, never a
gap. A failure is ignored, since the push it follows has already happened. A replica last synced before the oldest
surviving version can no longer catch up from versions alone, which is the same for the CLI’s own server.
Between the browser and the Worker
The page’s replica syncs through RemoteServer, a TaskChampion Server that speaks tc_core::wire to the Worker instead
of touching the bucket. The Worker runs CloudServer and answers from it, so everything above (conditional writes,
snapshots, cleanup) happens there, and the page never holds the key.
- Pull in batches. TaskChampion asks for the version after the one it has, one at a time.
GET /api/tc/pull?since=answers with up to 24 versions in one body (each 16 bytes of id, 16 of parent, a length and the version), andRemoteServerhands them out as asked, so a replica 80 versions behind costs about four requests. The limit is the 50 subrequests a request may make on the free plan: each version is a read from R2. - Push is
POST /api/tc/push?parent=, answered with the new version and how much the server would like a snapshot, or withbehindand the newest version when somebody else got there first, which makes TaskChampion pull and push again. Snapshots go the same way (GET/POST /api/tc/snapshot) and are what a new device starts from. - When. After every write, when you return to the tab, every 30 seconds while it is visible, and when another tab of
the same browser says it pushed (over a
BroadcastChannel; only a write is announced, so tabs don’t answer each other forever). Before the first command there is nothing to answer from unless a saved copy was found, so the first pull is waited for. - Failing. A sync that can’t reach the Worker (offline, or Access signed you out) changes nothing: the writes are in the replica and the saved copy, and go up with the next sync that works. The page says so and offers a retry and, as a last resort, to discard the saved copy and start from the bucket.
The saved copy
LiveStorage::save writes the tasks, the working set, the version it is based on and the changes not yet pushed, as JSON,
in one IndexedDB record, soon after a change and after every sync. The browser gzips it when it can (CompressionStream;
about 17 times smaller, because most of it is the log of changes made on this device, which TaskChampion keeps for a
task’s history), and an older uncompressed record is still read. It is only the browser’s own copy: nothing in the bucket
or the sync format depends on it. A copy that can’t be read (another format, damaged) is dropped and fetched again.
Limits of the platform
A Worker request has hard limits, and this app is shaped by them. They bind the Worker, and since the engine moved into the page the Worker does very little: it checks a token, opens or seals a few objects and, on a cold instance, derives the key. The measurements below are of the engine from when it ran in the Worker; they show what the engine costs (now in the browser, where there is no such limit) and what it took to keep it under 10 ms.
| Workers Free | Workers Paid | |
|---|---|---|
| CPU time per request | 10 ms | 30 s (up to 5 min) |
| Memory per isolate | 128 MB | 128 MB |
| Subrequests per request (R2 calls count) | 50 | 10,000 |
What it costs
Measured with crates/tc-core/tests/memory.rs and a local workerd:
- The key. Deriving it (PBKDF2, 600,000 rounds) is the heaviest thing a cold instance does. The Worker asks the runtime’s native Web Crypto, which takes about 40 ms; the same in WebAssembly took about 250 ms. A warm instance reuses it.
- A warm request was a few milliseconds of CPU for a hundred tasks and, before the cache below, tens for thousands (filtering, sorting, urgency, serialising).
- Memory is small. Restoring a snapshot of 3,000 tasks peaks near 6 MB, replaying 1,200 versions from nothing near 6
MB, and a request over 3,000 tasks 8 MB (19 MB for a full
export). Animportof the most one takes (5,000 tasks, a 2 MB file) peaks near 51 MB, and itsundostep keeps about 8 MB while the instance lives, which is why the request is capped. The test fails if a change makes any of these much worse.
It runs on Workers Free
So does its author’s deployment, with R2 and Access on their free plans too. What is left for the Worker is a token check, a read or two from R2 and, on the first request after an idle spell, deriving the key. One such request on a real deployment, back when the Worker also restored the snapshot and ran the command, used about 550 ms and still completed, because Cloudflare tolerates a Worker that only infrequently runs over, and ends requests with error 1102 only when it keeps doing so. The app retries a read-only request once, because the retry meets a warm instance. Workers Paid ($5/month) lifts the limit if that ever bothers you. R2 calls count toward the 50 subrequests, which is why a pull carries at most 24 versions (see Sync).
For scale, one personal deployment over its first nine days of a billing cycle used about 1,100 requests a day (1% of the free plan’s 100,000), about 460 R2 writes and listings and 1,100 R2 reads a day (roughly 1.4% and 0.35% of R2’s free 1 million and 10 million a month) and next to no storage, and was billed $0.00.
What is done about CPU
Turning every stored task into the engine’s form (Facts) took about 2 µs a task, and every command began with it, which
at 3,000 tasks was most of a read. The engine now keeps that list between commands (tc_core::warm, next to the replica)
and rebuilds it only when the storage’s change counter (LiveStorage::generation, bumped when a transaction that changed
anything commits or is taken back) has moved. Most requests change nothing, so they skip it. tests/warm.rs checks, after
every step of a long random run of commands, direct writes and pulls, that the cached list equals a fresh read.
The page’s own requests were trimmed to match: _names is _tags and _projects in one request, and the reminder poll
asks _remind (seven fields) instead of _rows (a full row with every virtual tag for each task). Measured natively at
3,000 tasks (WebAssembly is somewhat slower, but the proportions hold):
| Request | Before | After |
|---|---|---|
next, list, count | about 8 ms | about 2 ms |
the name lists (_tags and _projects) | 2 × 6.2 ms | one request, 0.5 ms |
| the reminder poll | 13.3 ms (_rows) | 0.9 ms (_remind) |
a write (modify on one task) | about 8 ms | about 1.7 ms |
A write doesn’t rebuild the list either. The storage keeps which tasks each of its last 32 transactions touched
(Generation::touched_since), and a list a few changes behind is brought up to date by reading just those tasks and
moving them into place (warm::patch). That is done only when no touched task depends on another or has another depending
on it, since a dependency changes whether tasks are blocked or blocking, which is worked out for the whole list; then the
list is rebuilt, as after a long pull or an import. The same random run checks the patched list against a fresh read after
every step, including steps that add and remove dependencies.
The web app
A Svelte 5 single-page app (web/), built with Vite and served as static assets.
The engine in the page
web/src/engine/:
engine.worker.tsis the Web Worker that loads the engine’s.wasm(crates/browser, built byscripts/build-engine.mjsintosrc/engine/pkg, typed by hand intc-engine.d.tsso type-checking and tests don’t need it built), restores the saved replica from IndexedDB (persist.ts), and answersprotocol.tsmessages one at a time.client.svelte.tsis the page’s side: it starts the worker once, sends commands, runs the syncs, and exposesstatus/errorfor the banner shown when a sync fails.api.tsmakes the choice:runCliandimportTasksgo to the engine in the page when it is available and to the Worker’s endpoints when it isn’t.getConfigstarts it (the engine needs the settings first) and hands it the settings again whenever they are read again.
Start-up isn’t a chain of requests. main.ts loads the engine’s Worker (preload: it fetches the .wasm and reads the
saved copy) and asks the server for what the page needs (prefetch: whether it is set up, the settings, which hooks the
script defines, and settings.json) before the app’s own code has loaded, so they all travel at once. Each answer is used
once, by the call that would have asked, and asked for again if it was refused (a deployment that isn’t set up refuses all
of them). A read of the settings already on its way is joined, not repeated. A config command’s new settings are saved on
the Worker (PUT /api/config/engine) before the engine takes them up. The CSP allowance is 'wasm-unsafe-eval', which lets
the page compile WebAssembly and nothing else.
Stores and pages
store.svelte.ts is the one store: the current page, the config, the console’s entries, the live report, the answers to
questions in flight, and the toast.
The pages (TasksView, ProjectsView, TagsView, SummaryPage, CalendarPage, BurndownPage, ConsoleView,
SettingsPage) are thin. Each asks the engine for a result and hands it to a shared component, so the console and the
pages draw the same thing. ResultView turns any CliResult into UI: ReportTable, SummaryView, CalendarView,
BurndownView, TaskInfo, ConfirmView, and the lines hooks printed under the result (a toast when a button, not the
console, ran the command). A report’s cells are written by the engine (tc_core::cells), in one call for the whole table
through formatCells in format.ts, which only tells it the browser’s zone at each moment so summer time is right.
The filter box (FilterInput, FilterChips) and the prompt share completion.ts; both feed the same engine.
Settings
One page, SettingsPage, with a tab per panel: TaskrcPanel (the folder of taskrc files: a list, the text of one,
themes, import of files, a folder or a link; rcpaths.ts has the path helpers; HolidaysForm and holidays.ts fetch a
country’s holidays and save them as a file), TaskshPanel, CommandsPanel, HooksPanel and UrgencyPanel. Each has its
own Apply and tells the page when it has edits, and all stay mounted so edits survive a change of tab. tasksh.ts decides
what the reviewed UDA and _reviewed report need and which config lines write them; the panel sends those to the
engine like any command, so nothing about tasksh is in the Worker.
The hooks editor
HooksPanel and hookEditor.ts: CodeMirror with Lua highlighting, loaded only when the tab opens (about 107 KB
compressed, its own chunk). The panel asks the Worker whether the script is all right a moment after typing (POST /api/hooks/check, the same Lua that will run it) and marks the line. A script that doesn’t load can’t be saved, and an
empty one removes the hooks. api.ts tells the engine which hooks the saved script defines (refreshHooks).
Review
tasksh’s review is the browser’s too, and only a client of the engine. review.svelte.ts is the session: it asks for
the _reviewed report (the uuids), shows each task with info, and carries out the menu with ordinary commands (modify reviewed:now, done, delete, with rc.confirmation:no), so undo, hooks and the settings work as for any command. A
question Taskwarrior asks mid-step (the recurring series) comes back as a confirm result and is put to the person
(asking), then the same command is sent again with the answer.
Two surfaces drive it, with one ReviewBar of buttons between them:
ConsoleInput: tasksh’s menu is keybindings, so whilereview.capturesa key is the answer, the prompt is off, and only what to change is typed.store.addEntryprints the banner, theinfoand the messages into the scrollback.ReviewCard: buttons and the same keys, opened from the top bar or by Open as card, which moves a console review there.
tasksh.ts has what is pure: reading review N, the menu keys, tasksh’s wording and the setup’s questions. The editor
the e choice opens reports its closing through App.svelte (review.editClosed).
Addresses
Every page has one, in route.ts: a Place (a report, a plain page, the boards or one board, Settings or one tab, a task)
with parsePath and placePath to turn it into a path and back. The Worker serves the app for them all (single-page
fallback). store.place is where the app is, derived from the page, report, board, tab and drawer. store.openFromAddress
goes where an address says (a page that needs the reports or boards waits until they are read, and the start asks again).
One effect in App.svelte keeps the address in step with store.place the other way, pushing a history entry for each
move but replacing the address when the front page finds where it is, when the drawer closes over the page under it, and
when Settings changes tab. A new page adds a Place, a test of its round trip, and a row in docs/using/pages.md.
A task is /task/<8 characters of its uuid>; store.openFromAddress finds it with uuid.startswith:<prefix> uuids.
DepLink is the id of another task (a dependency, a parent) with a hover, focus or tap card; in the drawer DepItem is a
line that expands to the same TaskCard, and the tasks that wait on this one are looked up with depends.has:<uuid> only
when asked. A project part or tag chip jumps to its entry on the Projects or Tags page (store.showProject / showTag).
The home page
A homepage key of settings.json ({ report } or { board }, read by parseHomepage). store.goHome() runs once the
settings have loaded, from the front page only, and does nothing for a task’s address or a report or board that is gone.
The General tab of Settings writes it.
Boards
The browser’s pages over the engine’s board module.
appsettings.tsreadssettings.json‘sboardswithboard_normalize(the problems are kept apart from the commands’) andserializeBoardswrites them back with the other keys as they were.boards.tsruns<filter> exportfor each distinct filter (sections with the same one share it) and a table’s report, hands the text toboard_aggregate, and sorts and trims the groups. Before it runs a filter it writes the board’s date ranges into it (board_substitute; the dates typed above the board take the place of the board’s own).BoardViewis the grid and the drag, resize and keyboard handling. Each step is worked out from where the drag began, on the layout as it was then, byboard_place, or on a narrow screen byboard_resize_flow, which resizes in the phone’s columns; sections are drawn whereboard_flowputs them there. A section’s settings are inDrawer, a modal panel that slides in from the side.SectionInfo, a modal<dialog>, is drawn from what the load already had:loadBoardkeeps, beside each result, the filter each series ran and theexporttext it got, so the panel parses and lists the tasks only when opened.
Charts. BoardChart is Chart.js on a canvas at the size of its box, loaded as a chunk of
its own (chartjs.ts: only the controllers and scales the boards use, and the matrix plugin that makes a heatmap; about 67
KB compressed, fetched when a board first draws a chart). Bar, line, area, pie, donut, radar, polar and heatmap charts are
one library’s work, and only the table is the app’s own. (On Android a heatmap is the one chart the app lays out itself,
as Koala Plot has none, and the widget’s BoardRenderer draws it on its canvas.)
What BoardChart is given, chartConfig in boardChart.ts, is a change of names and colours from the engine’s Chart
(labels, and a dataset of values per series), which already has Chart.js’s shape on purpose. It is the contract any front
end draws from, mapping it to its own chart library. Nothing reads the replica.
Task commands
The browser’s own. appsettings.ts reads and writes settings.json ({ "taskCommands": [{ name, command, icon? }] },
other keys kept), which the Worker only stores (GET/PUT /api/settings; tc-core’s appsettings checks that it is a
JSON object under 64 KB and nothing more).
taskCommands.ts writes a command out on the line you typed: the first word that is not part of the filter, as the engine
reads a line, is the command word, and only that word can be one, so add fail the test is untouched. A name is written
out once per line, so a command can extend the Taskwarrior command of the same name. ConsoleInput expands before it
decides where the result goes and hands store.run what was typed for the history. The same commands are the ⋯ menu
(MoreMenu) in a table row, the task drawer and the bulk bar, which send <tasks> <the command's words> like any button.
Icons are Lucide’s, picked in IconPicker. The app’s own are bundled and found by name (bundledIcons in
lucide.ts). Any other is fetched as one SVG file from jsdelivr at the Lucide version pinned there (LUCIDE_VERSION), then
read into a short list of plain shapes (svgToShapes: only path, circle, rect, line, polyline, polygon and
ellipse, with numeric or path attributes; anything else refuses the whole file) and drawn inline by NodeIcon in the
page’s colour. So the CDN supplies data and never code, and the CSP needs only connect-src for that host (and for
holidata.net and date.nager.at, which supply holiday dates as text). lucideLib.svelte.ts keeps what was fetched (in
memory, and the last 150 in localStorage) and the picker’s list of names and search words (tags.json, about 50 KB
compressed, fetched when the picker opens).
Bulk actions
BulkBar, BulkModify, selection.svelte.ts and bulk.ts: the table’s checkboxes feed one selection, and the bar sends
ordinary commands (the whole uuids of the ticked tasks side by side, which Taskwarrior reads as one group, then done,
delete or modify with the words the editor made), so the engine needs nothing of its own. CLI hands the prompt a
line through store.requestPrompt.
Colours
The engine sends each row’s colour as palette indexes (Row.style) and the chart colours with the config. colors.ts
draws them (the soft basic sixteen per light/dark page, exact xterm colours otherwise, a contrast guard), scheme.svelte.ts
says which page it is, and themes.ts and the taskrc tab’s picker put a bundled theme’s lines into the taskrc (the theme
files are static assets).
What the browser stores
The replica (one IndexedDB record, see Sync) and small conveniences (command history, reminder
settings). Settings live in the bucket: web/config.json (with one backup) and the app’s own web/settings.json (none).
How correctness is kept
The goal is Taskwarrior’s behaviour, not a lookalike, so the method is to read the C++, port it, and check it against
the real task 3.5.0: run both on a scratch data directory and compare. Examples: calendar layouts and week numbers,
burndown charts bar by bar, summary/projects/tags output, calc results, confirmation prompts, and the dates each
date.iso/dateformat combination accepts.
| Layer | What it proves |
|---|---|
cargo test (unit) | Each port against fixtures taken from the real task |
tests/cli_exec.rs | Real command lines against a real replica: writes, reports, confirmations, recurrence, calc, show/config, hooks (injected through Options.hooks) |
tests/sync_cost.rs, replica_sync.rs | R2 call counts per sync; two replicas converging |
tests/memory.rs | Peak memory of a cold start and of requests |
tests/lua_net.rs | http and secret() against a fake host: the allowlist (exact, wildcard, no user-info tricks), https only, request limits, a failed request as nil and a reason, a secret’s value kept out of everything, and a second call while one waits on the network |
tests/lua_hooks.rs | Lua hooks through the real execute: each hook point, the tag set, dates and durations, UDAs, refusing, what a script may not change, hooks=off, syntax errors with their line, and the sandbox (no files or loading, an endless loop is stopped) |
crates/hook-shim/tests/protocol.rs | meghnaad-hook as task runs it, from a task on standard input to what it prints and exits with: each hook, the changes written back in task’s form, refusing, a script that doesn’t load, secrets by exact name, http against a local server, the zone, install and uninstall |
tests/saved.rs | A replica saved to bytes and loaded again is the same replica, carries on the same way and syncs; unreadable copies are refused |
tests/remote.rs, wire unit tests | A replica syncing through the plain protocol (both halves, bytes encoded and decoded) ends where a direct one does: batches longer than one pull, pushing, losing a race, snapshots both ways, an empty bucket |
board unit tests, and a_board_reads_what_export_writes, finished_tasks_split_into_succeeded_and_failed_by_a_uda and spans_over_an_export_are_what_the_weekly_burndown_counts in cli_exec | Grid placement (the moved section wins, the overlapped are pushed down, everything floats up, clamping, the two-column minimum, keyboard steps, reading order, the phone’s flow and resizing), the saved format (what is valid, what is dropped and named), counts, sums, averages and comparisons of numbers and durations, grouping by value, by each tag and by a date in each step, zone and week start with empty periods kept, and the refusals. The last of the three checks pending, started and done against burndown.weekly week by week |
tc_core::helpers, sortspec, complete, highlight, urgency_settings, cells, preview and markdown unit tests | What every front end calls instead of keeping a copy: splitting and quoting a command line, a date in each dateformat token and week rule, a duration in one unit, how a recurrence period reads, every header click, grouping and sort-override case, what Tab completes, how a line is coloured, how urgency terms are named, what every column format shows, what a typed add line comes to, and the Markdown subset (nesting, checklists, safe links, the limits that keep a hostile text quick) |
npm --prefix web test | The browser-side logic (it loads the built engine for the helpers): formatting, completion, the store, the API client, and the engine client (with a stand-in Web Worker: one sync at a time, the other tabs are told of a write and never answer each other, a failed sync shows and clears) |
scripts/interop-local.sh | The real task CLI and the Worker against one local S3 bucket, both directions: tasks, UDAs, recurring series, snapshots |
scripts/auth-check.mjs | Forged, expired and wrong-audience Access tokens are all refused |
scripts/*.test.mjs | The build and deploy scripts, including package-release.mjs: the prebuilt release has the built Worker and app, the scripts, and a config with no build step, and none of the sources |