Skip to content

fix: order collisions, task-complete undo, filing from a fresh install, and four defects - #10

Merged
malinfossum merged 8 commits into
mainfrom
fix/task-bugs
Aug 17, 2026
Merged

fix: order collisions, task-complete undo, filing from a fresh install, and four defects#10
malinfossum merged 8 commits into
mainfrom
fix/task-bugs

Conversation

@malinfossum

Copy link
Copy Markdown
Owner

Eight commits. Every change is either a defect confirmed by reproduction or a fix you asked for directly.

The "can move up but not down" bug — confirmed, root cause found

create() set order = siblings.length. A delete leaves a hole, so the count can equal an order still in use: create three tasks (0, 1, 2), delete the middle one, and the next create takes order 2 again.

Two tasks sharing an order make swapOrder a silent no-op — swapping two equal values changes nothing — so "Move down" appears in the menu and does nothing. It also makes the sort order between the pair arbitrary, because the view and the controller sort from different sources.

Proven with a failing test before the fix:

result
d.order after a delete-induced gap 2, colliding with c.order
list after swapOrder(c, d) unchanged

moveToSection already used max(order)+1 with a comment explaining this exact hazard. create now does the same — in all three models: tasks, sections and areas all had it, and all three drive a Move up/down.

Note: you said moving works on your phone. The defect is real regardless — it only bites once a delete has left a gap, so a database that has never hit that state behaves fine.

Undo when a task is checked off

Completing a plain task wrote straight to the model with no feedback and no way back; only recurring tasks got a toast. Now shows "Task completed" with Undo, in the completing direction only — un-checking a task is the undo.

Undo sets completed:false explicitly rather than toggling again, because you can un-check the box by hand while the toast is still up and a second toggle would re-complete it.

Filing a task from the Focus tab

Not a bug — a design gap. "Move to…" was gated on sections.length > 1, so a fresh install (one area, one section) never saw the item at all, and the notepad's File shortcut was gated the same way. There was no route from a task to anywhere else and no hint one existed.

The picker now carries "+ New section…", and both gates are gone. The new section is created in the task's own area, so you are never asked to pick one. Undo removes the created section as well as moving the task back, so it is a complete reversal rather than a partial one.

Four more defects from the backlog

  • Toast "Undo" that did nothing — every toast rendered the button, including those passing no onAction, so clicking it merely dismissed and told you a reversal had happened when none had.
  • Three in-flight races — capture and the section add row both held their text across an awaited write, so a fast double-Enter created duplicate tasks; onCycleTheme read a value the write only updates afterwards, so two fast clicks skipped a cycle step.
  • Next hero misaligned.next-card's own inline padding nested inside .task's, putting the hero row 16px in from both edges relative to every group row.
  • Heading levels skipped — group headings were <h3> under the page <h1>; the Next card's <h2> bridged it on Today, but the other three tabs had no bridge.

Verified

  • 251 tests pass (was 247; +4 regression tests), biome check . clean, npm run build clean, no console errors
  • Move-down: failing tests written first, then fixed
  • Undo on complete: full cycle in the browser — completed 0 → 1, row disappears, toast; Undo → completed 0, row returns
  • New section: 1 section → create → 2 sections + task moved → Undo → 1 section + task back
  • Alignment: left, right, title and star edges all match the group rows exactly at 375px and 1280px, no horizontal scroll
  • Theme: two same-tick clicks advance two steps

Not covered

A real-device pass is still owed and I can't do it — the in-app browser is desktop Chromium at a resized viewport. Worth noting that a CDP click landing dead centre on the 16px checkbox did not fire the handler, while a scripted click did; that's the same tool limitation already recorded for Enter, not an app bug, but it means touch behaviour here is unverified.

create() set `order = siblings.length`, but a delete leaves a hole, so the
count can equal an order still in use: create three tasks (0, 1, 2), delete
the middle one, and the next create takes order 2 again.

Two tasks sharing an order make swapOrder a SILENT no-op — swapping two
equal values changes nothing — so the "Move down" menu item appears and
does nothing. It also makes the sort order between the colliding pair
arbitrary, because both call sites sort by `order` from different sources
and a stable sort then preserves two different input orders.

