diff --git a/src/socrates120x/timeline.py b/src/socrates120x/timeline.py index 7f455ea..a9d75d0 100644 --- a/src/socrates120x/timeline.py +++ b/src/socrates120x/timeline.py @@ -129,7 +129,18 @@ def _sprint_events(project: Path) -> list[TimelineEvent]: return events -_DATED_DECISION = re.compile(r"\((\d{4}-\d{2}-\d{2})\)") +# The date stamp `socrates decide` and `_decisions_md` both emit is +# anchored at the END of the bullet, immediately before the closing +# `**` and optional trailing whitespace. Anchoring here prevents a +# user-typed date in the decision body (e.g. "Migrate by (2024-12-31) +# (2026-05-20)") from being misread as the recording date — the +# previous unanchored `\((\d{4}-\d{2}-\d{2})\)` regex took the FIRST +# match in the line. +_DATED_DECISION_END = re.compile(r"\((\d{4}-\d{2}-\d{2})\)\*{0,2}\s*$") +# Fallback: any (YYYY-MM-DD) anywhere in the line, in case the line +# does NOT end in the canonical `)**` (older files, hand-edited +# bullets). Used only if the anchored match fails. +_DATED_DECISION_ANY = re.compile(r"\((\d{4}-\d{2}-\d{2})\)") def _decision_events(project: Path) -> list[TimelineEvent]: @@ -141,7 +152,7 @@ def _decision_events(project: Path) -> list[TimelineEvent]: stripped = line.lstrip() if not stripped.startswith("- "): continue - m = _DATED_DECISION.search(stripped) + m = _DATED_DECISION_END.search(stripped) or _DATED_DECISION_ANY.search(stripped) if not m: continue try: @@ -149,7 +160,9 @@ def _decision_events(project: Path) -> list[TimelineEvent]: except ValueError: continue content = stripped[2:] # strip "- " - content = _DATED_DECISION.sub("", content).strip() + # Strip ONLY the trailing date stamp (anchored) so dates that appear + # in the body are preserved in the rendered timeline entry. + content = _DATED_DECISION_END.sub("", content).strip() content = content.strip("*").strip() events.append(TimelineEvent( date=d, diff --git a/tests/test_timeline.py b/tests/test_timeline.py index f4b65fc..9011520 100644 --- a/tests/test_timeline.py +++ b/tests/test_timeline.py @@ -85,3 +85,50 @@ def test_format_timeline_renders_events(project: Path) -> None: text = format_timeline(events, use_color=False) assert yesterday.isoformat() in text assert "[journal]" in text + + +def test_decision_with_user_date_in_body_uses_trailing_recording_date( + project: Path, +) -> None: + """User decision text can mention a date in parens (e.g. a deadline). + The PREVIOUS unanchored regex took the FIRST date in the line, which + was the user's date, not the date the decision was recorded. Anchor + to the trailing `)**` so the recording date wins.""" + decisions = project / "planning" / "DECISIONS.md" + decisions.write_text( + decisions.read_text() + + "\n\n## Decisions added after init\n\n" + + "- **Migrate by (2024-12-31) for compliance (2026-05-20)**\n" + ) + events = build_timeline(project) + decision_events = [e for e in events if e.kind is EventKind.DECISION] + # The decision must be dated 2026-05-20 (the recording date), + # NOT 2024-12-31 (the user's deadline date inside the bullet text). + matching = [e for e in decision_events if "Migrate by" in e.title] + assert matching, "decision was not detected at all" + assert matching[0].date == _dt.date(2026, 5, 20), ( + f"expected recording date 2026-05-20; got {matching[0].date} — " + f"likely picked the user-typed (2024-12-31) at the front of the line" + ) + # The user's date in the body should be preserved in the rendered title + # (we only strip the trailing recording stamp). + assert "2024-12-31" in matching[0].title + + +def test_decision_with_no_trailing_stamp_falls_back_to_any_date( + project: Path, +) -> None: + """Pre-fix files / hand-edited bullets may have just `(YYYY-MM-DD)` + somewhere in the line with no closing `**`. Still detect them via + the unanchored fallback.""" + decisions = project / "planning" / "DECISIONS.md" + decisions.write_text( + decisions.read_text() + + "\n\n## Decisions added after init\n\n" + + "- legacy bullet style (2025-03-15)\n" + ) + events = build_timeline(project) + decision_events = [e for e in events if e.kind is EventKind.DECISION] + matching = [e for e in decision_events if "legacy bullet" in e.title] + assert matching + assert matching[0].date == _dt.date(2025, 3, 15)