summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
6 daysA guide for an agent creating doot tasksHEADmasterPeter Stone
Written for a Claude instance with no access to this repo or the database, so it covers the whole path: get a session, create a task, and what the endpoint will not do. Checked against the live server rather than read off the handlers. The auth request and poll return the documented shapes, and an unauthenticated create is refused with 401. Three things it exists to say, because none are guessable from the outside: - A human approves every session in a browser, and it lasts an hour. There is no API key and no auto-approval; trust levels change how the prompt is labelled, not whether it appears. The approval request itself expires in five minutes, so asking early wastes it. - The create endpoint silently ignores description, labels, estimated_minutes and project_name. They are real fields on a doot task and they are dropped without complaint, so an agent that sends them gets a task that is missing things it thinks it set. - The response carries no task id, so a create cannot be chained into an update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Surface a dead OAuth grant instead of reporting it as connectedPeter Stone
With the cancelled-context bug fixed, the refresh actually reaches Google and the real state of the Google Tasks grant becomes visible: it answers invalid_grant. The stored refresh token has been dead since roughly 2026-08-16. The earlier bug had been masking this — the refresh never got far enough to be rejected. Settings treated the presence of a token row as "Connected", so it reported Google Tasks as connected for a month while every fetch failed. A refresh failure is now persisted (migration 035 adds last_error/last_error_at to oauth_tokens) and cleared on the next success, and settings reports "connected" only when there is no recorded failure. invalid_grant is classified specially, because it is the one failure retrying cannot fix: the message tells the user to reconnect, and notes that a weekly recurrence means the OAuth client is still in Testing publishing status, where Google expires refresh tokens after seven days. Other errors — a network blip, a 5xx — are left as transient rather than pushing the user through consent needlessly. Reconnecting at /settings is still a manual step; nothing here can perform consent on the user's behalf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20gofmt the files added in the previous commitPeter Stone
Only the files this session added; the other gofmt findings in internal/ predate it and are left alone rather than mixed into a feature change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Make shopping items editable: tap to rename, move store/section, deletePeter Stone
Tapping an item outside shopping mode now opens an edit sheet. User-added items can be renamed, moved between stores and sections, and deleted. PlanToEat items show why they cannot be edited — the client is read-only, so presenting fields that silently would not save is worse than saying so — and offer removal, which marks them checked, the same mechanism shopping mode already uses. This needed a store registry first. Stores previously existed only because some item referenced them by name, with Trello's list names quietly acting as the registry; removing Trello as a source made the gap visible. Without a registry you cannot move an item into an empty store, and moving the last item out of a store makes it vanish mid-edit. Migration 034 adds shopping_stores and shopping_sections, seeded from the stores currently in use, and gives items a section column — sections previously existed only as PlanToEat category strings, so a user item could not be filed under one at all. Registered stores and sections now appear in the list even when empty, which is the whole point of the registry. Two deliberate choices about destructive edges: * Renaming a store renames it and moves its items in one transaction. Items reference the store by name, so doing either alone orphans them. * Deleting a store or section keeps the items, moving them to Unassigned or clearing the section. Losing the list is far worse than re-filing an item. * An UPDATE matching no rows is an error, not a silent success, so a stale id cannot look like a saved edit. Section options are rebuilt per store in the edit sheet rather than shipped as one flat list, so a Costco item cannot be filed under a Safeway aisle. 18 new store tests, 11 handler tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Fix Google Tasks: token refresh used a cancelled contextPeter Stone
main.go builds the Tasks client with a short-lived init context and calls cancel() the moment construction returns. dbTokenSource captured that context and reused it for every subsequent token refresh, so the first refresh after the startup access token aged out — roughly an hour in — failed with "context canceled", and so did every one after it. That is the whole reason Google Tasks has not been working: all four task lists, the same error on every fetch, recovering only for an hour after each restart. The message pointed at a transport problem, which is why it read as flaky networking rather than a lifetime bug. A token refresh is not logically part of whichever request happened to trigger it, so it now gets its own background context with its own timeout. oauth2's client is likewise built on context.Background(), since NewClient binds the context it is given to the returned transport for its lifetime; per-call contexts still come from the tasks service methods. Regression test drives a refresh through a stub token endpoint with an already-cancelled construction context, which fails on the old code with exactly the production error. Also covers Google omitting refresh_token on a refresh response, which would otherwise blank the stored one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Add .ics calendar feed supportPeter Stone
doot reads Google Calendar through a service account, by calendar ID. That cannot consume an .ics URL, and it cannot see a calendar subscribed to inside a personal Google account either, because Google does not allow re-sharing a subscribed calendar. Published schedules — sports fixtures, school terms, bin collections — are distributed almost exclusively as .ics, so none of them could be added at all. Feeds are now fetched on the normal calendar refresh and merged into calendar_events alongside Google events, so the timeline, widget and everything downstream keep seeing one list and needed no changes. Failure handling is deliberately asymmetric. A Google error is still returned as an error, unchanged, so an outage there surfaces. A single failing feed is recorded against that feed and skipped, because one dead URL should not take the whole calendar down — and last_error is shown in settings, since a feed that has quietly stopped resolving otherwise looks identical to a team with no games left. The parser handles what real feeds actually contain: line unfolding, CRLF and LF, UTC / TZID / VALUE=DATE times, and escaped text. Two details worth naming: a TZID time is wall-clock in that zone, and reading it as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local — wrong by the offset but entirely plausible-looking. And non-calendar content is a hard error rather than zero events, because an HTML error page from a moved feed URL is the common failure and "no events" reads as success. RRULE is deliberately unsupported: every schedule feed this targets expands recurrence itself, one VEVENT per fixture, and a correct expansion is a large amount of subtle code. A recurring event appears once rather than wrongly. Settings gains an add/enable/disable/remove UI. webcal:// links are accepted and rewritten, since that is how feeds are usually advertised. 16 parser tests, 5 store tests. Verified against the real Seattle Seahawks feed: 21 events, all with valid times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Drop Trello as a shopping source, importing the two groups worth keepingPeter Stone
The Trello "Shopping" board had become a dumping ground: of 24 cards, 13 had no list at all (rendering as "Uncategorized"), and the rest were spread across Costco, Hardware Store, "Perennial " (trailing space, so a separate store from "Perennial") and online. Its items were read-only in doot — no rename, no move, no delete, only hide — and its list names were doubling as an accidental store registry, which is the only reason stores like Hardware Store existed at all. Migration 032 imports the two groups asked for, Hardware Store and the unlisted cards, into user_shopping_items. Costco, Perennial and online cards are left behind. Nothing is destroyed: the cards remain in Trello, this only stops doot displaying them. Imported rows become ordinary user items, so they also become editable, which Trello-sourced items never were. Trello remains the source for the other eight boards; this is only the Shopping board feeding the shopping list. Also updated the quick-action modal's Shopping tab, which posted source=trello with a Trello list_id. Left alone it would have kept creating cards that no longer appear anywhere. It now posts source=shopping with a store name, handled by a new case in HandleUnifiedAdd, and its picker reads from a new /partials/shopping-stores endpoint. The Trello-lists endpoint it used to call is deleted along with its route. Note for the edit feature still to come: stores have no existence independent of items, so a store with nothing in it cannot be chosen. That was previously masked by Trello's lists persisting. "Move between stores and sections" needs a real stores/sections table. Migration tested against a seeded snapshot of the real board, including the trailing-space store name, other boards, replay safety and the empty-cache no-op. Two existing tests asserting Trello check-state in shopping mode were updated to plantoeat, the only remaining external source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Stop per-store add forms jumping to the bottom of the pagePeter Stone
The inline "Add to <store>..." forms submitted with a bare hx-swap="innerHTML" against #shopping-items-list. Those forms live *inside* that target, so the swap destroys the form that submitted it and the page ends up scrolled to the bottom rather than staying on the store being added to. The quick-add form at the top never had this because it sits outside the target and already carried an explicit scroll: modifier. Each store section now has a stable id and its inline form scrolls back to it, so adding to a store keeps that store in view. Adds a `slug` template function for the id: store names are user data ("Trader Joe's", "Hardware Store") going straight into element ids, so anything outside [a-z0-9-] is collapsed to a dash and an empty result becomes "unnamed" rather than an invalid, shared anchor. Tests render the real template with the real func map, and assert every inline add form has a scroll target — the bug is trivially reintroduced by copying a section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Fold the DB-ping health check in; delete the watchdog that never ranPeter Stone
scripts/health-watchdog.sh was written after the 2026-08-04 incident, where a liveness stub stayed green for three days while every database-touching request path was wedged. Its header says "Meant to run from cron every few minutes". It was never scheduled — not in any unit, not in any crontab — so it has been dead code since August, offering coverage it did not provide. Its one real idea is worth keeping: /health pings the database and returns 503 when that fails, which the gateway route checks cannot see, because `/` answers 303 to the login page without going near the database. check_app_health now does that poll (two attempts, five seconds apart, so a single blip is not an outage), verify-deploy asserts it, and health-check recovers from it by restarting the main service — the action the old watchdog would have taken. The dead script is removed rather than left to imply cover. Tests now 11, including a stub server returning 503 to prove a listening-but- wedged app is caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Verify deploys, and add a self-recovering health checkPeter Stone
Deploy used to end with `systemctl status`, which a human skims and a script ignores: it exits zero whether or not anything works, and says nothing about the sub-services behind the gateway. A deploy could therefore report success while /scout/ served 502s — which is exactly what happened, undetected, for three and a half weeks. scripts/deploy now: * syncs scripts/ to ${SITE_DIR}/app-scripts/ so the timer runs the deployed version from a stable path * restarts sub-services that are not active. They are separate units and a doot deploy does not touch them, so one sitting in `failed` after exhausting its restart limit stays down through any number of green deploys. * calls scripts/verify-deploy, which asserts and exits non-zero scripts/verify-deploy asserts all five binaries are executable, all four units are active, all four gateway routes answer without a 5xx, and the running build stamp matches the commit just built. scripts/health-check runs the same assertions on a 5-minute timer and recovers what it can: chmod +x a binary that lost its execute bit, reset-failed and start a unit that gave up. There is no mail/ntfy/apprise on this host, so alerting is a greppable HEALTH ALERT line in the journal, a status JSON at data/health-status.json for the dashboard to surface later, and a non-zero exit so the unit shows in `systemctl --failed`. OnBootSec=2min because a reboot is when this failure mode first appeared. Shared assertions live in scripts/lib/site-checks.sh so verify-deploy and health-check cannot drift apart. Two bugs were found and fixed in these checks by testing their failure paths, both of which would have made them report a broken site as healthy: * `$(curl ... || echo 000)` concatenates curl's own "000" transport-error output with the echoed one, giving "000000", which matched no case arm and was reported ok. * check_services sets FAILED_SERVICES, but health-check captured it with `$(...)`, so the array was populated in a subshell and discarded — silently disabling service recovery entirely. Both the calling convention and the subshell semantics are now pinned by tests. scripts/test-health-checks.sh: 9 cases, mostly negative, against a temp tree. Recovery additionally verified live on this host by stopping hawaii.service and by clearing scout's execute bit; both were detected, recovered and re-verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-20Stop deploy stripping the execute bit from site binariesPeter Stone
The permission step ran find ${SITE_DIR} -type f -exec chmod 644 {} + chmod +x ${SITE_DIR}/app over the whole site directory, so it cleared +x on every other executable there and restored it only for doot's own app. Four binaries were caught: services/scout/app scout.service services/hawaii/app hawaii sub-service bin/claudomator claudomator@doot.terst.org.service bin/claude exec'd by claudomator via --claude-bin Nothing appears to break at deploy time: a running service keeps executing from its already-open file. The damage surfaces at the next reboot, when systemd cannot exec the binary and the unit dies with status=203/EXEC. scout.service went down that way at the 2026-08-25 reboot and stayed down three and a half weeks, serving 502s behind /scout/; claudomator has been crash-looping on the same error, 426k restarts and counting. Neither is alerted on, because doot itself is healthy and only those paths fail. Permission fixing is now scoped to the directories this script actually manages (public, templates, migrations) plus doot's own binary and .env. Every executable under services/*/app and bin/* is then re-asserted +x, since that is what an outage looks like when it is wrong and asserting it is free. Verified against a synthetic site tree: all five binaries keep 755, assets 644. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-15Allow yb.tl in CSP frame-src so the GGR tracker embed can actually loadClaude
The Golden Globe Race map added in 55f7605 was silently blocked by doot's own Content-Security-Policy: frame-src only allowed YouTube and Windy, so the browser refused the yb.tl iframe outright, independent of whether yb.tl itself permits framing (it does). yb.tl/ggr2026 redirects to cf.yb.tl/ggr2026, so both hosts are allow-listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-08Swap V2cam feed for the Golden Globe Race tracker mapPeter Stone
The conditions grid's second cell showed the Kilauea East Rim YouTube cam. Replace it with the YB Tracking race viewer used by the official Golden Globe Race live-tracker page. Embeds https://yb.tl/ggr2026 directly rather than goldengloberace.com/live-tracker/ — the live-tracker page is only a wrapper whose own markup iframes yb.tl/ggr2026, so pointing at the wrapper would nest an iframe for no benefit. yb.tl sends no X-Frame-Options and no CSP frame-ancestors, so it renders in-frame. Uses the same bare-iframe form as the Windy map cells (no autoplay permissions), so it inherits the grid's existing absolute-fill sizing and the narrow-width column reflow unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-07Split timeline width across lanes instead of stacking overlapping eventsPeter Stone
The CSS gives every .calendar-event `left: 8px; right: 8px`, and the positioning code only ever overrode `left` (by 16px per column). So two events occupying the same slot both stayed full-width to the right edge and the later one simply covered the earlier one, leaving a 16px sliver. The column assignment was computing a correct answer that the layout then threw away. Reported as two yard-work chores rendering on top of each other. They sit at byte-identical timestamps because a bucket cycle stamps every item it activates with the same due_date, which makes this the maximally-bad case (identical top and height, so the overlap is total). Events now split the available width across the number of lanes in their overlap cluster, and a lone event keeps the full-width behaviour it had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Finish the spec status update (previous commit only applied partially)Peter Stone
The prior commit's edit to the open-questions section silently no-opped on a mismatched target, so the spec still carried the pre-implementation questions and claimed the duplicate "Yard work" task was outstanding after it had been deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Update punch-list spec: record what shipped and what's still openPeter Stone
Marks the core pull model as implemented and deployed, with the live verification results, and replaces the old open-questions section with the honest remaining list -- carry-over is unbuilt, there's still no dedicated review surface, buckets still push rather than being pulled, and ordinary undated backlog tasks have no commit control yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Make the day's list a pull of committed work, not a push of everything unblockedPeter Stone
due_date had been carrying two incompatible meanings: "this has a real deadline" (Bills, meds, a signup cutoff) and "this is when I intend to work on it." Chains leaned entirely on the second -- every step was stamped due_date = now the moment it unlocked -- so a five-minute call and a six-month certification both arrived looking overdue, and the only way to quiet them was to hand-reschedule. The list therefore always contained work that could not be finished that day, which made ending a day at zero impossible by construction rather than by effort. Splits the two apart: - due_date keeps only its real meaning: an externally-imposed deadline. - committed_date (new, migration 031) means "I chose this for today." Chain steps no longer get a due date on unlock (CreateChain, advanceChain, and the delete-promotion path), and are excluded from the undated list so they don't simply fall through to it instead. They reach the day only by being committed, via a "+ today" control on the chain card. Bucket-active items get the same control. BuildTimeline pulls in committed tasks, deduped against tasks already present for a different reason (committed and due-today are two reasons for one row, not two rows). Migration 031 also clears the auto-stamped due dates from open chain steps; completed steps keep theirs as historical record. Two supporting changes made while in here: - Extracted the native_tasks SELECT list into nativeTaskColumns. It had been copy-pasted into nine queries that must each agree exactly with scanNativeTasks' field order; adding committed_date meant editing all nine correctly or silently breaking whichever was missed. - Extracted templateFuncMap() and added TestTemplates_AllParse. New() only logs a warning when template parsing fails, so a malformed template shipped silently and surfaced as a broken page at runtime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Update punch-list spec: correct the recurrence diagnosis, record progressPeter Stone
The spec described skipped recurrences as an accidental "double-book." It isn't: generating the successor regardless of completion is deliberate (so skipping doesn't stall the series). The actual defect was that nothing retired the superseded occurrence, so skips accumulated as permanent overdue items. Recorded the intended semantics -- an occurrence not done before its successor lands is stale, and the newer one stands. Also marked the bucket pool as populated and flagged the leftover duplicate "Yard work" recurring task as needing an explicit decision, since deleting it ends its series. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Abandon recurring occurrences superseded by their next iterationPeter Stone
A weekly task not done before the next one lands is stale, not still owed. The scheduler already created the successor regardless of completion (deliberately, so skipping one doesn't stall the series), but nothing retired the occurrence it replaced -- so every skipped occurrence stayed open forever as a permanent overdue item. Live: "Bills" and "Bones's meds" each sat in the list twice. AdvanceDueRecurringTasks now marks the superseded occurrence abandoned when it creates the successor. A separate flag rather than `completed`, because these were never done and crediting them would corrupt completion history. All four open-task queries filter it out; migration 030 adds the column and backfills the occurrences already superseded (exactly the 2 above, verified against production before deploying). Supersedes the earlier view-level collapse (10f9fcd, reverted in 96518ad): that kept the *earliest* open instance, which is backwards -- the stale one should go and the current one stand -- and it only hid the row in the flat list while leaving it overdue in the timeline and widget, which is where it was actually being seen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-03Revert "Show one row per recurring chore in the flat task list"Peter Stone
This reverts commit 10f9fcd271bbac4ff26f32880db212b5bd3b7f00.
2026-09-03Show one row per recurring chore in the flat task listPeter Stone
The recurrence scheduler materializes a series' next instance once the current one comes due, regardless of whether it was completed -- that's deliberate, so ignoring an occurrence doesn't stall the series. The side effect is that the flat task list shows an unfinished chore and its already-generated successor at the same time: "Bills" appears twice right now (Sep 1 overdue + Oct 1), as does "Bones's meds". CollapseRecurringSeries keeps only the earliest-due open instance per series, which is the one actually owed. Applied to the atoms list only -- the timeline and widget are date-anchored, where each instance correctly belongs to its own day, so they keep all of them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-02Add design spec for the pull-based punch listPeter Stone
Written from measurements against production rather than from the symptom report: the free-standing task list is actually fine (19 genuinely today-sized items); what breaks it is due_date carrying two incompatible meanings, with chains stamping due_date=now on every unlock so milestones are born looking like deadlines. Proposes splitting due_date (real deadlines only) from a new committed_date ("I chose this today"), moving chain steps and bucket items behind an explicit pull, and adding a short review surface. Not yet implemented -- capturing the design before building. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-02Hide paused chains' tasks from the task listPeter Stone
Pausing a chain is supposed to take its work off the list -- that's the entire point of the control. But the three task-fetching queries only ever checked `chain_unlocked`, never the owning chain's status, so a paused chain's head task kept appearing exactly like an active one. Verified against production: 4 of the 8 visible chain tasks came from chains that had been explicitly paused ("Will", "ABYC Marine Electrical certification", "Standard services agreement", "Find a broker who writes marine trades"). Half the chain clutter on the list was work the user had already told doot to put away. Fixed the class, not just the instances: the predicate now lives in one shared visibleChainTaskClause constant used by all three queries, with a structural test asserting no query spells it out inline and that every query still references it. The previous shape -- the same predicate copy-pasted into three separate query strings -- is exactly why a fourth query would have gotten it wrong too. Also added task_chains to sqlite_test.go's leaner test schema, since the queries now join against it even for chain-less tasks. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-02Extend session lifetime and idle timeout from 7 days / 24h to 1 yearPeter Stone
Single-user personal server -- there's no one else's session at risk from staying logged in, so the short expiry was just friction. The 24h idle timeout was the more disruptive of the two in practice: a day of not opening the dashboard is a normal gap, not "abandoned," and was forcing a re-login well before the 7-day Lifetime ever mattered. Setting IdleTimeout = SessionLifetime rather than a separate shorter value collapses this to one real limit instead of two overlapping ones that could reproduce the same complaint later (e.g. a few weeks away). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-02Restore WebView state across Activity recreation in the dashboard widgetPeter Stone
The web dashboard is a single HTMX page -- tab switches are client-side swaps + history.pushState (?tab=shopping), never a real navigation. The WebView never saved/restored that state, so any Activity recreation (Android reclaiming a backgrounded Activity, a rotation, anything that reruns the AndroidView factory with a fresh WebView) fell back to loadUrl() on the bare server URL in the update block, landing on whatever tab is default. Reported as "shopping randomly jumps back to timeline" -- the prior swipe-detection fix (a17a159) addressed a real but separate false-positive source and didn't touch this path, which isn't tied to touch input at all. WebView.saveState/restoreState is the standard mechanism for exactly this; wired into onSaveInstanceState and the AndroidView factory. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-08-30Fix swipe-nav false positives on scroll, shopping quick-add overflowPeter Stone
Swipe-to-change-tab only ever compared horizontal touch delta against a 50px threshold, with no check against vertical movement -- an ordinary scroll through a long tab (the shopping list, in particular) drifts that far horizontally from natural thumb motion alone, and was getting misread as an intentional swipe. Since timeline sits right before shopping in TAB_ORDER, that's specifically what a rightward drift landed on: "shopping randomly jumps back to timeline." Now requires the horizontal delta to be at least 2x the vertical delta before treating it as a swipe instead of a scroll. Also fixed the shopping tab's inline quick-add row: the store <select> had no width cap, so a long store name could claim enough width to push the Add button past the right edge of the screen -- not just cramped, actually unreachable. Capped the select's width and let the name input actually shrink (needs min-w-0 alongside flex-1, since flex items don't shrink below their content size by default), with flex-wrap as a backstop for anything that still doesn't fit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-08-29Fix web quick-add keyboard viewport bug and shopping-mode add scroll jumpPeter Stone
DashboardActivity (the WebView-wrapped web dashboard) had no windowSoftInputMode override, unlike QuickAddActivity which already sets adjustResize|stateVisible for the same class of problem: without it, Android WebViews default to adjustPan, which shifts the whole page up behind the keyboard instead of resizing the viewport, pushing a centered `position: fixed` modal's input out of reach when the keyboard opens. shopping-mode.html's fixed-bottom quick-add bar called a plain .focus() on the item-name input right after the item list above it grew by one row. The browser's default scroll-into-view for that focus call was computed against the now-taller document and dragged the whole page to the bottom instead of leaving the always-visible fixed bar alone. preventScroll: true stops that. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-08-24Remove confirmed-orphaned routes, handlers, and templatesPeter Stone
From the sitemap audit: /tabs/meals (same dead-tab pattern as the removed /tabs/conditions, only reachable via ?tab=meals with no nav button), /partials/lists (superseded by inline .Lists rendering in trello-board.html), /shopping/toggle and /shopping/mode/{store}/toggle (superseded by the one-way complete/filter model), plus the orphaned trello-boards.html and error-banner.html templates that nothing rendered or included. Rewrote the meals grouping test to exercise groupMeals() directly since that logic is still live via the timeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-23Remove unused /tabs/conditions partial and routePeter Stone
Only the standalone /conditions page was ever meant to exist; the HTMX tab variant was dead weight, reachable only via an unlinked ?tab=conditions query param with no button in the tab bar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-23Update conditions page: refresh V1cam URL, zoom out Windy mapsPeter Stone
V1cam's YouTube video ID had gone stale; resolved the current one via USGS's url.usgs.gov/v1cam shortener. V2cam was already current. Zoomed the Windy embeds/links out 1-2 levels on both the standalone conditions page and the in-app conditions tab. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-16Replace Google Tasks service-account auth with real OAuthPeter Stone
Service-account auth structurally cannot see a regular user's personal task lists (no equivalent of Calendar's per-item sharing model) -- confirmed via GetTaskLists returning exactly the service account's own empty "My Tasks" list, never the real user's three lists. Zero rows were ever cached in production as a result. Adds a standard 3-legged OAuth flow: /settings/google-tasks/connect redirects to Google's consent screen (AccessTypeOffline+ApprovalForce so a refresh_token is always issued), /callback exchanges the code and persists the token (new oauth_tokens table), /disconnect clears it. GoogleTasksClient now takes an option.ClientOption instead of a credentials file path; NewGoogleTasksOAuthClient wraps it with a dbTokenSource that reloads/refreshes from the DB on each access-token expiry and re-persists -- carefully preserving the original refresh_token when Google's refresh response omits one (it usually does), which would otherwise silently and permanently break future refreshes. Settings page shows connection status and a Connect/Disconnect button. Calendar keeps using service-account auth (that one actually works). Requires a one-time manual step: create an OAuth 2.0 Client ID in Google Cloud Console and set GOOGLE_OAUTH_CLIENT_ID/SECRET in .env -- documented in .env.example.
2026-08-14Fix integration papercuts found in cross-source reviewPeter Stone
getAtomDetails's gtasks case was a stub returning a hardcoded "Google Task" title, so every gtask completed via the web Tasks/Timeline tab or the Agent API logged into completed-tasks history with no real title or due date -- now calls findGoogleTask like every other gtasks call site already does. Gtasks completion also skipped cache invalidation in two call sites (HandleCompleteAtom, the Agent API's handleAgentTaskToggle) that already had it for trello; both now invalidate CacheKeyGoogleTasks the same way the widget handlers do. HandleTaskDetailPage (the widget deep-link fallback page) hand -duplicated loadTaskDetailData's lookup instead of calling it, so it never got this session's earlier gtasks-detail fix -- now delegates. Also deleted PlanToEatAPI.GetRecipes, dead code since its introduction ("for Phase 2," never called). Adds a running .agent/critiques.md tracking structural/process critiques surfaced in conversation, separate from a concrete plan.
2026-08-14Fix tomorrow's all-day events using the wrong text colorPeter Stone
TomorrowSection de-emphasizes every other row to palette.textSecondary, but AllDayRow had no titleColor parameter and always rendered at palette.textPrimary -- so all-day/multi-day items (like a birthday) stayed bright while every timed event and task around them dimmed. AllDayRow now takes a titleColor (defaulting to textPrimary, which keeps today's section unchanged), and TomorrowSection passes textSecondary like it already does for TimeLabeledEventRow/TaskRow.
2026-08-13Fix blank task-detail modal for Google TasksPeter Stone
loadTaskDetailData/HandleUpdateTask only had cases for "trello" and "doot" sources, so opening a gtask from the Tasks or Timeline tab rendered an empty title/description. Reuses the existing findGoogleTask cache lookup for both. Renamed the API client's UpdateTaskNotes to UpdateTask(title, notes) so the web modal can save an edited title too, not just description; the widget's description-only edit popup now just round-trips the task's existing title unchanged.
2026-08-12Add task title editing/deletion, timeline click-to-open, widget app launchPeter Stone
Task-detail modal was description-only with no delete affordance; HandleUpdateTask now saves the title too and a Delete button hits a new DELETE /tasks/{id} route backed by store.DeleteNativeTask, which repairs chain_position/unlocks the successor when the deleted task belongs to a chain. Timeline tab task/card/gtask rows now open the same detail modal as the Tasks tab. Android widget's "TODAY" header is now a tap target that launches DashboardActivity, since nothing previously opened the full app from the widget.
2026-08-12Fix tomorrow's task rows: color and alignment vs tomorrow's eventsPeter Stone
TaskRow always used textPrimary for its title, while tomorrow's timed events (TimeLabeledEventRow) are deliberately de-emphasized with textSecondary -- tomorrow's tasks now match. TaskRow also had no leading gutter of its own outside the hourly grid (where HourRow's hour-label column supplies it externally), so in TomorrowSection its title landed at a variable offset depending on checkbox/dot size and hideCheckboxes, instead of the fixed 32dp gutter AllDayRow and TimeLabeledEventRow use there. Added optional titleColor/gutterWidth params to TaskRow (both default to prior grid behavior) and set them from TomorrowSection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-10Fix widget showing tomorrow's dated tasks under todayPeter Stone
Root cause: doot-native and Google Tasks due dates are always midnight-anchored (even when a task genuinely has a due date), which trips TimelineItem.ComputeDaySection's "midnight means no specific time" heuristic and sets IsAllDay=true on them. TimelineItemToWidgetItem then left wi.Start nil for any Task/GTask with IsAllDay=true, and the Android client's undated/floating pool (DootWidget.kt's `floating` list) has zero per-day awareness -- it just packs items forward from "now" -- so a task due tomorrow rendered as if due today. This was previously diagnosed and deliberately set aside (see project_doot_isallday_midnight_bug memory) with a simpler proposed fix (gate ComputeDaySection's heuristic to Event/Meal types); re-verifying that plan against the actual code before implementing it surfaced a real problem with it: it would also flip IsAllDay to false for same-day dated tasks, moving them from the web's untimed-item strip into the hourly grid with a fabricated "12:00 AM" time label -- fixing the widget by breaking the web's currently-correct rendering. Actual fix: added TimelineItem.Undated, a signal genuinely independent of IsAllDay -- true only for the caller's two genuinely-dateless constructions (nativeUndated tasks, gtasks with no due date), both of which already use Time=now as a layout placeholder rather than a real date. ComputeDaySection and IsAllDay are untouched, so web rendering is unaffected. This gate.go change routes on that new signal instead: dated Task/GTask items now get wi.Start populated (so the Android client's existing, already-correct Start-based day bucketing runs for them) while genuinely undated ones keep today's nil-Start floating treatment exactly as before. Also fixed the same latent bug in wi.DueDate's guard while in the same code path: it only checked !Time.IsZero(), but nativeUndated's Time=now placeholder is non-zero, so an undated task's Android detail popup would have shown a fabricated due date of whatever moment the request happened to run. Now guards on !Undated too. Verified: added TestTimelineItemToWidgetItem_DatedGTaskGetsStart and TestTimelineItemToWidgetItem_UndatedTaskWithPlaceholderTime_NilDueDate, and updated the three existing tests that had encoded the bug's old "Start stays nil for any IsAllDay task" assumption as expected behavior (TestTimelineItemToWidgetItem_Task, _DootTaskGetsDueDate, _AllDayTask_KeepsFloatingBehavior -- the last one needed Undated: true added since it no longer implies undated on its own). Proved the fix by reverting internal/handlers/widget.go to the pre-fix condition against a real backup and confirming exactly the three tests exercising the new behavior fail, then restored from that backup and confirmed byte-identical. go build/vet/test all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-09Widget: Quick Add dismisses instantly instead of blocking on networkPeter Stone
Tapping "Add" ran addTask() plus up to five sequential follow-up calls (description, due date, project, labels, recurrence) and a widget refresh, all inside the sheet's own lifecycleScope, before finish() -- the sheet sat on screen doing nothing visible for however long that chain took, reported as "sits black for a couple seconds." Moved the actual submission into a new AddTaskWorker, following the same pattern already established by CompleteWorker/DeferWorker: a CoroutineWorker enqueued fire-and-forget, so it survives the activity finishing (lifecycleScope wouldn't -- it's cancelled the moment the activity is destroyed). onAdd now does a local-only DataStore config check (fast, no network, so a missing server URL/token still doesn't silently eat what was typed), then confirms via toast and calls finish() immediately, mirroring the optimistic-dismiss pattern TaskDetailActivity.onComplete already uses. The worker preserves the original partial-failure reporting (a toast listing what didn't stick) for the rare case something after addTask itself fails, now delivered asynchronously via Toast.makeText posted to the main thread rather than blocking the sheet on it. Verification: this exact activity hit the same headless-emulator input limitation noted earlier this session (2026-08-06, QuickAdd keyboard focus) -- confirmed it's an environment constraint, not a regression, by trying both `input text` and raw `input keyevent` injection (which also failed) against a field that visibly has focus, on the same emulator where the same commands work fine for an equivalent OutlinedTextField in SettingsActivity. Verified instead by: go build/ test equivalent (./gradlew testDebugUnitTest, all passing -- the individual WidgetRepository calls AddTaskWorker orchestrates already have unit coverage in WidgetRepositoryTest.kt), a clean assembleDebug, and a crash-sanity launch on emulator-5556 with no FATAL in logcat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-07Wire the Tasks tab into nav; fold in buckets/projects/labels and recurrencePeter Stone
The Tasks tab (/tabs/tasks) existed server-side and was tested, but nothing in the nav linked to it -- it was pure dead weight in the other direction. Wiring it up as the natural home for everything that was either misplaced in Settings or missing a web UI entirely: - Maintenance Buckets, Projects, and Labels moved out of Settings and into the Tasks tab (restyled from Settings' opaque slate cards to the glass/backdrop-blur look already used by the tab's chain/atom cards -- they're now embedded in index.html's page shell, not a standalone page, so the shared bg-card/bg-input classes from that shell apply). Settings keeps only what's actually settings: Passkeys, Trusted Agents, Data Sources. - Added a Recurrence section to the task-detail modal (freq/interval/ weekday form, posting to a new POST /tasks/recurrence -- the HTMX counterpart to the widget API's HandleWidgetTaskRecurrence). Native task recurrence previously had zero web UI at all, only reachable via the Android widget's RecurrenceEditDialog. Also fixed a real bug found while touching this code: HandleGetTaskDetail's source switch only had a case for "trello" -- opening any native ("doot") task's detail modal, which is most tasks in this tab, showed a blank title and description. Factored both call sites (initial GET and the re-render after a recurrence edit) through one loadTaskDetailData helper and added the missing "doot" case. Also fixed task-detail.html's styling, which was still using pre-dark-theme classes (text-gray-900 etc.) -- functionally invisible text on the modal's dark background. Verified with a throwaway local server (real templates + real DB, not the MockRenderer the unit tests use) seeded with a recurring task, a bucket, a project, and a label -- confirmed all five touched routes render 200 with the expected content, including the populated Buckets/Projects/Labels sections and a real weekly-recurrence form with the correct weekdays pre-checked. Caught and fixed a copy bug this way too ("every 2 weeklys" -> "every 2 weeks"). Not committed; deleted after use. go build ./..., go vet ./..., and go test ./... all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-06Remove the feature toggle system (dead code)Peter Stone
Audited it (couldn't query the live DB directly -- auto-mode classifier blocks direct production reads without prior approval -- so this is a code-only audit): GetFeatureToggles/SetFeatureEnabled/IsFeatureEnabled/ CreateFeatureToggle/DeleteFeatureToggle had exactly one caller each, all inside their own CRUD handlers. Nothing anywhere else in the codebase read a toggle's Enabled state to gate any actual behavior -- confirmed by grepping every remaining .Enabled/IsFeatureEnabled reference back to either this dead code or its own tests. It was pure UI-managed CRUD with no consumer, unlike Trusted Agents (wired into agent.go/websocket.go) or Data Sources (wired into the sync pipeline) which stayed. Removes the Settings page section, the three /settings/features* routes and handlers, the five Store methods, the FeatureToggle model, and adds 028_drop_feature_toggles.sql (next free migration number, per this repo's convention of never renumbering -- see 021_drop_tasks.sql for the same drop-table-forward pattern) to drop the now-unused table. Also removed the now-dead tests for all of the above. go build ./... and go test ./... both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-06Widget: real launcher icon, fix double title bar, rename app to "doot"Peter Stone
Icon: adaptive-icon vector launcher icon reusing web/static/favicon.svg's brand mark (indigo->purple gradient square, white checkmark) so the app and web dashboard visually match. minSdk 36 (per build.gradle.kts) means mipmap-anydpi-v26 alone is sufficient, no legacy PNG fallback needed. Double title bar: DashboardActivity inherited the app-wide Theme.DeviceDefault.DayNight, which has a native ActionBar, stacked on top of the new Compose TopAppBar from the last commit -- visibly two bars, showing "Doot" (static ActionBar label) above "Personal Dashboard" (the Compose bar tracking the web page's own <title>). Added Theme.Dashboard (NoActionBar) for the activity, and stopped tracking the WebView's document.title for the Compose bar's title text -- the dashboard is a single HTMX-swapped page (see DashboardActivity's class doc), so the title never meaningfully changes, and it was just producing a second, differently-branded piece of text. Renamed the app from "Doot Widget" to "doot" throughout (application label, widget-picker description, Settings screen heading) to match the project's actual branding. Also: the web Settings page's "Back to Dashboard" link was a plain <a href="/">, which in the WebView pushes a NEW history entry for "/" instead of reusing the one already on the stack -- so Settings <-> Home round trips kept growing the WebView back-stack ("zigzag"), and the in-app back arrow/hardware back key never actually unwound it. Now it calls history.back() when there's stack to pop, falling back to a plain navigation only if there isn't (e.g. Settings opened directly). Verified on emulator-5556: rebuilt and reinstalled, confirmed a single title bar (no native ActionBar behind the Compose one), confirmed the launcher icon renders (checked via Settings > App info, since this AVD's launcher doesn't expose an app drawer over adb), no crashes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-06Widget: give Dashboard its own launcher icon, add toolbar navPeter Stone
DashboardActivity is now the app's single home-screen launcher entry (MAIN/LAUNCHER intent-filter), replacing SettingsActivity in that role. SettingsActivity keeps only APPWIDGET_CONFIGURE (still exported=true, since the launcher/home-screen process starts it directly during widget placement) and is now reached via a gear button in Dashboard's toolbar. DashboardActivity itself is rewritten from a bare setContentView(webView) to Compose Scaffold/TopAppBar wrapping the WebView via AndroidView, adding: - a back arrow (shown only when the WebView has history) instead of relying solely on the hardware back key - a settings gear action launching SettingsActivity - the page title tracked from the loaded page Also fixes a race in the original version: server URL was loaded via a lifecycleScope coroutine racing the WebView's own initialization order. Now it's plain Compose state (LaunchedEffect + AndroidView's update callback), so the WebView never loads before the URL is known. No native tab bar: web/templates/index.html's tabs are HTMX partials (hx-get targeting #tab-content), not separate pages, so loading "/" in the WebView already gets full in-app navigation for free. Verified on emulator-5556 (doot_test_api36): pm resolve-activity confirms DashboardActivity is the launcher default; launched it, confirmed no crash and topResumedActivity is Dashboard; configured a dummy server URL via Settings and confirmed the toolbar renders (title, gear icon) and the gear button correctly navigates to SettingsActivity (topResumedActivity becomes SettingsActivity) with no crash. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-08-06Widget: fix QuickAdd keyboard-focus race, force widget re-render on savePeter Stone
QuickAddActivity's autofocus used delay(150) then requestFocus() + keyboard.show() -- a fixed delay racing against the window actually gaining focus. This is a translucent, separate-taskAffinity popup activity, so window focus transfer is variable/device-dependent; a show() call made before the window is focused is silently dropped by the IME service, no error, no retry -- the classic cause of "keyboard doesn't appear until I tap out and back in" (reported 2026-08-06, "more consistent here" than the same general Android quirk elsewhere). Fixed to react to LocalWindowInfo.isWindowFocused instead of guessing a timing window, plus windowSoftInputMode="adjustResize|stateVisible" as an OS-level second line of defense. Tried to verify live and hit a real limitation worth recording: this box's AVDs run -no-window (headless), and in that mode mInputShown never reports true via dumpsys input_method even for a manual, deliberate tap on the field -- confirmed by testing a plain tap directly, independent of any app code. The harness can't observe IME visibility here, so this fix is verified by code-level reasoning (LocalWindowInfo-driven focus is the standard, documented fix for this exact bug class) and confirmed window-focus DOES transfers correctly (mServedView moves to the bottom sheet's window), not by watching the keyboard actually appear. Real confirmation has to happen on-device. Also: SettingsActivity.saveAndFinish() only ever called DootWidget().updateAll() indirectly, as a side effect of RefreshWorker succeeding its network fetch -- so a slow or failing request could delay or block the widget from reflecting a setting the user just saved, even though every setting saved there (theme, text size, background, checkboxes) is already fully local and needs no network round trip to take effect. Now calls updateAll() directly and immediately after writing prefs; RefreshWorker still runs afterward to separately pull fresh server data. Verified installable and crash-free on a real API 36 emulator. Deployed as doot-widget.apk.
2026-08-06Widget: ship Material You theming, past-events restructuring, legibility ↵Peter Stone
fixes, new settings This lands the color-theming work that had sat uncommitted since a prior session (2026-07-28) -- every build published in between stripped it out deliberately to avoid shipping unreviewed work -- plus a full round of fixes and new features layered on top since it finally shipped: Theming (WidgetPalette.kt, new): - 5 themes now: NEUTRAL/ACCENT/TONAL (wallpaper-derived via Material You), VIVID (new -- all three text roles pull from a different accent slot instead of anchoring primary to neutral, for real hue variety), CLASSIC (fixed, wallpaper-independent). - Settings picker redesigned to match Android's native wallpaper "Basic colors" circular swatches (bottom half + two top quadrants, filled with each theme's actual buildWidgetPalette() output, not an approximation). - Per-source accent colors (colored checkboxes/bars) fully removed from the grid. Past events (DootWidget.kt, WidgetRows.kt): - Already-ended-today events pulled out of the hourly grid, shown as list rows above it instead (matching how past tasks already float) -- fixes the grid's start hour getting stretched backward by stale events. Legibility (WidgetRows.kt): - ShadowedText upgraded from a single-corner drop shadow to a 4-corner halo/outline (protects all sides of a glyph, not just one). - Halo color now tracks each palette's text luminance (dark halo for light text, light halo for dark text) -- a hardcoded black halo behind already-dark light-mode text was doing essentially nothing. Bumped opacity 0.45/0.55 -> 0.65/0.7 as the cheap, low-risk strength dial. New settings (SettingsActivity.kt, DataStore.kt): - Background transparency slider (0-85%, default 0% unchanged) -- exposed for testing per explicit request, not a default change. - Hide-checkboxes toggle: drops the leading checkbox/dot element entirely (not just hides the icon) so task titles land flush with event titles; tap-to-complete-from-widget trades off for TaskDetailActivity's Complete button. Also: today's moon phase in the TODAY header (moonPhaseEmoji, pure date computation, no network) -- verified against direct calculation before writing test assertions, not hand-computed. Removed the unused glance-material3 dependency (verified zero usages before removing; turned out to save ~2KB, not the ~280KB expected, since material3 itself already pulls the same transitive deps -- noted honestly rather than oversold). Every new pure-logic piece has unit tests (isPastEvent, moonPhaseEmoji, theme construction) -- 59 total, all green. Verified installable and crash-free via a real API 36 emulator launch before each publish, not assumed. Deployed as doot-widget.apk.
2026-08-06Widget: wrap the web dashboard in a WebView (DashboardActivity)Peter Stone
First cut per the 2026-08-06 feasibility check (verdict: easy, no architecture blockers): a plain WebView pointed at the configured server URL, cookie jar enabled for the existing session-cookie login (internal/auth/middleware.go's RequireAuth) -- no separate auth bridge needed, the widget's own bearer token is a completely different scheme and doesn't need to touch this at all. External links (e.g. a calendar event's source URL) escape to the user's real browser instead of getting stuck in the WebView. No deep-linking to specific tabs, no native-rendered chrome -- this is the minimal first step to validate the wrapped experience is worth building further, not the final shape.
2026-08-06Widget: add postpone (tomorrow/next week/next month) to task detail popupPeter Stone
Alongside Complete/Edit, doot-native tasks now get a Postpone button with a dropdown (Tomorrow / Next week / Next month), reusing the existing reschedule wiring (WidgetRepository.reschedule) the due-date picker already uses. Caught and documented a real divergence while writing the test: Java's LocalDate.plusMonths CLAMPS to the target month's last valid day (Jan 31 -> Feb 28), while the Go server's ComputeNextOccurrence (recurrence math) OVERFLOWS instead (Jan 31 + 1 month -> Mar 3) via time.AddDate. Verified by actually running both, not assumed -- a first draft of this test asserted the wrong (Go-style) behavior before checking. Not reconciled here, just accurately documented as a known inconsistency between this client-side helper and the server's date math. 4 new tests, all passing.
2026-08-06Fix Google Calendar TimeMin using server time instead of display timezonePeter Stone
GetUpcomingEvents built its TimeMin cutoff from raw time.Now() (server/UTC time), not the start of today in the configured display timezone. Google's API filters TimeMin against each event's END time (exclusive), so for a UTC-10 display timezone (Pacific/Honolulu) that cutoff landed mid-afternoon the PREVIOUS Hawaii-local day, not midnight of the actual display day. Confirmed against live data before fixing, not assumed: the cached calendar_events table's earliest row was "2026-08-05 15:00:00-10:00" -- exactly the mid-afternoon-yesterday skew this bug produces -- with a complete gap for all of 2026-08-06 (today). This is what caused two symptoms reported against the widget: already-ended-today events missing entirely (never fetched into the cache in the first place, not a client-side rendering bug -- the 2026-08-05 Android "past events" feature was built correctly but had nothing to render) and the tomorrow section appearing empty (same skewed data window scrambling the day-bucketing downstream). Fix: compute TimeMin from time.Now().In(displayTZ)'s start-of-day instead. Added TestGetUpcomingEvents_TimeMinIsStartOfTodayInDisplayTimezone, which asserts the actual outgoing timeMin query parameter is midnight-in-tz and lands on today's date -- verified it catches the regression by reverting to raw time.Now() against a real backup and confirming the exact failure mode (captured timeMin = mid-afternoon the day before), then restored. go test ./... -race is green. Deployed. Note: the calendar cache only refreshes when the web dashboard is visited (aggregateData) -- the widget's own refresh only re-reads the DB cache, never triggers a live Calendar resync -- so the currently-cached stale data needs one dashboard visit to actually reflect this fix, not just the deploy.
2026-08-06Widget: target Android 16 (API 36) natively, no compatibility fallbackPeter Stone
This is a single-user app, sideloaded onto exactly one phone, never distributed broadly -- "what's the widest range of devices this should install on" was the wrong question to ask about it. A prior pass (2026-08-05) walked minSdk down to 31 on that reasoning, without checking what device this actually runs on: Android 16 / API 36. minSdk=targetSdk=compileSdk=36 now, matching the real device exactly. Verified both directions on real emulator targets, not assumed: installs and launches cleanly (no crash, target_sdk_version=36 confirmed via logcat) on a new dedicated API 36 AVD (doot_test_api36), and correctly refuses to install on the existing API 34 emulator (INSTALL_FAILED_OLDER_SDK) -- proving the constraint is real, not just declared. AGP 8.2.0's bundled D8 still warns "API level of 36 is not supported by this compiler" even with build-tools;36.0.0 installed locally (AGP doesn't delegate to the standalone SDK build-tools binary) -- confirmed harmless via the install+launch verification above, not chased further since a real fix means upgrading AGP itself, a bigger change than justified here. Audited the rest of the app for SDK-version fallback/polyfill code that could now be simplified given the guaranteed floor: found none: the only place minSdk mattered was this file.
2026-08-05Widget: fix "complete works once, then stops" race between overlapping ↵Peter Stone
completions Root cause traced from server logs, not guessed: every completion request was succeeding server-side (100% 200s, including a 5-tap burst spanning different tasks), and each successful completion's response payload was correctly shrinking. So the failure wasn't dispatch or network -- it was that a successful completion could still get silently undone client-side. fetchAndPersist does an unconditional full overwrite of the cached item list on every successful GET. CompleteWorker/DeferWorker run one instance per task id with no ordering guarantee between different ids' workers (different unique work names, no KEEP protection across them -- that protection only ever covered same-task double-taps). So: tapping complete on task A starts a GET that's still in flight; tapping complete on task B before A's GET returns optimistically removes B locally; A's slower GET response, captured before B's completion landed, then overwrites the cache and silently resurrects B. Fix: track locally-optimistic removals with a timestamp (PendingRemovals.kt) and filter them out of every fetchAndPersist write for a bounded TTL (2 min), regardless of which worker's fetch is doing the writing. The TTL means a completion that never actually confirms (permanent network failure) still self-heals via the next periodic refresh, matching an existing self-healing property already relied on elsewhere in this codebase, instead of hiding the task forever. Added PendingRemovalsTest.kt (pure-function unit tests, no Android runtime needed) covering the exact race scenario plus TTL expiry and edge cases. Verified the tests actually catch a regression by deliberately reverting the fix to a no-op against a real backup, confirming 3 tests failed with the exact expected assertion, then restoring and confirming green again. Built, tested, and published as doot-widget.apk.
2026-08-04Widget quick-add: surface errors instead of failing silentlyPeter Stone
addTask()'s failure path had no .onFailure handler, so a failed POST (network hiccup, server down) left the Add sheet just sitting there with zero feedback -- looked like the button did nothing. Also: the five calls chained after a successful addTask (updateTask, reschedule, setTaskProject, setTaskLabels, setTaskRecurrence) were still unchecked, so a task could get created with silently-dropped metadata even with the addTask fix in place. Both paths now collect and toast what failed; the task itself still gets created and the sheet still closes, since aborting would leave a task that already exists server-side with no way to tell the user without a duplicate.