<feed xmlns='http://www.w3.org/2005/Atom'>
<title>doot.git, branch master</title>
<subtitle>doot — personal productivity web app
</subtitle>
<id>https://git.terst.org/doot.git/atom?h=master</id>
<link rel='self' href='https://git.terst.org/doot.git/atom?h=master'/>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/'/>
<updated>2026-11-08T20:38:43+00:00</updated>
<entry>
<title>A guide for an agent creating doot tasks</title>
<updated>2026-11-08T20:38:43+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-08T20:38:43+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=2cd25ba21d7194428f84db2a544aed81a0a294fd'/>
<id>urn:sha1:2cd25ba21d7194428f84db2a544aed81a0a294fd</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Surface a dead OAuth grant instead of reporting it as connected</title>
<updated>2026-09-20T17:44:02+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T17:44:02+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=6431cad59bda3da4bafb58fdf91f6a946e42af43'/>
<id>urn:sha1:6431cad59bda3da4bafb58fdf91f6a946e42af43</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>gofmt the files added in the previous commit</title>
<updated>2026-09-20T08:12:18+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T08:12:18+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=464f9e09c6cde9a2fe66a18500736206ae233d0a'/>
<id>urn:sha1:464f9e09c6cde9a2fe66a18500736206ae233d0a</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Make shopping items editable: tap to rename, move store/section, delete</title>
<updated>2026-09-20T08:11:38+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T08:11:38+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=c7b18e70ea87c287240243aed867b3a8b51689cb'/>
<id>urn:sha1:c7b18e70ea87c287240243aed867b3a8b51689cb</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Fix Google Tasks: token refresh used a cancelled context</title>
<updated>2026-09-20T08:05:38+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T08:05:38+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=4c22de26ed3720411c2c2351f900dd4c1415b3f8'/>
<id>urn:sha1:4c22de26ed3720411c2c2351f900dd4c1415b3f8</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Add .ics calendar feed support</title>
<updated>2026-09-20T07:57:36+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T07:57:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=4d160762aeef531208f8cf6094a0e58a9e7d53ec'/>
<id>urn:sha1:4d160762aeef531208f8cf6094a0e58a9e7d53ec</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Drop Trello as a shopping source, importing the two groups worth keeping</title>
<updated>2026-09-20T07:47:48+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T07:47:48+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=c5ab2f9a7f6a0b1e456e13934268d1509504c55a'/>
<id>urn:sha1:c5ab2f9a7f6a0b1e456e13934268d1509504c55a</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Stop per-store add forms jumping to the bottom of the page</title>
<updated>2026-09-20T01:32:44+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T01:32:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=b970be11ee895c34e019231022e9e5244e4aec2f'/>
<id>urn:sha1:b970be11ee895c34e019231022e9e5244e4aec2f</id>
<content type='text'>
The inline "Add to &lt;store&gt;..." 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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Fold the DB-ping health check in; delete the watchdog that never ran</title>
<updated>2026-09-20T00:36:17+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T00:36:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=36a82d934366cc2ddbd92ee3885b1c547eeb8244'/>
<id>urn:sha1:36a82d934366cc2ddbd92ee3885b1c547eeb8244</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Verify deploys, and add a self-recovering health check</title>
<updated>2026-09-20T00:34:23+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-20T00:34:23+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=c58f7519936dc547ee2aec5bfbb1e477356656d9'/>
<id>urn:sha1:c58f7519936dc547ee2aec5bfbb1e477356656d9</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
</feed>
