Skip to content

get-todos finds tasks carried forward by block reference - #16

Merged
muellerei merged 8 commits into
mainfrom
fix/get-todos-follow-block-refs
Sep 16, 2026
Merged

muellerei merged 8 commits into
mainfrom
fix/get-todos-follow-block-refs

Conversation

@muellerei

Copy link
Copy Markdown
Owner

Closes #15.

A todo block exists once. Carrying it into later journals is done with a ((block-ref)), which is not a copy but the same block in a second place — checking off the reference checks off the original. get-todos found tasks through :block/page alone, so a task carried forward was invisible to every date range:

$ get-todos --from 2026-09-14 --to 2026-09-16 --json
{"todos": [], "count": 0}        # three tasks stood in those journals

The failure was quiet, which is what made it hard to catch: an empty task list looks like an empty week. The same reading is what Logseq's own (between …) does, which is why this arrives in the forum rather than in a bug tracker — "the tasks are not in the journal pages and the between query only looks at the journal page dates" (discuss.logseq.com).

What changed

:block/refs is a real relation, so no string matching on the ((uuid)) form. One extra query for the whole command, not one per task — ~0.17s against 256 tasks.

A task stays one row. page and uuid still name the original block; the days it was carried into are added as references, sorted newest first because Datalog guarantees no order.

  • --refs-limit N (default 10, 0 keeps all) caps the list; the remainder is reported as references_withheld
  • --no-follow-refs restores the older reading and skips the read rather than paying for it

Why 10 and not 3

get-backlinks --limit defaults to 3 and the issue proposed inheriting it. Measured first — it does not carry over, because an entry there is a block of text and an entry here is a date. Against the live graph (58 carried tasks, 204 occurrences):

--refs-limit tasks trimmed dates kept
3 12 of 58 (21%) 110 of 204
10 4 of 58 (7%) 168 of 204
20 1 of 58 191 of 204

The distribution breaks at 10: above it sit only four long-carried tasks (11, 14, 18, 33 occurrences) — exactly what references_withheld is for.

Breaking

A range query can return more tasks than before, up to 58 more on the measured graph. Nothing was removed; page and uuid are unchanged, and a task with no references carries no new field. CHANGELOG entry sits under Changed.

The jd is None rule from #4 is untouched and now governs reference pages too: 44 of 248 occurrences sit on ordinary pages and fall out of a range, rather than reopening the gap #4 closed.

Verification

  • 26 tests on this path; each was checked by removing the fix and confirming it fails
  • Full suite: 746 green
  • Live graph: --no-follow-refs reproduces the defect exactly, the default returns the three tasks

Two things only the real graph showed: a pull answers None rather than {} for a page with none of the pulled attributes, and plain-text output needed semicolons — a journal page is named 2026-09-16, Wednesday, so comma-joining two of them reads as four.

Docs

README command table and a new design section ("A task is where it stands, not only where it was written"), AGENTS.md, CHANGELOG, and the --help text for --from, which claimed tasks were dated by the page they sit on.

A todo block exists once. Carrying it into later journals is done with a
((block-ref)), which is not a copy but the same block in a second place —
checking off the reference checks off the original. get-todos found tasks
through :block/page alone, so a task carried forward was invisible to every
date range: --from 2026-09-14 --to 2026-09-16 returned nothing on a graph
where three tasks stood in exactly those journals.

The failure was quiet, which is what made it hard to catch: an empty task
list looks like an empty week.

Resolve the :block/refs relation instead, in one extra query for the whole
command rather than one per task (~0.17s against 256 tasks). A task stays one
row: page and uuid keep naming the original block, and the days it was carried
into are added as references, sorted newest first because Datalog guarantees
no order.

--refs-limit (default 10) caps that list and reports the rest as
references_withheld, the bargain get-backlinks --limit and find-block --limit
already make. 10 rather than their 3 because an entry here is a date, not a
block of text, and because the measured distribution breaks there: a cap of 3
trims 12 of 58 carried tasks, a cap of 10 trims 4. --no-follow-refs restores
the older reading and skips the read rather than paying for it.

A reference on a page carrying no journal-day falls out of a range, the same
rule origin pages have followed since 0.11.0 — 44 of 248 measured reference
occurrences sit on ordinary pages, and letting them through would reopen the
silent gap that decision closed.

Closes #15
"0 keeps all" reads as though --refs-limit were the only reason an occurrence
goes uncounted. It is not: with --from/--to, occurrences outside the range and
on pages with no journal-day are withheld too, so --refs-limit 0 leaves a
non-zero count and looks like a bug.

Found in review against a live graph, where it cost two runs to work out that
the withheld occurrences lay outside the queried week rather than above the
limit. The docstring on _place_references already explained it; the help text
and README did not, and those are what a caller reads.

Behaviour unchanged — a task carried since March must not look new just
because the query asked about this week. A test now holds the distinction,
verified by making --refs-limit 0 zero the counter and watching it fail.
Ten cases at the boundaries of the new block-ref handling, each written after
running it against the implementation and each checked by breaking the branch
it covers and confirming this test is the one that fails.

Two of them cover branches that would have failed quietly rather than loudly:

- The marker filter has to reach the reference query. Pass a fixed marker set
  instead of markers_str and nothing in the output looks wrong — occurrences
  simply attach to tasks --status was meant to exclude. The test asserts the
  query string, not the result, because the result cannot show this.
- A null reference result must not kill the command. LogseqAPI.call returns
  resp.json() and screens only dicts carrying an "error" key, so a null body
  arrives as None and the command dies on TypeError while the todo query
  itself succeeded.

Also pinned: the default cap of 10 at the boundary where an off-by-one would
show, refusal of a negative --refs-limit (without the guard, deduped[:limit]
silently drops the last entry and withheld overcounts), the order of dated
against undated occurrences, the original-name/name fallback, and the
plain-text branch that reports a count when no date survives the range.

Three further cases were written, measured, and dropped again: their plausible
mutations turned out behaviour-equivalent, so the tests would have recorded
coverage without holding anything.

Fixes the error text for a negative --refs-limit, which still said "0 keeps
all" after the help text stopped saying it.
The README's example block showed get-todos with two ordinary tasks, which is
the case that never had a problem. Someone who carries tasks forward by
((block-ref)) — the ordinary way to keep a task alive in Logseq — could read
the whole section without learning that the command now follows them.

Shows the output rather than describing it: [page] and "also on" side by side
say by themselves that origin and occurrence are different things and that
both are reported.
test_get_todos_markers checked rec.queries[0]. get-todos now builds two
queries, and the second one — the :block/refs read — carries the same markers.
The test passes today because both share one string formatted by edn_string,
so it holds by accident of the implementation rather than by checking what it
claims to check: give the reference query its own formatting and the test
stays green while the contract is gone.

Checks every query the command built instead. Verified by having
_fetch_todo_references format the markers itself, which the assertion now
catches and did not before.

Also names the cost of following refs in the --help notes, where the other
commands that pay for an extra read name theirs (--with-children "one extra
API read per match", --with-context "costs no extra read"). It sat only in a
code comment and in the CHANGELOG, neither of which a caller reads, and the
negative form on --no-follow-refs ("saves one read") is not where someone
deciding whether a call is expensive would look.
examples/weekly-todos.sh read `data.get('tasks', [])`, but get-todos --json
has answered `{"todos": …, "count": …}` since the initial import. The default
swallowed the mismatch: the script printed "Total: 0 open tasks" against any
graph, and the per-page breakdown under it stayed empty. That reads as a quiet
week, not as a broken example — the same shape of silent wrong answer this
tool argues against elsewhere.

Reads `data['todos']` now, so a future rename fails loudly instead of counting
zero. Two tests pin both halves: the example may only read keys the payload
carries, and the payload keeps carrying them.

Predates this branch by every commit — found while checking the block-ref work
for consistency against the rest of the repo.
A task's date used to be the day it was written down, so "how long have I been
carrying this?" had no answer in the payload. With occurrences resolved it
does: references counts the journals a task was pulled into inside the window,
references_withheld the ones before it, and their sum is a duration.

carried-over-todos.sh reports that, longest-carried first. It reads --json
once and passes the payload to both jq stages rather than querying twice, and
splits the PAYLOAD declaration from its assignment so a failed read stops the
script instead of printing an empty week — verified by pointing it at a closed
port and checking the exit code.

Run against a real graph before being committed, which is how two wordings got
fixed: a task written on the day it stands now reads "not carried" instead of
"carried: 0 day(s)", and the summary counts the same threshold as the detail
lines rather than one higher.

The example-payload test now scans the examples directory instead of naming
one file, so the next example is covered the day it is added.
The list under "Scripting Examples" is a hand-maintained view of a directory,
which is the defect this file already guards for the command tables: nothing
recomputed it, so a new script is listed only if whoever added it remembered.
Adding carried-over-todos.sh was that case — the entry was written by hand and
could as easily have been forgotten.

Derives the expectation from examples/*.sh instead, in both directions: a
script that is not listed fails, and a listed script that no longer exists
fails too. Verified by removing an entry and by inventing one.

CONTRIBUTING.md names only the directory, not the scripts, so it needs nothing.
@muellerei
muellerei merged commit 2d614c3 into main Sep 16, 2026
4 checks passed
@muellerei
muellerei deleted the fix/get-todos-follow-block-refs branch September 16, 2026 10:35
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.

get-todos: date range misses todos carried forward by block reference

1 participant