<feed xmlns='http://www.w3.org/2005/Atom'>
<title>doot.git/internal/api, 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-09-20T17:44:02+00:00</updated>
<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>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>Replace Google Tasks service-account auth with real OAuth</title>
<updated>2026-08-16T00:03:51+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-16T00:03:51+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=3660486153a16760d2b980e546bbbd29408fb8d4'/>
<id>urn:sha1:3660486153a16760d2b980e546bbbd29408fb8d4</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Fix integration papercuts found in cross-source review</title>
<updated>2026-08-14T08:12:29+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-14T08:12:29+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=b55cfbbd433bed6035dfa228ee700e2cca060ca4'/>
<id>urn:sha1:b55cfbbd433bed6035dfa228ee700e2cca060ca4</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Fix blank task-detail modal for Google Tasks</title>
<updated>2026-08-13T08:38:29+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-13T08:38:29+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=a03d7673e7adf9a575c7272b2b528a74b230535e'/>
<id>urn:sha1:a03d7673e7adf9a575c7272b2b528a74b230535e</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Fix Google Calendar TimeMin using server time instead of display timezone</title>
<updated>2026-08-06T01:50:56+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-06T01:50:56+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=c8ebaba6e04169359d84e00ffa2d797f40bc077e'/>
<id>urn:sha1:c8ebaba6e04169359d84e00ffa2d797f40bc077e</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Fix production wedge: propagate context to Google Calendar API calls</title>
<updated>2026-08-04T20:10:25+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-04T20:10:25+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=0a410243dea33f204764000be81814e541dcae48'/>
<id>urn:sha1:0a410243dea33f204764000be81814e541dcae48</id>
<content type='text'>
Three .Do() calls in google_calendar.go accepted a ctx parameter but
never chained .Context(ctx) into the actual SDK call, so the existing
global 60s request timeout never reached the blocking network call.
One hung Google Calendar request wedged every DB/session-touching
request path in production for three days (2026-08-01 through
2026-08-04), undetected because /health unconditionally returned 200
throughout.

- Wire .Context(ctx) into GetUpcomingEvents, GetEventsByDateRange, and
  GetCalendarList.
- Bound aggregateData's four external fetches with a per-fetch
  sub-context as defense-in-depth (only effective if the callee
  actually honors ctx -- documented as such, not oversold).
- Make GetUpcomingEvents/GetEventsByDateRange fetch calendars
  concurrently instead of sequentially: a review of this fix caught
  that a shared per-fetch deadline over a sequential loop would starve
  calendars past the first under any real latency, silently caching
  partial results as complete. Concurrent fetches give every calendar
  an equal shot at the same deadline instead.
- /health now does a real PingContext DB check instead of a static
  "ok" (Handler.PingDB, tested for both healthy and closed-DB cases).
- Add internal/api/context_audit_test.go: an AST-based structural
  guard that fails any future .Do() call in google_*.go missing
  .Context(...) anywhere in its chain, so this class of bug can't
  silently recur. Verified by deliberately reintroducing the original
  bug against a backup and confirming the guard catches it.
- Add scripts/health-watchdog.sh: cron job restarts the service if
  /health fails twice in a row, five minutes apart.

go test ./... -race is green. Deployed and live-verified.
</content>
</entry>
<entry>
<title>fix: Google Tasks due dates shifted a day earlier in negative-offset timezones</title>
<updated>2026-07-28T02:37:23+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-07-28T02:37:23+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=2da86b009c76bb7688103da49a9125d35d3a1ed8'/>
<id>urn:sha1:2da86b009c76bb7688103da49a9125d35d3a1ed8</id>
<content type='text'>
Google Tasks' due field is date-only but always serialized as an
RFC3339 timestamp fixed at midnight UTC regardless of the user's
timezone. Converting that instant into displayTZ (dueDate.In(tz))
reinterpreted it as a real moment in time instead of re-anchoring the
same calendar date to local midnight -- midnight UTC becomes 2pm the
previous day in UTC-10 (Pacific/Honolulu), so every dated Google Task
silently landed one day earlier than its actual due date. A task due
tomorrow showed up in today's section.

Fix: extract the Y/M/D from the UTC-anchored timestamp (which IS the
intended calendar date) and rebuild midnight in displayTZ from those
components, instead of converting the instant.
</content>
</entry>
</feed>