Use max(order)+1, which is what moveToSection already does, with a comment
there explaining exactly this hazard.

Two regression tests: one pins create()'s gap-robustness, one drives a real
reorder across a delete-induced gap and asserts the list actually changes.
Every toast rendered an "Undo" button, including the ones that pass no
onAction (onSaveRecurrence, onRemoveRecurrence). Clicking it merely
dismissed the toast, so the user was told a reversal had happened when
nothing had been reversed — worse than showing no button at all.

Render the button only when onAction is supplied, and drop the now-dead
`if (onAction)` guard inside the handler.
Completing a plain task wrote straight to the model with no feedback and no
way back — only recurring tasks got a toast. An accidental tap on a 16px
checkbox silently removed the task from view.

Show a "Task completed" toast with Undo, in the completing direction only:
un-checking a task IS the undo, so a toast there would offer to redo what
the user just reversed.

Undo sets completed:false explicitly rather than calling toggleCompleted a
second time. The user can un-check the box by hand while the toast is still
up, and a second toggle would then re-complete the task instead of undoing
anything.
Same defect just fixed in tasks.create: both assigned `order` from the
sibling COUNT, and a delete leaves a hole, so the count can equal an order
still in use. Both models expose swapOrder and drive a Move up/down menu,
and swapping two equal order values is a silent no-op.

Use max(order)+1 in both. One regression test each, asserting the orders
stay distinct across a delete-induced gap.
"Move to…" was gated on there being another section to move to, so a fresh
install — one area, one section — never saw the item at all. There was no
route from a task to somewhere else, and nothing hinting one existed. The
File shortcut on the notepad was gated the same way and so never appeared
either.

Add "+ New section…" to the move picker and drop the gate from both. The
new section is created in the task's own area, so the user is never asked
to pick one, and the move then goes through the ordinary path — same toast,
same cascade-race swallows.

Undo removes the created section as well as moving the task back. The
section exists only because of this action, so leaving an empty stray would
make undo a partial reversal. It is removed only after the task has moved
out, so the delete can never take a task with it.
All three share a shape: read state, await a write, act on the value read
before the await. Precedent for the fix is handleToggleComplete's
`completing` Set.

- capture commit(): the input is deliberately not cleared until the write
  resolves, so a second Enter mid-write read the same text and created a
  duplicate task. Guarded with a boolean.
- the section "Add task" row: identical, guarded with a Set keyed by
  section so two different rows can still commit concurrently.
- onCycleTheme: read currentChoice, which applyState only updates after the
  round-trip, so two fast clicks computed the same next choice and the
  cycle skipped a step. Advances an optimistic cursor instead, re-synced to
  the model in applyTheme so a system flip cannot leave it stale.

Verified in the browser: two same-tick clicks on the theme control now
advance two steps (system → dark).
…levels

Two defects surfaced by the Plan 3 reviews.

Alignment: `.next-card` carried its own 16px inline padding, which nested
inside `.task`'s, so the hero row sat 16px in from both edges — its title
and its right-hand columns were 16px off every group row below. §7 asks for
alignment "across every group" and the hero is not a group, so no check
covered it. The card now pads vertically only and the "Next" label supplies
its own inset. Measured at 375px and 1280px: left, right, title and star
edges all match the group rows exactly.

Heading levels: group headings were <h3> under the page <h1>. On the Today
tab the Next card's <h2> bridged the gap, but the other three tabs have no
Next card, so the outline skipped a level. The Next card and the groups are
siblings in the same panel, so the groups belong at <h2> too.
onSaveRecurrence and onRemoveRecurrence close the dialog with
rerender:false, which sets a pending-focus flag and leaves the write's
notify to consume it. On the /not found/ swallow there is no write, so no
notify and no render, and the flag survives.

That used to be harmless. With the focus fallback added in Plan 3 it is
not: the flag is consumed by the next 60s tick instead, pulling focus out
of whatever the user had moved on to up to a minute later.

Render explicitly on both swallow paths so the flag is spent immediately.
@malinfossum
malinfossum merged commit d10035b into main Aug 17, 2026
1 check passed
@malinfossum
malinfossum deleted the fix/task-bugs branch August 17, 2026 09:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant