From 7a32f025e3ed1106ff8f2ea0d863358a09e18a5f Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 20:07:45 +0200 Subject: [PATCH 1/2] feat(wiki): group a topic's open items under their list's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A topic page listed the open items of its lists as one flat list, with nothing to say which list an item was on, who it was for or by when. Now each list naming the topic has its own group under its linked name, and an item shows the people it is for and its due day as `📅 16 Oct 2026`, in the household's date format. Items are copied from the list files, never through the model, and done items are not shown. Refs: #198 --- stacklets/memory/bot/cli/wiki.py | 24 ++++---- stacklets/memory/bot/open_items.py | 56 +++++++++++++++++++ stacklets/memory/bot/templates/topic.de.md | 3 +- stacklets/memory/bot/templates/topic.en.md | 3 +- stacklets/memory/bot/topic_page.py | 6 +- .../unit/stacklets/test_memory_topic_page.py | 23 ++++++-- 6 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 stacklets/memory/bot/open_items.py diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 0054c346..d3ac0314 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -86,13 +86,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import diary # noqa: E402 import home_page # noqa: E402 +import open_items # noqa: E402 import person_page # noqa: E402 import topic_page # noqa: E402 # One module owns what a todo line is; `stack memory topic` reads # through it the same way, by bare name from this folder. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from stack.lists import LISTS_DIR, ListError, read_list # noqa: E402 +from stack.lists import LISTS_DIR, FamilyList, ListError, read_list # noqa: E402 HELP = "Regenerate the family wiki's home and member pages" @@ -632,9 +633,10 @@ async def _generate_topic( # what was filed come from disk and the index, as they are. page_dir = f"{bucket_prefix}/{topic_slug}" sources = _topic_sources(entries, cross_refs, topic_slug, people or {}) - todos = _open_todos(page_dir) + lists = _family_lists(bucket_prefix) prompt = topic_page.prompt( - display=display, scope=scope, lang=lang, todos=todos, + display=display, scope=scope, lang=lang, + todos=open_items.topic_texts(lists, topic_slug), evidence_text=topic_page.evidence( sources, {n: e.get("summary") or "" for n, e in enumerate(entries + cross_refs, 1)}), ) @@ -647,7 +649,8 @@ async def _generate_topic( except topic_page.AnswerError as e: _err(f"{page_dir}: the model's answer is unusable ({e}); the page stays as it was") return 1 - page = topic_page.render(answer, display=display, sources=sources, todos=todos, lang=lang) + page = topic_page.render(answer, display=display, sources=sources, lang=lang, + open_items=open_items.topic_items(lists, topic_slug, lang)) page = _with_references(page, entries + cross_refs, page_dir=page_dir, lang=lang) @@ -697,19 +700,16 @@ def _family_names(people: dict[str, Person]) -> list[str]: for p in people.values()] -def _open_todos(page_dir: str) -> list[str]: - """The open items of the lists that name the topic at `page_dir`, in list order.""" - bucket, _, topic = page_dir.rpartition("/") - items: list[str] = [] +def _family_lists(bucket: str) -> list[tuple[str, FamilyList]]: + """Every list in `bucket` with its vault path; a file that does not read is skipped.""" + lists = [] for file in sorted((_brain_dir() / bucket / LISTS_DIR).glob("*.md")): path = f"{bucket}/{LISTS_DIR}/{file.name}" try: - lst = read_list(file.read_text(encoding="utf-8"), path=path) + lists.append((path, read_list(file.read_text(encoding="utf-8"), path=path))) except (OSError, ListError): continue - if topic in lst.topics: - items += [i.text for i in lst.open_items] - return items + return lists def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str: diff --git a/stacklets/memory/bot/open_items.py b/stacklets/memory/bot/open_items.py new file mode 100644 index 00000000..be8183b6 --- /dev/null +++ b/stacklets/memory/bot/open_items.py @@ -0,0 +1,56 @@ +"""The open items of the family's lists, as a topic page shows them. + +A list is a file of its own (`/lists/.md`, format in +`stack.lists`) that names its topics and links its items to people. The +wiki shows them where the family already looks: a topic's page lists the +open items of every list naming the topic, under the list's name. + +The items are copied as they are, never through the model, and a done +item is never shown: the page says what is still to do. A due day reads +`📅 16 Oct 2026`, the marker the list files use, in the household's +date format. + + lists = [("family/lists/trip.md", read_list(text, path="family/lists/trip.md"))] + topic_items(lists, "trip", "en") +""" + +from __future__ import annotations + +from page_template import format_date +from stack.lists import FamilyList, Item + +Lists = list[tuple[str, FamilyList]] + + +def topic_items(lists: Lists, topic: str, lang: str) -> str: + """The open items of the lists naming `topic`, each list under its linked name.""" + groups = [] + for path, lst in lists: + if topic not in lst.topics or not lst.open_items: + continue + lines = [_line(item, lang, _people(item)) for item in lst.open_items] + groups.append("\n".join([f"**{_link(path, lst)}**", *lines])) + return "\n\n".join(groups) + + +def topic_texts(lists: Lists, topic: str) -> list[str]: + """The open items' texts for `topic`, for a prompt that only needs to know them.""" + return [item.text for _, lst in lists if topic in lst.topics for item in lst.open_items] + + +def _line(item: Item, lang: str, names: list[str] | None = None) -> str: + """`- [ ] Book the lodge · Homer · 📅 16 Oct 2026`, with the `names` given.""" + bits = list(names or []) + if item.due: + bits.append(f"📅 {format_date(item.due, lang)}") + return " · ".join([f"- [ ] {item.text}", *bits]) + + +def _people(item: Item) -> list[str]: + """The names of the people an item is linked to, as the list writes them.""" + return [link.label for link in item.links if link.person] + + +def _link(path: str, lst: FamilyList) -> str: + # Absolute from the vault root, as every wiki link (see `_relative_link`). + return f"[{lst.title}](/{path})" diff --git a/stacklets/memory/bot/templates/topic.de.md b/stacklets/memory/bot/templates/topic.de.md index 869f04de..bad3ec38 100644 --- a/stacklets/memory/bot/templates/topic.de.md +++ b/stacklets/memory/bot/templates/topic.de.md @@ -10,7 +10,8 @@ Absatz mit leerem Platzhalter fällt ganz weg, samt Überschrift. summary eine Zeile: wofür das Thema der Familie dient, vom Modell facts die wichtigsten Fakten als Bezeichnung und Wert, vom Modell state was entschieden, erledigt oder geändert wurde, und von wem, vom Modell - todos die offenen Punkte der Aufgabenliste, wie die Familie sie führt + todos die offenen Punkte der Listen zum Thema, unter dem Namen der Liste, + vom Compiler übernommen notes, bookmarks, documents alles, was unter dem Thema abgelegt ist, das Neueste zuerst, vom Compiler elsewhere Einträge an anderer Stelle, die das Thema nennen, vom Compiler diff --git a/stacklets/memory/bot/templates/topic.en.md b/stacklets/memory/bot/templates/topic.en.md index aa17e442..3ff051ca 100644 --- a/stacklets/memory/bot/templates/topic.en.md +++ b/stacklets/memory/bot/templates/topic.en.md @@ -9,7 +9,8 @@ slot is empty is left out whole, heading included. summary one line: what the topic is for the family, from the model facts the key facts as label and value, from the model state what has been decided, done or changed, and by whom, from the model - todos the open items of the topic's todo list, as the family keeps it + todos the open items of the lists naming the topic, under each list's name, + copied by the compiler notes, bookmarks, documents everything filed under the topic, newest first, by the compiler elsewhere pages filed elsewhere that mention the topic, by the compiler diff --git a/stacklets/memory/bot/topic_page.py b/stacklets/memory/bot/topic_page.py index ca6de777..8218bc2b 100644 --- a/stacklets/memory/bot/topic_page.py +++ b/stacklets/memory/bot/topic_page.py @@ -82,8 +82,8 @@ class Source: def render(answer: Answer, *, display: str, sources: list[Source], - todos: list[str], lang: str) -> str: - """The topic page body. `todos` are the open items, in the list's order.""" + open_items: str, lang: str) -> str: + """The topic page body. `open_items` are the topic's lists, already written out.""" def listed(kind: str) -> str: rows = sorted((s for s in sources if s.kind == kind), key=lambda s: s.date, reverse=True) @@ -94,7 +94,7 @@ def listed(kind: str) -> str: "summary": answer.summary, "facts": "\n".join(fact(label, value, c) for label, value, c in answer.facts), "state": "\n".join(f"- {sentence(t)}{c}" for t, c in answer.state), - "todos": "\n".join(f"- [ ] {item}" for item in todos), + "todos": open_items, "notes": listed("note"), "bookmarks": listed("bookmark"), "documents": listed("document"), diff --git a/tests/unit/stacklets/test_memory_topic_page.py b/tests/unit/stacklets/test_memory_topic_page.py index d01222d7..43c6d33b 100644 --- a/tests/unit/stacklets/test_memory_topic_page.py +++ b/tests/unit/stacklets/test_memory_topic_page.py @@ -3,8 +3,8 @@ Read from the outside, as the family reads the published page. What these tests promise: -- The open items are the family's own todo list, exactly, and done - items are not shown. +- The open items are the family's own lists naming the topic, exactly, + under each list's name, and done items are not shown. - Everything filed under the topic is listed under its kind with who filed it and when, newest first; what was filed elsewhere has a section of its own. @@ -88,9 +88,24 @@ def _section(page: str, heading: str) -> str: class TestWhatTheCompilerLists: - def test_the_open_todos_as_the_family_keeps_them(self, brain): + def test_the_open_items_under_their_lists_name_as_the_family_keeps_them(self, brain): assert _section(_topic(brain, ANSWER), "Still to do") == ( - "- [ ] Pack sunscreen\n- [ ] Find a dog sitter") + "**[Trip](/family/lists/trip.md)**\n- [ ] Pack sunscreen\n- [ ] Find a dog sitter") + + def test_every_list_naming_the_topic_with_who_and_by_when(self, brain): + (brain / "family" / "lists" / "packing.md").write_text( + "---\ntype: list\ntitle: Packing\ntopics:\n - trip\n---\n\n" + "- [ ] Tent [Homer](../people/homer.md) 📅 2026-10-13\n", encoding="utf-8") + page = _topic(brain, ANSWER) + still = page.split("## Still to do\n", 1)[1].split("\n## ", 1)[0].strip() + assert still == ( + "**[Packing](/family/lists/packing.md)**\n- [ ] Tent · Homer · 📅 13 Oct 2026\n\n" + "**[Trip](/family/lists/trip.md)**\n- [ ] Pack sunscreen\n- [ ] Find a dog sitter") + + def test_the_model_reads_the_open_items_but_does_not_write_them(self, brain): + model = _Model(ANSWER) + _topic(brain, ANSWER, model=model) + assert "Pack sunscreen" in model.prompts[0] and "Book the lodge" not in model.prompts[0] def test_notes_newest_first_with_who_and_when(self, brain): assert _section(_topic(brain, ANSWER), "Notes") == ( From 02e792387fa55a4654d6406f45952c2f884f9d0c Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 12 Oct 2026 20:07:46 +0200 Subject: [PATCH 2/2] feat(wiki): show a person's open list items on their page A person's page said nothing about what is still theirs to do, though the lists link items to people. The page now has a "Still to do" section under the facts at a glance: the open items linked to the person from every list, soonest due first, each with its due day and a link to its list. A person with nothing open has no such section. Refs: #198 --- stacklets/memory/bot/cli/wiki.py | 1 + stacklets/memory/bot/open_items.py | 17 +++++++-- stacklets/memory/bot/person_page.py | 7 ++-- stacklets/memory/bot/templates/person.de.md | 4 +++ stacklets/memory/bot/templates/person.en.md | 4 +++ .../unit/stacklets/test_memory_person_page.py | 36 +++++++++++++++++++ 6 files changed, 65 insertions(+), 4 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index d3ac0314..f73e7ba7 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -569,6 +569,7 @@ async def _generate_member( parts, name=display, first_name=first_name, entries=entries, lately=lately, docs=docs, links={n: _reference(entries[n - 1])[1] for n in docs}, lang=lang, + open_items=open_items.person_items(_family_lists(shared_bucket), slug, lang), ) page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug, lang=lang) diff --git a/stacklets/memory/bot/open_items.py b/stacklets/memory/bot/open_items.py index be8183b6..e46152c7 100644 --- a/stacklets/memory/bot/open_items.py +++ b/stacklets/memory/bot/open_items.py @@ -1,9 +1,11 @@ -"""The open items of the family's lists, as a topic page shows them. +"""The open items of the family's lists, as a topic page and a person page show them. A list is a file of its own (`/lists/.md`, format in `stack.lists`) that names its topics and links its items to people. The wiki shows them where the family already looks: a topic's page lists the -open items of every list naming the topic, under the list's name. +open items of every list naming the topic, under the list's name; a +person's page lists the open items linked to them, from every list, +soonest due first. The items are copied as they are, never through the model, and a done item is never shown: the page says what is still to do. A due day reads @@ -12,6 +14,7 @@ lists = [("family/lists/trip.md", read_list(text, path="family/lists/trip.md"))] topic_items(lists, "trip", "en") + person_items(lists, "homer", "en") """ from __future__ import annotations @@ -33,6 +36,16 @@ def topic_items(lists: Lists, topic: str, lang: str) -> str: return "\n\n".join(groups) +def person_items(lists: Lists, person: str, lang: str) -> str: + """The open items linked to `person`, soonest due first, each naming its list.""" + theirs = [(item, path, lst) for path, lst in lists for item in lst.open_items + if person in item.people] + # Undated items come after every dated one, in the order of their lists. + theirs.sort(key=lambda found: (not found[0].due, found[0].due)) + return "\n".join(f"{_line(item, lang)} · {_link(path, lst)}" + for item, path, lst in theirs) + + def topic_texts(lists: Lists, topic: str) -> list[str]: """The open items' texts for `topic`, for a prompt that only needs to know them.""" return [item.text for _, lst in lists if topic in lst.topics for item in lst.open_items] diff --git a/stacklets/memory/bot/person_page.py b/stacklets/memory/bot/person_page.py index bd7a7318..f530eca4 100644 --- a/stacklets/memory/bot/person_page.py +++ b/stacklets/memory/bot/person_page.py @@ -128,12 +128,14 @@ def read_answer(raw: str, *, lately: list[int]) -> Parts: # ── The page ───────────────────────────────────────────────────────────── def render(parts: Parts, *, name: str, first_name: str, entries: list[dict], - lately: list[int], docs: list[int], links: dict[int, str], lang: str) -> str: + lately: list[int], docs: list[int], links: dict[int, str], lang: str, + open_items: str = "") -> str: """The page body, from the template for `lang`. `links` maps a citation number to the published path of its source, for the Documents list. Entries the model wrote no line for fall - back to their title, so Lately never loses an entry. + back to their title, so Lately never loses an entry. `open_items` + are the list items linked to the person, already written out. """ lang = language(lang) lately_lines = [ @@ -157,6 +159,7 @@ def render(parts: Parts, *, name: str, first_name: str, entries: list[dict], "more": more, "lately": "\n".join(lately_lines), "documents": "\n".join(doc_lines), + "todos": open_items, } return fill(template("person", lang), slots) diff --git a/stacklets/memory/bot/templates/person.de.md b/stacklets/memory/bot/templates/person.de.md index 1ec76256..3a1863f0 100644 --- a/stacklets/memory/bot/templates/person.de.md +++ b/stacklets/memory/bot/templates/person.de.md @@ -9,6 +9,7 @@ keine Dokumente hat, bekommt keine Überschrift „Dokumente“. tagline eine warme Zeile, vom Modell geschrieben about ein kurzer Absatz, vom Modell geschrieben facts geboren, Schule oder Beruf, Arzt, Auto: Bezeichnung und Wert, vom Modell + todos die offenen Listenpunkte für sie, nächster Termin zuerst, vom Compiler übernommen loves Aufzählung, vom Modell geschrieben more ein Abschnitt, den das Modell mit eigener Überschrift ergänzen kann lately die neuesten Einträge über die Person, vom Compiler datiert @@ -24,6 +25,9 @@ Dieser Kommentar wird nicht veröffentlicht. ## Auf einen Blick {{facts}} +## Noch zu tun +{{todos}} + ## Was {{first_name}} liebt {{loves}} diff --git a/stacklets/memory/bot/templates/person.en.md b/stacklets/memory/bot/templates/person.en.md index acecb34f..2dd4b118 100644 --- a/stacklets/memory/bot/templates/person.en.md +++ b/stacklets/memory/bot/templates/person.en.md @@ -9,6 +9,7 @@ documents has no Documents heading. tagline one warm line, written by the model about a short paragraph, written by the model facts born, school or job, doctor, car: label and value, from the model + todos the open list items linked to them, soonest due first, copied by the compiler loves bullets, written by the model more a section the model may add, with its own heading lately the latest entries naming them, dated by the compiler @@ -24,6 +25,9 @@ This comment is not published. ## At a glance {{facts}} +## Still to do +{{todos}} + ## What {{first_name}} loves {{loves}} diff --git a/tests/unit/stacklets/test_memory_person_page.py b/tests/unit/stacklets/test_memory_person_page.py index f131deb7..f9abf4d5 100644 --- a/tests/unit/stacklets/test_memory_person_page.py +++ b/tests/unit/stacklets/test_memory_person_page.py @@ -100,6 +100,42 @@ def test_documents_lists_only_what_was_filed_as_a_document(self, brain): "(/family/documents/2025/01/2025-01-18-birth-certificate-p47.md) · 18 Jan 2025") +class TestWhatIsStillToDo: + """The list items that are Maggie's, from every list, soonest due first.""" + + LISTS = { + "schule": ("---\ntype: list\ntitle: Schule\n---\n\n" + "- [ ] Vaccination card [Maggie](../people/maggie.md)\n" + "- [ ] Field trip form [Maggie](../people/maggie.md) 📅 2026-10-20\n" + "- [x] Lunch money [Maggie](../people/maggie.md) 📅 2026-10-01\n"), + "einkauf": ("---\ntype: list\ntitle: Einkauf\n---\n\n" + "- [ ] Pacifier [Maggie](../people/maggie.md) 📅 2026-10-14\n" + "- [ ] Duff [Homer](../people/homer.md)\n"), + } + + def _with_lists(self, brain): + (brain / "family" / "lists").mkdir(parents=True) + for slug, text in self.LISTS.items(): + (brain / "family" / "lists" / f"{slug}.md").write_text(text, encoding="utf-8") + + def test_open_ones_only_soonest_due_first_each_with_its_list(self, brain): + self._with_lists(brain) + page = _page(brain, ANSWER, [FIRST_WORD]) + assert page.split("## Still to do\n", 1)[1].split("\n\n", 1)[0] == ( + "- [ ] Pacifier · 📅 14 Oct 2026 · [Einkauf](/family/lists/einkauf.md)\n" + "- [ ] Field trip form · 📅 20 Oct 2026 · [Schule](/family/lists/schule.md)\n" + "- [ ] Vaccination card · [Schule](/family/lists/schule.md)") + + def test_it_sits_under_the_facts_at_a_glance(self, brain): + self._with_lists(brain) + page = _page(brain, ANSWER, [FIRST_WORD], lang="de") + assert "## Noch zu tun\n- [ ] Pacifier · 📅 14. Okt. 2026" in page + + def test_nothing_open_for_her_no_heading(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD]) + assert "## Still to do" not in page + + class TestAnEmptySectionIsNotThere: def test_no_documents_no_documents_heading(self, brain): page = _page(brain, ANSWER, [FIRST_WORD, SQUISHEE])