Seven minor releases shipped in one extended session (v0.14.0 → v0.20.0; +4972 LOC across 44 files in the bundled commit 67c7e7c). v0.21.0 is the punctuation — a deliberate maintenance pass before any new feature work, sequenced by blast radius (small wins → focused refactors → structural splits → test coverage). No behaviour changes for the user; the audit findings are documented here so future-me has the receipts.
Two-pass survey (delegated Explore agent first, then a deeper hand-pass after the agent's report turned out to under-rate the structural debt — the agent never opened atrium/src/ui/window.rs, the largest file in the repo at 6105 lines).
The deeper pass added: cargo clippy -W clippy::pedantic (909 raw warnings, ~250 actually actionable after stripping doc-style noise + must-use suggestions); unwrap() / expect() audit on production-only code (~38 occurrences, almost all defensive GTK template downcasts in ui/task_list.rs); long-function census via clippy too_many_lines (5 functions exceed 100 lines, worker.rs::handle() at 366 the standout); and a test-coverage gap pass (notably atrium-cli/src/args.rs at 1561 lines with zero tests).
- Migration 0013 —
task_clock_entrytimestamps. v0.17.0's CLOCK table shipped withoutcreated_at/modified_at(every other table in the schema has them). Added as nullable columns with a backfill (created_at = started_at;modified_at = COALESCE(ended_at, started_at)) plus the standardWHEN OLD = NEWtrigger. Worker INSERT paths stamp them explicitly; readers populate from the row.user_version12 → 13. - Stale Org parser docstring.
atrium-org/src/org/parse.rs:37claimed "active timestamps lose time-of-day" — wrong since v0.19.0'stask.scheduled_time. Updated to reflect the post-v0.19.0 reality (date and time-of-day split into separate columns). - Quick Entry shortcut sniff validation. The modal's
:LETTERsniff now rejects non-ASCII-alphanumeric trigger characters at the GUI layer, mirroringvalidate_shortcut_keyin the worker. Prevents a:🎉from attempting template matching that could never succeed. - Reminder service
Utc::now()consolidation. Loop iteration captures onenowfor the lookup + sleep-window calculation rather than callingUtc::now()three times. The post-sleep re-check still needs a fresh timestamp (the outernowis from before the sleep) — that one stays. - Targeted clippy pedantic sweep. Auto-fixed via
cargo clippy --fix: 27format!("{}", x)→format!("{x}")modernizations, 22 redundant closures (|x| f(x)→f), 18map().unwrap_or()→map_or(), 8match-as-let-elserewrites. Touched 24 files; no behaviour changes; clippy-D warningsstill green.
atrium-cli/src/args.rs had no inline test module (the audit's "1561 lines, 0 tests" finding). Investigation revealed a separate atrium-cli/src/tests.rs covering the older subcommands well, but with no coverage for v0.17.0+ additions. Added 34 tests inline in args.rs covering: clock (status / log / in / out + the bare-clock-as-status alias), template (list / add / minimal + with shortcut), --deadline-warn and its --warn alias, negative-warn rejection. Workspace total now 888 tests (was 854).
A second item from the audit — Quick Entry shortcut-sniff unit tests (item 12) — was deferred. The sniff logic is inline in a GTK closure; testing it cleanly needs extraction first, which is a separate refactor.
atrium-cli/src/main.rs (2604 lines, 76 functions). Pulled out the two most isolated subcommand surfaces:
atrium-cli/src/clock.rs(245 lines) —clock status / log / in / out+print_one_entryatrium-cli/src/template.rs(217 lines) —template list / add / edit / remove+find_template_by_name+resolve_project_id
main.rs is now 2155 lines (saved 449). CliError, CliResult, json_escape promoted to pub(crate) so sub-modules can use them. Dispatch sites in main.rs unchanged — sub-module exports re-imported by name via use clock::* / use template::* (explicit list). The remaining perspective / export / import orchestration stays in main.rs for now (later extraction passes can pull more out).
atrium-core/src/db/read.rs (2288 lines, second-largest in atrium-core). Converted to a read/ directory with four sub-modules carved out:
read/clock.rs(127 lines) — clock-entry queries (CLOCK_COLUMNS, clock_entry_*, list_clock_entries, total_clock_minutes, active_clock, clock_entries_per_project)read/counts.rs(228 lines) — counting queries (CanonicalCounts, count_open_, count_done_total_, count_tasks)read/search.rs(162 lines) — SQL fast-path + FTS5 (SqlBindValue, list_tasks_matching, search_tasks, bm25_for_terms)read/templates.rs(64 lines) — Quick Entry template queriesread/mod.rs(1780 lines) — task / area / project / heading / tag / perspective queries; shared TASK_COLUMNS + task_from_row used by sub-modules viasuper::
Pragmatic split — pulled out the four most-isolated sections rather than fragmenting all 11 surfaces into separate files. Public API unchanged: callers continue to use crate::db::read::function_name. The 1051-line test module stays in mod.rs (uses super::*).
atrium-core/src/db/worker.rs handle() (366-line dispatch loop, the standout too_many_lines warning in the maintenance audit). Each Command arm used to inline a 5-7 line "send delta + maybe notify dirty" body. Factored that out into 12 small helper methods on Worker — emit_task_created / emit_task_updated / emit_area_* / emit_project_* / emit_tag_* / emit_perspective_* (created/updated/deleted variants per kind). Each dispatch arm is now uniformly 5-7 lines: call work fn → check Ok → call helper → respond.
Helpers chosen over a macro on the basis that regular Rust is debugger-friendly and IDE go-to-def works through normal method calls. The helpers are reusable for future commands and individually testable.
Phase 18.5 wrapped at v0.19.0; Phase 19.5 (productivity essentials) opens with two pieces that pair naturally — a real preferences dialog and the first notification surface, with the dialog exposing the toggle that gates the new reminder service. Both items have been deferred for several minor cycles; landing them together avoids two separate "settings shape" conversations.
The first app-level preferences UI in Atrium. Closes a long-standing gap where the only way to adjust the app was gsettings set io.github.virinvictus.atrium ... from a shell. Built on AdwPreferencesDialog (libadwaita 1.6+) — the predecessor AdwPreferencesWindow is deprecated in favour of dialogs.
- General page. Default mode (Simple / Builder, drives the existing
modeGSettings key), theme override (Follow system / Light / Dark — newthemekey, applied viaadw::StyleManager::set_color_schemeimmediately on change and replayed at boot), high-legibility font toggle (Atkinson Hyperlegible — wires the existinghigh-legibility-fontkey), vault path with a folder picker (gtk::FileDialog::select_folder). - Capture page. Quick Entry shortcut as a single
AdwEntryRowaccepting GTK accelerator syntax (<Control><Alt>space). Backed by the existingquick-entry-shortcutkey. The runtime accelerator listener already rebinds when the key changes; no separate rebind plumbing needed. - Notifications page. Master switch (
notifications-enabled, default true) — gates the v0.20.0 reminder service. Off-by-toggle is observed by the service on every fire, so flipping the switch takes effect without restart. - Wiring.
app.preferencesaction withCtrl+Commaaccelerator; primary menu's "Preferences…" entry triggers it. Each page is a single function returning anAdwPreferencesPageso adding the Phase 20 Backups page later is a one-method addition.
The first time-based notification surface. Per-task reminder_at UTC timestamps drive a single tokio task on the GLib MainContext that polls the next pending reminder, sleeps until it fires, and emits a notification. Designed deliberately as the GUI's reminder owner — Phase 20's atriumd will own out-of-process reminders later, so this service only runs while the app is open.
- Schema. Migration
0012_task_reminder_at.sqladdstask.reminder_at TEXT NULL(RFC 3339 UTC) plus a partial indexidx_task_reminder_at_openon open future reminders only (WHERE reminder_at IS NOT NULL AND completed_at IS NULL) — keeps the dispatch query fast on libraries with thousands of past reminders.user_version11 → 12. - Read helper.
next_pending_reminder(conn, after) -> Option<(i64, DateTime<Utc>)>— single row, soonest open future reminder. Used by the service loop. - Service.
atrium::reminders::spawn(pool, app)returns a cheap-to-cloneReminderServiceexposingwake(). The loop wakes from either atokio::time::sleepto the next reminder OR atokio::sync::Notifyping; sleep is capped at one hour as a defensive re-query against clock jumps + suspend/resume. The TaskChanges bridge (bridge_task_changes) callswake()after every batch so freshly-set reminders take effect without a timer wait. - Notification shape.
gio::Notificationtitled "Reminder" with the task title as body. Notification ID isatrium-reminder-{task_id}— re-firing for the same task replaces rather than stacks. Default action isapp.show-task::ID(parameterised i64 action; opens the inspector for that task). - Master-switch behaviour. When
notifications-enabledis false at fire time, the reminder is silently skipped (the loop continues). Documented limitation: disabling notifications during an open reminder window swallows that reminder permanently — re-enabling does not back-fill. A "last-fired-at" column would address this; deferred until a real user asks. - CLI.
atrium-cli add --reminder "YYYY-MM-DD HH:MM"accepts a local-time timestamp and stores it as UTC. Mirrors the--timeflag style from v0.19.0.
3 new tests: next_pending_reminder_returns_soonest_open_task and next_pending_reminder_returns_none_when_all_past_or_completed (atrium-core/src/db/read.rs) cover the dispatcher's lookup, plus update_task_sets_and_clears_reminder_at in worker_tests.rs for the column round-trip. The notification fire path itself isn't unit-tested — it touches gio::Application::send_notification, which a unit test can't observe. Workspace passes 854 tests; fmt + clippy clean. Schema version 12.
Phase 18.5 finishes with both Tier-2 items bundled into one minor: ID-based links between tasks ([[id:UUID][label]]) and time-of-day on the SCHEDULED cookie. All seven Phase 18.5 items now shipped across v0.14.0 → v0.19.0; Phase 19.5 productivity essentials are next.
The org-roam-adjacent power feature, with zero schema impact. Karl Voit's UOMF advocates ID links for portability; Atrium already generates :ID: UUIDs that round-trip cleanly, so the only missing pieces were body-text recognition + clickable rendering + an insertion affordance.
- Body-link parser. New
atrium_core::linksmodule surfacesparse_body_links(body) -> Vec<BodyLink>with byte ranges, target UUIDs, labels, and a "has explicit label" flag. Forgiving: malformed brackets, non-id:link types (file:/https:/mailto:), and unterminated constructs are silently skipped. 10 unit tests cover each shape. - Inspector body rendering.
gtk::TextTagwith foreground accent + underline applied to every link range; re-applied on every buffer change so live edits keep highlights. Click gesture walks the iter at the click position to a parsed link, invokes a navigation callback. Both modes (Builder pane + Simple Mode dialog). - Click navigation. New
task_id_for_uuidread helper resolves the link's UUID to a row id; the window routes through the existingopen_inspector_for(id). Stale links (UUID points to a deleted task) silently no-op rather than erroring — the user's click was a navigation attempt, not a state mutation. - Link… picker (Builder Mode).
notes_groupgains a header-suffix button → popover withgtk::SearchEntry+ filteredgtk::ListBox(case-insensitive substring match against title, capped at 50 rows). Clicking a row inserts[[id:UUID][title]]at the cursor. Lazy pool resolution via the newpool_sourceinstall closure — the picker stays read-pool-agnostic.
The Todoist mapper's DroppedTimeOfDay lossy entry is finally closed.
- Schema. Migration
0011_task_scheduled_time.sqladdstask.scheduled_time TEXT NULL(HH:MM format). Companion-column shape rather than upgradingScheduledForto a sum type — keeps the existing TEXT sort semantics intact and avoids the API ripple of changingScheduledFor::Date(NaiveDate)to a sibling variant.user_version10 → 11. - Org parser/emitter. Parser captures the time token between the day name and any repeater/warning suffix into
OrgTask.scheduled_time. Emitter writes it back in canonical<DATE Day HH:MM +Nx -Md>order — time before suffixes per Org's standard layout. Round-trip stable. Importer + watcher diff path thread the column through new task creates and external-edit syncs. DEADLINE time-of-day is recognised but silently dropped — Atrium has nodeadline_timecolumn, and an explicit round-trip would need a sibling addition; defer until a real user asks. - GUI. Inspector pane (Builder) gets a Time
gtk::Entryrow beneath Schedule. Visible only when scheduled_for is a Date (Someday + None can't carry a meaningful time). ParsesHH:MMon focus-leave and dispatchesTaskUpdate::scheduled_time_value. Forecast day cards prefix Scheduled-reason rows withHH:MMwhen present (Deadline + DeferEnds rows are time-blind). Calendar Month View shows the time on inline day-cell rows + the day-peek popover. - CLI.
--time HH:MMflag onaddaccepts a 24-hour time; combined with--scheduled today/--scheduled YYYY-MM-DDto produce a date+time capture from the shell. Todoist mapper retired itsDroppedTimeOfDay.pushsite — the recurrence parser already extracted the time, the mapper now threads it into the new column.
10 new link-parser tests in atrium-core::links + SCHEDULED time round-trip in the Org parser/emitter + 1 worker scheduled_time set/clear test + smokes for the Calendar / Forecast time prefix render. Schema version 11.
The fifth and final Phase 18.5 Tier-1 item lands. All five Phase 18.5 Tier-1 items are now shipped — Atrium's surface is meaningfully different from "another GNOME todo app" in the ways the original Phase 18.5 research said real Org users would notice: per-task DEADLINE warning windows (v0.14.0), statistics cookies + body inline checkboxes (v0.15.0), custom TODO sequences (v0.16.0), CLOCK time tracking (v0.17.0), and Quick Entry templates (v0.18.0). The remaining Phase 18.5 work is Tier-2 polish (Org links between tasks, time-of-day on scheduled_for); after that, Phase 19.5 productivity essentials.
Schema. Migration 0010_quick_entry_template.sql adds the quick_entry_template table — id, name (UNIQUE), shortcut_key (UNIQUE, NULL = no shortcut), target_project_id (FK SET NULL on project delete; NULL = Inbox), prefix, default_tags (JSON array string), position. modified_at trigger uses the same WHEN OLD = NEW pattern as elsewhere. user_version 9 → 10. Worker enforces the "single ASCII alphanumeric character" rule on shortcut_key (a CHECK constraint would work in SQL but the error message would be cryptic; a worker-side check yields a clear DomainError::InvalidShortcutKey).
Worker + read. Three new commands (CreateQuickEntryTemplate, UpdateQuickEntryTemplate, DeleteQuickEntryTemplate) plus their WorkerHandle shims. Read helpers: quick_entry_template_by_id, list_quick_entry_templates (ordered by position). The default_tags JSON encode/decode happens at the worker/read boundary; the GUI sees Vec<String>. DomainError::InvalidShortcutKey { got } is the new variant; falls under the existing DbError::Domain umbrella so no new error-tree plumbing needed.
Quick Entry modal. New picker bar above the entry, hidden when no templates are configured. Each template renders as a gtk::ToggleButton labelled "shortcut · name" (or just name when there's no shortcut). Clicking activates: pre-fills the entry text with the template's prefix, stashes the template object for commit, and toggles off any other active picker button (mutual-exclusion). Clicking the active button again deactivates and clears the entry text.
Shortcut sniff. The modal's connect_changed handler watches typed text for :LETTER (colon + single character + space). When LETTER matches a template's shortcut_key, the template auto-activates: entry text becomes the template's prefix, the matching picker button toggles on. The trailing-space requirement avoids hijacking :c mid-word; the user has committed to the trigger before activation fires. Once a template is active, the sniff is dormant — no re-interpretation of : characters in the template's body.
Commit semantics. Active template threads through to commit(): target_project_id becomes the new task's project; default_tags merge with inline #tag parser output (template tags first, parsed tags appended unless they collide by case-insensitive name). Empty entry input still rejects (no template-only captures); the user has to type a real title to commit.
CLI. atrium-cli template list (TSV / JSON / human formats with project resolution). atrium-cli template add NAME --shortcut LETTER --project NAME --prefix TEXT --tag TAG (project resolves via case-insensitive substring match against project.title; multi-match errors out with the candidate list). atrium-cli template edit NAME --rename NEW --shortcut LETTER|none --project NAME|none --prefix TEXT --tag TAG. atrium-cli template remove NAME (case-insensitive lookup).
5 new tests in worker_tests: round-trip create, multi-char shortcut rejected, non-alphanumeric shortcut rejected, delete returns NotFound on second attempt, update changes fields. Schema version 10.
The flagship Phase 18.5 feature. One of the top two reasons real users stay on Org-mode instead of Things or OmniFocus — actual-time tracking distinct from estimated_minutes (intent), accumulated across multiple work sessions, with full round-trip to Org's :LOGBOOK: drawer so Emacs users see the same data. The largest schema-affecting item in Phase 18.5 (one new side table, one new worker command surface, parser + emitter + watcher all extended), and the differentiator that makes Atrium meaningfully more than "another GNOME todo app."
Schema. Migration 0009_task_clock_entry.sql adds the task_clock_entry side table — id, task_id (FK CASCADE), started_at, ended_at (NULL = running), note. Index on (task_id, started_at). user_version 8 → 9. The single-active-clock invariant (at most one row across the entire table has ended_at IS NULL) is enforced by the worker rather than a partial unique index — keeps the schema simple, and the worker is the only writer anyway. v0.16.x binaries reading a v0.17.0 DB ignore the table.
Worker. Three new commands: ClockIn { entry, responder }, ClockOut { task_id, responder }, DeleteClockEntry { id, responder }, plus ImportClockEntry { task_id, started_at, ended_at, note, responder } for the watcher and importer paths (caller-provided timestamps, skips the auto-close invariant since the source file is trusted). clock_in on task B auto-closes any other open entry on task A first — mirrors Emacs's global clock; what every Org user expects when they org-clock-in on a different headline. Re-clocking on the same task surfaces the existing open entry rather than double-stamping (returns previously_closed_task_id: None so the dispatcher knows nothing changed). Both directions emit TaskChanges for the affected task(s) so the inspector pane refreshes.
Read layer. New clock_entry_by_id, clock_entry_task_id, list_clock_entries (per-task, newest-first), total_clock_minutes (closed-only, computed via SQLite's julianday arithmetic), active_clock (the single-row lookup), and clock_entries_per_project (the writer's batch loader; one query per project flush rather than per task).
Inspector pane (Builder). New "Time" group between Notes and Builder fields. Renders three things: a Start/Stop gtk::Button (label flips based on running state), a "Total HH:MM" row (hidden when zero), and a per-session log of (duration, started) rows. Open entries surface as "Running — started …". Click Start → worker.clock_in; click Stop → worker.clock_out. Auto-refreshes on TaskChanges so a CLI clock-in reflects in the open inspector.
Org parser/emitter. New OrgTask.clock_entries: Vec<OrgClockEntry> field carrying captured CLOCK lines from a :LOGBOOK: drawer, plus logbook_unknown_lines for verbatim round-trip of state-change log lines and other non-CLOCK content. Parser recognises both shapes — CLOCK: [start]--[end] => H:MM [note] (closed) and CLOCK: [start] (running). Emitter writes the closed form only; in-progress entries are deliberately suppressed (the file would churn every running second; the next clock-out flushes). Timestamps treated as UTC throughout (mirrors the existing CLOSED-cookie convention).
Watcher. New sync_clock_entries(task_id, parsed_entries) walks each task's parsed CLOCK lines after the main diff. Matches by started_at (sub-second precision means starts are unique per task in practice); inserts added entries via import_clock_entry, deletes ones the file no longer carries, refreshes ones whose ended_at or note changed (delete-and-reinsert). External Emacs clock-out flows back into the DB; external CLOCK-line additions create matching DB entries.
CLI. atrium-cli clock in <id> [--note TEXT] opens an entry (auto-closing any other). clock out <id> closes the open entry on a task (soft no-op when none). clock log <id> prints all entries for a task in TSV / JSON / human format with totals. Bare atrium-cli clock (or clock status) shows the currently-running entry across the DB.
Open-by-design limitation. Org timestamps emit + parse in UTC. Mirrors the long-standing CLOSED-cookie behavior (since v0.7.x). Users in non-UTC timezones see UTC clock times when they open the file in Emacs — accurate but unfamiliar. The Inspector pane displays in chrono::Local so the GUI feels right; only the file representation is UTC. Switching the on-disk representation to local time means asymmetric parse-emit (parse-as-local + emit-as-local) and risks broken round-trip across timezone changes; deferred.
12 new tests across worker (clock_in opens, auto-close-on-different-task, clock_out idempotent on no-open-clock, clock_in-then-out records duration), Org parser (4 LOGBOOK shapes including malformed-line preservation), and emitter (closed roundtrip, in-progress suppression, mixed-state emit). Schema version 9.
The most-cited Org feature in the v0.6.19 research pass — Bernt Hansen's NEXT-replaces-priority workflow, Jethro Kuan's processing pipeline, every GTD-with-Org tutorial — lands. Per-vault declared TODO keyword sequences round-trip through Atrium's vault sync end-to-end: the writer projects them as #+TODO: preambles, the watcher validates against them, the Inspector pane picks from them, the CLI manages them. Zero schema impact (sequences live in the sidecar; existing task.orig_keyword from v0.7.12 carries the labels through Atrium's TODO/DONE binary).
Sidecar. <vault>/.atrium/config.toml gains a [[todo_sequences]] array-of-tables slot. Each entry carries name, workflow (open keywords), and done (completion keywords) — mirroring Org's #+TODO: STATE1 STATE2 | DONE1 DONE2 shape. The hand-rolled TOML parser learns one new value type (single-line inline string arrays); the rest of the schema stays unchanged. Empty todo_sequences emits a commented placeholder so an Emacs-side power user editing the sidecar by hand sees the section is intentional. Backward-compat: pre-v0.16.0 sidecars without the section parse cleanly to an empty Vec; existing tag colours + perspectives + mode survive untouched.
Writer. write_project_to_vault reads the sidecar's first sequence (single-sequence-per-vault is the typical Org pattern; multi-sequence support stays available in the sidecar shape but the writer projects only the first into #+TODO: for now). When configured, every project file's preamble carries #+TODO: workflow | done. The vault-writer's sidecar refresh path now reads the on-disk sequences before re-emitting (the DB doesn't carry sequences, so without this merge every flush would silently erase them).
Watcher. New keyword_is_done + keyword_is_known helpers walk the configured sequence's sets. The to_new_task and diff_from paths thread the sequence through; org_keyword_to_orig learns to stash workflow keywords under sequence-aware logic so a NEXT or WAITING task round-trips with its label intact. Unknown keywords (out of both sets) surface a new VaultEvent::UnknownKeyword { source, keyword } event so the GUI can prompt the user — they still preserve via the existing Custom path; never destroy data.
Builder Mode Inspector. New adw::ComboRow keyword picker between the title row and the dates group. Visible only when a sequence is configured (no sequence = no override = the title-row checkbox stays the binary toggle). Selection writes through to task.orig_keyword + task.completed_at together — picking a workflow keyword reopens; picking a done keyword stamps now() (or preserves the existing completion timestamp on a re-pick).
CLI. atrium-cli vault sequences list/set/clear --vault PATH. Vault path is required because atrium-cli is process-isolated from the GTK GSettings store. set --workflow TODO,NEXT,WAITING --done DONE,CANCELLED replaces the configured sequence outright. Output respects --json / --tsv / --human.
Architecture choice flagged for follow-up. The watcher reads the sidecar on every diff event. That's cheap (small file, buffered I/O) and means a sidecar edit takes effect immediately without restart, but if profiling ever shows it as a bottleneck the writer's existing last_sidecar cache pattern can mirror to the watcher.
15 new tests across the sidecar (round-trip with single + multi sequences, missing-section degrades cleanly, string-array parse with embedded escapes), the watcher (keyword_is_done + keyword_is_known + org_keyword_to_orig under varied sequence configurations), and the writer (#+TODO: emit gated on sequence presence). Schema unchanged at version 8.
Two Phase 18.5 features land bundled because they reinforce each other: Karl Voit names both essential, and Org's org-checkbox-hierarchical-statistics (default on) folds body checkboxes into parent statistics cookies as one unified count. The bundle ships zero migrations and works in both modes.
Statistics cookies — Tier-1. The Org parser learns to recognise [done/total] and [N%] cookies on the headline between the title and tags. Captured cookies are stripped from the title text (so task.title stays clean) and stashed on a new OrgTask.statistics_cookie field whose variant preserves the user's chosen shape across the round-trip. The writer's new stamp_statistics_cookies walker computes the values fresh from DB state on every emit — a stale [2/5] self-heals to [3/5] the next time the writer flushes. New count_done_total_per_project + count_done_total_per_parent SQL helpers in atrium-core::db::read feed both the writer projection and the GUI inline cookie label.
Body inline checkboxes — Tier-2. New atrium_core::checkbox module surfaces a small forgiving parser: - [ ], - [X], - [-] (plus + and * bullet variants for Org compatibility), with a toggle_body_checkbox(body, line_index) helper that rewrites a single line in place. The body string stays the source of truth — the Inspector renders a projection above the Notes textview as a list of gtk::CheckButton rows; clicking a checkbox toggles the line in the buffer, which triggers the Inspector pane's worker dispatch (Builder Mode) or simply updates the buffer that the dialog's Apply will pick up (Simple Mode, transactional). 14 unit tests in the checkbox module cover all three states, alternative bullets, indented checkboxes, indeterminate-clears-to-unchecked toggle semantics, trailing-newline preservation, and done/total counting.
Cookie + checkbox integration. The cookie counter folds body-checkbox done/total alongside child-TODO counts. A parent task with three subtasks (one DONE) and four body checkboxes (two checked) gets [3/7]. A task with no subtasks but a body checklist still earns a cookie. Mirrors what every "Org statistics cookies aren't recursive by default" tutorial expects — child counts stay local to the immediate parent (per org-hierarchical-todo-statistics's default), but body checkboxes count regardless.
GUI surface. Task list rows whose task has children or body checkboxes show the cookie as a small dim suffix on the title (between the title and the tag pills). Mirrors Org's headline shape: title, cookie, tags. New .atrium-task-cookie CSS class with tabular figures so [3/5] doesn't shift width when a count crosses a digit boundary. The cookie label updates live as children get toggled (the diff applier re-runs the cookie resolver on every TaskChanges update).
Open architectural work for follow-ups (deliberately not in v0.15.0).
- Inline checkbox embedding inside the textview. v0.15.0 renders checkboxes as a separate "Subtasks" group above the textview. Org-mode-style in-text widgets (a clickable
[ ]rendered inside the rich text) require GtkTextChildAnchor + add_child_at_anchor scaffolding. Polish item; the current shape is functional. - Cookie display in the sidebar projects. Sidebar projects already carry numeric badges from
count_open_per_project; v0.15.0 doesn't touch them. The vault file gets the cookie projection, and the per-task row shows the cookie. Switching the sidebar badges to the cookie shape would be visual cleanup, not a feature. - Recursive cookie counting. Org's
org-hierarchical-todo-statisticsdefaults to non-recursive (immediate children only); we follow. A custom Atrium recursive variant could come if real users ask.
14 unit tests in atrium-core's new checkbox module + cookie-shape tests in the Org parser + emit roundtrip tests + cookie-projection tests in the writer. No GTK unit tests this release; the visible behaviour is exercised through the regression smoke. Schema unchanged at version 8.
First Phase 18.5 item lands. Org-mode's per-deadline -Nd / --Nd warning suffix (DEADLINE: <2026-04-15 Wed -7d> — "surface 7 days early") becomes a per-task override on Atrium's previously-global TODAY_DEADLINE_WINDOW_DAYS constant. A sensitive deadline can now surface in Today earlier than the default 7-day heads-up window without disturbing how unmarked tasks behave; users coming from Org-mode get the round-trip they expect. The roadmap calls this out as the lowest-cost Tier-1 item — schema is one additive column, the read query gains a COALESCE, and the Org parser/emitter learns to recognise the suffix shape it had been ignoring.
Schema. Migration 0008_task_deadline_warn_days.sql adds task.deadline_warn_days INTEGER NULL. user_version 7 → 8. Append-only per the post-v0.2.0 schema rule; existing rows default NULL = "use the global default." v0.13.x binaries reading a v0.14.0 DB ignore the column.
Today list + sidebar badge. db::read::list_today + count_open_canonical.today both moved their static today + N horizon into a per-row SQL expression: deadline ≤ date(?today, '+' || COALESCE(deadline_warn_days, ?default) || ' days'). The badge count and the list contents stay in lockstep — one COALESCE in two queries. The pure-Rust mirrors in task_list::CanonicalList::Today::matches and atrium_search::eval::is_in_today_list got the same treatment so in-memory filter evaluation agrees with the SQL.
Org parser/emitter. parse_timestamp_inner walks the post-date tokens once and pulls the first that matches +/++/.+ (repeater) and the first that matches -/-- (warning) regardless of order — Org allows either sequence. Both warning prefixes parse to the same u32 days; Atrium normalises onto the single-dash form on emit since there's no global-default-override concept that would distinguish them. Day units land canonically; week/month/year units (-2w, -1m, -1y) fold into 14/30/365-day approximations on parse so the integer-day column stays straightforward. SCHEDULED-side warnings are accepted and round-trip verbatim into a sibling OrgTask.scheduled_warning field — Atrium doesn't model them in the DB but won't drop them on a round-trip.
Vault watcher diff. External Emacs edits to the -Nd suffix flow back into task.deadline_warn_days via the existing diff path (a new parsed_warn != existing.deadline_warn_days arm dispatches TaskUpdate::deadline_warn_days_value). Tested end-to-end: append a TODO with -7d → DB lands at Some(7) and the writer round-trips the cookie; flip the suffix to --14d → DB lands at Some(14) and the writer normalises onto -14d; remove the suffix entirely → DB clears to NULL.
Builder Mode UI. inspector_pane.rs gains an adw::SpinRow ("Heads-up window", range 0–60, step 1) wired to the deadline-button callback so the row's visibility tracks whether the task has a deadline set. 0 in the SpinRow means "use the default 7"; any positive value writes the override via TaskUpdate::deadline_warn_days_value. Autosaves on value-changed, consistent with the existing pane's pattern. Simple Mode's modal Inspector dialog stays unchanged — Builder-only UI exposure, schema column round-trips through the vault regardless of mode (mode-as-view, per the Phase 10 acceptance test).
CLI. atrium-cli add --deadline-warn N (alias --warn) and atrium-cli edit --deadline-warn N|none. Negative integers reject at parse time; none clears the column. info's human-readable output gains a warn N days before deadline row when the column is set.
Tests. Six new parser tests cover -7d, --7d normalisation, repeater + warning in either order, w/m/y unit folding, and SCHEDULED-side verbatim round-trip. Three new emit tests cover plain warning round-trip, repeater + warning canonicalisation order, and --7d → -7d normalisation. Four new read-layer tests cover per-task override winning over the global default, override below the default still being honoured, warn=0 semantics (deadline-or-past only), and badge-vs-list parity. One worker test covers set/clear via TaskUpdate. One end-to-end watcher integration test covers external add → DB sync → writer round-trip → external flip → DB sync → external clear → DB clear.
11 new tests across parser, emitter, read layer, worker, and end-to-end watcher scenarios; full ship gate (scripts/regression.sh) passes. Schema version 8.
Hotfix on top of v0.13.4. The fresh-vault seed bypassed RecentWrites (the shared self-write filter set the writer + watcher both consult to suppress self-induced echoes), so on every fresh-vault boot the watcher saw all 50 of Brandon's seeded .org files as external edits, fed them through the worker as no-op updates, the writer flushed each project, and the writer's pre-flush conflict check legitimately treated each file as foreign — backing every one up to <file>.atrium.bak.<UTC>. Boot logs filled with 50 spurious "vault conflict: external edit" warnings; ~/Tasks collected 50 stale backup files.
Fix wires the missing handshake. New VaultLoopHandle::recent_writes() accessor returns a clone of the shared Arc<RwLock<RecentWrites>> so out-of-band writers can register the files they wrote before attach_watcher consumes the handle. The seed in boot_data_layer snapshots this Arc before attaching the watcher, then in the per-project loop stats each file's mtime immediately after write_project_to_vault and records (path, mtime) in the set. By the time the watcher's 200 ms debounce fires, all 50 records are in — its self-write filter matches and skips the events; the worker never sees an update; the writer never wakes up to back anything up.
Validation on Brandon's box: deleted ~/Tasks/.atrium/config.toml to re-trigger the seed path. Boot log:
INFO atrium: vault watcher attached
INFO atrium: fresh vault seeded from DB count=50
Zero conflict warnings; zero .atrium.bak.* files left behind. Pre-fix the same boot produced 50 of each.
Test count holds at 829. Schema unchanged at v7. The race-window analysis is documented in the commit body.
VERSION + Cargo.toml + patchnotes.md + AppStream metainfo bumped to 0.13.5.
The Phase 17 vault loop is change-driven — the VaultWriter only fires when the worker emits notify_project_dirty(project_id). Setting vault-path on a fresh empty directory and restarting Atrium therefore did nothing visible: the existing DB sat unmirrored until the user edited a task. The expected behaviour is "see my existing tasks mirrored to disk on first connect", and that previously took a separate atrium-cli export org PATH invocation.
Fix: on every boot, after the vault loop attaches, check for <vault>/.atrium/config.toml. The writer creates that sidecar on every project flush, so its absence is a reliable "never been written" signal. When absent, spawn a tokio task that calls write_all_projects_to_vault against a fresh read connection, then writes the sidecar via build_from_db + write_sidecar. Two-phase because write_all_projects_to_vault doesn't touch the sidecar itself; without the explicit sidecar write, every subsequent boot would re-detect a fresh vault and re-seed.
Why background, not synchronous: a 10K-task DB takes a couple of seconds to dump and the cold-start budget (spec §8) is < 250 ms. The seed spawns on the existing tokio runtime so the GTK window appears immediately while the seed runs in the background. Errors are best-effort logged; atrium-cli export org PATH remains available as the manual fallback.
Backward compat is verbatim. Existing populated vaults (sidecar present) skip the seed unchanged — only fresh-or-erased vaults trigger it. Pre-existing .org files in a vault that's somehow lost its sidecar get backed up to <file>.atrium.bak.<UTC> by the v0.10.1 conflict-detection path before the seed overwrites them — hand-edits are never lost, only relocated.
read_vault_setup_from_settings now returns the vault path alongside the config + loop + receiver tuple, so boot_data_layer can detect the sidecar without re-reading GSettings.
Manual smoke (the path Brandon hit):
gsettings set io.github.virinvictus.atrium vault-path ~/Tasks
mkdir -p ~/Tasks # if absent
cargo run -p atrium
# After ~250ms the GUI is up; shortly after, ~/Tasks/ contains
# every project's .org file plus ~/Tasks/.atrium/config.toml.
Test count holds at 829. Schema unchanged at v7.
VERSION + Cargo.toml + patchnotes.md + AppStream metainfo bumped to 0.13.4.
Pure stylistic patch found by visual inspection of an exported vault — the Org writer was emitting headlines back-to-back with the :END: of one drawer butting directly against the * of the next headline. Org-agenda parses both forms cleanly, but Emacs's own writers (with the default org-blank-before-new-entry) always insert a blank line before a new headline. Reading Atrium's vault in DoomEmacs surfaced the visual cramping immediately — every headline read as part of one big block instead of as discrete entries. A single-line addition in emit_task pushes a trailing newline after the last piece of headline content and before recursing into children; the recursion unwinding handles every separator (parent → first child, sibling → sibling, last child → parent's next sibling). The parser tolerates blank lines anywhere outside drawers so spec §7.3.3 round-trip discipline is intact. One existing byte-exact test (emit_no_cookie_line_when_no_dates) updated to expect the trailing blank. Test count holds at 824. Schema unchanged at v7.
The atrium-inline Slice 3 popover (v0.13.0) wired into the bottom-of-list capture entry and the Quick Entry modal but deliberately skipped the per-row inline-rename Entry: "the row's edit Entry recycles frequently and the popover lifecycle would need additional teardown bookkeeping." This patch closes that gap.
Where it attaches: the GtkSignalListItemFactory's setup() callback, which runs once per row's lifetime — ahead of bind() and across recycles. The popover lives with the title_entry widget, not with whichever task is currently bound. No teardown bookkeeping needed; recycling a row swaps the bound task, not the popover.
Where the pool comes from: build_factory gained a fourth parameter, a PoolFn: Fn() -> Option<ReadPool> + Clone + 'static closure. Lazy by design — the read pool isn't attached when the factory is built (factory construction happens in window setup, before attach_data_layer). The closure resolves the pool fresh from a weak-window ref each time setup() needs it; by the time the first row reaches setup, attach_data_layer has long since fired.
Behaviour matches the other surfaces. Tab/Enter accept the highlighted candidate; ↓/↑ navigate; Esc dismisses without committing. Esc dismisses only the popover — the existing key controller checks the popover state structurally so Esc-to-cancel-rename still reaches the row's existing handler when no popover is open. Focus-leave dismisses the popover, then the existing focus-leave handler commits the rename and switches the stack back to the display label.
Until the user enters edit mode (F2 / right-click → Rename / double-click into edit), the title_entry is invisible (the title_stack shows the display label) and the popover's listeners stay quiet. No overhead per paint or per scroll. The first text change fires the refresh, evaluates the inline-syntax context, and surfaces candidates if the cursor is on a #/@/! token.
The inline_complete module's docstring is updated to drop the "defer to v0.13.x" note and document the row-level rationale.
No new tests this release — the popover wiring is GTK-side glue and the underlying parser semantics are already covered by the existing 49 atrium-inline tests + 3 inline_complete byte/char tests. Test count holds at 824 across the workspace; fmt + clippy clean; regression gate clean. Schema unchanged at version 7.
VERSION + Cargo.toml + patchnotes.md + AppStream metainfo bumped to 0.13.2.
A small follow-up patch closing the v0.10.1 sidecar carryover. The Phase 17 sidecar (<vault>/.atrium/config.toml) shipped tag colors + mode preference at v0.10.1; saved Perspectives reserved a [perspectives] placeholder section ("for future use") because perspective definitions cross more boundaries (renderer config, column lists) and the round-trip path wasn't fleshed out yet. Phase 18's Todoist mapper showed the shape — a Vec<entry> flowing cleanly through the worker — and v0.13.1 applies it to perspectives.
Format: TOML's array-of-tables ([[perspectives]]), one block per entry, in stored position order. Each block carries name, filter, optional icon, renderer ("list" / "board", defaults to "list" when omitted), and optional renderer_config (opaque JSON, survives the TOML basic-string layer's escape rules including embedded double-quotes). DB-generated fields (id, uuid, timestamps, position) are deliberately omitted — they'd confuse a hand-edit and re-import assigns fresh values matching source-file order anyway.
Backward-compat is verbatim. Pre-v0.13.1 sidecars carried a single-bracket [perspectives] placeholder followed by a # Reserved for future use. comment. The v0.13.1 parser still parses those cleanly — the section is treated as Unknown, contents silently dropped. The empty-Vec emit path now produces a commented # [[perspectives]] example so hand-editors see the new format's intent without breaking external tools that scanned for the old placeholder.
Implementation note. The hand-rolled TOML parser learned one new shape — array-of-tables. The parse_text cursor became a typed Cursor enum (Toplevel / Tags / Perspective(usize) / Unknown) so key/value lines bind structurally instead of through a string-match cascade. The "no toml crate dependency" decision still holds; the schema growth is bounded and explicit.
build_from_db now reads list_perspectives (sorted by position) and projects each row into a PerspectiveEntry. The end-to-end test creates two perspectives through the real worker, builds the sidecar, asserts every field including the renderer_config JSON survives, then verifies a text round-trip preserves the structure.
Test count: 824 across the workspace (up from 817 at v0.13.0; 7 new sidecar tests). Schema unchanged at version 7. No new third-party crates. Regression gate clean.
VERSION + Cargo.toml + patchnotes.md + AppStream metainfo bumped to 0.13.1. spec.md and roadmap.md unchanged — the spec already named saved Perspectives as a sidecar slot at v0.10.1; this patch implements behavior the contract already documented.
A polish + extraction arc on top of v0.12.0's Phase 18 work. The inline-syntax parser (#tag, @today, etc.) was small in v0.1 and grew steadily — Phase 6c shipped the original Quick Entry parser; Phase 18 added Todoist's mapper alongside it; v0.13.0 unifies the vocabulary, expands it (!N priority, @<weekday>), lifts the parser out of atrium-core into its own atrium-inline workspace crate, and adds a tab-completion popover so the syntax becomes discoverable instead of memorised.
Three slices, six commits in this release:
Slice 1 — inline rename routes through quick_entry. F2 / right-click → Rename / double-click into edit on a task row now runs the new title through atrium_inline::parse. A user can rename "Wash dishes" → "Wash dishes #urgent @today" and pick up the urgent tag plus a today schedule in one keystroke instead of opening the Inspector. Plain-text renames take a fast path identical to the pre-Slice-1 single-update flow — no behaviour change for the common case. Empty title after parsing rejects (the row never goes nameless). Title + scheduled + deadline land in a single update_task so the listener side sees one notify cycle. Tags are added, never removed (the rename surface doesn't show existing tags, so a destructive merge would surprise users — the Inspector and tag editor stay the channels for tag removal). New ParsedEntry::is_plain_title() lets the rename path branch on a structural check rather than a string comparison.
Slice 2 — !priority + @weekday tokens. Two new token shapes, both matching Phase 18's Todoist mapper vocabulary so the import → edit → re-import loop stays consistent:
!1/!2/!3— set priority. 1 = high, 3 = low (Todoist convention). Strict 1-3 range matches the v0.12.0 mapper's policy: priority 4 is Todoist's default "no priority" and emits no token.!none/!4/!9/!highfall through to the title verbatim. Multi-!Ntokens — last wins (mirrors@today/@tomorrowoverride semantics).@<weekday>— set scheduled_for to the next occurrence of that weekday on or after today. Both 3-letter (@mon) and full-name (@monday) forms accepted, plus aliases (@tues,@weds,@thur,@thurs). Case-insensitive (@MON,@Mon,@mOnall parse). When today's weekday matches the target, returns today (the "you typed@monon a Monday, you mean today" call). ISO@yyyy-mm-ddcontinues to win over weekday parsing.
New priority: Option<u8> field on ParsedEntry. The typed enum sticks around so a future Phase 19.5 numeric priority column can adopt it directly without a parser change. New ParsedEntry::projected_tag_names() augments the free-form #tag set with a priority-N projection for capture-flavoured surfaces (Quick Entry modal, bottom-of-list entry, CLI capture). New is_priority_tag_name(&str) -> bool helper for the rename surface so it can identify stale priority-* tags during the merge. The rename surface uses the typed priority field directly so it can swap one priority tag for another atomically (single-valued semantics) without losing the user's free-form #tag set.
Backward compat preserved verbatim. Unrecognised @foo still falls through to the title (regression test pinned). Plain text renames still take the same single-update fast path.
Slice 3 — atrium-inline crate extraction + tab completion. Two-part slice that lifts the parser into its own crate, then wires a discovery affordance on top.
-
Crate extraction.
atrium-core::quick_entry→atrium-inlineworkspace member. atrium-core stays inline-syntax-agnostic; the extraction goes one way, atrium-inline → atrium-core (atrium-inline pulls atrium-core forScheduledFor, never the reverse). atrium-inline's dep graph stays at chrono + atrium-core — no rusqlite, tokio, or gtk reaches it, so the post-1.0atrium-tuiand the v1.0atriumdcapture daemon can pull the parser without dragging in the storage layer. atrium-cli + the GTK binary depend onatrium-inlinedirectly. Same shape as the v0.9.0 atrium-org extraction. -
Tab-completion popover. New
atrium/src/ui/inline_complete.rswires the newatrium_inline::completionsmodule (pure context-detection + candidate-filtering helpers, fully unit-tested) into a smallgtk::Popoverthat floats below an inline-syntax-awaregtk::Entry. Active when the user types#/@/!and shows candidates that match what they've typed so far. Tab and Enter accept the highlighted candidate; ↓ opens the popover from the closed state when the cursor is on a recognised token (mirrors how a desktop-search box reveals its suggestions on first arrow-key); Escape dismisses without committing. Focus-leave dismisses too so a click elsewhere doesn't strand the popover.accept_candidateswaps the partial token for the chosen candidate while preserving the user's marker character. GTK ↔ atrium-inline byte / char conversion handled by tested utf8_byte_offset / char_count_at_byte helpers.
Wired into the bottom-of-list capture entry and the Quick Entry modal. The Quick Entry modal's open() signature gained a third argument — tag_pool: Option<ReadPool> — pulled via the new AtriumWindow::read_pool_for_quickentry accessor (mirror of the existing worker_handle_for_quickentry). Inline-rename in the task-list factory deliberately stays out of scope for this slice — the row's edit Entry recycles frequently and the popover lifecycle would need additional teardown bookkeeping. Renames still parse through atrium-inline at commit time so @mon blindly typed there still applies; only the visible suggestions defer to a v0.13.x patch.
Vocabulary curation. The popover surfaces full-name keywords (today / tomorrow / someday / deadline / monday … sunday) and the three priority levels (1, 2, 3). The 3-letter weekday shortcuts (@mon / @tue / …) stay parser-recognised but don't clutter the suggestion list. A schedule_keywords_match_parser regression guard fails loudly if a new full-name keyword lands in the parser without being added to the candidate list.
spec.md §6 — Quick Entry vocabulary. Updated to document the v0.13 tokens and to surface the architectural commitment that the same parser drives Quick Entry, the bottom-of-list entry, the inline-rename surface, and the CLI capture. The same parser, the same vocabulary, four surfaces.
Test count: 817 across the workspace (up from 798 at v0.12.0). atrium-inline itself contributes 49 tests (was 31 in atrium-core::quick_entry — 18 new, of which 13 cover Slice 2's parser additions and 5 cover the new helpers); inline_complete adds 3 byte/char-conversion tests. Schema unchanged at version 7. The regression gate (scripts/regression.sh) stays under 2 seconds.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.13.0.
The cross-platform productivity app most likely-to-migrate Linux user is leaving behind now has a real export path into Atrium. New atrium-cli import todoist PATH --into PROJECT_NAME [--dry-run] reads a Todoist CSV export, walks its row stream, and materialises the project + sections + tasks + tags + recurrence rules through the single-writer worker. Anchored to the home.csv "butter test" — Brandon's daughter Rin's chore-tracker — which round-trips Todoist → DB → vault → re-parse without losing data or scrambling structure.
Three hand-rolled stdlib parsers. import::todoist::parser (CSV → typed rows), import::todoist::recurrence (NL phrasing → RFC 5545 RRULE + scheduled anchor), and import::todoist::mapper (row stream → worker calls + ImportSummary). All three are stdlib-only — no csv crate, no regex (the workspace regex is for the search engine; the recurrence parser uses pattern-matching by tokenised words because it's clearer for the small phrase set). The CSV parser tolerates UTF-8 BOMs, quoted fields with embedded commas, escaped double-quotes, and blank separator rows; the TYPE column gates Meta / Section / Task / Blank classification. The recurrence parser handles every phrasing in the home.csv fixture plus sensible extensions: "Every Sunday at 10am", "every 3 day at 9am" (Todoist's singular typo), "Every 1stday" (no space), "3 days ago at 15:00" (past-dated single occurrence — no rule).
Mapper layout. Each Todoist row becomes a worker call: meta records in summary.meta_entries; section calls WorkerHandle::ensure_heading; task calls create_task with parent_id chain inferred from INDENT (1 = top-level, 2 = subtask of preceding indent-1, etc.). The CONTENT column's @labels are stripped from the title and become Atrium tags via ensure_tag + set_task_tags. PRIORITY 1-3 emits a priority-N tag; 4 is Todoist's default and emits no tag (keeps the noise floor low for the home.csv fixture, which is all priority-4). DESCRIPTION → task.note. DATE column → repeat_rule + scheduled_for via the recurrence parser; failures + dropped time-of-day, timezone, duration, deadline → per-row lossy entries.
Position layout for vault round-trip. Heading positions come from the worker's next_heading_position (1.0, 2.0, …). Top-level tasks then get an explicit update_task to set position = section_idx + i * 0.001 so they slot strictly between heading rows. The Org writer's build_project_tree (new this release) reads that ordering and emits each section's tasks as depth-2 children of the preceding heading. Subtasks inherit per-parent positions from next_task_position(parent_id, …) automatically.
WorkerHandle::ensure_heading. Idempotent heading-create-by-(project_id, LOWER(title)) — mirrors ensure_area / ensure_tag. New NewHeading { project_id, title } input type, new Command::EnsureHeading variant, new read::heading_by_id + list_headings_in_project supporting reads. The handler emits notify_project_dirty(project_id) so a configured vault writer picks the change up. Four worker_tests pin: creates-when-absent, idempotent-per-(project, title-NOCASE), scoped-to-project, increments-position.
Org writer learns project sub-headings. build_org_tree → build_project_tree(tasks, headings, tag_names). The new function takes the union of (heading rows, top-level tasks) and sorts by position with headings winning on tie. Walks in order: each heading becomes a depth-1 keyword-less OrgTask carrying :ID: (uuid); subsequent top-level tasks attach as depth-2 children of the preceding heading. Tasks before any heading stay at depth 1 — the writer behaves identically to pre-v0.12 for projects with no headings. Two new tests pin the layout: write_emits_headings_as_depth1_sections (interleaved 5-row layout) and write_keeps_pre_heading_tasks_at_top_level. The previous headings_skipped known-limit paragraph in the writer's docstring is gone.
Determinism via name-based UUIDs. Each task gets a v5 UUID derived from SHA-1(project_name || NUL || title) under a frozen Todoist namespace. Re-running the importer onto the same project produces stable IDs, which keeps the Org-vault :ID: round-trip clean across re-imports. The uuid crate gained the additive v5 feature flag (pulls in sha1_smol via the existing crate). atrium-cli grew direct uuid and thiserror deps (was transitive through atrium-core).
atrium-cli import todoist subcommand. ImportSource::Org | Todoist { project_name } replaces the unit-only enum. parse_import learns --into PROJECT_NAME; trying it on import org errors out (the org file's #+TITLE: is canonical). The dispatcher reads the CSV, parses + maps it through the three layers, and renders the summary in TSV / --json / --human. JSON shape mirrors the Org importer's; human mode prints heading + task + tag counts plus meta_entries and per-row lossy notes.
The home.csv butter test. home_csv_round_trips_through_db_and_vault is the closing acceptance gate: parse the sanitised home.csv (10 sections, 46 tasks), apply through the mapper, write to a vault directory via atrium_org::org::write_project_to_vault, re-parse the emitted .org file, assert structural fidelity. Pinned invariants: 10 sections + 46 tasks land; 2 distinct tags survive (chore, home); first section is "Sunday: Prep for the week", last is "One offs"; total task count across the recursive tree is 46; "Check for essentials" lands at depth 2 with 7 nested children at depth 3; "Check for milk, add to list" preserves its embedded comma; the recurring parent task carries :RRULE: FREQ=WEEKLY;BYDAY=SU in the property drawer; @chore / @home survive as Org headline tags; no @-prefixed leftovers remain in any title.
PRIORITY=4 policy. Todoist treats 4 as "no priority" (the default). Atrium emits no tag for it — Brandon's bias toward signal over noise, and the home.csv fixture is uniformly priority-4 so emitting priority-4 would pollute every task. Priority 1-3 (user-elevated) does emit priority-N tags. When Phase 19.5's numeric priority column lands, the mapper will switch from tag projection to direct column writes; the public surface (ImportSummary) stays stable.
Test count: 798 across the workspace (up from 765 at v0.11.0). Schema unchanged at version 7. The regression gate (scripts/regression.sh) stays under 2 seconds.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.12.0.
Builder Mode gains a third lens over the same task data Forecast and Agenda already cover. Forecast is the 30-day strip; Agenda is the chronological-band view (Overdue / Today / Tomorrow / This Week / Next Week); Calendar is the paper-calendar grid for users who think in calendar pages. The earlier roadmap framing called Phase 12.5 "subsumed by Agenda"; that turned out to be wrong — the calendar lens is a different mental model and v0.11 re-engages it as a Builder-only canonical page (mirroring Forecast's shape, not a Perspective renderer).
The grid. New atrium/src/ui/calendar.rs ships pure date-math helpers (first_of_month, grid_anchor, grid_end, last_day_of_month, week_rows, previous_month / next_month, build_month_grid) and the GTK widget tree built on top. The grid is 7×N (Mon-start ISO weeks; matches the Agenda buckets). Each DayCell shows: day number, count badge when there are tasks, up to 3 inline task titles, and a "+N more" overflow popover when the day has more than fits. Today's cell carries an emphasis class; out-of-month leading / trailing cells render muted so the focal month reads cleanly.
Navigation. Header strip carries Prev / Today / Next buttons + a month/year MenuButton that opens a 4×3 month-picker popover. Page Up / Page Down step months when the calendar has focus (scoped via gtk::ShortcutController so the keys stay free for other surfaces). Ctrl+Shift+M opens the page from anywhere.
Drag-to-reschedule. Mirrors Forecast's pattern: each inline task title is a DragSource carrying the task id; each cell is a DropTarget accepting i64 and updating scheduled_for via the worker. Out-of-month leading and trailing cells accept drops too, so users can drag into the previous or next month from the visible rows. Spec mentions a Shift-modifier for deadline-vs-schedule but defers the decision; v0.11 ships plain schedule and the modifier becomes a v0.11.x patch if Brandon asks for it.
Click-day-to-filter. Single-click on a cell opens a peek popover with the day's full task list — each task is a flat button that opens the inspector. Empty days surface a "Nothing scheduled" line so the affordance is consistent. Double-click drills into the standard list view via a scheduled:YYYY-MM-DD search expression — the user gets full editing affordances (drag, multi-select, complete) instead of being stuck in the calendar peek.
Narrow-window collapse. Below 600 px (COMPACT_WIDTH_THRESHOLD), the month grid swaps for a vertical week strip — 7 day cards stacked vertically, focused on the week containing today (or the first week of the viewed month if today's outside it). Each card shows the day's full task list inline. The window watches its own notify::default-width and rebuilds the calendar when the threshold flips; a Cell<Option<bool>> cache avoids rebuild storms during a drag-resize.
Builder-only. Sidebar entry "Calendar" sits between Forecast and Review in Builder Mode's top-tier extras; mode-flip filters it out in Simple. The Ctrl+Shift+M accelerator stays bound system-wide (AtriumWindow::show_calendar no-ops when in Simple) so users in Builder always get the shortcut without leaking the Builder feature into Simple's surface.
Tests: 13 new calendar lib tests. Cover the date-math edge cases: month boundaries (Jan 31 → Feb 1), leap February (29 days in 2024), DST transitions (March 2026 starts Sunday → 31 in-month days; November 2026 → 30), short and long months (5 vs 6 row grids), year wrap on prev/next, today-cell marking, out-of-month flagging, completed-task and deadline-only-task exclusion (the paper-calendar idiom uses the When-axis only).
ActiveList::Calendar variant added with canonical_title() returning "Calendar". top_tier_extras(builder=true) now produces 5 entries (Agenda, Forecast, Calendar, Review, Logbook) — the existing test pinned this and was updated.
Test count: 650 across the workspace (up from 637 at v0.10.3). Schema unchanged at version 7. No new third-party dependencies.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.11.0.
v0.10.3 (2026-05-09) — Phase 17 closer: RRULE canonicalisation + divergence detection + agenda-parity acceptance
v0.10.3 closes the Phase 17 patch arc. The RRULE canonicalisation contract (spec §7.3.3 rule 3) now runs end-to-end: writer emits both the best-fit Org cookie and the full :RRULE: property drawer entry; watcher catches the case where a user edits only the cookie in Emacs and rewrites the file to match the canonical :RRULE: (DB stays canonical). The agenda-parity acceptance test pins Atrium's Agenda canonical page against a spec-derived reference org-agenda classifier. Phase 17 (vault → DB two-way sync) is closed.
rrule_cookie helpers. New atrium-org/src/rrule_cookie.rs ships three pure functions:
rrule_to_org_cookie(rrule_text, mode)and the typed siblingrrule_to_org_repeater— RRULE → cookie.FREQ=WEEKLY→++1w;FREQ=DAILY;INTERVAL=3→++3d;FREQ=MONTHLY;BYMONTHDAY=1→++1m(lossy — the BYMONTHDAY clause stays canonical in:RRULE:). ReturnsNoneonly on malformed input (missing or unknown FREQ).org_repeater_to_rrule(repeater)— cookie →FREQ=WEEKLYorFREQ=DAILY;INTERVAL=3. The inverse projection; cookies can only express FREQ + INTERVAL.cookie_matches_rrule(repeater, rrule_text)— the equality check used by divergence detection. BY-clauses in the stored RRULE don't count as divergence; the cookie can't express them by design. Only flags as diverged when the user actually changed the cookie's frequency or interval.
Hand-rolled FREQ + INTERVAL parser; no toml-style dependency.
Writer wiring. scheduled_repeater_from_task was a None-returning placeholder since v0.7.10 with a comment about flipping it on later. v0.10.3 flips it on: reads task.repeat_rule + task.repeat_mode, runs them through rrule_to_org_repeater, returns the typed OrgRepeater the emitter consumes. SCHEDULED lines for repeating tasks now emit <2026-05-11 Mon ++1w>; the canonical :RRULE: still lives in the property drawer as the source of truth. Stock org-agenda renders the cookie; Atrium reads :RRULE: on read-back.
Watcher fixes two related v0.10.0 → v0.10.2 gaps. The :RRULE: property had no path through the watcher: to_new_task ignored it on create and diff_from didn't compare it on update. A user adding BYDAY=MO,WE to the property in Emacs would not propagate to DB. Fix: to_new_task now reads :RRULE: and threads it through NewTask.repeat_rule; diff_from compares against existing.repeat_rule and uses TaskUpdate.repeat_rule_value.
Divergence detection. New collect_rrule_divergences walks parsed headlines and flags any task whose scheduled_repeater (cookie) doesn't match its :RRULE: property under cookie_matches_rrule. For each divergence the watcher:
- Emits
VaultEvent::RruleDiverged { source, title, cookie, rrule }. - After the diff applies, synchronously calls
write_project_to_vaultto rewrite the file. The writer'sscheduled_repeater_from_taskprojects the canonical:RRULE:back to the right cookie, so the file becomes self-consistent. The user's cookie edit is reverted;RecentWritesswallows the resulting inotify echo.
The toast: "<title>: Org cookie diverged from :RRULE: — DB kept the canonical rule".
Phase 17 closing acceptance test. New agenda_parity_with_reference_org_agenda in atrium/src/ui/agenda.rs synthesises a vault with tasks across every bucket plus the "shouldn't appear" edge cases:
today_scheduled/today_deadline→ Todaytomorrow_scheduled→ Tomorrowthis_week_after_tomorrow/this_week_deadline→ This Weeknext_week_start/next_week_end→ Next Weekbeyond_next_week→ Noneoverdue_deadline→ Overdueoverdue_with_today_schedule→ Overdue (precedence)no_anchor/someday→ Nonecompleted→ Nonedeferred_future→ None
Both Atrium's classify and a spec-derived reference org-agenda classifier (mirroring Org's agenda-list day-window logic) run over each task and must agree. Visual layout / sort order between the two surfaces still differs (GTK card sections vs Emacs text agenda); the test pins SEMANTIC parity only — the contract spec §17 closes with.
Multi-day RRULE round-trip fixture. New tests/fixtures/org/rrule_patterns.org covers the three migration cases plus a daily-with-interval control:
- Weekly single-day (BYDAY=SU) — cookie
++1wlossless when SCHEDULED is a Sunday. - Weekly multi-day (BYDAY=MO,WE) — cookie
++1wbest-fit; canonical in:RRULE:. - Monthly day-of-month (BYMONTHDAY=1) — cookie
++1mbest-fit; canonical in:RRULE:. - Daily INTERVAL=3 — both representations express it.
All four round-trip through Atrium with the canonical :RRULE: preserved verbatim in the property drawer.
Phase 17 status: closed. Every checkbox under roadmap §17 ticks. The patch arc:
- v0.10.0:
notify-backed watcher;RecentWritesself-write filter; reader → DB diff by:ID:;:ID:allocation on read. - v0.10.1: GUI wiring (
spawn_vault_loop); writer-side conflict detection;<vault>/.atrium/config.tomlsidecar;VaultEventchannel; realDomainError/UiError/AtriumError. - v0.10.2: malformed-file pause/resume; custom-keyword preservation (two real bugs fixed); file-removal toast; concurrent-edit + 1K-task parse latency tests; new
ParseRecovered+FileRemovedevents. - v0.10.3:
rrule_cookiehelpers; writer emits both cookie +:RRULE:; watcher syncs:RRULE:to DB; divergence detection rewrites cookie-only edits to match canonical; multi-day round-trip fixture; agenda-parity acceptance test.
What's next. v0.11 opens Phase 18 (Todoist CSV). Phase 12.5 (Calendar Month View) is re-engaged from its earlier "subsumed by Agenda" framing — the calendar lens is a different mental model than the chronological-band Agenda or the 30-day Forecast strip; tasks are tracked but it slots after the v0.10 work closes.
Test count: 637 across the workspace (up from 616 at v0.10.2). Schema unchanged at version 7. No new third-party dependencies.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.10.3.
v0.10.2 (2026-05-09) — Phase 17 reliability slice: malformed-file pause/resume, custom-keyword fix, concurrent-edit hardening
The v0.10.0 / v0.10.1 vault loop ran the happy path. v0.10.2 hardens the unhappy ones — malformed files, custom keywords, file removals, concurrent edits — and adds three of the four roadmap §17 test scenarios. Two real v0.10.0 bugs surface and get fixed in the process.
Malformed-file pause/resume. When the watcher hits a parse error on a vault file, sync pauses for that file until it parses cleanly again. The user sees one VaultEvent::ParseFailed toast on the pause transition, then silence until a VaultEvent::ParseRecovered toast confirms sync resumed. Repeated bad saves no longer re-toast on every inotify event. The watcher's paused: HashSet<PathBuf> (shared via Arc<Mutex<>> across the run loop) tracks state; mark_paused returns whether the path was already in the set so the toast only fires on transitions; clear_paused returns true exactly once when the file goes back to clean.
Custom-keyword preservation fixed (two real bugs from v0.10.0). Spec §7.3.3 rule 1 requires WAITING / IN-PROGRESS / BLOCKED and other non-canonical Org keywords to round-trip verbatim via task.orig_keyword. The importer always honoured this, but the v0.10.0 watcher had two gaps:
ParsedTask::to_new_taskonly handledOrgKeyword::Cancelled— theCustomvariant fell through and the keyword was lost on create. Result: a freshWAITINGheadline appearing in the vault would land in DB as a plainTODO.diff_fromdidn't compareorig_keywordat all, andTaskUpdatehad noorig_keywordfield anyway. Result: an external flip fromWAITINGtoIN-PROGRESSon an existing task would not sync — DB kept the old keyword forever.
Fix: new TaskUpdate.orig_keyword: Option<Option<String>> field + builder method; the worker's update_task SQL builder threads it through; is_noop updated. New private helper org_keyword_to_orig in vault_watcher.rs drives both create + diff paths so they stay in lockstep. Pinned by external_custom_keyword_round_trips_through_orig_keyword.
File removal: toast + retain (spec §3.5). When a user rms a vault file or moves it out of the vault, Atrium now retains the project's tasks (DB canonical, vault projected) and surfaces a VaultEvent::FileRemoved toast. A stray rm no longer silently leaves stale rows; the next project flush recreates the file from DB. Per-headline deletion (a TODO removed from a file that still exists) is unaffected — it already round-trips through diff_and_apply's "in DB but not in parsed → delete" branch.
Concurrent-edit test scenario. New concurrent_atrium_and_external_edit_preserves_user_content_as_bak integration test drives the full Phase 17 race: spawn the loop, seed a project + task, fs::write external content, immediately update the same task title via the worker. Asserts the writer-side conflict detection catches the race, snapshots the user's content to .atrium.bak.*, the main file ends up with the DB rename, the user's content does not propagate to DB (writer beat watcher), and a ConflictBackup event surfaces.
Large-file parse latency test. New large_file_parses_under_budget lib test generates a 1000-headline .org file with realistic shape (file-level :PROPERTIES:, per-task SCHEDULED + DEADLINE cookies, body content) and asserts the parse stays under 500 ms wall (debug-mode budget; real machines see low tens of ms). The number to watch: if this ever reports >100 ms in debug, it's a hint to look at parser allocation patterns before users with big vaults hit it.
What's still open in the v0.10.x patch arc:
- v0.10.3:
rrule_to_org_cookiehelper; writer emits both Org cookie +:RRULE:property; RRULE divergence detection on read-back; multi-day RRULE round-trip test; agenda-parity acceptance test (Phase 17 closer).
Test count: 616 across the workspace (up from 611). Five new tests:
malformed_file_pauses_then_recovers(vault_watcher_integration)external_custom_keyword_round_trips_through_orig_keyword(vault_watcher_integration)concurrent_atrium_and_external_edit_preserves_user_content_as_bak(vault_watcher_integration)external_file_removal_preserves_tasks_and_toasts(vault_watcher_integration)large_file_parses_under_budget(org/parse lib tests)
Schema unchanged at version 7. No new third-party dependencies.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.10.2.
The v0.10.0 first slice landed the watcher mechanics but kept the GTK binary running write-only. v0.10.1 takes the loop the rest of the way: a save in Doom Emacs against the configured vault now lands in Atrium's task list within ~250 ms, and the conflict / parse-fail surfaces show up as toasts. Plus a cleanup pass — one bug fix, one round of comment surgery, and the four-year-old #![allow(dead_code)] scaffolding around AtriumError finally earns its keep.
GUI wiring + VaultEvent channel. New atrium_org::spawn_vault_loop(root, pool) replaces the broken spawn_org_vault_with_watcher (which took a WorkerHandle that didn't exist at the natural call point — chicken-and-egg). The new shape returns (VaultConfig, VaultLoopHandle, events_rx): pass the VaultConfig into spawn_worker_with_vault so the worker boots with the writer half installed, then feed the resulting WorkerHandle into VaultLoopHandle::attach_watcher to finish the wiring. The events receiver carries VaultEvent notices the GUI bridges to AtriumWindow::show_toast. boot_data_layer switched to the new builder; the GTK binary boots with both halves of the loop wired and the toast bridge active when a vault-path GSetting is configured.
Conflict detection (spec §7.3.3 rule 5). The writer now stats the destination file before each atomic-overwrite. If the file's mtime isn't in RecentWrites — meaning Doom Emacs / vim-orgmode / any external editor touched it since Atrium's last self-write — the current contents snapshot to <file>.atrium.bak.<UTC-timestamp> first. The format is filesystem-safe (no colons), UTC, and sortable so multiple backups for the same file order chronologically when listed. Spec rule 5 — last-writer-wins by mtime, the loser is preserved — is now mechanically enforced; without this guard the sequence "Atrium GUI mutates DB at T1, user saves in Emacs at T1+50, writer flushes at T1+110" silently destroyed the user's external edit. A VaultEvent::ConflictBackup event surfaces the source / backup pair; the GTK binary toasts it.
Sidecar config (Phase 16 carryover). New atrium-org/src/sidecar.rs ships <vault>/.atrium/config.toml with tag colours round-tripped to disk. Hand-rolled minimal TOML (no toml crate dependency — same ethos as the hand-rolled Org parser; the schema is small enough that a focused emitter / parser beats fighting a full-toml AST). The vault writer refreshes the sidecar at the end of every flush burst that touches tag state and skips the IO when content is unchanged via a last_sidecar cache. Mode and saved-perspective slots are reserved (the file always emits the section headers so Emacs-side tools see the shape) but not yet written — mode lives in GSettings (only the GTK binary knows it), and perspectives need a paired importer.
Worker domain invariants. DomainError was a four-year-old placeholder with one unconstructed Invariant(String) variant. v0.10.1 gives it real, enforced rules:
ParentProjectMismatch— the schema's FK ensures a subtask'sparent_idexists, but can't express "lives in the same project as the subtask itself." The worker checks before insert increate_taskand catches the move-orphans-parent case inupdate_task. Subtask hierarchies must stay within a project.EmptyFilterExpr— perspectives with a blank filter have no rows; rejected increate_perspective+update_perspectiveso the GUI editor surfaces the failure rather than producing a no-op sidebar entry.
DbError gained #[from] DomainError so domain rejections flow through the existing Result<_, DbError> API. The UiError + AtriumError types in the GTK binary lost their #![allow(dead_code)] lid; UiError::VaultPathInvalid is now constructed when the user's vault-path GSetting points at an uncreatable directory, and boot_data_layer returns Result<BootedDataLayer, AtriumError> instead of anyhow::Result.
Bug fix — flatten_one recursion. The v0.10.0 vault watcher silently dropped TODOs nested under non-keyword headings:
* Backlog
** TODO Real task
Real task would never land in the DB on external sync — the watcher's flatten_one bailed on the first non-keyword headline and returned without visiting children. The importer (org/import.rs::import_task) always handled this correctly per spec §7.3.1 ("project sub-headings are organisational, not structural"); the watcher now matches. Pinned by a new external_add_under_subheading_creates_db_task integration test.
Comment audit. Six doc-comment sites carrying band-aid framing from earlier patch arcs (atrium-core/src/db/command.rs, atrium-org/src/org/{mod,parse,import,write}.rs, atrium-org/src/vault_watcher.rs) were rewritten. The rule: state the current behaviour, name any genuine constraint, point at the open roadmap item by section. No more "lands in v0.7.X" / "for now" / "follows in" voice.
What's still deferred in the v0.10.x patch arc per the Phase 17 roadmap entry:
- v0.10.2: malformed-file pause/resume — repeated parse failures on the same file pause sync for that file (current behaviour: warn + drop event, retry on next event).
- v0.10.3: RRULE divergence detection on read-back; agenda-parity acceptance test gating the v0.10.x → v0.11.0 close.
Test count: 611 across the workspace (up from 590), all green: 5 worker-domain tests, 1 watcher integration regression, 3 conflict-detection unit/integration tests, 8 sidecar lib tests + 1 integration test, plus the new spawn_vault_loop end-to-end. Schema unchanged at version 7. No new third-party dependencies.
VERSION + Cargo.toml + spec.md + roadmap.md + patchnotes.md + README.md + CLAUDE.md + AppStream metainfo bumped to 0.10.1.
The DB → vault direction has been live since v0.7.16 / Phase 16. v0.10.0 closes the loop: edits made in Emacs / Doom / vim-orgmode against the configured vault flow back into the SQLite store within ~250 ms.
The watcher. New atrium-org/src/vault_watcher.rs hosts a tokio task that pairs with the existing VaultWriter. It uses the notify crate (sign-off granted; v8.x; the canonical Rust file-watcher used by watchexec / cargo-watch) to subscribe to .org create / modify / delete events under the vault root. Events debounce 200 ms keyed on file path (last-deadline-wins, matching the writer's pattern); after debounce the watcher parses the file through the existing parse_org_file_with_meta, computes a diff against current DB state, and submits writes through WorkerHandle.
The self-write filter. Without coordination, every write the writer emits would echo back through inotify and trigger a redundant read/diff cycle. New atrium-org/src/self_write.rs exposes RecentWrites, an Arc<RwLock<>>-shared set the writer pushes to and the watcher consults. The match is mtime-based exact tuple equality on (path, mtime_just_written), not a TTL window on path alone. The first design used path+TTL and the integration tests immediately surfaced the failure mode: an external edit happening within the TTL window after Atrium's own write got swallowed because the writer's record was still "recent" when the watcher's debounce fired. mtime-based matching is exact — Linux ext4 stores nanosecond mtimes so two distinct writes never collide; Atrium-from-Atrium echoes match exactly; real external edits produce a different mtime and fall through. The TTL stays as a memory bound (2 seconds) but doesn't gate the match.
The diff. vault_watcher::diff_and_apply resolves the project by file-level :ID: (creating one if the file is new), snapshots current DB tasks for that project, and walks the parsed headline tree:
- Tasks present in parsed but missing in DB →
WorkerHandle::create_task. Headlines parsed without:ID:get a freshly-minted UUIDv4; the worker's autonotify_project_dirtyafter the create triggers the writer to rewrite the file with the now-stable property, and the self-write filter swallows the resulting inotify event. - Tasks present in DB but missing in parsed →
WorkerHandle::delete_task. - Tasks present in both →
WorkerHandle::update_taskfor any field difference (title, schedule, deadline, completed_at) plusWorkerHandle::set_task_tagsfor tag-set differences.
TaskUpdate.completed_at. Atrium previously had only toggle_complete (which stamps now()) for state transitions. The vault watcher needs to round-trip CLOSED: [2026-04-01 Wed 09:00] cookies verbatim — the source timestamp must land in the DB. New TaskUpdate.completed_at: Option<Option<DateTime<Utc>>> field + builder method; the worker SQL builder gained the matching branch. Some(None) clears (re-opens), Some(Some(when)) sets. Schema unchanged; no migration.
The wiring. New ergonomic builder atrium_org::spawn_org_vault_with_watcher(root, pool, worker_handle) spawns the writer + the watcher sharing one RecentWrites set, returning the VaultConfig ready to thread into spawn_worker_with_vault. The legacy spawn_org_vault (write-only — the v0.8.0 / v0.9.0 shape) stays available for callers that want write-only behaviour or just the writer half (tests).
Three integration tests at atrium-org/tests/vault_watcher_integration.rs pin the working slice end-to-end:
external_add_creates_db_task— append a new TODO headline to a vault file viafs::write; assert the DB has the new task and the rewritten file gained an:ID:property.external_edit_completes_db_task— flip TODO → DONE in the file; asserttask.completed_atlands.external_delete_removes_db_task— splice a headline out of the file; assert the matching DB row is gone.
What's deferred to the v0.10.x patch arc per the Phase 17 roadmap entry:
- v0.10.1: conflict detection (mtime race → loser preserved at
<file>.atrium.bak.<timestamp>); GUI wiring (spawn_vault_watcherfrom the GTK boot path). - v0.10.2: malformed-file pause/resume (parse error → pause that file, toast surfaced; auto-resume when it parses again).
- v0.10.3: RRULE divergence detection on read-back (per the canonicalisation contract spec §3.5 + roadmap Phase 17).
- v0.10.4: agenda-parity acceptance test gating the v0.10.x → v0.11.0 close.
Test count: 590 (up 8 — three integration tests + four RecentWrites unit tests + one watcher diff test bundled into the integration suite). Schema unchanged at version 7. New direct dependency: notify v8 in atrium-org (sign-off granted in this patch). Ship-gate runs in under 2 seconds.
VERSION + Cargo.toml + spec + roadmap + patchnotes + README + CLAUDE.md + AppStream metainfo bumped to 0.10.0.
The Phase 16 Org projection — parser, emitter, importer, vault writer task — moves out of atrium-core::sync into its own workspace crate, atrium-org. atrium-core stays Org-agnostic; the worker hooks into the projection through a new VaultDirtyNotifier trait. Workspace is now five crates (atrium-core, atrium-search, atrium-org, atrium-cli, atrium). Pre-Phase-17 housekeeping; no behaviour change, no schema change, test count unchanged at 582.
The split. What moved into atrium-org:
atrium-core/src/sync/org/{parse,emit,import,write}.rs→atrium-org/src/org/*. Same public API; the only path change for callers isatrium_core::sync::org::*→atrium_org::org::*.atrium-core/src/sync/vault_writer.rs→atrium-org/src/vault_writer.rs. Now uses anOrgVaultNotifierwrapper that implsatrium_core::VaultDirtyNotifier.atrium-core/tests/org_roundtrip.rs(+ the five fixture.orgfiles) →atrium-org/tests/. The Org-related worker_tests entries (import_org_file_*/import_org_directory_*/spawn_with_vault_writes_org_file_on_task_create) moved to a new integration testatrium-org/tests/worker_org_integration.rs.
What stayed in atrium-core:
atrium-core/src/sync/atomic.rs(write-temp + fsync + rename helper — generic, not Org-specific).atrium-core/src/sync/json.rs(lossless DB snapshot — works on any projection).
The trait abstraction. New atrium-core/src/db/vault_hook.rs exposes:
pub trait VaultDirtyNotifier: Send + Sync {
fn notify_project_dirty(&self, project_id: i64);
}
pub struct VaultConfig {
pub notifier: Arc<dyn VaultDirtyNotifier>,
}The atrium-core worker holds an Option<Arc<dyn VaultDirtyNotifier>> instead of a concrete mpsc::Sender<VaultWriteRequest>. atrium-org's OrgVaultNotifier wraps the sender and provides the impl. Ergonomic helper atrium_org::spawn_org_vault(root, pool) returns a ready-to-use VaultConfig so the GUI / CLI boot paths stay one-call.
Schema rule cleanup. atrium-core::db::migrations was pub(crate); promoted to pub so atrium-org's integration tests can reach in for fresh-DB setup without depending on atrium_core::db::open for every test fixture. Production code never calls migrations directly; db::open remains the public entry point.
Why now? Phase 17 (vault → DB inotify sync) is the next chunk of code, and it'll grow the projection layer further. Splitting the surface before that work starts keeps atrium-core's ~5K-line data layer focused on the worker / read pool / domain model, and gives atrium-org a clean home for the inotify watcher when it arrives.
The Phase 18 Todoist importer (when it lands) will follow the same shape: another sibling crate, depending on atrium-core, with its own write side. The architectural commitment that every non-GUI surface stays CLI-testable still holds — atrium-cli depends on atrium-org directly for the import org / export org / export json paths.
Workspace version bumped to 0.9.0 across Cargo.toml, VERSION, spec, roadmap, README, CLAUDE.md, AppStream metainfo. Schema version unchanged at 7. No new dependencies; atrium-org borrows from the same locked workspace set.
Phase 16 (Org-mode import + DB → vault writer) ships, capping the eleven-patch v0.7.6 → v0.7.18 build-out. The GTK binary, atrium-cli, and the hand-rolled atrium-core::sync::org parser/emitter let a user keep a vault at the configured path, edit tasks in Atrium, and have the .org files reflect the change inside ~150 ms — readable in stock org-agenda, Doom, or any other Org-aware tool. All Phase 16 roadmap bullets are now [x] except the deferred <vault>/.atrium/config.toml sidecar (Phase 17 follow-up).
The maintenance pass that release discipline requires of every major:
- Worker test split.
atrium-core/src/db/worker.rs(2622 lines, half tests) split intoworker.rs(1469 lines, source only) andworker_tests.rs(1161 lines) loaded via#[cfg(test)] #[path = "worker_tests.rs"] mod tests;. Same coverage; tractable file size. - Dead-code prune in the Org writer.
build_org_treecarried aHashMap<i64, usize>populated then discarded withlet _ = by_index;— scaffolding from the v0.7.10 iteration. Removed. - Comment audit. Bulk pass across
atrium-core/src/syncandatrium-core/src/dbreduced per-patch// v0.7.X — …markers from 74 → 26. The survivors flag load-bearing context (additive migrations, spec rules, schema columns); the rest were navigation noise.
Four-doc sweep landed on spec.md, roadmap.md, patchnotes.md, README, CLAUDE.md, and the AppStream metainfo. Schema unchanged at version 7; no new dependencies; 582 tests, all green.
Phase 17 (vault → DB inotify sync) is next.
The GTK binary now reads the vault-path GSettings key on boot and routes through spawn_worker_with_vault when the key is non-empty, closing the loop opened by v0.7.16's auto-debounced worker write hook. Until v0.7.18, no GUI caller was passing a VaultConfig — every DB write needed atrium-cli to flush.
boot_data_layer builds the ReadPool first (the VaultConfig needs it), reads vault-path via gio::Settings::new(APP_ID), and either passes Some(VaultConfig) (auto-creating the directory if missing) or None (DB-only mode, current behaviour). Misconfigured paths log a tracing::warn! and fall through to None so the app never refuses to start over a vault config error.
atrium-core re-exports VaultConfig + spawn_with_vault as spawn_worker_with_vault from the crate root so callers don't dive into the worker module path.
A graphical Settings → Org Vault → Choose folder UI to manage the key is deferred to Phase 19.5's AdwPreferencesWindow. Until then: gsettings set io.github.virinvictus.atrium vault-path /path/to/vault.
Pure additive change: no schema, no dependency changes, 582 tests still green.
The Phase 16 roadmap requirement: "import → export → diff = empty (modulo whitespace and section ordering)." atrium-core/tests/org_roundtrip.rs delivers it across five fixtures at atrium-core/tests/fixtures/org/:
kitchen_sink.org— every spec §7.3 feature mixed (TODO/DONE/CANCELLED keywords, SCHEDULED/DEADLINE/CLOSED with repeaters, headline tags,:PROPERTIES:drawer, body with bullets, nested subtasks, file-level metadata).custom_keywords.org— WAITING / BLOCKED / IN-PROGRESS preservation viaorig_keyword.deep_nesting.org— 4+ levels of subtask hierarchy.project_metadata.org— file-level#+TITLE:+:PROPERTIES:block with:SEQUENTIAL:/:REVIEW_INTERVAL:/:LAST_REVIEWED:.unicode.org— CJK, Cyrillic, emoji, accented Latin.
Each test imports the fixture through the worker, exports back to a fresh path, parses both source and regenerated, and asserts AST equality on a paired-normalised shape. Normalisation strips fields that intentionally don't preserve (:CREATED: / :MODIFIED: — schema-auto-stamped; round-trip-added :ID: per §7.3.3 rule 2; tag order — sets, not lists). Strict on title, keyword (incl. custom), tags content, cookie dates, property values, body, subtask hierarchy, and file-level metadata.
The fixture surfaced two real importer gaps:
-
NewTask.completed_at: Option<DateTime<Utc>>— previously the DONE/CANCELLED path calledtoggle_completeafter create, stampingnow()instead of the source CLOSED cookie's timestamp. The importer now threadsorg.closeddirectly intoNewTask.completed_at. Toggle still fires when the source had a TODO/DONE/CANCELLED keyword but no CLOSED cookie. AllNewTaskcall sites updated (atrium-clirun_add, the worker's repeating-task respawn, the GUI undo restore — undo now preserves the original completion timestamp too). -
CANCELLED via
orig_keyword— Atrium's domain has TODO/DONE only;completed_atdoesn't distinguish "completed normally" from "cancelled." v0.7.12'sorig_keywordfor non-canonical keywords (WAITING etc.) now also stashes CANCELLED. The writer's orig-keyword-first lookup picks it up automatically and round-trip preserves the keyword exactly.
Every Task / Project write through the SQLite worker now triggers a background rewrite of the affected project's .org file in the configured vault. Atrium and Emacs can run side-by-side against the same vault and stay in sync (DB → vault direction; vault → DB is Phase 17's inotify watcher).
atrium-core::sync::vault_writer — new module hosting the
background writer:
VaultWriteRequest::ProjectDirty(i64)is the request type.VaultWriterowns the vault root + aReadPool+ apending: HashMap<i64, Instant>keyed by project_id where the value is the deadline after which the project should be flushed.run()is atokio::select!loop: receive requests + tick on a 50ms interval. Receiving extends a project's deadline by 100ms (last-deadline-wins coalescing); the tick flushes any project past its deadline.spawn_vault_writer(root, pool)spins up the task and returns the request sender.
Latency: ~150 ms (debounce 100ms + tick 50ms) from a DB
write landing to the corresponding .org file appearing.
Below human-perceptible threshold.
Worker integration. New spawn_with_vault(conn, vault: Option<VaultConfig>) entry point alongside the existing
spawn. VaultConfig { root: PathBuf, read_pool: ReadPool }.
The worker stashes a vault_tx: Option<mpsc::Sender<VaultWriteRequest>>
internally; a notify_project_dirty(project_id) helper
non-blockingly try_sends through it (full channel → drop,
not block — under absurd load the worst case is one stale
vault file). spawn(conn) becomes a thin wrapper that
delegates with vault: None, so atrium-cli and tests stay
unchanged.
Dispatch sites. Every Worker command that mutates a
project's task set or project metadata now calls
notify_project_dirty:
CreateTask/UpdateTask/ToggleComplete—task.project_idDeleteTask— captures the project_id BEFORE deleting (since the row goes away)CreateProject/UpdateProject/ArchiveProject/MarkReviewed— the project's idMarkTaskReviewed—task.project_idSetTaskTags—task.project_id
Architecture choices documented in the module doc: why a separate task (single-writer SQLite discipline; vault writes shouldn't block command processing on large projects); why debounce inside the writer (keeps worker dispatch sites trivial); why mpsc instead of broadcast (single consumer + overflow tolerable).
Tests:
vault_writer_emits_project_file_on_dirty_request— the isolated writer task: send a request, wait, verify file appears.vault_writer_debounces_burst_into_one_write— 5 rapid requests over 50ms collapse into one final write.spawn_with_vault_writes_org_file_on_task_create— the end-to-end story: spawn the worker with a vault, create a task, the file lands automatically.
What's NOT in v0.7.16 (deferred to v0.8.0's maintenance
pass): GUI integration with the GSettings vault-path key
(the worker accepts a vault config but no caller passes one
yet — atrium-cli stays unchanged, the GTK binary still uses
the no-vault spawn); rollback to .atrium.bak.<timestamp>
on integrity failure (v0.7.15's Err return is the
detection layer; the recovery layer needs the v0.7.16 hook
to make decisions on, which it now has).
With the importer + writer +
multi-file walk in place, every vault write now goes through a
post-write parse-back assertion: the file we just wrote must
re-read cleanly through Atrium's own parser. If it doesn't, the
emit returns an io::Error::Other describing the divergence,
and a tracing::warn lands in the log so the failure is visible
even when the caller swallows the error.
emit_org_file_with_meta now calls verify_emitted_file
after the atomic rename. The verification path:
- Re-read the just-written file from disk.
- Parse it via
parse_org_file_with_meta. - On success →
Ok(()). On any read or parse error →Err(io::Error::Other)with the underlying error wrapped + atracing::warnevent.
The hand-rolled parser is intentionally permissive — anything it
doesn't recognise lands in body or unknown_lines — so "rejects"
in practice means an io::Error from the read itself (e.g. the
file mysteriously vanished mid-write, or the user hit a
permission flip on the parent directory). It's the minimum bar
the spec calls for: "newly-written file parses cleanly with
Atrium's own reader."
Rollback to .atrium.bak.<timestamp> is the second half of
the spec rule (§7.3.3 rule 5: "Conflicts are surfaced, not
silenced"). It defers to v0.7.16+ alongside the auto-debounced
worker write hook, since both paths need to know how to recover
gracefully — preserving the previous file content before the
atomic rename + writing it back to a .bak on integrity
failure is a meaningful infrastructure piece on its own.
v0.7.15's Err return lets callers (the v0.7.16 worker hook) make
that decision.
With v0.7.6 → v0.7.13 in
place, Atrium can round-trip a single .org file through the
DB. v0.7.14 lifts the importer to the vault-as-directory level
so users can pull an entire ~/Tasks/ into Atrium with one
command.
WorkerHandle::ensure_area (mirror of ensure_tag).
Command::EnsureArea { name, responder } + an idempotent
inner helper. Probes the area table for a row whose title
matches name case-insensitively (the area.title column
isn't NOCASE-collated, so the match runs at the query level
via LOWER(title) = LOWER(?1)); returns the existing row when
found, creates a new one otherwise. Used by the multi-file
importer to map vault subdirectories onto Atrium areas
without duplicating existing rows on re-import. Test covers
case-insensitive dedup + creation of distinct names.
import_org_file_with_area. v0.7.9's import_org_file
becomes a thin wrapper that delegates with area_id = None;
the new _with_area form accepts an Option<i64> so the
directory walker can file projects under their resolved area.
import_org_directory(handle, vault_root, dry_run) -> Vec<ImportSummary>. Walks the vault root:
- Files at
<vault_root>/<project>.org→ unfiled Project. - Files at
<vault_root>/<area>/<project>.org→ Project filed under Area<area>(created via ensure_area when absent). - Skips dot-prefixed entries (
.atrium/,.git/, hidden temp files) for safety. - Skips non-
.orgfiles silently. - Sub-directories nested deeper than one level get a warning and skip — spec §7.3.1 has exactly one level of areas.
Returns one ImportSummary per imported file plus a synthetic
trailing summary for stragglers when only-skipped warnings
need a home.
atrium-cli routing. run_import stats the path and routes
file → import_org_file, directory →
import_org_directory. New print_import_directory_summary
aggregates counts across files for the human banner +
expands per-project detail underneath. --json output for
scripts.
End-to-end smoke verified manually: a 3-file vault
(Inbox.org at root, Personal/Errands.org, Work/Q3.org)
imports into 3 projects, 2 areas (auto-created), 2 tags
(auto-created via ensure_tag); atrium-cli list projects
renders the hierarchy as Personal › Errands and Work › Q3.
v0.7.12 closed the per-task half of the round-trip discipline; v0.7.13 closes the per-project half. With both in place, an .org file's preamble
- headlines + drawer entries all survive a vault → Atrium → vault round-trip cleanly.
Parser additions. parse.rs gains an additive
parse_org_text_with_meta / parse_org_file_with_meta pair
that returns an OrgFile { directives, file_properties, headlines } instead of just a Vec<OrgTask>. The legacy
parse_org_text / parse_org_file keep their shape (call the
with-meta path and discard the preamble) so existing callers
don't break. Directives keys are upper-cased on parse for
case-insensitive lookups (#+title: and #+TITLE: both
produce the key "TITLE"). The :PROPERTIES: state machine
now distinguishes file-level (no current headline) from
headline-attached drawers; the former lands in
file_properties, the latter stays on the OrgTask.
Emitter additions. emit.rs gains
emit_org_text_with_meta / emit_org_file_with_meta that
takes the OrgFile shape. Directives sorted before emit so
HashMap iteration order can't perturb round-trips. A blank
line separates preamble from the first headline only when both
exist.
Importer threading. import_org_file reads #+TITLE:
(falls back to the file stem) and the file-level :PROPERTIES:
drawer for :ID: / :SEQUENTIAL: / :REVIEW_INTERVAL: /
:LAST_REVIEWED: / :ARCHIVED:. NewProject grows additive
last_reviewed_at and archived_at fields (Option)
to receive the imported values. The worker's create_project
SQL extends to include the two columns.
Writer threading. write_project_to_vault now builds an
OrgFile with #+TITLE: directive + a file-level :PROPERTIES:
block carrying every project metadata field that's set,
emitted via emit_org_file_with_meta. Project-level fields
that are NULL / default don't emit, keeping clean projects'
preambles minimal.
Round-trip test (project_metadata_round_trips_through_db)
imports a vault file with full project metadata, verifies the
DB row carries the expected values, exports back, and asserts
the regenerated file's preamble matches the source's project-
level fields. With this in place, projects round-trip cleanly
without losing project-scope flags.
Closes the loop on spec §7.3.3 rule 1 — "Custom keywords map to a sentinel state on import; the original is stashed in :ORIG_KEYWORD: and restored on export" — at the data-model level rather than as a generic property string in the .org file.
Migration 0007_task_orig_keyword.sql adds a task.orig_keyword
TEXT NULL column. user_version 6 → 7. Existing tasks default
NULL = "no custom keyword recorded." v0.7.11 binaries reading a
v0.7.12 DB ignore the column.
Domain Task + NewTask gain orig_keyword: Option<String>.
Threaded through the read mapper, the worker INSERT, and every
NewTask / Task literal site (test_support, worker.rs's repeating-
task respawn, atrium-cli's run_add, atrium/src/ui/window.rs's
undo restore). Repeating-task respawn carries the value forward
so a WAITING task that completes still respawns as WAITING.
Importer maps OrgKeyword::Custom(name) → orig_keyword = Some(name) + canonical TODO sentinel. No more lossy note;
the original is preserved in the DB.
Writer's task_to_org checks orig_keyword first when
choosing the headline keyword. Falls back to canonical TODO /
DONE based on completed_at when the column is NULL. Atrium's
UI never surfaces the column — completion semantics still flow
through completed_at alone.
Why a column instead of :ORIG_KEYWORD:? Atrium's task
model already has typed columns for everything else (tags,
defer, repeat, etc.); a generic property bag would be
out-of-character. The column is purely a round-trip anchor; if
a user removes the source vault file, the original keyword
still survives in the DB. The downside — a non-vault user
sees WAITING tasks rendered as TODO in Atrium's UI — is
intentional: Atrium's three canonical states are the surface
contract; the orig_keyword is upstream interop.
End-to-end test (custom_keyword_round_trips_through_db)
imports a file with WAITING, IN-PROGRESS, and TODO
headlines; exports the resulting DB; the regenerated file's
keyword sequence matches the source. Without this column the
test would fail with three TODO headlines.
The Org vault projection (v0.7.6 → v0.7.10) is interoperable with Emacs / vim-orgmode but lossy on constructs Atrium doesn't fully model (custom keywords fold to TODO; project sub-headings drop through the writer; etc.). The roadmap explicitly calls for a complementary lossless format: "Atrium native JSON export ships in this phase too — universal lossless backup format." v0.7.11 delivers it.
atrium-core::sync::json. New module. Top-level
[Snapshot] struct holds a Vec<T> per domain table:
areas / projects / headings / tasks / tags /
task_tags (as (task_id, tag_id) pairs) / perspectives.
Plus metadata: version ("1" for the v0.7.11 schema),
exported_at UTC timestamp, atrium_version (CARGO_PKG_VERSION).
Every domain type already derives Serialize / Deserialize so
the serializer is mostly composition.
build_snapshot(conn) reads every relevant table; uses
list_all_projects (a new additive read primitive that
includes archived projects, unlike the active-only
list_projects) so the backup is complete. New read
primitives list_headings and list_task_tags cover the
remaining tables.
export_db_to_json_text(conn) returns pretty-printed JSON.
export_db_to_json_file(conn, path) goes through the v0.7.6
write_atomic helper so a crash mid-write leaves any
previous backup intact.
atrium-cli export json PATH [--dry-run]. New export
target. Mirrors the export org shape: dry-run reports the
snapshot dimensions (counts per table) without writing; real
mode writes a single .json file at PATH. Output: human
(default) or --json (machine-readable summary).
Re-import is deferred — the use case is restore-from- backup, not a hot path. A snapshot → DB importer can land when there's a concrete need (cross-version migration, etc.).
DbError::Sync(String) variant added for serialization-
layer failures. Currently only the JSON exporter touches it
(serde_json failures, vanishingly rare).
v0.7.9 gave us the importer (Org → DB); v0.7.10 lands the writer (DB → Org) so users can round-trip in both directions. With this patch, an Atrium DB can be projected to a vault directory, edited with Emacs / vim- orgmode / any Org tool, and re-imported — the round-trip discipline holds for every spec §7.3 construct already covered by the importer.
atrium-core::sync::org::write::write_project_to_vault.
Reads a project + every task in it (open + done) + tag names
through a read-only Connection, builds an OrgTask tree
mirroring spec §7.3.2's field mapping in reverse:
- Task title → headline text
- Task note → body verbatim
- Task tags → headline
:tag1:tag2: - completed_at present → DONE keyword + CLOSED cookie
- completed_at None → TODO keyword
- scheduled_for / deadline → SCHEDULED / DEADLINE cookies
- task.uuid →
:ID:property - repeat_rule →
:RRULE:property - estimated_minutes →
:EFFORT:H:MM - defer_until →
:DEFER_UNTIL:YYYY-MM-DD - parent_id chain → nested headlines (depth = parent.depth + 1)
The destination path is <vault_root>/<area_title>/<project_title>.org
(or <vault_root>/<project_title>.org for unfiled projects).
Filename sanitization replaces filesystem-hostile chars with
_ and collapses runs; empty / all-bad titles default to
"untitled". Emit goes through the v0.7.8 emit_org_file →
v0.7.6 write_atomic so a crash mid-write leaves the previous
file intact (spec §7.3.3 rule 6).
write_all_projects_to_vault walks list_projects and
calls write_project_to_vault for each. Used by the new CLI.
New read primitive list_all_in_project. The existing
list_project filters completed_at IS NULL; for the writer we
need open + done so the projected file reflects the full
project state. Additive — doesn't change the existing read API.
atrium-cli export org PATH [--dry-run]. New subcommand
parsed via args::parse_export. Walks every project in the DB
and writes one .org file per project under PATH. Dry-run mode
walks the project list and prints what would be written
without touching disk. Output: human (default) or --json
(machine-readable summary with per-project counts + paths).
Limitations consciously deferred to v0.7.11+: Project
sub-headings (the heading table) aren't emitted yet — they
round-trip as the importer's headings_skipped count grows on
each cycle. Custom keywords (WAITING, etc.) round-trip back
to TODO; the :ORIG_KEYWORD: machinery follows. File-level
project metadata (#+TITLE:, :SEQUENTIAL:,
:REVIEW_INTERVAL:, :LAST_REVIEWED:, :ARCHIVED:) not yet
emitted. Auto-debounced worker write hook (Atrium → vault on
TaskChanges) lands as a separate patch.
v0.7.6–v0.7.8 gave us the
foundation, parser, and emitter; v0.7.9 lands the one-shot
importer that lets users pull an existing .org file into the DB
through atrium-cli.
NewTask.uuid / NewProject.uuid (additive). Both creator
structs gain an Option<String> UUID field. None (and empty
strings) keep the historical "worker generates a fresh v4"
behaviour; Some(s) lets the importer preserve :ID: from the
source vault file (spec §7.3.3 rule 2: ":ID: is the round-trip
anchor"). All existing call sites updated. Three new worker
tests cover the round-trip + the empty-string fallback.
atrium-core::sync::org::import_org_file. Parses the file
through parse_org_file, derives the project title from the
file stem, and walks the headline tree creating tasks via the
worker. Field mapping per spec §7.3.2:
- Headline → Task.title
- Headline
:tags:→ Atrium tags viaensure_tag(idempotent), attached viaset_task_tags - Body → Task.note (verbatim)
- TODO / DONE / CANCELLED → keyword (DONE/CANCELLED toggled
via
toggle_completeafter create) - Custom keywords → folded to TODO with a lossy note
- SCHEDULED →
scheduled_for, DEADLINE →deadline :ID:→Task.uuid:RRULE:→Task.repeat_rule(verbatim):EFFORT:(H:MMorHh[Mm]form) →estimated_minutes:DEFER_UNTIL:→defer_until- Children → tasks with
parent_idset
Dry-run mode. import_org_file(handle, path, dry_run=true)
walks the parse tree and tallies what would be created
without touching the DB. The atrium-cli surface is
atrium-cli import org PATH --dry-run.
Limitations consciously deferred: project sub-headings
(headlines without a TODO keyword) skipped and counted in
headings_skipped — heading-table writes follow in v0.7.10+.
DONE / CANCELLED tasks have completed_at = now(), not the
CLOSED cookie's timestamp — surfaced as a lossy note. Repeater
suffixes on SCHEDULED / DEADLINE recorded in the parsed tree
but not converted to RFC 5545 RRULE; use :RRULE: for canonical
round-trips. Multi-file vault walk lands in v0.7.10+. Re-imports
always create new rows; full bidirectional sync (Phase 17) adds
upsert-by-:ID:.
atrium-cli import org PATH [--dry-run]. New subcommand
parsed via args::parse_import, dispatched through the existing
worker-runtime helper. Output formats: human (default),
--json (machine-readable summary).
v0.7.6 + v0.7.7 gave us the foundation + the parser; v0.7.8 lands the emitter that pairs with it to satisfy spec §7.3.3's round-trip discipline. With both halves in place, Atrium can now read an Org vault file and write it back without losing or reordering the constructs the spec §7.3 mapping pins down.
atrium-core::sync::org::emit_org_text takes a &[OrgTask]
and returns the Org text. Per-task layout:
- Headline:
*× depth +KEYWORD(if any) + title +:tag1:tag2:(if tags). - Cookie line below the headline (only when at least one of
scheduled / deadline / closed is set): SCHEDULED/DEADLINE
rendered as active timestamps (
<YYYY-MM-DD Day [+repeater]>) joined by single spaces; CLOSED rendered as inactive ([YYYY-MM-DD Day HH:MM], with the time elided when it's the parser's noon-UTC default — matches Emacs's "date-only CLOSED" shape). :PROPERTIES:drawer (only when there are properties or unknown_lines): keys emitted in sorted order soHashMapiteration randomness can't perturb round-trips. Empty values emit as bare:KEY:per Org's canonical form.- Body preserved verbatim from
OrgTask::body; trailing newline added on emit (parser strips it on read). - Children rendered recursively at depth+1 immediately after the parent's body.
atrium-core::sync::org::emit_org_file wraps the text emit
through the v0.7.6 write_atomic helper, satisfying spec
§7.3.3 rule 6. A crash mid-write leaves the previous version of
the file intact.
Round-trip discipline. 13 dedicated roundtrip_* tests
parse a representative input, emit it, re-parse, and assert the
two parsed trees are equal. Coverage spans every spec §7.3
construct: simple TODO, DONE+CLOSED, scheduled+deadline,
all three repeater modes (+1d, ++1w, .+2w), headline
tags, properties drawer, body verbatim preservation, nested
subtasks, project sub-headings (no keyword), custom keywords
(WAITING), unknown-lines preservation inside the drawer, and a
kitchen-sink test combining everything in one document.
v0.7.6 laid the foundation (sync module + atomic write + GSettings); v0.7.7 lands the parser that everything from here on builds on. No third-party dep — the v0.7.6 dep-research established that orgize and starsector were both too dormant to take, and a focused passthrough parser fits the use-case better anyway.
atrium-core::sync::org::parse_org_text / parse_org_file.
Reads Org text → Vec<OrgTask> tree. Coverage matches spec §7.3:
- Headlines
*+ [KEYWORD ]title [:tag1:tag2:]. Stars give the depth;KEYWORDrecognised as TODO / DONE / CANCELLED, with custom keywords (e.g.WAITING) preserved verbatim underOrgKeyword::Custom. - Cookies on the line below a headline: SCHEDULED, DEADLINE
(active timestamps
<...>), and CLOSED (inactive[...]). All three can co-exist on one line. - Repeater suffixes on SCHEDULED / DEADLINE:
+1w,++1w,.+1wparsed intoOrgRepeater { mode, interval, unit }. :PROPERTIES:drawer with:KEY: valueentries until:END:. Keys preserve case. Garbage lines inside the drawer are preserved into the task'sunknown_linesfield for round-trip safety.- Headline tags
:foo:bar:validated for the canonical Org shape (rejects:foo bar:with whitespace inside). - Nested subtasks: depth-based tree resolution. Headlines without a TODO keyword become project sub-headings per spec §7.3.1; deeper headlines nest under their nearest shallower ancestor.
- Headline body — everything between cookies/properties and the next headline — captured verbatim. Source blocks, tables, custom drawers, latex, links: all flow through unchanged so v0.7.8's emitter can re-emit them without loss.
The "preserve unknown constructs verbatim" rule (spec §7.3.3
rule 1) is satisfied at two layers — body content stays
verbatim; properties drawer entries that don't pattern-match
land in OrgTask::unknown_lines for explicit round-trip.
Limitations consciously deferred to v0.7.8+: multi-line
property values, active-timestamp time-of-day (date-only matches
Atrium's scheduled_for), file-level #+TITLE: capture (lands
when the importer needs the project title).
Pure additive change. No schema changes. No new dependencies.
The roadmap calls for Org-mode import + two-way vault sync, staged across v0.7.6 → v0.8.0 with each patch shippable on its own. v0.7.6 lands the foundation pieces that everything later builds on, plus the dep-research decision that reverses the original plan.
Org parser dep-research and the reversal. CLAUDE.md listed
orgize as a pending dep for Phase 16. The v0.7.6 survey turned
up two practical issues: orgize's last stable release (0.9.0,
November 2021) is four years old; the active line has been in
alpha (0.10.0-alpha.X) since November 2023. The obvious
alternative — starsector 1.0.1 — looked cleaner on paper
("structural parser/emitter with emphasis on avoiding edits
unrelated to changes") but its last release was October 2022 and
it pulls orgize-alpha as a transitive anyway. Conclusion:
hand-roll the Org subset Atrium needs, fitting the
CalibreQuarry stdlib-only ethos. The "preserve unknown
constructs verbatim" rule (spec §7.3.3 rule 1) is actually
easier in a focused passthrough parser: capture every
unrecognised line into the task's unknown_lines field and
re-emit verbatim on write. CLAUDE.md's dependency-discipline
section now records this decision so future passes don't
re-litigate it.
Sync module skeleton. atrium-core/src/sync/mod.rs
declares the module structure for the Phase 16 work coming in
v0.7.7+: atomic (write-temp + fsync + rename helper, lands
this patch) and org (hand-rolled parser + emitter, lands in
v0.7.7).
Atomic write helper. atrium-core/src/sync/atomic.rs
implements write_atomic(path, contents) -> io::Result<()> per
spec §7.3.3 rule 6: write to <path>.atrium.tmp in the same
directory, fsync, rename atomically. Best-effort cleanup of the
temp file on failure. Five tests cover the happy path,
overwrite, no-temp-file-leftover, and error cases (missing
parent dir, root path).
Vault-path GSettings key. New vault-path key in
data/io.github.virinvictus.atrium.gschema.xml, default empty
string (= "no vault configured"). Atrium runs DB-only when
unset, which is a valid configuration per spec §3.5. The proper
Settings → Org Vault → Choose folder UI lands in Phase 19.5;
v0.7.7+ patches will read the key directly via gio::Settings
when wiring the importer / writer / sync hook.
Test count: 119 + 174 + 1 + 106 + 106 = 506 (up 3 from v0.7.5's 503 — the new atomic-write tests). Pure additive change. No schema changes. No new dependencies.
The polish list deferred from v0.7.3 / v0.7.4 finally lands. Five items, all aimed at reducing remaining boxiness on the rows and panes after v0.7.0–v0.7.2 set the foundation.
Tag pill softening. The .atrium-task-tags chip retired the
visible bg-color (alpha(@accent_bg_color, 0.15)) in favour of
bare colored Pango spans. Each <span foreground=HEX>#tag</span>
still renders the per-tag colour for a row with multiple tags
the colors stack inline, reading as typography rather than as a
Bootstrap-style badge stuck onto the row. The .completed
override goes away (no more chip background to dim; the row's
existing opacity does the work).
Inspector empty state. The big AdwStatusPage with the edit-symbolic icon and "No task selected" / "Select a row to edit it here." was claiming the full pane during navigation; v0.7.5 swaps it for a small centred caption ("Select a task to edit it here.") near the top of the pane. The pane's atmospheric tint signals the inspector's home; the caption is just a hint.
Sidebar filter ghost. The "Filter lists…" search entry got
the same opacity-on-hover/focus treatment the v0.7.0 quick-add
introduced. New .atrium-filter-ghost class on the GtkSearchEntry,
mirroring .atrium-quick-add semantics — dim at rest, full on
:hover / :focus / :focus-within.
Row separator fade. The .atrium-task-listview > row
border-bottom alpha dropped 0.30 → 0.12. After the v0.7.0 row
margin bump (6 → 9 px) the separators were reading as ledger-
grid against the new whitespace; the lower alpha keeps a quiet
scan-tracking line without outshouting the spacing.
Sidebar selection soft-fill. The :selected state on
sidebar rows gained border-radius: 8px, an outline: none
override, and a 4 px horizontal margin so the rounded fill has
breathing room to bloom rather than clipping at the listbox
edge. Mirrors the v0.7.0 task-row selection treatment —
selection becomes a glow, not a flat-bottomed rectangle.
Pure CSS + small UI tweaks. No schema changes. No new dependencies. 503 tests still green.
The Review page's "This week" weekly walk shipped at v0.7.2 with no way to acknowledge an item — clicking through it revealed the gap. v0.7.4 closes it with a true Mark Reviewed action mirroring the Phase 13 project-level pattern.
Schema. Migration 0006_task_last_reviewed_at.sql adds an
additive task.last_reviewed_at TEXT NULL column. Mirror of
project.last_reviewed_at from migration 0001. Existing user
DBs migrate cleanly; user_version 5 → 6.
Worker. Command::MarkTaskReviewed { id, responder } +
WorkerHandle::mark_task_reviewed(id) mirror the project-side
wiring exactly. Handler runs UPDATE task SET last_reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?1, fetches
the updated row, emits TaskChanges{updated: vec![task]}. Two
new tests cover the round-trip and the not-found case.
UI. Each row in the Review page's "This week" section now
carries a trailing flat Mark Reviewed button (the agenda
row treatment stays exactly the same — it's wrapped in a
horizontal Box with the button as a sibling). Clicking the
button dispatches worker.mark_task_reviewed; the row drops
out via the TaskChanges-driven page rebuild.
apply_task_changes now routes Review the same way it routes
Forecast / Logbook / Agenda / Perspective — full page rebuild
on any delta.
Filter. refresh_review_page now excludes tasks whose
last_reviewed_at is within the last 7 days from today.
After 7 days the row resurfaces if it still matches the
weekly-walk filter. A small inline note above the section
("Mark items reviewed to hide them for 7 days.") tells users
what the button does.
Test count: 119 + 171 + 1 + 106 + 106 = 503 (up 2 from v0.7.3's 501 — the two MarkTaskReviewed worker tests). docs/schema.md picked up migration 0006 + the new column entry. Pure additive change. No spec semantics shifted; no new dependencies.
Two functional gaps Brandon caught after living with v0.7.2:
the inspector had no way to mark a task complete (he had to
bounce back to the row to click the checkbox), and there was
still no GUI path to add or edit a saved perspective (only the
shared rename/delete actions and the renderer-config dialog had
landed; creating a new perspective required atrium-cli).
Inspector check-off. A circular CheckButton now sits at the
leading edge of the inspector's title row, mirroring the row-
checkbox in the task list (same .selection-mode class). State
reflects task.completed_at; clicks dispatch through
worker.toggle_complete(id). A Cell<bool> latches the
persisted state so the worker round-tripping the toggle doesn't
ping-pong with the user click. Reachable while the inspector is
open without leaving the pane.
Perspective editor. A new prompt_edit_perspective dialog
covers all four perspective fields in one place: name, filter
expression, renderer (List / Board radios), and columns
(comma-separated tag names; sensitive only when Board is
selected). Used in two flows:
- Create. A "+" affordance trailing the Perspectives
sidebar section header opens the dialog in create mode. On
Save, dispatches
worker.create_perspective(NewPerspective{ name, filter_expr, renderer, renderer_config, .. }). - Edit. The right-click context menu on a perspective row
collapsed from three items (Rename / Configure renderer /
Delete) to two (Edit… / Delete). Edit opens the dialog
pre-filled with the existing values; on Save, dispatches a
full
worker.update_perspective(PerspectiveUpdate)covering name + filter + renderer + renderer_config in one round-trip.
The previous Rename + Configure renderer flows still exist as
plumbing (the win.rename-active and win.configure-renderer
actions are unchanged), they just no longer appear in the
perspective context menu — Edit subsumes both. Other surfaces
that fire win.rename-active against a perspective (none
currently) would still work.
Pure code patch. No schema changes, no new dependencies. Test count unchanged at 501. Ship-gate runs in under 2 seconds.
Brandon's after-v0.7.1 review of the Review page surfaced two problems we'd previously planned to fix in tier 3 of the v0.7 polish arc but hadn't gotten to: the canonical Review page and the seeded "Weekly Review" perspective both lived in the sidebar under almost the same name and showed completely different content (Review page: "All caught up"; Weekly Review perspective: a long list of tasks). And the upper-left corner still had the centered "Lists" header from libadwaita's default sidebar auto-title, which contradicted the magazine-spread treatment v0.7.0 introduced for the right side.
v0.7.2 fixes both:
-
Review = Weekly Review merge. The canonical Review page now renders two sections in one surface — "Projects to review" (the existing Phase 13 review queue) followed by "This week" (the open-tasks-this-week filter that was formerly seeded as a saved perspective). Both sections show inline notes when empty; the page falls back to "All caught up" only when both are empty. Section 2 reuses
agenda::build_rowfor visual consistency with the Agenda canonical page; clicking a row opens the Inspector for that task. The seeded "Weekly Review" perspective is retired (theseed_initial_perspectiveshelper, theWEEKLY_REVIEW_NAMEconstant, and the fourseed_weekly_review_*tests removed; the filter constant survives asREVIEW_WEEKLY_WALK_FILTER, used by the GUI's refresh path). Existing user DBs keep their row (we don't delete data); fresh DBs and fixtures land clean. -
Drop the "Lists" centered title. The sidebar's AdwHeaderBar now carries an empty AdwWindowTitle as its title-widget, suppressing the auto-rendered "Lists" label. The header becomes pure chrome (which is empty, since show-end-title-buttons=false), and the filter entry below acts as the sidebar's visual top. Mirrors the title-suppression on the content side from v0.7.0.
Pure code patch — no schema changes, no new dependencies, no spec semantics shifted. Roadmap: this is the tier-3 functional work from the v0.7 polish arc landing earlier than planned, at Brandon's call. The visual refinement (tag pills, inspector empty-state, filter ghost, row separators, sidebar selection softening) ships next as v0.7.3.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.7.2.
Brandon's first reaction to v0.7.0: the magazine-spread title landed, but the upper-left corner now showed visible "colour breaking" — distinct horizontal bands of tone where the headerbar, filter entry, and listbox met. Three things were stacking unhelpfully:
- The v0.6.10 standalone
headerbaraccent gradient — painted a leading-edge accent on every headerbar in the app, including the inner sidebar + content headerbars. - The libadwaita-default headerbar background — the inner headerbars had their own elevated bg-color sitting on top of whatever surface I'd painted underneath.
- The v0.7.0 surface gradients — applied only to the inner
widgets (
.navigation-sidebarlistbox,.atrium-inspector-panePreferencesPage), not to the headerbar / filter / scrolled-window above them. The atmospheric tint started mid-surface, leaving a visible band where it began.
v0.7.1 simplifies all three:
- Drop the v0.6.10 standalone headerbar gradient. Surface
gradients do the atmospheric work now; the headerbar layer was
redundant. Replaced with a scoped
.atrium-main-toolbar headerbar { background: transparent; box-shadow: none; }rule so the surface flows continuously behind the headerbars. - Replace the v0.7.0 directional surface gradients with a flat
per-pane tint. The 160deg / -20deg gradients were painting
banded tones across surfaces; the flat
background-color: alpha(@accent_color, 0.04)paints a uniform warmer tone across the whole sidebar / inspector. No bands. - Move the tint from the inner widget to the whole pane. Class
.atrium-sidebar-paneon the sidebar's AdwToolbarView (so the tint covers the headerbar area, the filter entry, and the listbox in one continuous fill); the inner widgets are made transparent so the parent's tint shows through. Same for.atrium-inspector-pane.
Net effect: the upper-left corner is no longer three stacked horizontal bands of slightly different tone. The sidebar reads as one continuous warmer surface from the top of the window down. Same for the inspector on the opposite side. The neutral content area in between is the calm centre.
Sacrifice: the directional gradient (warm at the corners, fading toward the centre) is gone. v0.7.0's "OF4-atmospheric" was ambitious; the flat tint is more "Things-3-calm" — uniform tone distinguishing the panes by hue rather than by gradient drama. The visible-banding cost wasn't worth the directional warmth.
Pure CSS + window.ui patch. No code changes. All 505 tests still green. VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.7.1.
The first big polish minor of the v0.7 line. Addresses Brandon's critical-eye review of the v0.6.21 screenshot: the app didn't feel "living" yet — accents had hard boundaries, the three panes were visually identical rectangles separated by 1 px verticals, selection states read as outlines instead of glows, and Linux-app disregard for whitespace had crept into the row rhythm and the inspector. Two tiers:
Tier 1 — Living surface (the fusion pass):
- Three-pane atmosphere. The sidebar's existing soft-accent
gradient bumps from 0.025 → 0.05 alpha; the inspector pane gains
a mirrored gradient on its leading edge (
-20degso the warm corner is on the opposite side). The two side panels now flank a neutral content area; the eye reads three connected spaces instead of one rectangle bisected by hard verticals.data/style.css. - Selection state on task rows is no longer a rectangle. The
default libadwaita selection paints a strong accent fill plus a
focus outline; combined with the area-stripe and the row
separator, selected rows looked like 1 px orange bordered boxes.
v0.7.0 ships a soft accent fill (alpha 0.14, no border, no
outline, rounded corners) — selection becomes a glow, not a
frame.
data/style.css. - Area accent moved from a 3 px hard left stripe to a row-wide
gradient bleed. The stripe approach made each row read as
"rectangle with stripe stuck on" — the eye saw the stripe as a
separate decorative element. The gradient (alpha 0.10 fading to
transparent at 40% width) makes the row read as area-tinted.
Six per-color rules updated; the reserved 3 px left-border on
.atrium-task-rowretired.data/style.css. - Sidebar section headers softened. The v0.3.0 treatment was
uppercase + tight tracking + a top-border divider — read as a
partition. v0.7.0 retires the all-caps and the divider for
medium weight, mixed case, breathing room above and below. The
headers introduce the rows that follow rather than separating
them from above.
data/style.css. - Quick-add entry as a ghost. The "Add task…" row at the
bottom of the list was always-visible and always-bordered. v0.7.0
dims it to ~0.45 opacity by default; hover or focus inside the
box brings it back to full presence with a 180 ms ease-out
transition.
data/window.ui+data/style.css.
Tier 2 — Whitespace pass (Brandon's specific call-out):
- Task-row vertical rhythm. Margin top + bottom 6 → 9 px on
every row. Things 3 / OmniFocus leave real air between rows;
Linux apps habitually do not. The change adds 6 px of total
vertical breathing per row without touching density on the row
content.
atrium/src/ui/task_list.rs. - Inspector pane field clustering. Was: Schedule + Deadline +
Project in one group, Tags alone in its own one-row group (an
orphan card the eye couldn't justify). Now: dates_group carries
only the date fields, and Project + Tags collapse into a new
Classify cluster — both fields answer the question "where does
this task live?" so the eye groups them naturally. Five visual
groups overall, none of them orphans.
atrium/src/ui/inspector_pane.rs. - Magazine-spread page title. "Today" (and every other view
name) was centered in the AdwHeaderBar — read as a tabular UI
heading, not a page title. v0.7.0 suppresses the auto-title in
the header bar and adds a strip below carrying the view name as
a large left-aligned heading + an optional supporting subtitle
beneath. The subtitle ships for Today (today's date in long
form), Upcoming ("Next 7 days"), and Forecast ("Next 30 days");
hidden on views without a useful subhead.
data/window.ui+atrium/src/ui/window.rs+data/style.css.
No schema changes. No new dependencies. All 505 tests still green; ship-gate runs in under 2 seconds.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.7.0.
Pure docs patch — bringing references that hadn't been touched in several minors back into alignment with the current state. No code touched. Ship-gate green.
The post-v0.4.0 release arc landed a lot in a hurry: the
search-engine extraction (atrium-search), the headless CLI
(atrium-cli), FTS5 ranking, the SQL-translation evaluator,
two new migrations (area.color + perspective.renderer /
renderer_config), the kanban renderer, the Agenda page, the
v0.6.x screenshot-driven cleanup arc, and the v0.6.19 / v0.6.20
roadmap revision. Several reference docs lagged behind. This
patch pulls them current.
README.md:
- Version badge
0.5.0→0.6.20. - "Both modes ship at v0.5.0." paragraph rewritten to "Both modes shipped early." with the current release noted.
CLAUDE.md:
- Status section: collapsed the "v0.6.0 carryover" framing (carryover is all shipped) and replaced it with three consolidated paragraphs walking the v0.5.0 → v0.5.4 search-engine arc, the v0.6.0 → v0.6.5 Slice D arc, the v0.6.6 → v0.6.10 perf / sidebar / soft-accent arc, and the v0.6.11 → v0.6.20 screenshot-cleanup + roadmap-revision arc.
- Authoritative documents:
roadmap.mddescription updated (now four sub-phases — 12.5, 15.5, 15.75, 19.5 — not three);patchnotes.mddescription updated ("v0.3.0 is the most recent release" → "v0.6.20 is the most recent release"). - Codebase map: header
v0.4.x→v0.6.20. Added the missing files:atrium-search/{dates,rank,sql_translate}.rs,atrium-core/{quick_entry,render}.rs, migrations0004_area_color.sql+0005_perspective_renderer.sql,atrium/src/ui/{agenda,board,logbook}.rs. Updated read.rs / command.rs descriptions to mention the surfaces added in v0.5.x and v0.6.x. Removed the liftedquickentry/parser.rsentry (parser moved toatrium-core::quick_entryat v0.4.5). - Test counts:
82 + 165 + 1 = 248 tests as of v0.4.0→119 + 173 + 1 + 106 + 106 = 505 tests as of v0.6.20.
docs/schema.md:
- Removed the "No mid-v0.1 schema changes" framing — the v0.1 freeze ended at v0.2.0.
- Added a migration-history table covering 0001 → 0005,
including the v0.5.0 additions (
area.color,perspective.renderer/renderer_config). - ER diagram: added
AREA.color,TASK.repeat_mode, the fullPERSPECTIVEentity (was missing entirely), and the saved-search relation. - Per-table rationale: added
repeat_modeto the task notes, added the missingperspectivesection, and addedcolorto the area section.
docs/perf-baseline.md:
- Refreshed the v0.0.28 capture against current binaries. Cold start: 30–40 ms / ~34 MB (was 25–33 ms / ~32 MB). Fixture generation across small / medium / large scales remains under 39 MB peak RSS at 50K tasks; the data layer is still nowhere near the §8 budget. Numbers within noise of the original capture despite four major arcs of feature work intervening.
- Note added: search-engine evolution did not regress the data-layer budget.
docs/regression.md:
- Step table: added the 5.5
atrium-cliend-to-end smoke (added at v0.5.x, grown through v0.6.x), with notes on what it covers — read paths over every canonical list, write paths over every CRUD subcommand, the kanban smoke against the fixture-seeded "Fixture Board" perspective, and the v0.6.5 perspective write-side smoke. - Cold-start observed numbers updated to match the refreshed perf baseline.
docs/keymap.md:
- Removed the "(view lands Phase 5)" suffixes — all six canonical lists shipped at v0.1.0.
- Added a note about Agenda / Forecast / Review joining the top-tier sidebar at v0.6.7 / v0.6.16 without dedicated number accels.
- Search-filter section rewritten — the flat AND-only
grammar grew into a full expression language at v0.4.0
/ v0.5.0; pointed at
spec.md§4.3 as the canonical reference and called out the?operator-reference popover. - Builder Mode chord table reframed — Builder Mode shipped
at v0.2.0 but the
Ctrl+Shift+F/Ctrl+P/Ctrl+Dchords are still aspirational slots (these features ship via the sidebar / Inspector today, not via accels).
docs/accessibility.md:
- Header note added: the Phase 8f findings cover the v0.1 surface area; the Builder Mode side pane, Forecast, Review, Perspectives, kanban renderer, and Agenda all inherit the same widget primitives but owe a full re-audit at the next minor.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.6.21.
Brandon course-corrected the original "read-only iCal calendar
feed" item that landed in v0.6.19's Phase 19.5 list. The right
integration model for a GNOME-native client running on Fedora
isn't a .ics file feed — it's reading the system's calendar
service.
GNOME 50's default calendar app (gnome-calendar) doesn't store
its own calendar data; it consumes Evolution Data Server (EDS),
the GNOME-wide calendar/contacts/tasks backend. The user has
already configured their accounts (Google, Nextcloud, local,
exchange-web-services, …) in EDS via GNOME Online Accounts. An
iCal-file feed would either duplicate that work or sit awkwardly
alongside it.
Updated framing: Atrium reads EDS via D-Bus and overlays calendar
events onto the Forecast / Today views as read-only context.
Endeavour does the same shape for tasks — Atrium does it for
calendars without becoming a calendar client. Dependency check
deferred: either libecal / libedataserver bindings or a
hand-rolled zbus D-Bus client. No .ics file plumbing.
Files touched:
roadmap.md— Phase 19.5 third item rewritten.spec.md— no change needed (it didn't reference the iCal framing; the calendar overlay isn't in the import / export table because it's not import / export — it's read-side display-only context).CLAUDE.md— "Phase 16 is what's next" paragraph item list updated.README.md— landing-paragraph item list updated.data/io.github.virinvictus.atrium.metainfo.xml— v0.6.19 release description updated to match.
Pure docs change; no code touched. Ship-gate green.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.6.20.
v0.6.19 (2026-05-08) — roadmap revision: drop Things 3, elevate Org-mode + Todoist, add Phase 19.5 (productivity essentials)
Pure docs change. Brandon commissioned a feature-survey pass against competing native-Linux + cross-platform todo apps to identify gaps in Atrium's roadmap. The findings drove a four-part revision.
1. Phase 16 (Things 3 Import) retired. .things JSON requires
a macOS export step Linux users don't have access to. As Brandon
put it: "how many people using GNOME are gonna be Things 3 users?"
Things 3 stays in the inspiration paragraph (Simple Mode's calm
- six-list shape comes from there) but the import phase goes
away. Same logic applied indirectly to OmniFocus — kept open as a
Phase 19 long-tail entry rather than its own phase, since
.ofocushas the same macOS-only access problem.
2. Org-mode promoted to Phase 16 + 17 (was 17 + 17.5). Brandon's
"MUST" interop direction. Atrium's vault is fully compatible with
Emacs / Doom / vim-orgmode out of the box: open the same
~/Tasks/ directory in org-agenda and the result should look
like Atrium's Agenda canonical page. The two-stage plan (one-shot
import + DB→vault writer at Phase 16; full two-way inotify sync
at Phase 17) stays, but the framing tightens to a single must-ship
goal and a new acceptance test pins the agenda parity (with a
synthesised vault, both Atrium's Agenda and M-x org-agenda
should bucket tasks the same way).
3. Todoist promoted to its own Phase 18. Was bundled into the Phase 19 long-tail. Brandon's gap-analysis prompt explicitly said "Todoist would be a good one" — its install base on Linux is real (web client + Linux Electron app) and CSV export is friction-free. Now first-class with its own phase. Phase 19 becomes the long-tail batch (Taskwarrior, VTODO, todo.txt, TaskPaper, OmniFocus).
4. Phase 19.5 added — productivity essentials. The gap-analysis surfaced nine items competing apps have that Atrium doesn't:
- System notifications / time-based reminders. Things 3 /
OmniFocus / Planify all push reminders via the system
notification daemon. Atrium has zero notification code
(
libnotify/gio::Notificationnot imported anywhere). For a productivity app this is the biggest 1.0 blocker. - Subtasks UI exposure.
parent_idhas been in the schema since0001_initial.sqlbut the GUI doesn't render the hierarchy. Schema-supported, UI-missing. - Evolution Data Server (EDS) calendar overlay — read-only.
Brandon course-corrected the original "iCal feed" framing:
Atrium is a GNOME-native client on a desktop that already
has a calendar service. EDS is the GNOME-wide
calendar/contacts/tasks backend that GNOME Calendar
(
gnome-calendar, default in GNOME 50) consumes; the user has already configured their accounts there. Read whatever EDS exposes via D-Bus and overlay events onto Forecast / Today. No.icsfile plumbing — that would duplicate what EDS already does properly. Endeavour does the same shape for tasks; Atrium does it for calendars without becoming a calendar client. AdwPreferencesWindow. No app-level preferences dialog exists; GSettings keys are set programmatically. Build one.- Task dependencies (
blocked_by). Taskwarrior treats this as fundamental. Newtask_dependencytable;is:availablepredicate extends to dependency-blocked tasks too. - Drag external files / URLs to capture. Standard Linux desktop pattern; explicit in Errands / Planify.
- Task templates. Reusable shapes (project + standard subtasks). Todoist; Org-mode capture templates as conceptual reference.
- First-run / onboarding. Sample tasks, welcome project, guided three-step intro. Standard commercial-app pattern.
- Backup / restore UI. SQLite file-copy is the existing escape hatch but no in-app affordance.
Each Phase 19.5 item names its source in roadmap.md.
Sources (read public README/docs/feature pages — no code copied):
- Errands — GTK4 / Python — subtasks, drag-drop, accent colors, CalDAV / Nextcloud sync.
- Planify — GTK4 / Vala — Todoist + Nextcloud + CalDAV sync, multi-reminder, attachments, recurring patterns.
- Endeavour — GTK4 / C — GNOME Online Accounts integration.
- Things 3 — macOS native — Today / This Evening / Upcoming / Anytime / Someday / Logbook canonical lists, magic plus button, calendar integration, share extensions, Things URL scheme, Siri / Shortcuts.
- OmniFocus 4 — macOS native — sequential vs parallel projects, Mail Drop, Omni Automation, web access, weekly review, focus mode.
- Taskwarrior — CLI — real task dependencies, virtual tags, urgency formula, UDA fields, hooks API, named dates, snooze.
- Todoist — cross-platform — natural language input, sub-tasks, sections, comments, file attachments, custom filters, list/board/calendar view toggle, templates.
- Super Productivity blog comparison piece — open-source productivity-app survey.
Files touched: roadmap.md (full Phase 16-19.5 rewrite),
spec.md (§7.1 import sources table cleaned, §7.4 Linux
landscape table updated, version line bumped), CLAUDE.md
("Phase 16 is what's next" line updated), README.md (landing
paragraph + Imports section + new Acknowledgments section).
No code changes. No tests touched.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.6.19.
Brandon asked for a top-to-bottom efficiency pass. After surveying
the codebase the honest answer is: Atrium is already pretty efficient
by construction (single-writer worker, read pool, prepared statements
via prepare_cached, WAL + tuned pragmas, cold start consistently
20–30 ms, ship-gate runs in under 2 seconds). The clippy pedantic
pass surfaced 250+ items but they're cosmetic — doc-markdown nits,
module-name-repetitions, etc. — not real efficiency wins.
The actual hot-path wins came from finishing two earlier deferrals plus eliminating one duplicate DB query:
-
List-renderer perspective path uses the SQL fast-path. v0.5.3 shipped the SQL translation evaluator and v0.6.6 wired it into the kanban refresh; the deferred case noted in the v0.5.3 patchnote was the regular list-renderer perspective path — saved Perspectives whose renderer is
"list". v0.6.18 wires the fast-path here too. Translatable filters (most:is:open,tag:work,due:today, …) load only matching rows from SQLite instead of pulling every task and filtering in Rust. At fixture scale (1k tasks) the win is measurable; at 10k+ it dominates. Untranslatable expressions (regex / fuzzy / compositeis:today/ etc.) keep the in-memoryfilter::applypath — no semantic change. -
Search-bar (SearchResults) path uses the SQL fast-path. Same shape. The bar fired
list_all_taskson every keystroke (after the 200ms debounce) when the parser successfully built an expression; now it fireslist_tasks_matchingwith the translatedWHEREclause instead. Same fallback behaviour for expressions the translator can't yet express. -
Eliminate duplicate tag-map DB query on perspective + search refresh. Both paths fetched
tag_names_per_taskandtag_info_per_taskback-to-back — same JOIN with one extra column on the second query. New helpercrate::ui::task_list::tag_names_from_pills(&TagPillMap) -> TagMapderives the name-only view from the colour-bearing pill map locally, so we fetch once and project twice. Saves one DB roundtrip per refresh.
What I deliberately didn't do:
-
Did not download other Rust to-do apps for inspiration. Brandon authorised it but the time cost is high and the marginal value is low — Atrium's architecture already follows the canonical patterns (worker queue, read pool, GtkListView factories with property bindings, FTS5 + bm25 ranking). The three wins above came from our own deferred work, not from external patterns. If a specific external technique becomes relevant later we can attribute it then.
-
Did not chase the 250+ pedantic clippy warnings. They're cosmetic —
doc-markdown,module-name-repetitions,must-use-candidate, etc. The standardcargo clippy --workspace --all-targets -- -D warningsis and stays clean. -
Did not refactor HashMap closure captures into
Rc<HashMap>. At our scale (typical user has < 200 areas + projects + tags combined) the per-refresh clones cost less than 1ms. The cleaner ownership model isn't worth the API churn until a real workload pushes back. -
Did not chase
LIKE %x%table scans. Bare-text matches in the SQL translator emitLOWER(t.title) LIKE %?% ESCAPE '\\', which can't use an index. At 100k tasks this would matter; at fixture scale (1k) it's ~5ms. The right answer is FTS5 for bare text — already used for bm25 ranking — but plumbing it through the translator is a bigger surgery best done when someone's actually feeling the pain.
Brandon flagged that clicking a task in the Forecast view did
nothing. The forecast row had a gtk::DragSource (so drag-to-
reschedule worked) but no gtk::GestureClick — the row was a
visual dead-end for tap-to-open.
v0.6.17 adds the same on_row_click callback shape board and
agenda already use. Single-click on any forecast row (including
the trailing rows under the Overdue card) activates
win.edit-details-for(id) and opens the task in the Inspector.
GTK4's drag-threshold means the click + drag controllers
coexist cleanly: a press that doesn't drift past the threshold
fires as a click; a press-and-drag past the threshold initiates
the reschedule drag.
What's in the patch:
atrium/src/ui/forecast.rs.build_page/build_overdue_block/build_day_card/build_entry_rowall gain anon_row_click: Fparameter (F: Fn(i64) + 'static + Clone). The callback plumbs frombuild_pagethrough the day cards down to each row'sGestureClick.atrium/src/ui/window.rs::refresh_forecast_page. Builds the closure with adowngraded window weak ref and routes throughWidgetExt::activate_action(window, "win.edit-details-for", id). Identical pattern to the board and agenda click handlers.
This closes the last "row doesn't open" gap I'm aware of — list / kanban / agenda / forecast all open Inspector on single-click now.
Brandon flagged that Logbook in the middle of the top-tier set (between Someday and Agenda) read as out of place — completed work was interrupting the flow of active / future-facing lists. v0.6.16 moves Logbook to the trailing slot so the past lives where the past belongs.
New top-tier order (both modes):
Inbox capture
Today today's plate
Upcoming future scheduled
Anytime no time commitment
Someday parked
Agenda now-picture across days
Forecast calendar projection (Builder-only)
Review project review queue (Builder-only)
Logbook completed past
The active/future-facing lists run unbroken from Inbox through Agenda; Builder mode inserts Forecast + Review without disturbing the bookends; Logbook closes the top tier so the sidebar reads as "now → future → past" top to bottom.
What's in the patch:
atrium/src/ui/window.rs. Logbook removed fromCANONICAL_LISTS(now five entries: Inbox / Today / Upcoming / Anytime / Someday).top_tier_extrasextended to always include Agenda + Logbook; Forecast + Review still gated on Builder mode and still sit between Agenda and Logbook.refresh_canonical_badgesupdated. Logbook's count badge moved from the canonical Vec to its ownlogbook_badge: Option<gtk::Label>cell; the refresher updates both. Therebuild_dynamic_sidebarloop captures the Logbook badge as it builds the top-tier rows so the count stays live acrossTaskChanges.- Three unit tests updated to match the new shape (
CANONICAL_LISTS.len()is 5, simple-mode extras are Agenda + Logbook, builder-mode extras are Agenda + Forecast + Review + Logbook).
CSS, behaviour, and badge tinting are unchanged — Logbook keeps
its .atrium-canonical-logbook purple-2 accent, just at a
different visual position.
Two real bugs Brandon surfaced testing v0.6.14:
-
Memory Watch dialog had no visible body / background. Labels appeared to float against the system desktop.
adw::Windowwith anAdwToolbarViewcontent slot doesn't auto-paint a window background on every theme — the toolbar's content slot is transparent and the window underneath wasn't rendering its@window_bg_color. CSS fix: explicitwindow.atrium-debug-pane { background-color: @window_bg_color }. The class was already on the window for the monospace font; we reused it. -
Debug menu's Generate Fixtures did nothing visible. The action handler was opening a fresh writable connection, writing rows directly, but never told the GUI to re-read. The worker's read pool kept showing the old cached state, so the sidebar / list looked exactly the same after the user clicked. Brandon worked around it by running fixture generation via the CLI, which spins a fresh process and starts cold. The fix is to queue a
rebuild_dynamic_sidebar+refresh_active_listafter the spawn_blocking write completes — the read pool then re-queries the DB and the new rows appear.
Code:
-
data/style.css— one CSS rule binds@window_bg_colorto the Memory Watch window class. -
atrium/src/ui/window.rs—rebuild_dynamic_sidebarwas private; promoted topubso the binary's debug action handler can call it. -
atrium/src/main.rs—install_fixture_actionrewritten. The DB write now runs viagio::spawn_blocking(off the main thread; ~30 ms small / ~150 ms medium so a UI freeze would be visible), and on completion the closure resumes on the GTK main thread to call the window's refresh methods. The previous code usedruntime().spawn(tokio) and tried to capture theadw::Application, which isn'tSend— the rewrite uses glib's main-context-local spawn which avoids the Send requirement entirely.
This closes the two bugs from the v0.6.14 screenshot. The soft-accent + screenshot-cleanup arc is still done; this is just the fixture/debug surface catching up.
The original Patch D was "day-band grouping in the main task list." Walking through the implementation surfaced a scope problem: the Today list is a single-day view by definition (every row would read "Today"), Logbook already has day-bands (Slice C2), and Agenda is the explicit "everything across days" view. Day-band grouping inside Today / Inbox / Anytime would duplicate Agenda; the only sensible target was Upcoming, which is a single-view scope rather than a main-list-wide change.
Reframed Patch D as two smaller polish wins that actually address what the screenshot showed:
-
Visible row separators.
GtkListView'sshow-separators=truewas on (window.ui) but the default separator on dark themes was so faint that 20+ rows read as a wall of text. v0.6.14 adds a 1px@borders-tinted bottom border to each task row (constrained by:has(.atrium-task-row)so kanban / agenda card rows don't inherit). The eye now has a clear stride between rows without the list looking like a heavy table. -
Recurrence icon (#9b). Tasks whose
repeat_ruleis set now show a smallview-refresh-symbolicicon at the right edge of the row, with a tooltip "Repeating task." The icon is a derived state cue — the original screenshot bug was the fixture shoving emoji into title strings (#9a, fixed in Patch A); the icon now reads correctness fromrepeat_ruleregardless of what the title says. Newrepeating: boolglib property onAtriumTask, computed at construction + onrefresh_from. The row factory appends agtk::Imageafter the deadline pill (preserves the existingnext_siblingchain so other bind logic stays unchanged) and toggles its visibility viaconnect_repeating_notify. Handler stashed underatrium-repeating-handlerand disconnected on unbind. The icon picks up the row-state tint when the task is overdue or today, matching the date-pill pattern from Patch B.
This closes the four-patch screenshot-cleanup arc:
- v0.6.11 Patch A — eight quick wins (eight files, low risk).
- v0.6.12 Patch B — state-aware row treatment (the biggest visual win; overdue red / today amber / upcoming accent).
- v0.6.13 Patch C — Inspector Notes placeholder.
- v0.6.14 Patch D — visible row separators + recurrence icon.
Small focused patch off the screenshot-cleanup arc. The Inspector pane's Notes field used to be a blank dark rectangle — first-run users had no way to know it was editable. v0.6.13 adds a placeholder hint that disappears the moment the user types.
GtkTextView doesn't have a native placeholder property the way
GtkEntry does, so the implementation is the standard GTK4 idiom:
overlay a GtkLabel (set to set_can_target(false) so clicks
pass through to the underlying TextView) inside a GtkOverlay
that wraps the TextView. The label's visibility tracks the
buffer's character count — visible when zero, hidden otherwise —
via connect_changed. The TextBuffer's autosave-on-focus-out
behaviour is unchanged.
Placeholder text reads "What / why / next step — autosaves on focus-out" so users who haven't read the docs (most of them, most of the time) understand both what kind of content belongs in the field and when their input will be saved.
The recurrence icon piece originally bundled with this patch
(#9b — derive an icon from repeat_rule) was deferred — issue
#9 was really about the fixture's emoji-prefixed titles, which
Patch A already fixed. The derived recurrence icon is a polish
"would be nice" rather than a screenshot-bug, so it can wait
for a real use case to push it.
Patch D (day-band grouping in the main task list — Today / Tomorrow / This Week / Later headers between rows) is the last one in the four-patch arc.
The biggest visual win in the screenshot-cleanup arc. Each row now classifies into one of three states based on its dates + completion state, and the leading checkbox + the right-hand schedule / deadline pills tint accordingly. The eye picks up "needs attention" without reading the dates.
States (mirrors the in-memory evaluator + agenda classify rules):
- Overdue — open AND deadline < today. Strong red on checkbox
- deadline pill. The eye doesn't get to look anywhere else.
- Today — open AND most-imminent date == today (where
most-imminent =
min(scheduled_for, deadline)). Warm amber. "What you said you'd do today." - Upcoming — open AND most-imminent date > today. Theme accent (blue by default) at lower alpha so the cue reads as quiet "on the way" rather than competing with the urgent states above.
- Neutral — no time anchor, completed, or scheduled-someday. No special tint; rows look as they did pre-v0.6.12.
Completed tasks (the existing .completed class) override the
state tints — a finished task should read as settled regardless
of when its deadline used to be.
What's in the patch:
atrium/src/ui/task_object.rs. Newrow_stateglib property onAtriumTask(""/"overdue"/"today"/"upcoming"). Newclassify_row_state(&Task) -> Stringfunction that walks the same rulesagenda::classifyuses. Bothfrom_task_with_tagsandrefresh_fromcall it so the property updates on every worker delta — a task whose deadline rolls past today flips state on the next refresh.atrium/src/ui/task_list.rs. Row factorybindadds the matching CSS class on initial bind, then aconnect_row_state_notifykeeps it in sync as the property mutates. Three classes (atrium-task-row-overdue/atrium-task-row-today/atrium-task-row-upcoming) are mutually exclusive — the factory drops all three before adding the current one. Handler stashed underatrium-row-state-handlerand disconnected on unbind.data/style.css. Three CSS rules per state, targetingcheckbutton check(the GtkCheckButton's checkmark) for the leading colour cue and.atrium-task-deadline/.atrium-task-schedulefor the date-pill colour. A fourth rule resets the colours when the row also has.completedso the strike-through treatment isn't fighting the state colour.
Patch C (Notes placeholder + derived recurrence icon) and Patch D (day-band grouping in the main task list) follow.
First patch off the screenshot-driven issue list logged in v0.6.10. Eight tightly-scoped low-risk fixes that ship together because each touches one file and the visual benefit is immediate. The harder items (state-aware row treatment, Notes placeholder, day-band grouping) follow in their own patches.
- Inspector "Defer until: Available now" → "Not deferred." "Available now" read as a status (every undeferred task is "available now"), not the date-shaped fact the row promises. The new copy treats the absence of a defer date as a date-shaped value.
- Inspector "Builder" subsection rename. The pane only renders in Builder Mode, so the "Fields exposed only in Builder Mode" subtitle was redundant noise. Title now reads Schedule depth; subtitle dropped.
- "Inbox" project chip suppressed on the Inbox view. Every row on that view is in Inbox by definition; the chip just duplicated what the page header said.
- Window title reflects the active view —
Atrium · Today/Atrium · Inbox/Atrium · Q3 plans. The window-level title shows in window managers, alt-tab overlays, and screencast picker UIs; the bareAtriumwas a brand sticker, not a context cue. - Fixture areas get colours from the six-swatch palette. Per-area accent stripes (Slice B2, v0.5.0) were invisible in
--fixture smallbecause no fixture area had a colour set. Now they cycle through the palette, demonstrating the feature without manual setup. - Fixture tags get colours from the same palette (staggered by one entry from areas). Pango-coloured tag pills (v0.3.0) had been monotone in screenshots because the fixture left every tag colour-less.
- Fixture cleanup: drop emoji prefixes on
Buy {item}/Reminder: …titles. Those characters were title text masquerading as derived state; a real "this is a recurring reminder" cue should come fromrepeat_rule, not a literal emoji in the title. (The derived recurrence-icon bit lands in Patch C.) AdwClampmax-content-size 720 → 960. Slice B1's 720 px cap left a visible dead zone on wide windows when the inspector pane was visible flush-right (sidebar + main + inspector + the centered clamp's gap). 960 reclaims that space without losing the paper-list calm.
This is one focused commit per the four-patch screenshot-cleanup plan logged in v0.6.10. Patch B is state-aware row treatment (overdue red, today amber, upcoming accent), Patch C is the Notes placeholder + recurrence icon, Patch D is day-band grouping in the main task list.
The default Adwaita dark theme reads as a uniform grey wall when an
app fills it edge-to-edge with content. v0.6.10 layers a thin
accent-warmth pass across six surfaces — barely perceptible per
rule, additive across the window — so the eye picks up structure
without any single surface screaming. Everything uses libadwaita's
named colour tokens (@accent_color, @warning_color,
@success_color, etc.), so light / dark / high-contrast themes
stay in lockstep.
What got tinted:
- Sidebar background. A diagonal accent-color gradient at 2.5% → 0% alpha. Almost invisible on its own, but it gives the sidebar a subtle directional cue that separates it from the main content without a hard divider.
- Header bars. Whisper of accent on the leading 35% (4% alpha fading to 0). The bar is otherwise a uniform grey strap; this hints at the accent without covering any controls.
- Page title in the header bar. "Today", "Inbox", "Agenda", etc. now render at weight 600 with a hair of letter-spacing. The page identity reads as a headline rather than just a label.
- Sidebar count badges. Those "131 / 75 / 178" numbers next to Inbox / Today / Upcoming are no longer plain grey — each picks up its row's canonical accent (Inbox → blue, Today → yellow, Upcoming → green, etc.) at the same alpha as the icon tint, so badge and icon read as a kindred set.
- Sidebar section headers. "AREAS" / "PERSPECTIVES" / "TAGS"
pick up a hint of
@accent_colorso they nudge away from pure grey toward the accent's hue. - Sidebar selection. Selected row uses a softer accent-tinged background (12% alpha) instead of the system's stark selected state. The canonical icon tint stays readable when the row is selected.
- Inspector pane group headings. "Title" / "Schedule" / "Tags" / "Notes" / "Builder" pick up an accent-warmth tint so the inspector reads as a curated detail panel rather than a cold form.
- Task row hover. Replaces v0.6.6's instant grey hover with an instant accent-tinged hover. Same speed (no transition — drag motion stays cheap), warmer hue.
This is a CSS-only patch. No code changes, no schema changes, no tests touched. The "Brandon ran v0.6.9 and surfaced two warnings" flow from the previous patch is unchanged — log is still quiet.
What's not in this patch (called out in the screenshot analysis but deferred to follow-up patches):
- State-aware status circles (red for overdue, amber for today, etc.) — needs a code-side CSS class per row state.
- State-aware date column (the "May 1" / "May 2" text picking up red on past-due, accent on today). Same shape — code-side per-row class.
- Inspector "Defer until: Available now" rephrasing — the value reads as a status, not a date.
- "Inbox" project chip on no-project tasks — duplicates the canonical-list selection signal.
- AdwClamp-induced dead zone on wide windows — the inspector pane lives flush against the right edge while the main task column is centered with empty space on either side.
Brandon ran the v0.6.8 binary and surfaced two real warnings in the log that were going unnoticed in CI:
-
CSS theme parser error at
style.css:488. A no-op placeholder rule from v0.6.1 used:not(:last-child)::after, which GTK4's CSS doesn't recognise (:not()and pseudo-element combinators differ from browser CSS). The rule never rendered anything anyway — replaced with a one-line comment explaining that visual separation between metadata segments comes from the parent box's spacing, not a pseudo-element. -
Search bar warning on every keystroke. GTK was emitting "The search bar does not have an entry connected to it. Call
gtk_search_bar_connect_entry()to connect one." on every captured key event. The fix is a one-liner —bar.connect_entry(&entry)inwire_search_bar. This had been missing because the entry lives inside a wrapper Box (so the?help button can sit alongside it), andGtkSearchBaronly auto-discovers an entry that's a direct child. Without the explicit connection, the bar'skey-capture-widget=task_list_viewhad nowhere to route forwarded keystrokes — they fell through and the warning fired.
Both fixes are surgical and surfaced no other warnings in the log Brandon shared.
End-of-session maintenance pass. Eleven v0.6.x releases shipped
since the v0.5.0 line (atrium-cli runtime fix → broken-pipe fix →
FTS5 bm25 → SQL-translation evaluator → Slice D foundation →
kanban GUI → kanban polish → renderer-config dialog → drag-drop →
Agenda canonical page → atrium-cli perspective write side →
kanban CPU mitigation → sidebar top-tier reorg). The contract
docs (spec.md, roadmap.md, README.md) lagged behind the
patches; this release brings them back into alignment per the
"Spec discipline" rule in CLAUDE.md.
What's in the patch:
spec.md— version header bumped from 0.5.0 to 0.6.7 with a one-line summary of what 0.6.x delivered. Three new sections added without renumbering the existing tail: §4.4 (FTS5) gains a "Bm25 + recency ranking" subsection documenting the saturating-relevance + half-life math; §4.5 (SQL-translation evaluator) describes the all-or-nothing translation rule, the parity-test backstop, and the current coverage / fall-back set; §4.6 (Perspective renderers) documents the'list'/'board'axis and the Slice D locked rules (leftmost-match-wins, "Other" trailing column, case-insensitive matching,move_to_columndrag-rewrite). The original §4.5 (Migrations) renumbers to §4.7. §5.2 (Builder Mode) gains a description of the kanban board renderer; new "Mode-agnostic additions" subsection covers Agenda + the v0.6.7 sidebar reorganisation.roadmap.md— Phase 15.75 rewritten to reflect what actually shipped. All seven previously-deferred items are now[x]-checked with their landing versions (Slice C v0.5.0 → v0.6.0, Slice D v0.5.4 → v0.6.5, FTS5 bm25 v0.5.2, SQL pushdown v0.5.3, sidebar reorg v0.6.7, CLI bulk operations v0.4.6, regression-script integration v0.5.x). Each line traces the actual code paths so the roadmap reads as a "what shipped where" map rather than a planning document.README.md— landing paragraph extended with a v0.6.x summary covering Slice D, FTS5 bm25, the SQL-translation evaluator, and the sidebar reorg. The detailed feature surface in the lower sections still describes v0.5.0 capabilities accurately, so a full README rewrite isn't due until the next major.- Code hygiene.
print_perspective_after_writehad a dead&Connectionparameter (introduced when refactoring perspective output); dropped it and the now-unused parameter throughrun_perspective_create. Two stale "Phase X will" promise comments updated — the SQL-translation comment inwindow.rs::refresh_active_listno longer claims "Stage 3 will add" (Stage 3 shipped at v0.5.3), andtask_list::ActiveList::task_matches's old "Phase 5c will revisit" promise is now an accurate description of the current behaviour. - Workspace clippy clean.
cargo clippy --workspace --all-targets -- -D warningsreports zero warnings. - Regression-script ship gate green at v0.6.8.
What's not in this patch (deliberately deferred — these are larger surgeries that warrant their own changes):
atrium/src/ui/window.rsis at ~5000 lines. Aui::sidebarextraction is the obvious next refactor target; the composite-template wiring couples a lot to it though, so it's a careful surgery not a quick cleanup.- The list-renderer Perspective path in
refresh_active_listdoesn't yet use the SQL fast-path (only the board path does, as of v0.6.6). Adding it is the same shape but the sort-spec / bm25 plumbing needs to align.
The "Builder" sidebar header is gone. Agenda / Forecast / Review no longer hide at the bottom of the sidebar in Builder mode — they now sit in the top tier alongside Inbox / Today / Upcoming / Anytime / Someday / Logbook, with their own accent tints:
- Agenda appears in both Simple and Builder modes (the agenda is a pure read view with no Builder-only concepts; it makes sense to surface it everywhere). Accent: warning red on the alarm-clock icon, so urgency reads at a glance.
- Forecast + Review stay Builder-only but join the top tier in that mode. Accents: cool blue (calendar) and success green (checkmark).
Perspectives section moves up from the bottom of the sidebar to right under the top-tier group — above Areas, below "the Inbox grouping," exactly as the user wanted.
Final sidebar order:
- Both modes: Inbox, Today, Upcoming, Anytime, Someday, Logbook, Agenda
- Builder mode adds: Forecast, Review (still in the top tier), then a "Perspectives" section header + its rows underneath
- Both modes continue with: Areas (and nested projects), Unfiled projects, Tags
What's in the patch:
atrium/src/ui/window.rs. Newtop_tier_extras(builder)helper returns the post-canonical rows that should appear in the current mode.rebuild_dynamic_sidebarnow appends those rows + the Perspectives section before Areas, instead of the old "Builder" section header at the bottom.canonical_accent_classextended to cover Agenda / Forecast / Review.data/style.css. Three new accent rules (.atrium-canonical-agenda→@warning_color,.atrium-canonical-forecast→@accent_color,.atrium-canonical-review→@success_color). Same alpha treatment the canonical rows already use, so they sit alongside without screaming.- Three new unit tests pin the top-tier shape (Simple = just Agenda; Builder = Agenda + Forecast + Review in that order) and the accent-class wiring so a future tweak can't quietly drop the tints.
Two targeted optimisations to address the CPU spike Brandon reported during kanban drag operations:
-
Drop the hover transition on board / agenda task rows. v0.6.1 added a
transition: background-color 120ms ease-outon.atrium-board-task-row(and Agenda inherited the same pattern). During a drag, the cursor crosses many rows in succession; each crossing fired a 120ms CSS animation producing continuous repaint work and a visible CPU spike. The hover background still applies — it's just instant now, so there's no per-frame paint cost. -
SQL fast-path on board refresh. v0.5.3 added the SQL translation evaluator to atrium-cli; v0.6.6 wires it into the GUI's
refresh_board_page. When the perspective's filter expression translates cleanly to SQL (most do — the fixture'sis:opendoes), we now load only the matching task rows from SQLite instead of pulling every row and filtering in Rust on every drop. At 1000-task scale that cuts the per-drop work meaningfully; at 10K+ it'll dominate. Falls back to the in-memory evaluator for expressions the translator doesn't yet cover (regex / fuzzy / composite is:today / etc.).
What's also in the patch:
atrium_core::SqlBindValueenum. Pulled the binding conversion out of atrium-cli's local helper and into a proper public type on atrium-core. The atrium GUI binary now bridges to it without needing a direct rusqlite dep.From<atrium_search::SqlValue> for atrium_core::SqlBindValuelives in atrium-search so call sites just say.into().filter::sort_tasks_by_specs. Tiny re-export of the sort-spec helper so the SQL fast-path in window.rs can apply explicitsort:modifiers without re-running the fullfilter::applypipeline.
If the CPU spike persists after this patch, the next move is
either (a) profile with tracing spans around the rebuild
to find the dominant cost, or (b) coalesce/debounce
TaskChanges-driven refreshes so rapid drops only trigger one
rebuild at the end. Both are clean follow-ups for a fresh
session.
Closes the gap that the only way to create or convert a saved
perspective from the shell was via direct SQL. Three new sub-
subcommands under atrium-cli perspective:
# Create a list-renderer perspective.
atrium-cli perspective create 'Q3 plans' --filter 'project:"Q3 plans"'
# Convert it to a kanban board.
atrium-cli perspective edit 'Q3 plans' --renderer board \
--columns 'todo,doing,done'
# Update the column list in place (renderer stays as board).
atrium-cli perspective edit 'Q3 plans' --columns 'backlog,todo,doing,done'
# Rename + re-icon + retune the filter in one shot.
atrium-cli perspective edit 'Q3 plans' \
--rename 'Q3 plans (rev 2)' \
--icon view-grid-symbolic \
--filter 'project:"Q3 plans" AND is:open'
# Back to a flat list.
atrium-cli perspective edit 'Q3 plans (rev 2)' --renderer list
# Tear it down.
atrium-cli perspective delete 'Q3 plans (rev 2)'Locked semantics:
- Name lookup is case-insensitive exact for write paths
(edit / delete) — substring fallback would risk editing the
wrong perspective on a typo. Read-only
kanban NAMEkeeps its substring fallback because there's no such risk. --renderer boardrequires--columnson create. On edit,--columnsalone is allowed if the perspective is already a board — that's the in-place column-list update.--icon noneclears the icon (back to the default); a bare value sets it.perspective editwith no flags is a noop — prints the existing row so the user gets a confirmation that they matched the right name.
What's in the patch:
atrium-cli/src/args.rs. NewSubcommand::Perspective(PerspectiveSub); newPerspectiveSubenum (Create / Edit / Delete) andPerspectiveArgsflag bundle; newEditIcontri-state for the--iconflag; newparse_perspectivebody parser that supports multi-word names + the full flag vocabulary. USAGE help text extended with the new shape.atrium-cli/src/main.rs. Newrun_perspectivedispatcher +run_perspective_create/run_perspective_edit/run_perspective_deletehandlers. Helper functionsbuild_renderer_config,synthesise_renderer_for_edit,parse_columns,resolve_perspective_exactkeep the renderer/columns logic in one place.- 13 argv-parsing tests. Cover create-minimum, missing --filter, board+columns, --rename rejection on create, invalid renderer, edit-with-all-flags, --icon none, edit-noop, delete-name-only, delete-rejects-body-flags, unknown sub, no-sub, multi-word names.
- Regression-script smoke (step 5.5). Now exercises the full create → edit (convert to board) → edit (update columns) → edit (back to list) → delete round-trip plus a
perspective edit … (no flags)noop and a--json list perspectivespost-condition assertion.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.6.5.
Org-mode-style "everything you should think about right now" view. A new canonical page (sidebar entry next to Forecast / Review) that groups open tasks into five chronological sections:
- Overdue — open AND
deadline < today. Surfaces past-due work first so it isn't buried under future scheduling. Heading is rendered in red to flag urgency at a glance. - Today — most-imminent date == today. "Most-imminent" is
min(scheduled_for, deadline). Same rule the regular Today list uses, plus deadline-today. - Tomorrow — most-imminent == today + 1.
- This Week — most-imminent within the rest of the current ISO Mon-start week (after Tomorrow). Empty on Sunday.
- Next Week — most-imminent within next ISO Mon-start week.
- Tasks farther out live in Forecast; tasks without a time anchor (no scheduled, no deadline) don't appear; completed and deferred-future tasks don't appear.
Each section is an Adwaita card with a heading + count and a vertical task list. Rows show title + date chip + project name
- tag pills. Click any row → opens in the Inspector. Empty
agenda gets an
AdwStatusPage"Nothing on the agenda" banner.
What's in the patch:
atrium/src/ui/agenda.rs. New module.AgendaSectionenum,classify(task, today)(returnsNonewhen not on agenda),group_by_section(tasks, today)returningVec<(AgendaSection, Vec<Task>)>in canonical order,build_page(today, tasks, …)returning the GTK widget. 14 unit tests covering the classification rules: completed-skip, deferred-future-skip, no-anchor-skip, someday-skip, overdue precedence, scheduled-today / deadline-today / scheduled-tomorrow, this-week / next-week boundaries, beyond-next-week-skip, most-imminent-wins-when-both-dates-set, group_by_section ordering and filtering.ActiveList::Agendavariant. Added totask_list::ActiveList; matched everywhere ActiveList is exhaustive.- Sidebar entry. Builder-mode sidebar gains an "Agenda" row between Forecast and Review (same group, same shape).
refresh_agenda_page+ content stack page.data/window.uiadds anagenda_hostAdwBin in a new GtkStackPage"agenda";refresh_active_listandapply_task_changesrouteActiveList::Agendathrough it.- CSS.
.atrium-agenda-section+.atrium-agenda-overdue(heading turns red) +.atrium-agenda-row-metastyling so the agenda reads as a focused composite view rather than another flat list.
The agenda is currently Builder-only (matches the pattern Forecast / Review / Perspectives use). A future polish pass could surface it in Simple Mode too — the underlying data is mode-agnostic.
The kanban is no longer read-only. Drag a task row to a different column → the task's tag set is rewritten so the kanban grouper buckets it under the new column on the next refresh:
- The leftmost configured-column tag in the task's current set is removed (that was the source column).
- The destination column's tag is added if not already present.
- Non-column tags pass through unchanged.
- Dropping on the trailing "Other" column just removes the source column tag — the task lands in Other for not matching any configured column.
The tag-set-rewrite logic is atrium_core::move_to_column —
pure-Rust, no GUI dependencies, eight unit tests cover the
combinatorial cases (move-to-column, move-to-other, move-to-same,
non-column passthrough, no-source, case-insensitive,
no-duplicate-on-existing, leftmost-only-removal).
The GUI side is plain GTK4 DnD: each row registers a
gtk::DragSource carrying the task id, each column card a
gtk::DropTarget accepting i64. The drop callback walks the
task's current tag names through move_to_column, then
dispatches worker.ensure_tag for each new name and
worker.set_task_tags to install the result. No-op short-circuit
when the new tag list is set-equal (case-insensitive) to the old
one — covers the common "drop on the same column" case without a
worker round-trip.
Closes the v0.6.0 gap that the only way to make a Perspective
render as a kanban was direct SQL or the test fixture. Right-
clicking a Perspective row in the sidebar now exposes a
"Configure renderer…" item that opens an AdwAlertDialog:
- Two radio toggles: List (default flat task list) / Board (kanban columns).
- When Board is selected, a comma-separated entry takes the column list — pre-populated with the existing columns when editing an already-configured board.
- Save → writes
perspective.rendererandperspective.renderer_configvia the worker.apply_library_changesre-renders the active perspective immediately, so the column layout appears without needing a sidebar refresh.
What's also in the patch:
BoardConfig::to_json/BoardConfig::from_json. The GUI dialog uses these to round-trip the JSON shape without pullingserde_jsoninto the GTK binary. Pinned by two unit tests — one for the round-trip, one for the exact emitted shape so a future serde derive tweak can't silently rename the JSON keys.
The CLI doesn't yet have a board-renderer setter (the v0.5.4
atrium-cli kanban NAME only renders an existing board). A
sibling patch will add atrium-cli perspective … for the
write side; for now, perspective creation/config from the
shell is "edit the DB directly or use the GUI dialog."
The first polish pass on the v0.6.0 kanban. Two gaps closed:
- Row metadata line. Project name, the most-relevant date (deadline trumps scheduled; Someday renders as the literal "Someday"), and tag pills (using the same Pango-coloured markup the regular task list uses) now appear under the title when any of them are set. Tasks with no metadata stay tight — the metadata row is suppressed entirely rather than rendering empty.
- Interactive checkbox. Clicking the checkbox toggles the
task's completion via the worker, same as the regular list
view. The board re-renders on the next
apply_task_changesdelta. Previously the checkbox was render-only.
Drag-drop between columns and a board-renderer editing UI are still the next slices.
The first GUI consumer for the v0.5.0 perspective.renderer /
renderer_config columns. A saved Perspective whose renderer = "board" now renders as a horizontal column layout in the GTK
binary instead of a flat list. Each column is a tag — leftmost
match wins, "Other" trailing column for tasks that don't match
any configured column. Same engine the v0.5.4 atrium-cli kanban
subcommand uses (atrium_core::render::group_into_board).
What's interactive in v0.6.0:
- Click any task row → opens it in the Inspector (same
win.edit-details-for(i64)action the regular list and keyboard shortcuts go through). - Vertical scrolling per column for tall task lists.
- Horizontal scrolling across the whole board when the column set exceeds the viewport.
What's not interactive yet (deferred to a follow-up patch):
- Drag-drop between columns. Today, moving a task between
columns is "edit the task's tags from the Inspector or via
atrium-cli edit ID --tag X --remove-tag Y." - The completion checkbox renders the state but isn't click-toggleable from the board view (use the regular task list or the Inspector).
- No board-renderer editing UI yet — to convert a Perspective
to a board, edit
rendererandrenderer_configdirectly. An editing dialog ships in a future slice.
What's in the commit:
atrium/src/ui/board.rs. New module.build_page(name, columns, on_row_click)returns a horizontally-scrollinggtk::Boxwith one card-styled column perColumn<'_>. Per-column scrolling caps at 420px tall; per-row click activates the inspector via the supplied callback.data/window.ui. NewGtkStackPagenamed"board"with anAdwBin id="board_host"host, mirroring the forecast/review/logbook pattern.atrium/src/ui/window.rs. Window struct gains aboard_hosttemplate child. Newrefresh_board_page(perspective)method orchestrates load → filter → bm25 rank → group → mount. TheActiveList::Perspective(id)branch in the active-list refresh checks the perspective's renderer;"board"switches to the board stack page, anything else falls through to the existing list rendering.data/style.css. Adwaita-card-class kanban columns, subtle hover tint on rows, transparent scroller backgrounds so the board reads as one surface rather than nested boxes.
VERSION / Cargo.toml / patchnotes / AppStream metainfo bump to 0.6.0.
The first slice of Slice D — saved Perspectives can now render as kanban boards. v0.5.4 ships the headless foundation: parser, grouping engine, and a complete CLI consumer; v0.6.0 will land the GUI rendering on top of these pieces.
The kanban contract is small and opinionated:
- Schema reused.
perspective.renderer = "board"plusperspective.renderer_config = '{"axis":"tag","columns":["…"]}'. These columns shipped at v0.5.0 (Slice A); this is what they're for. - Leftmost match wins. A task with multiple matching tags appears in only the leftmost matching column. Kanban is a state view — a task is in one state at a time.
- "Other" trailing column. Tasks that don't match any
configured column always appear in a final
"Other"bucket so the kanban stays honest about coverage. Users who want a tighter view tighten the perspective filter (e.g.,is:open AND tag:true). - Case-insensitive tag matching. Mirrors the rest of the search-engine tag rules.
What landed:
atrium-core::rendermodule. New file.Renderer::from_columns(renderer, config_json)parses the(renderer, renderer_config)pair into a typedRendererenum.group_into_board(tasks, &cfg, &tag_names_per_task)walks a task list and emits oneColumn<'_>per configured column plus the trailingOther. 17 unit tests cover parsing rejection (unknown axis, blank columns, missing config, unknown kind), grouping rules (untagged → Other, leftmost-wins, case-insensitive, input-order preservation, empty input).atrium-cli kanban NAME. New subcommand. Resolves a perspective by case-insensitive name (exact first, substring fallback), parses its renderer_config, runs the perspective's filter expression through the v0.5.3 SQL fast-path / in-memory eval, groups by tag, and prints columns. TSV / JSON /--humanformats. Errors clearly when the perspective is missing or its renderer is"list"instead of"board".- Fixture board perspective.
--fixture smallseeds a"Fixture Board"perspective with three tag columns (tag-0,urgent-3,home-4) so the kanban subcommand has something to render in test contexts and the CLI smoke step can exercise it without seeding a perspective by hand. - Regression-script kanban smoke.
scripts/regression.shstep 5.5 now exercisesatrium-cli kanban Fixture Boardin TSV / JSON / human formats plus the negative case (atrium-cli kanban Weekly Reviewmust error with"is a list, not a board"since the seeded Weekly Review is a list-renderer perspective).
The GUI rendering of board perspectives — switching from a flat list to a horizontal column layout, drag-drop between columns rewriting the underlying tag — lands in v0.6.0. The agenda/overview view (Slice D2) follows.
The fourth v0.6.x carryover. The Calibre-style search expression
language now executes at the SQLite layer instead of pulling every
row into memory and filtering in Rust — for queries that translate
cleanly. The translator's "all-or-nothing" rule keeps semantics
unchanged: anything that can't be expressed in SQL (regex match
modifiers, fuzzy matches, sequential-project state, the composite
is:today family) falls back to the in-memory evaluator. The two
paths are pinned to identical behaviour by 21 parity integration
tests in atrium-cli.
The win matters most at the 100K-task scale (spec §8 perf budget). A search that previously loaded 100K rows + iterated them in Rust now lets SQLite's query planner do the work using its existing indexes. Wired into atrium-cli for v1; the GUI search-bar + saved-Perspective wiring follows in a sibling patch.
atrium-search::sql_translate. New module.try_translate(&Expr, today) -> Option<SqlClause>walks the parsed AST and emits a SQLWHEREfragment + parameter list when every node maps cleanly to SQL. ReturnsNonefor any subtree containingMatchKind::Regex,MatchKind::Fuzzy,State::Available/Queued,State::Today/Inbox/Upcoming/Anytime/Someday(composite list-membership),State::InArea/Archived,Field::Project/Area(deferred — would need joins), or any unsupportedField/MatchKindcombination. 21 unit tests.atrium-search::dates. Extracted fromeval.rsso the SQL translator and the in-memory evaluator share the same date-keyword arithmetic (today,thisweek,5daysago, …). Single source of truth — no drift possible between paths.atrium-core::db::read::list_tasks_matching. New helper that runs a pre-built SQLWHEREfragment + bound params against thetasktable and decodes the resulting rows. Plainprepare(notprepare_cached) since the WHERE clause varies per query — caching would unboundedly grow the per-connection statement cache.atrium-cli::filtered_tasks. New private helper consumed byrun_searchandresolve_matching_tasks. Callstry_translatefirst; onSome, executes vialist_tasks_matching; onNone, falls back to the existinglist_all_tasks+ in-memoryevaluatepath. Same input expression → same task ID set on both paths.- Parity tests. 21 cross-validation tests in
atrium-cli/src/tests.rs::sql_parityseed a small mixed-shape fixture (open + done + overdue + scheduled + deferred + repeating + tagged tasks), run a battery of expressions through both paths, and assert identical id sets. Includes negative tests confirmingtry_translatecorrectly rejects regex / fuzzy /is:today.
The third v0.6.x carryover off the deferred list. Bare-text searches
(atrium-cli search milk, the GUI search bar with a freeform word)
now rank by FTS5's bm25 blended with a 30-day half-life recency
factor. Stronger matches and freshly-touched tasks rise to the top
instead of every result coming back in task.position order.
atrium-search::rankmodule. Two pure helpers —collect_text_termswalks the parsed AST forExpr::Textnodes,blend_relevancemapsbm25+days_since_modified→ a single comparable score on a stable scale. Twelve unit tests cover the math (saturating relevance, recency half-life, clamped negative days, AND/OR/NOT walking, field-scoped exclusion).atrium-core::db::read::bm25_for_terms. Queries FTS5 with the term set unioned viaOR, returnsHashMap<task_id, bm25>for the matching rows. User input is double-quote-stripped + phrase-quoted so a stray"can't inject MATCH operators. Six tests cover the empty / blank / quote-injection edge cases plus a term-frequency rank check.- CLI wiring (
atrium-cli).run_searchcalls the rank helper after the in-memory evaluator, only when the query has bare text and no explicitsort:modifier. Skipped automatically whensort:is present so power users keep their explicit ordering. - GUI wiring (
atrium/ui/filter::rank_by_bm25_recency+ window.rs). Same fast-path applied to both the search-bar's transient SearchResults list and saved Perspectives whose filter contains bare text. Four window-side unit tests cover the no-op / strong-match / recency-tiebreak / unscored-fallback cases. - No new dependencies. Sits on the existing FTS5
task_ftsvirtual table that's been in place since migration0001_initial.sql.
A focused patch with three small, coupled fixes that the v0.5.0 ship-gate hadn't been wide enough to catch.
- atrium-cli runtime nesting fix.
with_writerpreviously calledHandle::current().block_on(...)from inside an outerruntime.block_on(...), which is a "Cannot start a runtime from within a runtime" panic the moment any write subcommand ran. Reshaped to spawn the worker insideblock_onand exit, then pass&Runtimeto eachrun_Xso subsequentblock_ons run outside async context. The worker future stays alive on the runtime; eachhandle.foo()awaits a single mpsc round-trip. No behavioural change at the user level — the panic was hit by every write path. - Ship-gate end-to-end smoke for atrium-cli.
scripts/regression.shstep 5.5 exercises every read subcommand, every search-operator class shipped at v0.5.0, the JSON formatter (now viahead -c 1to also exercise the broken-pipe path), the add → info → search → edit → complete → delete write round-trip, and the bulkdelete --wheredry-run /--forceflow. Closes the architectural commitment that every non-GUI surface stays CLI-testable — without this step, the runtime nesting panic would have shipped silently in v0.5.0. - Broken-pipe behaviour. Rust's default-installed SIGPIPE handler is
SIG_IGN, which means aprintln!to a closed stdout panics on the next write. Atrium-cli now resets SIGPIPE toSIG_DFLat startup (inlineunsafe extern fn signalso we don't add alibcdep) — pipes intohead,head -c N,q-pressed pagers, etc. now exit cleanly instead of dumping a Rust panic message onto the user's terminal.
A meaty minor — this release rolls together fifteen post-v0.4.0 patches into one shippable boundary. Three threads finished and one started:
- Phase 15.75 (partial) — visual polish + per-area accent. Foundation migrations, beauty pass, and per-area colour rendering all landed. The board view (Slice D) and GTD-audit work (Slice C) remain for v0.6.0 / Phase 15.75 finish.
- Phase 15.5 deferred-list — closed. Every search-engine line item the v0.4.0 release punted into "v0.4.x patch" territory shipped: state-predicate coverage,
sort:modifier, ↑/↓ history,?operator-reference popover, fuzzy match, plus the SQL-translation evaluator and FTS5 ranking still pending for a future patch. - Architectural extraction — atrium-search + atrium-cli. The search engine and a full headless CLI both live as their own workspace crates. The GTK binary is no longer the gatekeeper for the search engine or the data layer.
- CLI-testable everything. Every non-GUI surface is now exercisable from the shell. Foundation for the 2.0-era TUI / atriumd capture daemon.
- Foundation (Slice A). Two additive migrations —
0004_area_color.sql(one new column onarea) and0005_perspective_renderer.sql(two new columns onperspective:renderer TEXT NOT NULL DEFAULT 'list'andrenderer_config TEXT NULL). Domain types and worker SQL grew alongside; user_version 3 → 5. No UI consumer yet for the perspective renderer columns — that's Slice D's board view, deferred to v0.6.0. - Visual rhythm (Slice B1).
.atrium-task-row:hovergains a subtle inset bottom border (@card_shade_color1px) plus alpha bump 0.08 → 0.10 for a "lift" cue..atrium-sidebar-sectionletter-spacing 0.04em → 0.06em — section headers read more clearly as labels..atrium-note-bodypicks upfont-style: italic+ tighter line-height (1.55 → 1.6); both Inspector surfaces (Simple-mode dialog + Builder-mode pane) now attach the class to their notes TextView so the editable Notes field reads as a writing surface, not a clone of the row chrome. Task list wrapped in anAdwClamp(max 720 px) so rows don't stretch into runway on wide windows. - Per-area accent (Slice B2).
prompt_for_taggeneralised toprompt_for_named_colorwith aplaceholderparameter. Tag callers (3 sites) pass "Tag name"; new area callers (2 sites) pass "Area name".prompt_create_areaand the Area arm ofprompt_rename_activenow both surface the six-swatch picker.build_area_rowmirrorsbuild_tag_row's coloured-dot pattern whenarea.coloris set.AtriumTaskgains anarea_colorglib property;apply_area_accenttoggles the matching.atrium-area-accent-{color}CSS class on bind + on every notify so a project move that shifts a task under a differently-coloured area updates the stripe in place. Six new CSS rules paintborder-left-colorat alpha 0.7 on each.atrium-area-accent-{color}class.replace_store_with_tags_seq+apply_changes_seqgrow anarea_color_for: Gclosure parameter alongside the existingcontext_for; three call sites inwindow.rspass the new resolver viabuild_area_color_resolver. - About-dialog icon resolution.
typography::register_icon_search_pathswalks three candidate paths (ATRIUM_DATADIR runtime env, compile-time install,CARGO_MANIFEST_DIR-relative dev fallback) and registers each existing one withgtk::IconTheme::for_display, so AdwAboutDialog'sapplication_icon(APP_ID)lookup finds the bundled SVG duringcargo rundevelopment. Installed builds were always fine. - Subtle warmth. Each canonical sidebar list now carries a quiet accent on its leading symbolic icon — Things-3-style. Inbox
@blue_3, Today@yellow_5, Upcoming@green_4, Anytime unchanged (intentional neutral beat), Someday@purple_3, Logbook@purple_2(faded). All wrapped in alpha 0.75–0.95 so accents read as personality, not signage. Also fixed the "cancel symbol" tag icons —tag-outline-symbolicisn't in the GNOME standard set; switched totag-symbolic.
- Canonical-list state predicates. Five new
is:NAMEshortcuts mirroring the canonical sidebar lists per spec §4.2:is:today,is:inbox,is:upcoming,is:anytime,is:someday. Each pairs with!is:NAMEfor the inverse. Closes the user-mental-model gap thatdue:today(correctly exact-match on Deadline) doesn't surface tasks scheduled for today —is:todayis the broader Today-list mirror. sort:modifier.sort:KEY(ascending) /sort:-KEY(descending) with primary → secondary composition. Recognised keys:due(aliasdeadline),scheduled(aliaswhen),defer,created,modified,completed,estimated,title,position. NULLs sort last regardless of direction (SQL convention). Implemented as a parser-time AST extraction (theExpr::Passplaceholder +ParseResult.sortsmetadata) so the evaluator never sees a sort modifier as a predicate.- Fuzzy
?modifier.tag:?workmatches with Damerau-Levenshtein within a length-aware threshold (≤4 chars → 1, 5–7 → 2, ≥8 → 3). Damerau (vs plain Levenshtein) counts a transposition of adjacent characters as a single edit, sotag:?wrokmatcheswork— the most common typing slip survives fuzzy without falling back to substring. - Search history (↑ / ↓). 20-entry in-memory ring buffer of recent committed queries. ↑ steps back, ↓ moves toward newer entries; pressing ↓ off the most-recent entry returns to the live entry. Pure-Rust
push_history_entry+cycle_history_cursorhelpers keep the state-machine logic out of GTK glue and unit-testable. - Operator-reference popover (
?button). The search bar grew a?GtkMenuButton; clicking opens a structured quick-reference organised by section (Boolean, Fields, Modifiers, Comparison & range, Date keywords, State, Sort). Closes the discoverability gap — without this the search-engine power was invisible to anyone who hadn't read spec §4.3.
atrium-core/src/search/ was lifted into its own sibling workspace crate atrium-search. Same code, same tests, no behaviour change — the move means the parser/evaluator can be fuzzed, benchmarked, and reused (atrium-cli + future TUI / atriumd / search server) without dragging the SQLite/worker layer along. atrium-core no longer depends on regex. The codebase map in CLAUDE.md documents the four-crate workspace.
A whole new headless binary, sibling to the GTK app:
- Read commands.
search EXPR(full search expression language, sort modifiers honoured),list NAME(canonical task lists: inbox, today, upcoming, anytime, someday, logbook, all; metadata lists: areas, projects, tags, perspectives),info ID(full task detail). - Write commands.
add TITLE [flags](full NewTask flag soup with date keywords, project resolution by case-insensitive substring, tag attachment via ensure_tag),capture LINE(Quick-Entry-style one-shot capture using the same inline-syntax parser the GUI's bottom-of-list entry uses — lifted fromatrium/src/quickentry/parser.rstoatrium-core/src/quick_entry.rsat v0.4.5),edit ID [flags](diff-based field updates including additive tag flags--tag X/--remove-tag X/--clear-tags),complete ID(toggle),delete ID. - Output formats.
--tsv(default — header row + sanitised columns;cut/grep-friendly),--json(serde_json array;jq-friendly),--human(pretty columns with truncation; for terminal viewing). - Database resolution.
--db PATHflag →ATRIUM_DB_PATHenv → XDG default. Read commands openSQLITE_OPEN_READ_ONLYso a buggy query attempting an INSERT errors at the engine — no CLI invocation can corrupt the user's database through a read path.
- 362 tests pass total (89 atrium + 63 atrium-cli + 136 atrium-core + 73 atrium-search + 1 mode-flip integration). Up from 248 at v0.4.0 (+114).
- Workspace shape: four crates (
atrium-core,atrium-search,atrium-cli,atrium). - Schema version: 5 (was 3 at v0.4.0; +0004 area_color, +0005 perspective_renderer).
- Migrations log:
0001_initial.sql(Phase 1) →0005_perspective_renderer.sql(v0.5.0 / Phase 15.75 Slice A).
spec.md§3.3 Process Topology rewritten to reflect the four-crate workspace + the architectural commitment that every non-GUI surface stays CLI-testable.spec.md§4.3 search expression language updated with the new operators (state predicates, sort modifier, fuzzy match) and §4.5 migrations log records 0004 + 0005.roadmap.mdPhase 15.75 records partial progress (Slices A + B done; C/D/E pending). Phase 15.5 deferred-list moves to "closed" with the line items shipped at v0.4.x.CLAUDE.mdcodebase map shows the four-crate layout and includes atrium-cli's structure.
Three slices remain on Phase 15.75's plan:
- Slice C — GTD audit fixes. Weekly-Review seed Perspective on first-run; Logbook day-grouping headers (Today / Yesterday / Last 7 Days / Older);
docs/gtd-patterns.mddocumenting the#waitinguser-tag idiom. - Slice D — Board view. Saved Perspectives gain a
renderer = 'board'option that renders the filter expression as a kanban with tag-axis columns. The schema columns shipped at v0.5.0 (Slice A); UI is Slice D. - Slice E — Documentation polish. Already partly subsumed by this v0.5.0 release notes entry; what remains is the fuller spec / roadmap / patchnotes pass that goes with the next minor.
- SQL-translation evaluator for the search engine. Translates the AST to a SQL
WHEREclause when expressible; falls back to in-memory eval for regex / complex tag predicates. Pure perf optimization — the in-memory path handles 100K tasks within budget today. - FTS5 bm25 + recency ranking on bare-text searches. Currently search returns matches unranked.
- CLI bulk operations.
atrium-cli complete --where 'is:overdue'to bulk-complete matched tasks. The pieces are all in place; just needs a flag-driven dispatcher. - Regression-script integration.
scripts/regression.shshould exercise atrium-cli end-to-end against a fixture DB so the architectural commitment is automatically verified at every release.
The search bar's filter language grew from a flat key:value shape into a full expression grammar. Saved Perspectives inherit it for free since they store filter expressions verbatim. Full reference in spec.md §4.3.
Boolean composition with grouping (AND / OR / NOT / !, parens, NOT > AND > OR precedence). Calibre match modifiers on every text field (tag:work substring, tag:=work exact, tag:~regex.* regex, tag:true / tag:false existence). Comparison + range on date and numeric fields (due:>today, due:2026-05-01..2026-05-31, estimated:>=30). Date keywords (today, thisweek, Ndaysago, Ndaysout, etc.). State predicates as is:NAME shortcuts (is:overdue, is:scheduled, is:repeating, etc.). New field operators: area:, project:, title:, note:, created:, modified:, completed:, estimated:, repeats:.
Implementation: new atrium-core/src/search/ module — lexer (Token stream), AST (Expr enum + supporting types with round-trip-shaped Display impls), recursive-descent parser, single-pass in-memory evaluator with lazy regex compilation cached per-query. regex crate added as a direct dependency (sign-off granted; already transitively present via tracing-subscriber).
Yellow .warning accent on the search entry when the parsed expression has unrecognised tokens; tooltip surfaces the typos. Three line items deferred to v0.4.x patches: SQL-translation evaluator, ↑/↓ history ring buffer, ? operator-reference popover — all polish, not correctness.
Tag colours wired end-to-end (six-swatch picker, sidebar dots, Pango-coloured pills via the existing markup property). Row hover states. Completion micro-animation (200 ms fade on toggle). Per-list empty-state warmth — distinct copy per canonical list instead of a generic "Nothing here." Sidebar section dividers. Header-bar Area › Project breadcrumb that updates as selection changes. Inspector-pane card treatment.
prompt_for_tag extends adw::AlertDialog with a custom extra-child Box for the swatch row — first non-trivial AlertDialog use beyond plain confirmations. Fully reactive: dragging the colour onto a tag instantly updates every visible pill via the existing LibraryChanges channel.
Filter-typo toast warnings (when an unknown field token is parsed away to freeform text, surface a toast so the user knows). Sidebar zero-state hint ("Add an area or project to get started"). Screen-reader badge labels (count badges in the sidebar gain accessible-description attributes). Inbox chip fallback on tasks lacking an explicit context.
Fixed: editing a tag's colour did not propagate to already-rendered pills until the row was re-laid-out (Pango markup re-render gap). Each LibraryChanges::tag update now triggers a per-task pill rebuild keyed on the tag id. Area › Project row context chip surfaces parent context inline so the eye doesn't have to track the sidebar.
Closes Phases 10–15 → Builder Mode shipped. Full RFC 5545 RRULE support via the rrule crate (sign-off granted before implementation). Three Org-mode completion semantics: +1d (regenerate from completion date), ++1d (regenerate from scheduled date), .+1d (regenerate from a "now" sentinel — only the days/weeks shift). Migration 0003_repeat_mode.sql — first ALTER post-v0.1 (the v0.1 schema freeze ends here; backwards-compatible migrations are now allowed per the schema discipline).
Inspector-pane repeat editor: dropdown → human label, RRULE preview shown live as the user adjusts. Worker regenerates the next occurrence on ToggleComplete for repeating tasks; user sees the new row pop in via TaskChanges without a refresh.
Saved searches as first-class sidebar entries. Save Search as Perspective… in the primary menu captures the current search-bar expression + view metadata into the new perspective table (migration 0002_perspectives.sql, additive). Renaming and deleting via the sidebar context menu. Perspectives inherit the full search expression language (Phase 15.5 will retroactively give them grammar improvements without schema changes).
Phase 12 Forecast (30-day calendar-axis, drag-to-reschedule) shipped at v0.1.3. Phase 13 Review queue at v0.1.16. Builder Mode UI shell at v0.1.1; defer dates + sequential-project rendering at v0.1.2. The v0.1.4 → v0.1.9 run resolved Inspector-pane edge cases (synchronous mode flip, Builder Inspector chord, Inspector hide-on-Simple-flip, populate-on-mount). The v0.1.10 → v0.1.15 run was the double-click hardening arc — getting double-click to open the Inspector / start inline edit reliably across GtkColumnView::activate, gesture interception, and edit-start race conditions. The fix that stuck: listen to GtkListView::activate (not pressed), defer edit-start to idle, and gate on the gesture-stream timing.
Closes Phases 0–9. Six canonical lists (Inbox / Today / Upcoming / Anytime / Someday / Logbook), areas + projects + tags + multi-tag, Quick Entry (Ctrl+Alt+Space), FTS5 search + flat filter expressions, multi-select + undo, Inspector + tag editor dialogs, sidebar find-as-you-type, full keyboard map, typography + accessibility, debug-pane Memory Watch, ship-gate regression script.
Three Phase 9 follow-ups carry to v0.1.x: the actual v0.1.0 git tag, Flatpak publish, public announcement. Two Phase 8 carryovers: README screenshots, Flatpak font-load verification.
The pre-1.0 cleanup arc. Phase 8h silenced two startup/shutdown GTK warnings. Phase 9a built the regression gate (scripts/regression.sh: fmt + clippy + test + cold-start sanity). Phase 9b finalised the README. v0.0.33 → v0.0.36 closed the Phase 7 follow-up surface (per-task tag editor, Inspector dialog, layout pass, double-click reliability, stop-eating-spaces in entries). v0.0.37 was the dialog primitives bugsweep: standardised on adw::Dialog for in-window modals (Inspector, tag editor); adw::Window for non-grab observers (Quick Entry, Memory Watch); adw::AlertDialog for confirmations. v0.0.38 added the deadlines-approaching heads-up to Today.
Bundled-font typography polish (Inter cv11/ss01 features, tabular figures audit on every numeric column). Atkinson Hyperlegible accessibility toggle (~80 KB SIL OFL, runtime-swappable). Packaging artefacts (desktop entry, AppStream metainfo, gschema XML, Flatpak manifest). Animation audit + Quick Entry fade-in keyframe. Memory Watch debug pane (/proc/self/status sampler, surfaces RSS + heap with a "drop caches" affordance). Accessibility audit (semantic roles, focus rings, screen-reader labels). Performance baseline against spec.md §8 budget — release build hits all four targets on Brandon's T14s.
FTS5-backed search (Phase 7a). Undo for toggle-complete + delete via a per-action undo stack; toast surfaces the affordance (Phase 7b). Multi-select + bulk operations — bulk complete / move / tag (Phase 7c). Filter expressions in the search bar — flat key:value shape that Phase 15.5 grew into the full grammar (Phase 7d). Find-as-you-type sidebar filter (Phase 7e). Full keyboard map — Ctrl+Z, F2 to rename, etc. (Phase 7f); written reference at docs/keymap.md.
Tag CRUD + sidebar Tags section (Phase 6a). Tag pills + inline #tag / @date parser — typing #work @today in any task entry creates the tag if absent and applies the date (Phase 6b). Quick Entry modal — Ctrl+Alt+Space anywhere on the desktop drops a tiny adw::Window for capture without grabbing focus from the prior application; same parser; closes on Enter (Phase 6c).
Sidebar hierarchy + remaining canonical lists (Phase 5a). Area / Project CRUD + the LibraryChanges delta channel paralleling TaskChanges for area/project mutations (Phase 5b). Count badges + drag-to-project (Phase 5c). Right-click context menus + sidebar selection refinement (Phase 5.5).
Single-writer worker + read-only pool (Phase 2): Command enum, TaskChanges delta, WorkerHandle, IO instrumentation via rusqlite's trace feature routing every SQL statement into a tracing span. Application shell (Phase 3): GTK4 + libadwaita window, sidebar shell, GSettings schema, font-install-on-first-run via fontconfig. Phase 4 brought Inbox + Today + the Calendar Month View item onto the roadmap. Phase 4.5 patched in drag-to-reorder + bottom-of-list entry.
Phase 0 (v0.0.3): Cargo workspace (atrium binary + atrium-core library), v0.1 dependency set locked, --debug skeleton, Meson wrapper, GitHub Actions CI. Phase 1 (v0.0.4): OmniFocus-superset schema in migration 0001_initial.sql (every Builder column present from day one), FTS5 virtual table + sync triggers, modified_at triggers with WHEN old = new clauses, stress-fixture generator at four scales. v0.0.5 added the "Beyond 1.0" roadmap section (post-1.0 horizon for atrium-tui).
Spec, roadmap, README, LICENSE, VERSION, logo. Org vault as a projection — SQLite canonical, .org files downstream — formalised in spec.md §3.5 + the §7.3 round-trip rules. Debug-first architecture (spec.md §3.4) — --debug opens an in-app debug surface for stress generators, edge-case fixtures, IO instrumentation, memory watch — built into the binary, not bolted on. Release discipline written down: every minor or major change touches spec.md, roadmap.md, patchnotes.md, and VERSION together; every major bump includes a maintenance pass.