<feed xmlns='http://www.w3.org/2005/Atom'>
<title>doot.git/web, 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-20T08:11:38+00:00</updated>
<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>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>Swap V2cam feed for the Golden Globe Race tracker map</title>
<updated>2026-09-08T06:44:15+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-08T06:44:15+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=55f7605862f6e7fc5b0ddd80d6abff510cdf79eb'/>
<id>urn:sha1:55f7605862f6e7fc5b0ddd80d6abff510cdf79eb</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Split timeline width across lanes instead of stacking overlapping events</title>
<updated>2026-09-07T07:07:32+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-07T07:07:32+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=86f1486b77cb345f3cfdacecbc676692cd0d89f7'/>
<id>urn:sha1:86f1486b77cb345f3cfdacecbc676692cd0d89f7</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Make the day's list a pull of committed work, not a push of everything unblocked</title>
<updated>2026-09-03T02:34:25+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-03T02:34:25+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=1477ee531045388de9e7e797200e5cd14e4a6d0e'/>
<id>urn:sha1:1477ee531045388de9e7e797200e5cd14e4a6d0e</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Fix swipe-nav false positives on scroll, shopping quick-add overflow</title>
<updated>2026-08-30T20:46:36+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-30T20:46:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=a17a15966be5db6de423cdcb7c427195102b99d8'/>
<id>urn:sha1:a17a15966be5db6de423cdcb7c427195102b99d8</id>
<content type='text'>
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 &lt;select&gt;
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Fix web quick-add keyboard viewport bug and shopping-mode add scroll jump</title>
<updated>2026-08-29T21:22:36+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-29T21:22:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=533d89befd1fe4b8134440e8d415d40a5fb7c04b'/>
<id>urn:sha1:533d89befd1fe4b8134440e8d415d40a5fb7c04b</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Remove confirmed-orphaned routes, handlers, and templates</title>
<updated>2026-08-24T06:20:52+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-24T06:20:52+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/doot.git/commit/?id=5c659d9046add8c77bd7699961a910505dcb4bc2'/>
<id>urn:sha1:5c659d9046add8c77bd7699961a910505dcb4bc2</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
</content>
</entry>
</feed>
