From ab52b468f0764cdb8a0babbf129c9ff25423690d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:14:25 +0100 Subject: [PATCH 01/18] Expect a filtered pass to leave an idle project out of a fresh output A date-filtered pass into a fresh --output used to write an idle project's combined page, empty for the window, because the index linked every project's. The index of a window's view lists only the projects the window shows, so the pass writes no page of an idle one: the test now pins that, and that no link of the index is dead. The test fails until the next commit, which makes the pass write that view. Co-Authored-By: Claude Opus 5.5 --- test/test_filtered_all_projects.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/test_filtered_all_projects.py b/test/test_filtered_all_projects.py index 4f388ead..481d444f 100644 --- a/test/test_filtered_all_projects.py +++ b/test/test_filtered_all_projects.py @@ -7,8 +7,9 @@ unfiltered output and was reported "cached". Converting every project instead would give each idle one an empty combined page, and mark it as rendered from a window, so that the next plain run re-rendered the whole -archive. A project is converted when a visible session overlaps the -window; the others are left untouched and reported as such. +archive. A project is converted when the window shows one of its +sessions (``window_session_ids``); the others are left untouched and +reported as such. """ from __future__ import annotations @@ -122,8 +123,7 @@ def test_a_filtered_pass_still_converts_a_changed_idle_project( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A changed file may hold messages its cached rows do not know of yet, - so the window cannot be judged from them: such a project is converted - as before.""" + so the pass refreshes the rows before reading the window from them.""" active = _build_project(tmp_path) idle = _build_idle_project(tmp_path) monkeypatch.setenv("CLAUDE_CODE_LOG_CACHE_PATH", str(tmp_path / "cache.db")) @@ -136,12 +136,12 @@ def test_a_filtered_pass_still_converts_a_changed_idle_project( assert "reply 0" not in text -def test_a_filtered_pass_into_a_fresh_output_writes_every_linked_combined_page( +def test_a_filtered_pass_into_a_fresh_output_leaves_an_idle_project_out( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """An idle project with no output at the destination still gets its - combined page, empty for the window: the index links every project's - combined page, and leaving one out would make that link dead.""" + """A project idle in the window is not part of its view: the index does + not list it, so the pass writes none of its pages, not even an empty + combined page.""" import re from claude_code_log.converter import process_projects_hierarchy @@ -164,6 +164,8 @@ def test_a_filtered_pass_into_a_fresh_output_writes_every_linked_combined_page( index = (fresh / "index.html").read_text(encoding="utf-8") # The cards' links are built by script from data embedded in the page. links = set(re.findall(r"[^\"' <>=]*combined_transcripts[^\"' <>]*", index)) - assert any(IDLE in link for link in links), links + assert any(active.name in link for link in links), links + assert not any(IDLE in link for link in links), links + assert not (fresh / IDLE).exists() dead = sorted(link for link in links if not (fresh / link).is_file()) assert dead == [] From 5c40f341b576e78a33a79aa6de6c75960cb8f74a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:18:24 +0100 Subject: [PATCH 02/18] Show only the date window in a date-filtered run's pages A run with --from-date/--to-date filtered the messages it rendered, but the pages around them still described the whole archive: the master index listed every project with its whole-archive totals, and linked every session's page, though the run wrote pages only for the sessions with messages in the window. Into a fresh --output, those links were dead; in place, the full view's files hid them. A filtered pass now writes a view of its window. Before converting, it refreshes each project's cache (unfiltered, as the conversion would, which then finds it fresh) and reads the window from it. One reading, window_session_ids, decides both the session pages the conversion writes and the sessions the index links: a trunk session with a message in the window, among those the index lists. A session whose first and last messages straddle the window, with none inside, is not in it; one whose window part has no user message of its own is, under its usual title. The workers get the set, so a session started in between waits for the next run rather than getting a page nobody links. The index lists only the projects the window shows, with the window's message count, tokens and time range. A project the window shows nothing of is not converted, as before, and now not listed either, so its combined page is no longer written into a fresh --output (the previous commit's test). Archived projects, with no source to take a window of, are left out. The render cache records a page by project and file name, so a view written outside the project's directory no longer marks the full view's rows stale: the next plain run would have rebuilt pages nothing touched. Written in place, the view keeps marking the pages it replaced. The trade: the refresh before the pool runs in the parent, one project at a time. Usually only the modified projects have anything to refresh; a cold archive refreshes serially. CacheManager.has_sessions_in_window, read from the session rows' first and last stamps, has no caller left and goes. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/cache.py | 41 +---- claude_code_log/converter.py | 279 +++++++++++++++++++++++++++----- test/test_date_filtered_runs.py | 19 ++- test/test_date_window_view.py | 254 +++++++++++++++++++++++++++++ 4 files changed, 511 insertions(+), 82 deletions(-) create mode 100644 test/test_date_window_view.py diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 79ae0336..2958ae0e 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -11,7 +11,7 @@ import threading import zlib from contextlib import contextmanager -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, get_args @@ -3451,45 +3451,6 @@ def update_html_cache( ), ) - def has_sessions_in_window( - self, from_dt: Optional[datetime], to_dt: Optional[datetime] - ) -> bool: - """Does a visible session of this project overlap the window? - - Read from the session rows' first and last timestamps, so it is - only as current as those rows: a caller must not trust it for a - project whose files changed since. ``from_dt`` / ``to_dt`` are - naive UTC, as ``utils.parse_date_bounds`` returns them. A session - whose stamps do not parse counts as overlapping. - """ - from .utils import parse_timestamp - - if self._project_id is None: - return True - - def naive_utc(stamp: Optional[str]) -> Optional[datetime]: - parsed = parse_timestamp(stamp) - if parsed is None: - return None - return parsed.astimezone(timezone.utc).replace(tzinfo=None) - - with self._get_connection() as conn: - rows = conn.execute( - """SELECT first_timestamp, last_timestamp FROM sessions - WHERE project_id = ? AND hidden = 0""", - (self._project_id,), - ).fetchall() - for row in rows: - first = naive_utc(row["first_timestamp"]) - last = naive_utc(row["last_timestamp"]) - if first is None or last is None: - return True - if (to_dt is None or first <= to_dt) and ( - from_dt is None or last >= from_dt - ): - return True - return False - def mark_rendered_from_window(self, html_path: str) -> None: """Record that ``html_path`` was just written from a date window. diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 489c0bbf..15b21b58 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -3829,6 +3829,7 @@ def convert_jsonl_to( archive_search_link: Optional[str] = None, render_jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, + window_sessions: Optional[frozenset[str]] = None, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -3877,6 +3878,11 @@ def convert_jsonl_to( single-threaded behaviour. An explicit int overrides the environment. See ``build_render_pool`` for the further conditions under which a pool is actually created. + window_sessions: With a date window, the sessions the caller read + the window as showing (``process_projects_hierarchy`` links + these from its index and checked their pages): no other + session page is written, even for a session that gained a + message in the window since. theme: HTML theme: ``"classic"``, ``"minimal"``, or ``"default"`` (the built-in default, ``utils.DEFAULT_THEME``). Ignored for Markdown/JSON. Themes overwrite the same filenames; every @@ -4089,6 +4095,23 @@ def convert_jsonl_to( # Deduplicate messages (removes version stutters while preserving concurrent tool results) messages = deduplicate_messages(messages) + # A date window is a view of its own: its session pages are the + # window's sessions, and it is recorded in the render cache only when + # written in place, where it replaced pages of the full view. + windowed = from_date is not None or to_date is not None + shown_sessions: Optional[set[str]] = None + record_window = True + if windowed and input_path.is_dir(): + cached_rows = cache_manager.get_cached_project_data() if cache_manager else None + shown_sessions = window_session_ids( + messages, dict(cached_rows.sessions) if cached_rows else {} + ) + if window_sessions is not None: + shown_sessions &= window_sessions + record_window = ( + Path(effective_output_dir).resolve() == Path(input_path).resolve() + ) + # Update title to include date range if specified if from_date or to_date: date_range_parts: list[str] = [] @@ -4328,6 +4351,7 @@ def convert_jsonl_to( if ( cache_manager is not None and update_cache + and record_window and _tracks_version_marker(format) ): if from_date is not None or to_date is not None: @@ -4369,6 +4393,8 @@ def convert_jsonl_to( theme=theme, render_pool=render_pool, fragment_store=fragment_store, + restrict_to_sessions=shown_sessions, + record_rendered=record_window and (update_cache or not windowed), ) finally: @@ -5080,6 +5106,103 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str, ) +def window_session_ids( + messages: list[TranscriptEntry], session_rows: Dict[str, SessionCacheData] +) -> set[str]: + """The sessions a date window shows, given its messages. + + A trunk session with a message in the window, among the sessions the + index lists (a row with a first user message, not a warmup, not an + agent's). Its window part need not hold a user message of its own, and + first and last stamps that straddle the window do not make it one. + + The one reading of the window for the three that must agree: the + session pages a windowed run writes, the ones its index links, and + the files its destination may not already hold. + """ + shown = { + sid + for sid, row in session_rows.items() + if row.first_user_message + and row.first_user_message != "Warmup" + and not is_agent_session(sid) + } + return collect_trunk_session_ids(messages, get_warmup_session_ids(messages)) & shown + + +@dataclass +class _WindowView: + """What a date window shows of one project, for the master index.""" + + session_ids: frozenset[str] + # The index's session entries, without their ``file`` link. + sessions: List[Dict[str, Any]] + aggregates: Dict[str, Any] + # Whether the project has anything to show. Without a cache there + # are no session rows to read sessions from, so it is whether the + # window holds a message at all. + shown: bool + + +def _project_window_view( + project_dir: Path, + cache_manager: Optional[CacheManager], + from_date: Optional[str], + to_date: Optional[str], +) -> _WindowView: + """Read what the window shows of ``project_dir``, from a fresh cache. + + The same load and filter as the conversion's (``convert_jsonl_to``), + so its sessions are the ones the conversion writes pages for. Each + session keeps its whole-session title, with the window's message count + and time range. + """ + messages, _tree = load_directory_transcripts( + project_dir, + cache_manager, + from_date, + to_date, + silent=True, + snapshot=cache_manager is not None, + ) + messages = deduplicate_messages( + filter_messages_by_date(messages, from_date, to_date) + ) + cached = cache_manager.get_cached_project_data() if cache_manager else None + rows = dict(cached.sessions) if cached is not None else {} + ids = window_session_ids(messages, rows) + window_data = compute_session_data( + messages, + include_cwd=False, + warmup_session_ids=get_warmup_session_ids(messages), + ) + sessions: List[Dict[str, Any]] = [] + for sid in ids: + row = rows[sid] + part = window_data.get(sid) + first = part.first_timestamp if part else row.first_timestamp + last = part.last_timestamp if part else row.last_timestamp + sessions.append( + { + "id": sid, + "summary": row.ai_title or row.summary, + "timestamp_range": format_timestamp_range(first, last), + "first_timestamp": first, + "last_timestamp": last, + "message_count": part.message_count if part else 0, + "first_user_message": row.first_user_message, + "team_name": row.team_name, + } + ) + sessions.sort(key=lambda s: (s["first_timestamp"] or "", s["id"])) + return _WindowView( + session_ids=frozenset(ids), + sessions=sessions, + aggregates=compute_project_aggregates(messages), + shown=bool(ids) if cache_manager is not None else bool(messages), + ) + + def build_session_title( project_title: str, session_id: str, @@ -5177,6 +5300,7 @@ def _generate_individual_session_files( render_pool: "Optional[RenderPool]" = None, fragment_store: "Optional[RenderFragmentStore]" = None, restrict_to_sessions: Optional[set[str]] = None, + record_rendered: bool = True, ) -> int: """Generate individual files for each session in the specified format. @@ -5192,7 +5316,12 @@ def _generate_individual_session_files( from another page (a source file spanning two sessions) — rendering that remnant would silently truncate the session's file. Restricted out, it renders complete when its own page's - load comes around. None (every other caller) considers all. + load comes around. A date-filtered conversion passes the + window's sessions (``window_session_ids``). None considers all. + record_rendered: Whether to record the files written in the + render cache. A date-filtered view written outside the + project's own directory is not: the rows are keyed by file + name, and are the full view's. Returns: Number of sessions regenerated @@ -5364,7 +5493,11 @@ def _record_session(unit: RenderUnit) -> None: regenerated_count += 1 # Update html_cache to track this generation (marker-tracked # formats only; JSON uses its own version-field freshness). - if cache_manager is None or not _tracks_version_marker(format): + if ( + cache_manager is None + or not record_rendered + or not _tracks_version_marker(format) + ): return if from_date is not None or to_date is not None: cache_manager.mark_rendered_from_window(unit.file_name) @@ -6230,9 +6363,11 @@ class _ProjectPlan: # better than bytes: see _holdback_plans. cached_message_count: Optional[int] = None error: Optional[str] = None - # A date-filtered pass left this project alone: none of its sessions - # overlaps the window (``_plan_project``). + # A date-filtered pass left this project alone: the window shows none + # of its sessions (``window``). outside_window: bool = False + # A date-filtered pass's view of this project, read before the pool. + window: "Optional[_WindowView]" = None # How big a project has to be before holding it out of the project pool @@ -6350,6 +6485,54 @@ def cost(plan: "_ProjectPlan") -> int: return held +def _window_project_summary( + plan: _ProjectPlan, + view: _WindowView, + *, + rel_dest: str, + variant: str, + combined_ext: str, + write_combined: bool, +) -> Dict[str, Any]: + """A project's card on a window view's master index. + + The same shape as the full view's card, with the window's totals and + sessions. + """ + jsonl_files = trunk_jsonl_files(plan.project_dir) + aggregates = view.aggregates + plan.stats.sessions_total = len(view.sessions) + return { + "name": plan.project_dir.name, + "path": plan.project_dir, + "html_file": f"{rel_dest}/{plan.output_path.name}", + "jsonl_count": len(jsonl_files), + "message_count": aggregates["total_message_count"], + "last_modified": ( + max(f.stat().st_mtime for f in jsonl_files) if jsonl_files else 0.0 + ), + "total_input_tokens": aggregates["total_input_tokens"], + "total_output_tokens": aggregates["total_output_tokens"], + "total_cache_creation_tokens": aggregates["total_cache_creation_tokens"], + "total_cache_read_tokens": aggregates["total_cache_read_tokens"], + "latest_timestamp": aggregates["latest_timestamp"], + "earliest_timestamp": aggregates["earliest_timestamp"], + "working_directories": ( + plan.cache_manager.get_working_directories() if plan.cache_manager else [] + ), + "is_archived": False, + "combined_suppressed": not write_combined, + "sessions": [ + { + **{k: v for k, v in session.items() if k != "team_name"}, + "file": f"{rel_dest}/session-{session['id']}{variant}.{combined_ext}", + } + for session in view.sessions + ], + "team_names": sorted({s["team_name"] for s in view.sessions if s["team_name"]}), + } + + def _plan_project( project_dir: Path, *, @@ -6365,7 +6548,6 @@ def _plan_project( page_size: int, theme: str = DEFAULT_THEME, generate_individual_sessions: bool = True, - window: Optional[tuple[Optional[datetime], Optional[datetime]]] = None, ) -> Optional[_ProjectPlan]: """Resolve destination and staleness for one project (no rendering). @@ -6374,15 +6556,9 @@ def _plan_project( shared cache DB's schema/migrations and this project's row exist before concurrent workers touch the DB. - ``window`` is a date-filtered pass's ``parse_date_bounds``. Such a - pass converts a project when one of its sessions overlaps the window, - whether or not anything changed, and leaves every other one alone: - converting a project with nothing in the window would rewrite its - combined output empty and mark it as rendered from a window, which the - next plain run re-renders. A project with a changed file is converted - as before, since its session rows cannot tell what the change holds, - and so is one whose combined output is missing at the destination, - since the index links it. + A date-filtered pass decides which projects to convert afterwards, + from what the window shows (``_process_projects_hierarchy``'s window + phase), so the staleness found here does not decide it. """ from .utils import project_destination @@ -6512,20 +6688,6 @@ def _plan_project( ) ) - outside_window = False - if window is not None and cache_manager is not None: - # A combined page missing at the destination (a fresh --output) is - # written even when empty for the window: the index links it. - # A project migration 016 zeroed heals here too: a user who only - # ever runs windowed passes would otherwise keep it at zero. - needs_work = ( - bool(modified_files) - or (write_combined and not output_path.exists()) - or unrefreshed - or cache_manager.has_sessions_in_window(*window) - ) - outside_window = not needs_work - if needs_work: stats.files_updated = len(modified_files) if modified_files else 0 stats.files_loaded_from_cache = len(jsonl_files) - stats.files_updated @@ -6554,7 +6716,6 @@ def _plan_project( stats=stats, source_bytes=sum(f.stat().st_size for f in jsonl_files), cached_message_count=cached_message_count, - outside_window=outside_window, ) @@ -6797,6 +6958,12 @@ def _process_projects_hierarchy( staleness plan (``_plan_project``) as well as the conversion. failed_projects: When given, the names of the projects that failed are appended to it (see ``process_projects_hierarchy``). + + With a date window the pass writes a view of it: the master index + lists the projects with a session in the window (``window_session_ids``), + with the window's totals, and each such project's combined page and + the window's session pages. Archived projects, which have no source + to render a window from, are left out. """ import time @@ -6930,7 +7097,6 @@ def _archive_search_link(plan: "_ProjectPlan") -> Optional[str]: page_size=page_size, theme=theme, generate_individual_sessions=generate_individual_sessions, - window=window, ) except Exception as e: stats = GenerationStats() @@ -6949,9 +7115,6 @@ def _archive_search_link(plan: "_ProjectPlan") -> Optional[str]: total_archived += plan.archived_count plans.append(plan) - to_convert = [p for p in plans if p.needs_work] - projects_with_updates = sum(1 for p in to_convert if p.stats.files_updated > 0) - def _archived_suffix(plan: _ProjectPlan) -> str: return f", {plan.archived_count} archived" if plan.archived_count > 0 else "" @@ -6973,10 +7136,33 @@ def _print_project_failed(plan: _ProjectPlan, error: str) -> None: plan.stats.add_error(last_line) print(f"Warning: Failed to process {plan.project_dir}:\n{error}") + # ---- Phase 1w (window): what the window shows, before any output is + # written. The session rows describe the files only once refreshed, so + # each project's cache is refreshed here, in the parent and one project + # at a time (unfiltered, as the conversion would; the conversion then + # finds it fresh). + if window is not None: + for plan in plans: + try: + ensure_fresh_cache_detailed( + plan.project_dir, plan.cache_manager, silent=True + ) + plan.window = _project_window_view( + plan.project_dir, plan.cache_manager, from_date, to_date + ) + except Exception: + _print_project_failed(plan, traceback.format_exc()) + continue + plan.needs_work = plan.window.shown + plan.outside_window = not plan.window.shown + + to_convert = [p for p in plans if p.needs_work and p.error is None] + projects_with_updates = sum(1 for p in to_convert if p.stats.files_updated > 0) + # Cached (no-work) projects are reported first, in stable # alphabetical order; regenerated projects follow as they finish. for plan in plans: - if not plan.needs_work: + if not plan.needs_work and plan.error is None: state = "no messages in the window" if plan.outside_window else "cached" print( f" {plan.project_dir.name}: {state}{_archived_suffix(plan)} " @@ -7093,6 +7279,8 @@ def _conversion_kwargs( # budget for the render fan-out, computed by the parent so the # two levels together never exceed `--jobs`. "render_jobs": render_jobs, + # Phase 1w linked these sessions from the index. + "window_sessions": plan.window.session_ids if plan.window else None, } # Projects that failed only because another process held the cache's @@ -7248,6 +7436,23 @@ def _convert_plan_inline( output_path = plan.output_path stats = plan.stats + if window is not None: + # A window view lists the projects the window shows, with what + # it shows of them, and links only the pages this pass wrote. + project_stats.append((project_dir.name, stats)) + if plan.window is not None and plan.window.shown: + project_summaries.append( + _window_project_summary( + plan, + plan.window, + rel_dest=_rel_to_index(dest_dir), + variant=variant, + combined_ext=combined_ext, + write_combined=write_combined, + ) + ) + continue + try: # Get project info for index - use cached data if available # Exclude agent files (they are loaded via session references) @@ -7455,10 +7660,12 @@ def _convert_plan_inline( ) continue - # Process archived projects (projects in cache but without JSONL files) + # Process archived projects (projects in cache but without JSONL files). + # Not in a window view: with no source left, the pass renders no window + # of theirs, and their pages are the full view's. archived_project_count = 0 archived_failures: List[str] = [] - for archived_dir in sorted(archived_project_dirs): + for archived_dir in sorted(archived_project_dirs if window is None else []): try: # Initialize cache manager for archived project cache_manager = CacheManager(archived_dir, library_version) diff --git a/test/test_date_filtered_runs.py b/test/test_date_filtered_runs.py index 531b4c0b..bdef6305 100644 --- a/test/test_date_filtered_runs.py +++ b/test/test_date_filtered_runs.py @@ -438,12 +438,14 @@ def test_a_bound_naming_an_instant_keeps_its_time() -> None: assert str(today.time()) == "23:59:59.999999" -def test_the_collect_fallback_writes_rows_from_every_message( +def test_a_filtered_pass_writes_no_session_rows_from_its_index_step( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """The pass's index step falls back to loading the project when the cache - has no data for it, and writes session rows from that load; under a - window, the load must still be unfiltered. + has no data for it, and writes session rows from that load. A filtered + pass builds its index from the window it read before converting, so it + never takes that fallback; the rows it writes are its unfiltered + refresh's. With a cache manager the project row always exists by then, so the fallback is reached here by having the cache report no project data. @@ -464,11 +466,16 @@ def spy(cache_manager: CacheManager, messages: List[Any]) -> None: monkeypatch.setattr(converter, "_update_cache_with_session_data", spy) monkeypatch.setattr(CacheManager, "get_cached_project_data", _no_project_data) capsys.readouterr() - _run(project.parent, "md-sessions", **FIRST_DAY) - - # Control: the fallback ran. + _run(project.parent, "md-sessions") + # Control: unfiltered, the fallback runs and writes rows. assert "using fallback processing" in capsys.readouterr().out assert written, "no session rows were written" + + written.clear() + _run(project.parent, "md-sessions", **FIRST_DAY) + assert "using fallback processing" not in capsys.readouterr().out + # What it does write is its refresh's, which reads the cache as empty + # here: rows from every message, as unfiltered. second_day = [s for s in _stamps_of_all(project) if s.startswith("2026-01-02")] assert second_day for stamps in written: diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py new file mode 100644 index 00000000..4864afd7 --- /dev/null +++ b/test/test_date_window_view.py @@ -0,0 +1,254 @@ +# pyright: reportPrivateUsage=false +"""A date-filtered run writes a view of its window, and only there. + +Every page such a run writes, and every page and link those pages show, +comes from the window: the master index lists the projects with a +session in it, with the window's totals, and each project links only the +session pages the run wrote. Written outside the project's own +directory, its pages are not recorded in the render cache as the full +view's, which shares their file names. + +The fixture's window is 2026-01-02. In ``-mixed``: + +- ``s-day1`` is all on the first day: outside; +- ``s-day2`` is all on the second: inside; +- ``s-span`` starts on the first day and has only a reply on the second, + so its window part has no user message: inside; +- ``s-gap`` runs from the first day to the third with nothing on the + second, so its first and last messages straddle the window: outside. + +``-idle`` is all on the first day. +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import pytest +from claude_code_log.converter import process_projects_hierarchy + +from test.test_date_filtered_runs import _append, _entry, _turn + +WINDOW = {"from_date": "2026-01-02", "to_date": "2026-01-02"} +INSIDE = {"s-day2", "s-span"} +OUTSIDE = {"s-day1", "s-gap"} + + +def _archive(root: Path) -> Path: + """Two projects under ``root/projects``; returns that directory.""" + mixed = root / "projects" / "-mixed" + mixed.mkdir(parents=True) + _append(mixed / "s-day1.jsonl", _turn("s-day1", 0, 1, "ONLY-DAY-ONE")) + _append(mixed / "s-day2.jsonl", _turn("s-day2", 0, 2, "ONLY-DAY-TWO")) + _append( + mixed / "s-span.jsonl", + _turn("s-span", 0, 1, "SPAN-ASKED") + + [ + _entry( + "assistant", + "s-span", + "s-span-a1", + "s-span-a0", + "2026-01-02T11:00:00Z", + "SPAN-ANSWERED-LATE", + ) + ], + ) + _append( + mixed / "s-gap.jsonl", + _turn("s-gap", 0, 1, "GAP-FIRST") + _turn("s-gap", 1, 3, "GAP-THIRD"), + ) + idle = root / "projects" / "-idle" + idle.mkdir() + _append(idle / "i-one.jsonl", _turn("i-one", 0, 1, "IDLE-ONE")) + return root / "projects" + + +@pytest.fixture +def projects(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("CLAUDE_CODE_LOG_CACHE_PATH", str(tmp_path / "cache.db")) + return _archive(tmp_path) + + +def _full(projects: Path, fmt: str = "html") -> None: + process_projects_hierarchy(projects, jobs=1, output_format=fmt) + + +def _window( + projects: Path, + output_dir: Optional[Path] = None, + *, + fmt: str = "html", + jobs: int = 1, +) -> Path: + return process_projects_hierarchy( + projects, + jobs=jobs, + output_format=fmt, + output_dir=output_dir, + **WINDOW, + ) + + +def _pages(root: Path) -> Dict[str, bytes]: + """Every rendered page under ``root``, by relative path. + + The vendored ``assets/`` are not pages: the same bytes whatever the + view, and left out of the destination check on purpose. + """ + return { + p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() + and "assets" not in p.relative_to(root).parts + and p.suffix in (".html", ".md", ".json", ".js") + } + + +def _render_rows(cache: Path) -> List[Tuple[Any, ...]]: + with sqlite3.connect(cache) as conn: + rows = conn.execute( + "SELECT p.project_path, h.html_path, h.message_count, h.generated_at" + " FROM html_cache h JOIN projects p ON p.id = h.project_id" + " ORDER BY 1, 2" + ).fetchall() + rows += conn.execute( + "SELECT p.project_path, h.html_path, h.message_count, h.generated_at" + " FROM html_pages h JOIN projects p ON p.id = h.project_id" + " ORDER BY 1, 2" + ).fetchall() + return rows + + +def _hrefs(text: str) -> Set[str]: + """The relative page links in an index page or the recent-data script.""" + found = re.findall(r"""href=['"]([^'"#?]+)""", text) + found += re.findall(r'"href":\s*"([^"#?]+)"', text) + # Not the search script's template literals, nor absolute links. + return {link for link in found if not link.startswith(("http", "/", "$"))} + + +def _session_ids(names: Set[str]) -> Set[str]: + return {m.group(1) for n in names if (m := re.search(r"session-(.+)\.\w+$", n))} + + +# ---- What the view shows --------------------------------------------------- + + +def test_the_index_lists_only_the_projects_active_in_the_window( + projects: Path, tmp_path: Path +) -> None: + _window(projects, tmp_path / "view", fmt="json") + index = json.loads( + (tmp_path / "view" / "all-projects-summary.json").read_text(encoding="utf-8") + ) + assert [p["name"] for p in index["projects"]] == ["-mixed"] + card = index["projects"][0] + assert {s["id"] for s in card["sessions"]} == INSIDE + # The window's totals: s-day2's exchange and s-span's late reply. + assert card["message_count"] == 3 + assert card["total_output_tokens"] == 10 + assert card["earliest_timestamp"] == "2026-01-02T10:00:00Z" + assert card["latest_timestamp"] == "2026-01-02T11:00:00Z" + span = next(s for s in card["sessions"] if s["id"] == "s-span") + assert span["message_count"] == 1 + # Named by its whole-session title, though its window part has no + # user message. + assert span["first_user_message"] == "SPAN-ASKED" + + +def test_no_page_of_the_view_links_a_file_it_did_not_write( + projects: Path, tmp_path: Path +) -> None: + """Rendered over a full view in place, a dead link would be hidden by + the full view's files: a fresh directory shows every one.""" + _full(projects) + view = tmp_path / "view" + _window(projects, view) + links = _hrefs((view / "index.html").read_text(encoding="utf-8")) + links |= _hrefs((view / "recent-data.js").read_text(encoding="utf-8")) + assert any("session-" in link for link in links), links + dead = sorted(link for link in links if not (view / link).is_file()) + assert dead == [] + assert "-idle" not in (view / "index.html").read_text(encoding="utf-8") + + +@pytest.mark.parametrize("jobs", [1, 2]) +def test_the_pages_written_are_the_pages_linked( + projects: Path, tmp_path: Path, jobs: int +) -> None: + """One set of sessions decides both: which session pages the run + writes, and which the index links. Each would otherwise read the + window its own way: the session rows' first and last stamps (s-gap + straddles the window), or the window's own user messages (s-span has + none).""" + view = tmp_path / "view" + _window(projects, view, jobs=jobs) + written = set(_pages(view)) + assert _session_ids(written) == INSIDE + + index = (view / "index.html").read_text(encoding="utf-8") + linked = {link for link in _hrefs(index) if "session-" in link} + assert linked == {p for p in written if "/session-" in p} + + +def test_a_session_that_arrives_during_the_pass_is_left_to_the_next( + projects: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The pass reads the window before converting and links what it read: + a session started in between is not linked, so its page is not + written either.""" + from claude_code_log import converter + + read = converter._project_window_view + + def read_then_start_a_session(project_dir: Path, *args: Any) -> Any: + view = read(project_dir, *args) + if project_dir.name == "-mixed": + _append(project_dir / "s-late.jsonl", _turn("s-late", 0, 2, "LATE")) + return view + + monkeypatch.setattr(converter, "_project_window_view", read_then_start_a_session) + view = tmp_path / "view" + _window(projects, view) + # Control: the conversion did see it, in the combined page. + combined = (view / "-mixed" / "combined_transcripts.html").read_text( + encoding="utf-8" + ) + assert "LATE" in combined + assert not (view / "-mixed" / "session-s-late.html").exists() + + +def test_an_archived_project_is_not_part_of_a_view( + projects: Path, tmp_path: Path +) -> None: + """With its source gone, a window of it cannot be rendered: its pages + are the full view's.""" + _full(projects) + for f in (projects / "-idle").glob("*.jsonl"): + _append(f, _turn("i-one", 1, 2, "IDLE-LATER")) + _full(projects) + for f in (projects / "-idle").glob("*.jsonl"): + f.unlink() + view = tmp_path / "view" + _window(projects, view) + assert "-idle" not in (view / "index.html").read_text(encoding="utf-8") + + +# ---- The render cache ------------------------------------------------------ + + +def test_a_view_written_elsewhere_leaves_the_render_cache_alone( + projects: Path, tmp_path: Path +) -> None: + """The render cache's rows are keyed by project and file name: marking + them for a view written elsewhere would make the next plain run + rebuild the full view's pages, which nothing touched.""" + _full(projects) + rows = _render_rows(tmp_path / "cache.db") + _window(projects, tmp_path / "view") + assert _render_rows(tmp_path / "cache.db") == rows From 01d81d584570563bce6d41d9143e4c43ad6cfa9d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:23:04 +0100 Subject: [PATCH 03/18] Write a date window's view only where none of its pages is, or --force A view of a date window written over a full one mixes the two: the full view's pages it does not rewrite stay beside its own, linked from nowhere. So a date-filtered run now checks, before writing anything, every page it is about to write, and refuses with exit 1 when one exists, naming the first and suggesting --force or another --output. Other files in the directory do not count, nor do the vendored assets/ and referenced images, which are the same bytes in any view. The pages checked are the ones written: the pass's index pages (index, search, recent page and its data), each shown project's combined page, and the session pages of window_session_ids, the set the index links. A single project or transcript checks its own combined page and session pages the same way. --force writes the view over them and removes nothing; it is only accepted together with a date window, and not by the provider pipeline, whose date-filtered run is unchanged. Written in place, forced, the view marks the pages it replaced stale, as before. Re-running a date filter in place over an earlier run now needs --force: the existing tests that do so pass it. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/cli.py | 30 +++- claude_code_log/converter.py | 132 ++++++++++++++-- test/test_cache_atomic_refresh.py | 1 + test/test_cache_integration.py | 11 +- test/test_cache_interrupted_refresh.py | 2 + test/test_date_filtered_runs.py | 17 ++- test/test_date_window_view.py | 203 +++++++++++++++++++++++-- 7 files changed, 363 insertions(+), 33 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index fa9ec029..100c60d4 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -23,6 +23,7 @@ ) from .converter import ( RegenerationReport, + WindowOutputExists, convert_jsonl_to, convert_jsonl_to_html, ensure_fresh_cache, @@ -1015,6 +1016,16 @@ def main() -> None: type=str, help='Filter messages up to this date/time (e.g., "1 hour ago", "today", "2025-06-08 15:00")', ) +@click.option( + "--force", + is_flag=True, + help=( + "With --from-date/--to-date: write the date window's view over the " + "files of it that already exist at the destination. Without it, such " + "a run refuses to start when any does. Nothing is removed: pages of " + "an earlier view that this one does not rewrite stay, unlinked." + ), +) @click.option( "--all-projects", is_flag=True, @@ -1214,6 +1225,7 @@ def convert( open_browser: bool, from_date: Optional[str], to_date: Optional[str], + force: bool, all_projects: bool, no_individual_sessions: bool, no_cache: bool, @@ -1260,6 +1272,11 @@ def convert( # usage error here, before any work starts. resolved_theme = _resolve_theme(theme) + # `--force` lets a date window's view overwrite the files of it already + # at its destination; there is nothing else for it to force yet. + if force and from_date is None and to_date is None: + raise click.UsageError("--force is only accepted with --from-date/--to-date.") + # Provider mode has three sub-modes: # * export — `--session-id `: render one session by id # * single-file — INPUT_PATH is a rollout FILE: render that one session @@ -1299,6 +1316,10 @@ def convert( conflicts: list[str] = [] if tui: conflicts.append("--tui") + if force: + # The provider pipeline writes its date window over a full view + # as before, so there is nothing for --force to allow. + conflicts.append("--force") if provider_wholesale: # Wholesale honors --expand-paths/--filter-path (Obsidian projection), # --combined, date range, -o/-f, --open-browser, and the cache flags @@ -1940,6 +1961,7 @@ def render_provider(destination: Path) -> Path: theme=resolved_theme, jobs=jobs, failed_projects=failed_projects, + force=force, ) # Count processed projects @@ -1979,6 +2001,11 @@ def render_provider(destination: Path) -> Path: from .providers import discover_providers detected = discover_providers().detect_provider_for_path(input_path) + if detected is not None and force: + raise click.UsageError( + f"--force does not apply to {input_path}, a {detected} " + "input: its date-filtered view is written as before." + ) if detected is not None: if input_path.is_dir(): _run_provider_wholesale( @@ -2122,6 +2149,7 @@ def render_provider(destination: Path) -> Path: render_jobs=( min(jobs, resolve_render_jobs(None)) if jobs is not None else None ), + force=force, ) # Report only work actually done this run. On a pure skip the converter # already printed its "... is current, skipping regeneration" line, so @@ -2160,7 +2188,7 @@ def render_provider(destination: Path) -> Path: if open_browser: click.launch(str(output_path)) - except FileNotFoundError as e: + except (FileNotFoundError, WindowOutputExists) as e: click.echo(f"Error: {e}", err=True) if debug: import traceback diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 15b21b58..4d0a14fd 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -3007,6 +3007,7 @@ def convert_jsonl_to_html( silent: bool = False, page_size: int = 2000, depth: RenderingDepth = DEFAULT_DEPTH, + force: bool = False, ) -> Path: """Convert JSONL transcript(s) to HTML file(s). @@ -3025,6 +3026,7 @@ def convert_jsonl_to_html( silent, page_size=page_size, depth=depth, + force=force, ) @@ -3829,6 +3831,7 @@ def convert_jsonl_to( archive_search_link: Optional[str] = None, render_jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, + force: bool = False, window_sessions: Optional[frozenset[str]] = None, ) -> Path: """Convert JSONL transcript(s) to the specified format. @@ -3878,6 +3881,11 @@ def convert_jsonl_to( single-threaded behaviour. An explicit int overrides the environment. See ``build_render_pool`` for the further conditions under which a pool is actually created. + force: With a date window, write over the files of the view that + already exist at the destination. Without it a windowed run + raises ``WindowOutputExists`` before writing anything when one + does (see ``window_session_ids`` for which session pages it + writes). Nothing is removed either way. window_sessions: With a date window, the sessions the caller read the window as showing (``process_projects_hierarchy`` links these from its index and checked their pages): no other @@ -4096,21 +4104,36 @@ def convert_jsonl_to( messages = deduplicate_messages(messages) # A date window is a view of its own: its session pages are the - # window's sessions, and it is recorded in the render cache only when - # written in place, where it replaced pages of the full view. + # window's sessions, it goes only where none of its files exists yet + # (or over them, forced), and it is recorded in the render cache only + # when written in place, where it replaced pages of the full view. windowed = from_date is not None or to_date is not None shown_sessions: Optional[set[str]] = None record_window = True - if windowed and input_path.is_dir(): - cached_rows = cache_manager.get_cached_project_data() if cache_manager else None - shown_sessions = window_session_ids( - messages, dict(cached_rows.sessions) if cached_rows else {} - ) - if window_sessions is not None: - shown_sessions &= window_sessions - record_window = ( - Path(effective_output_dir).resolve() == Path(input_path).resolve() - ) + if windowed: + assert output_path is not None + if input_path.is_dir(): + cached_rows = ( + cache_manager.get_cached_project_data() if cache_manager else None + ) + shown_sessions = window_session_ids( + messages, dict(cached_rows.sessions) if cached_rows else {} + ) + if window_sessions is not None: + shown_sessions &= window_sessions + record_window = ( + Path(effective_output_dir).resolve() == Path(input_path).resolve() + ) + if not force: + view_files = [output_path] if write_combined else [] + if generate_individual_sessions and shown_sessions: + view_files += [ + effective_output_dir / f"session-{sid}{suffix}.{ext}" + for sid in sorted(shown_sessions) + ] + existing = _first_existing(view_files) + if existing is not None: + raise WindowOutputExists(existing) # Update title to include date range if specified if from_date or to_date: @@ -5106,6 +5129,33 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str, ) +class WindowOutputExists(Exception): + """A date-filtered run found one of its files already at its destination. + + A window view goes into a destination that holds none of the files it + writes, or over them when forced: written over a full view, it would + leave that view's other pages beside its own, linked from nowhere, and + mixed with it. Raised before anything is written; ``path`` is the + first such file. + """ + + def __init__(self, path: Path) -> None: + self.path = path + super().__init__( + f"{path} already exists. A date-filtered run writes only into a " + "destination that holds none of the files it writes: pass --force " + "to overwrite them (nothing is removed), or choose another --output." + ) + + +def _first_existing(paths: "Iterator[Path] | list[Path]") -> Optional[Path]: + """The first of ``paths`` that exists, if any.""" + for path in paths: + if path.exists(): + return path + return None + + def window_session_ids( messages: list[TranscriptEntry], session_rows: Dict[str, SessionCacheData] ) -> set[str]: @@ -6533,6 +6583,37 @@ def _window_project_summary( } +def _window_view_files( + plans: List[_ProjectPlan], + *, + index_root: Path, + output_format: str, + combined_name: str, + session_suffix: str, + write_combined: bool, + generate_individual_sessions: bool, +) -> Iterator[Path]: + """Every page a date-filtered pass over ``plans`` writes, index first. + + The rendered pages only: the vendored ``assets/`` and referenced + images are the same bytes in any view, so writing over them loses + nothing. A session page is ``session-``. + """ + from .recent import RECENT_DATA, RECENT_PAGE + + yield index_root / get_index_filename(output_format) + if output_format == "html": + yield index_root / "search.html" + yield index_root / RECENT_PAGE + yield index_root / RECENT_DATA + for plan in plans: + if write_combined: + yield plan.dest_dir / combined_name + if generate_individual_sessions and plan.window is not None: + for sid in sorted(plan.window.session_ids): + yield plan.dest_dir / f"session-{sid}{session_suffix}" + + def _plan_project( project_dir: Path, *, @@ -6779,6 +6860,7 @@ def process_projects_hierarchy( jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, failed_projects: Optional[List[str]] = None, + force: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -6851,6 +6933,7 @@ def process_projects_hierarchy( jobs=jobs, entry_store=entry_store, failed_projects=failed_projects, + force=force, ) if use_cache: # The pass's workers have all exited: the files they rewrote @@ -6925,6 +7008,7 @@ def _process_projects_hierarchy( jobs: Optional[int] = None, entry_store: "Optional[ParsedEntryStore]" = None, failed_projects: Optional[List[str]] = None, + force: bool = False, ) -> Path: """Process the entire ~/.claude/projects/ hierarchy and create linked output files. @@ -6958,6 +7042,10 @@ def _process_projects_hierarchy( staleness plan (``_plan_project``) as well as the conversion. failed_projects: When given, the names of the projects that failed are appended to it (see ``process_projects_hierarchy``). + force: With a date window, write over the view's files that + already exist at the destination; without it the pass raises + ``WindowOutputExists`` before writing anything when one does. + Nothing is removed either way. With a date window the pass writes a view of it: the master index lists the projects with a session in the window (``window_session_ids``), @@ -7140,7 +7228,8 @@ def _print_project_failed(plan: _ProjectPlan, error: str) -> None: # written. The session rows describe the files only once refreshed, so # each project's cache is refreshed here, in the parent and one project # at a time (unfiltered, as the conversion would; the conversion then - # finds it fresh). + # finds it fresh). The view's files are then checked at the + # destination, all of them, before the first is written. if window is not None: for plan in plans: try: @@ -7155,6 +7244,20 @@ def _print_project_failed(plan: _ProjectPlan, error: str) -> None: continue plan.needs_work = plan.window.shown plan.outside_window = not plan.window.shown + if not force: + existing = _first_existing( + _window_view_files( + [p for p in plans if p.error is None and not p.outside_window], + index_root=index_root, + output_format=output_format, + combined_name=combined_name, + session_suffix=f"{variant}.{combined_ext}", + write_combined=write_combined, + generate_individual_sessions=generate_individual_sessions, + ) + ) + if existing is not None: + raise WindowOutputExists(existing) to_convert = [p for p in plans if p.needs_work and p.error is None] projects_with_updates = sum(1 for p in to_convert if p.stats.files_updated > 0) @@ -7279,7 +7382,8 @@ def _conversion_kwargs( # budget for the render fan-out, computed by the parent so the # two levels together never exceed `--jobs`. "render_jobs": render_jobs, - # Phase 1w linked these sessions from the index. + # Phase 1w checked the view's files and linked these sessions. + "force": True, "window_sessions": plan.window.session_ids if plan.window else None, } diff --git a/test/test_cache_atomic_refresh.py b/test/test_cache_atomic_refresh.py index a6e4d763..f650ddd1 100644 --- a/test/test_cache_atomic_refresh.py +++ b/test/test_cache_atomic_refresh.py @@ -936,6 +936,7 @@ def run(from_date: Optional[str] = None, to_date: Optional[str] = None) -> None: jobs=1, from_date=from_date, to_date=to_date, + force=from_date is not None, ) run() diff --git a/test/test_cache_integration.py b/test/test_cache_integration.py index 400dc05d..87efbda5 100644 --- a/test/test_cache_integration.py +++ b/test/test_cache_integration.py @@ -262,7 +262,15 @@ def test_cli_date_filtering_with_cache(self, setup_test_project): # Run with date filtering (should use cached data where possible) result2 = runner.invoke( main, - [str(project_dir), "--from-date", "2023-01-01", "--to-date", "2023-01-01"], + [ + str(project_dir), + "--from-date", + "2023-01-01", + "--to-date", + "2023-01-01", + # In place, over the first run's view. + "--force", + ], ) assert result2.exit_code == 0 @@ -436,6 +444,7 @@ def test_cache_with_date_filtering_performance(self, setup_test_project): from_date="2023-01-01", to_date="2023-01-01", use_cache=True, + force=True, ) assert output.exists() diff --git a/test/test_cache_interrupted_refresh.py b/test/test_cache_interrupted_refresh.py index 3633ec93..980a6a2d 100644 --- a/test/test_cache_interrupted_refresh.py +++ b/test/test_cache_interrupted_refresh.py @@ -460,6 +460,7 @@ def test_a_date_filtered_run_refreshes_the_cache_in_full( jobs=1, from_date="2025-12-31", to_date="2026-01-01", + force=True, ) after = CacheManager(project, get_library_version()).get_cached_project_data() assert after is not None @@ -505,6 +506,7 @@ def test_a_date_filtered_run_does_not_start_a_pending_refresh( jobs=1, from_date="2025-12-31", to_date="2026-01-01", + force=True, ) manager = CacheManager(project, get_library_version()) data = manager.get_cached_project_data() diff --git a/test/test_date_filtered_runs.py b/test/test_date_filtered_runs.py index bdef6305..e8e9a97b 100644 --- a/test/test_date_filtered_runs.py +++ b/test/test_date_filtered_runs.py @@ -123,9 +123,18 @@ def _run( from_date: Optional[str] = None, to_date: Optional[str] = None, ) -> None: - """A pass over the whole hierarchy, as ``--all-projects`` runs it.""" + """A pass over the whole hierarchy, as ``--all-projects`` runs it. + + A filtered pass writes its view in place, over the full one, which + takes ``--force``. + """ process_projects_hierarchy( - projects_dir, from_date, to_date, jobs=1, **SHAPES[shape] + projects_dir, + from_date, + to_date, + jobs=1, + force=from_date is not None or to_date is not None, + **SHAPES[shape], ) @@ -139,7 +148,7 @@ def _convert( Unlike the hierarchy pass, which leaves a project with no changed file alone, this renders on every run, so a filtered run reaches files - that were complete. + that were complete. In place, as here, a filtered run is forced. """ options = dict(SHAPES[shape]) convert_jsonl_to( @@ -148,6 +157,7 @@ def _convert( from_date=from_date, to_date=to_date, silent=True, + force=from_date is not None or to_date is not None, **options, ) @@ -259,6 +269,7 @@ def run(from_date: Optional[str] = None, to_date: Optional[str] = None) -> None: to_date=to_date, silent=True, render_jobs=2, + force=from_date is not None, ) run() diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py index 4864afd7..e79f233c 100644 --- a/test/test_date_window_view.py +++ b/test/test_date_window_view.py @@ -4,9 +4,10 @@ Every page such a run writes, and every page and link those pages show, comes from the window: the master index lists the projects with a session in it, with the window's totals, and each project links only the -session pages the run wrote. Written outside the project's own -directory, its pages are not recorded in the render cache as the full -view's, which shares their file names. +session pages the run wrote. The view goes into an output directory that +holds none of the files it writes, or over them with ``--force``, which +overwrites and removes nothing. Its pages are not recorded in the render +cache as the full view's, which shares their file names. The fixture's window is 2026-01-02. In ``-mixed``: @@ -29,7 +30,14 @@ from typing import Any, Dict, List, Optional, Set, Tuple import pytest -from claude_code_log.converter import process_projects_hierarchy +from click.testing import CliRunner + +from claude_code_log.cli import main +from claude_code_log.converter import ( + WindowOutputExists, + convert_jsonl_to, + process_projects_hierarchy, +) from test.test_date_filtered_runs import _append, _entry, _turn @@ -83,6 +91,7 @@ def _window( output_dir: Optional[Path] = None, *, fmt: str = "html", + force: bool = False, jobs: int = 1, ) -> Path: return process_projects_hierarchy( @@ -90,6 +99,7 @@ def _window( jobs=jobs, output_format=fmt, output_dir=output_dir, + force=force, **WINDOW, ) @@ -178,14 +188,14 @@ def test_no_page_of_the_view_links_a_file_it_did_not_write( @pytest.mark.parametrize("jobs", [1, 2]) -def test_the_pages_written_are_the_pages_linked( +def test_the_pages_written_are_the_pages_linked_and_the_pages_checked( projects: Path, tmp_path: Path, jobs: int ) -> None: - """One set of sessions decides both: which session pages the run - writes, and which the index links. Each would otherwise read the - window its own way: the session rows' first and last stamps (s-gap - straddles the window), or the window's own user messages (s-span has - none).""" + """One set of sessions decides all three: which session pages the run + writes, which the index links, and which files a destination may not + hold. Each would otherwise read the window its own way: the session + rows' first and last stamps (s-gap straddles the window), or the + window's own user messages (s-span has none).""" view = tmp_path / "view" _window(projects, view, jobs=jobs) written = set(_pages(view)) @@ -195,13 +205,30 @@ def test_the_pages_written_are_the_pages_linked( linked = {link for link in _hrefs(index) if "session-" in link} assert linked == {p for p in written if "/session-" in p} + # Each page the run writes makes a destination holding it refuse the + # run; a page of the full view outside the window does not. + for page in sorted(written): + dest = tmp_path / f"holds-{page.replace('/', '_')}" + (dest / page).parent.mkdir(parents=True) + (dest / page).write_text("mine", encoding="utf-8") + with pytest.raises(WindowOutputExists) as raised: + _window(projects, dest) + assert raised.value.path == dest / page + assert (dest / page).read_text(encoding="utf-8") == "mine" + for page in ("-mixed/session-s-day1.html", "-mixed/session-s-gap.html"): + dest = tmp_path / f"holds-{page.replace('/', '_')}" + (dest / page).parent.mkdir(parents=True) + (dest / page).write_text("mine", encoding="utf-8") + _window(projects, dest) + assert (dest / page).read_text(encoding="utf-8") == "mine" + def test_a_session_that_arrives_during_the_pass_is_left_to_the_next( projects: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """The pass reads the window before converting and links what it read: - a session started in between is not linked, so its page is not - written either.""" + """The pass reads the window before converting, links what it read and + checks those pages at the destination: a session started in between is + neither, so its page is not written either.""" from claude_code_log import converter read = converter._project_window_view @@ -239,7 +266,57 @@ def test_an_archived_project_is_not_part_of_a_view( assert "-idle" not in (view / "index.html").read_text(encoding="utf-8") -# ---- The render cache ------------------------------------------------------ +# ---- Where the view goes --------------------------------------------------- + + +def test_a_view_over_a_full_one_is_refused_and_writes_nothing( + projects: Path, tmp_path: Path +) -> None: + _full(projects) + before = _pages(projects) + rows = _render_rows(tmp_path / "cache.db") + with pytest.raises(WindowOutputExists) as raised: + _window(projects) + # The master index is the first file such a run would write. + assert raised.value.path == projects / "index.html" + assert _pages(projects) == before + assert _render_rows(tmp_path / "cache.db") == rows + + +def test_forced_in_place_it_overwrites_and_removes_nothing( + projects: Path, tmp_path: Path +) -> None: + _full(projects) + _window(projects, force=True) + mixed = projects / "-mixed" + # Rewritten from the window. + combined = (mixed / "combined_transcripts.html").read_text(encoding="utf-8") + assert "ONLY-DAY-TWO" in combined and "ONLY-DAY-ONE" not in combined + # Left as they were, unlinked: the full view's pages outside the window. + for sid in OUTSIDE: + assert (mixed / f"session-{sid}.html").is_file() + assert "ONLY-DAY-ONE" in (mixed / "session-s-day1.html").read_text(encoding="utf-8") + assert (projects / "-idle" / "combined_transcripts.html").is_file() + assert "-idle" not in (projects / "index.html").read_text(encoding="utf-8") + + +def test_forced_in_place_it_leaves_the_rewritten_pages_stale( + projects: Path, tmp_path: Path +) -> None: + """The next run without a window rebuilds what the view replaced, and + only that.""" + _full(projects) + _window(projects, force=True) + marked = { + (Path(path).name, page) + for path, page, count, _at in _render_rows(tmp_path / "cache.db") + if count == -1 + } + assert marked == { + ("-mixed", "combined_transcripts.html"), + ("-mixed", "session-s-day2.html"), + ("-mixed", "session-s-span.html"), + } def test_a_view_written_elsewhere_leaves_the_render_cache_alone( @@ -252,3 +329,101 @@ def test_a_view_written_elsewhere_leaves_the_render_cache_alone( rows = _render_rows(tmp_path / "cache.db") _window(projects, tmp_path / "view") assert _render_rows(tmp_path / "cache.db") == rows + + +def test_one_project_converted_on_its_own_follows_the_same_rule( + projects: Path, tmp_path: Path +) -> None: + mixed = projects / "-mixed" + convert_jsonl_to("html", mixed, silent=True) + rows = _render_rows(tmp_path / "cache.db") + with pytest.raises(WindowOutputExists) as raised: + convert_jsonl_to("html", mixed, silent=True, **WINDOW) + assert raised.value.path == mixed / "combined_transcripts.html" + + view = tmp_path / "view" + convert_jsonl_to("html", mixed, output_root=view, silent=True, **WINDOW) + assert _session_ids(set(_pages(view))) == INSIDE + assert _render_rows(tmp_path / "cache.db") == rows + + convert_jsonl_to("html", mixed, silent=True, force=True, **WINDOW) + assert "ONLY-DAY-ONE" not in (mixed / "combined_transcripts.html").read_text( + encoding="utf-8" + ) + + +def test_one_transcript_file_follows_the_same_rule( + projects: Path, tmp_path: Path +) -> None: + transcript = projects / "-mixed" / "s-day2.jsonl" + page = convert_jsonl_to("html", transcript, silent=True) + with pytest.raises(WindowOutputExists) as raised: + convert_jsonl_to("html", transcript, silent=True, **WINDOW) + assert raised.value.path == page + elsewhere = tmp_path / "day-two.html" + convert_jsonl_to("html", transcript, elsewhere, silent=True, **WINDOW) + assert "ONLY-DAY-TWO" in elsewhere.read_text(encoding="utf-8") + + +# ---- The command line ------------------------------------------------------ + + +def test_the_refusal_names_the_file_and_both_ways_out(projects: Path) -> None: + _full(projects) + result = CliRunner().invoke( + main, + [ + "--all-projects", + "--projects-dir", + str(projects), + "--from-date", + "2026-01-02", + "--to-date", + "2026-01-02", + ], + ) + assert result.exit_code == 1, result.output + assert str(projects / "index.html") in result.output + assert "--force" in result.output and "--output" in result.output + + +def test_force_is_accepted_with_a_window_only(projects: Path) -> None: + result = CliRunner().invoke( + main, ["--all-projects", "--projects-dir", str(projects), "--force"] + ) + assert result.exit_code == 2, result.output + assert "--force" in result.output + + result = CliRunner().invoke( + main, + [ + "--all-projects", + "--projects-dir", + str(projects), + "--force", + "--from-date", + "2026-01-02", + "--to-date", + "2026-01-02", + ], + ) + assert result.exit_code == 0, result.output + + +def test_the_provider_pipeline_takes_no_force(tmp_path: Path) -> None: + """Its date-filtered run still writes over a full view, so there is + nothing for --force to allow there yet.""" + result = CliRunner().invoke( + main, + [ + "--provider", + "codex", + "--from-date", + "2026-01-02", + "--force", + "-o", + str(tmp_path / "out"), + ], + ) + assert result.exit_code == 2, result.output + assert "--force" in result.output From 7a6497511159750b02cf367c0d1aced0b5ba9049 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:23:32 +0100 Subject: [PATCH 04/18] Document the date window's view, its destination and --force A user guide page, "Viewing a date window": what a filtered run's pages show, where the view may go and the two ways out of a refusal (another --output, or --force, which removes nothing, with a way to clear what it leaves), what is not checked (vendored assets, referenced images), and what the view leaves in the render cache. The README's date-filter examples now write to a directory of their own, and say that an in-place re-run needs --force. CONTRIBUTING's cache paragraph and the application model describe the window phase (the refresh before the pool, window_session_ids as the one reading, the destination check) and the render cache rule: rows are marked only for a view written in place, and the index, search and recent pages have none. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 21 ++++++--- README.md | 17 ++++--- dev-docs/application_model.md | 29 +++++++----- docs/date-window.md | 85 +++++++++++++++++++++++++++++++++++ docs/index.md | 3 +- mkdocs.yml | 1 + 6 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 docs/date-window.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77bc6eb1..0b77351d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -660,13 +660,20 @@ The tool implements a SQLite-based caching system for performance: its total at 0 until it finishes. Only the refresh writes entry rows: a run renders what its refresh committed, so lines written to a session while it renders appear on the next run. A date-filtered run refreshes the - cache as an unfiltered one would and leaves the files it wrote from its - window marked stale, so the next unfiltered run re-renders them. Its - refresh therefore loads whole files when they changed and the incremental - refresh declines, not just the window's entries, and nothing when no file - changed. A date-filtered `--all-projects` pass converts the projects with a - session in the window (or a changed file, or no combined page at the - destination) and reports the others as having no messages in it. A pass that loses a project ends with a count of the + cache as an unfiltered one would (whole files when they changed and the + incremental refresh declines, nothing when no file changed); an + `--all-projects` pass refreshes every project before converting any, + serially in the parent, to read which sessions the window shows. It + writes a view of the window (`window_session_ids` decides its session + pages, the index's links and the check below alike), into a destination + that holds none of its pages or, with `--force`, over them, removing + nothing; the vendored `assets/` and referenced images are not checked. + Written in place, it leaves the `html_cache`/`html_pages` rows of the + files it replaced marked stale, so the next unfiltered run re-renders + them; written anywhere else it touches no such row, since they are keyed + by project and file name and describe the full view. The master + `index.html`, `search.html` and the recent page have no row: every run + rewrites them. A pass that loses a project ends with a count of the failed projects, and the CLI exits 1 - **Performance**: 10-100x faster loading for large projects diff --git a/README.md b/README.md index b240c0c2..0b4d3cd5 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ claude-code-log --all-projects # Process all projects and open in browser claude-code-log --open-browser -# Process all projects with date filtering -claude-code-log --from-date "yesterday" --to-date "today" -claude-code-log --from-date "last week" +# A view of a date window, in a directory of its own +claude-code-log --from-date "yesterday" --to-date "today" -o ~/yesterday +claude-code-log --from-date "last week" -o ~/last-week # Skip individual session files (only create combined transcripts) claude-code-log --no-individual-sessions @@ -152,10 +152,17 @@ claude-code-log /path/to/directory -o combined_transcripts.html claude-code-log /path/to/directory --open-browser # Filter by date range (supports natural language) -claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" -claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" +claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" -o ~/yesterday +claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" -o ~/recent ``` +A date-filtered run writes a view of its window: only the projects and +sessions with messages in it, with the window's totals. It goes into a +destination that holds none of the pages it writes, so over the pages of +an earlier run it refuses to start: give it another `-o`, or `--force` to +write over them (nothing is removed). See [Viewing a date +window](https://daaain.github.io/claude-code-log/date-window/). + ### Feeding Past Conversations to an LLM The combination `--depth agent --format md --compact` produces condensed Markdown suitable as context for an LLM to review or distill patterns from past work: diff --git a/dev-docs/application_model.md b/dev-docs/application_model.md index 3ed85571..c89bbbb2 100644 --- a/dev-docs/application_model.md +++ b/dev-docs/application_model.md @@ -147,15 +147,22 @@ the per-project render fan-out is enabled, any budget left over after one worker per stale project is handed to it, § 2.10), and **collect** (sequential — per-project index data is read back from the cache and the cross-project `index.html` is written last). Under `--from-date` / -`--to-date` the plan converts a project when one of its visible sessions -overlaps the window (`CacheManager.has_sessions_in_window`, from the -session rows' first and last timestamps), when a file changed, whose -rows cannot yet say what it holds, or when its combined output is missing -at the destination (a fresh `--output`), which the index links; every -other project is left untouched and -reported as having no messages in the window. Converting those too would -rewrite each one's combined output empty and mark it as rendered from a -window (§ 2.3), so the next plain run would re-render the archive. Workers +`--to-date` the pass writes a view of the window, and a **window** phase +runs between plan and execute: each project's cache is refreshed in the +parent, serially, and the window is read from it +(`_project_window_view`). `window_session_ids` (a trunk session with a +message in the window, among the sessions the index lists) is the one +reading of it: it decides the session pages the conversion writes (the +workers get the set, so a session started meanwhile waits for the next +run), the sessions the index links, and the pages the destination may +not already hold. That check (`_window_view_files`: the index pages, +each shown project's combined page and its window's session pages, not +the vendored assets) runs before anything is written and raises +`WindowOutputExists` unless the run is forced; nothing is ever removed. +A project the window shows nothing of is not converted, is reported as +having no messages in the window and is left out of the index, which +gives each listed project the window's totals; archived projects are +left out too. Workers run silent; the parent prints one progress line per project as results arrive. All workers share the WAL-mode SQLite cache DB — each writes only its own project's rows, and WAL serialises the short write @@ -341,7 +348,9 @@ count describes them: instead of recording one, `mark_rendered_from_window` sets the count of each file's `html_cache` row (and of page 1's `html_pages` row, which shares the combined file's name) to -1, which no session or project matches, so the next -unfiltered run re-renders it. +unfiltered run re-renders it. Only a view written in place does: the rows +are keyed by project and file name, so a view written to another +directory touches none, and the full view's pages stay current. The rows stay, so a paginated project keeps its page count. JSON output has no `html_cache` row and goes by its own `version` field; a JSON file written from a window carries the window as `date_range`, and diff --git a/docs/date-window.md b/docs/date-window.md new file mode 100644 index 00000000..bf2badb5 --- /dev/null +++ b/docs/date-window.md @@ -0,0 +1,85 @@ +# Viewing a date window + +`--from-date` and `--to-date` turn a run into a view of that window: +every page it writes, and every page and link those pages show, comes +from the window. + +```bash +# Yesterday's work across all projects, in a directory of its own +claude-code-log --from-date "yesterday" --to-date "yesterday" -o ~/yesterday + +# One project's last week +claude-code-log /path/to/project --from-date "last week" -o ~/last-week +``` + +Both bounds take natural language (`"today"`, `"3 days ago"`, +`"2026-01-02 15:00"`); either may be left out. + +## What the view shows + +- **The index** lists only the projects with a session in the window, and + each project card counts the window: its messages, its tokens, the + time between its first and last message. Each session it lists is + shown with its usual title and the window's part of it. +- **A project's combined page** holds the window's messages. +- **A session page** is written for each session with a message in the + window, and only those. A session that only crosses the window, with + messages before and after it but none inside, is not part of it. +- **Every page states its window** in its title, e.g. *(from 2026-01-02 to + 2026-01-02)*, so it is never taken for the whole archive. + +Archived projects, whose transcripts are gone, are not part of a view: +there is no source left to take a window of. + +## Where the view goes + +A date-filtered run writes into a destination that holds none of the +pages it is about to write. Other files in the directory do not count, +nor do the vendored `assets/` and referenced images, which are the same +bytes in any view. + +If one of its pages is already there, the run writes nothing and exits +with an error naming the first such file. This is what happens when you +re-run a date filter in place, over the pages of an earlier run: + +```text +Error: /home/me/.claude/projects/index.html already exists. A date-filtered run +writes only into a destination that holds none of the files it writes: +pass --force to overwrite them (nothing is removed), or choose another +--output. +``` + +You then have two ways out: + +- **`-o` / `--output` another directory.** This is the usual way: the + full view stays where it is, and the window's view goes next to it. +- **`--force`** writes the window's view over the existing pages. It + removes nothing: pages of the full view that the window does not + rewrite (session pages outside the window, other projects' pages) stay + where they are, no longer linked from the new index. `--force` is only + accepted together with a date window. To clear the others out, mark the + time before the run and delete the pages it did not rewrite (check the + list without `-delete` first): + + ```bash + touch /tmp/before-view + claude-code-log --from-date "yesterday" --force + find ~/.claude/projects -name '*.html' ! -newer /tmp/before-view -delete + ``` + +## The render cache + +The cache records which pages are current, by project and file name. A +view written in place, with `--force`, marks the pages it replaced, so +the next run without a date filter rewrites them in full. A view written +anywhere else leaves those records alone: the full view's pages, which +it did not touch, stay current. + +The transcripts themselves are cached as usual. A date-filtered run +refreshes the cache exactly as a run without a filter would, so the next +plain run finds it up to date. + +!!! note + Sessions from other providers (`--provider codex`) are not covered yet: + their date-filtered run still writes over the full view, and takes no + `--force`. diff --git a/docs/index.md b/docs/index.md index d3e375bc..605cb9a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,7 +40,8 @@ uvx claude-code-log@latest --tui - **Interactive timeline** — zoomable, click-to-scroll conversation navigation - **Themes** — `--theme minimal` for a compact light/dark page with sub-agents and forks as foldable branches ([themes guide](themes.md)) -- **Date range filtering** — natural language (`"today"`, `"last week"`) +- **Date range filtering** — natural language (`"today"`, `"last week"`); a + filtered run writes a view of just that window ([date window guide](date-window.md)) - **Depth levels & compact mode** — `--depth` and `--compact` for LLM-friendly Markdown - **Server-side Markdown** — syntax-highlighted rendering via wenmode - **Obsidian / vault output** — `--expand-paths` mirrors each project under its real diff --git a/mkdocs.yml b/mkdocs.yml index 0d265253..137ba454 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,7 @@ nav: - Themes: themes.md - Searching the whole archive: archive-search.md - Recent sessions across all projects: recent-sessions.md + - Viewing a date window: date-window.md - Watching a session as it runs: live-updates.md - Restoring archived sessions: restoring-archived-sessions.md - Reference: From c24c22781bf40fc59553c60f3a9d371b5a8ef512 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:30:39 +0100 Subject: [PATCH 05/18] Pin that only the view's own pages make a destination refuse it The tests pinned the refusing side of the destination check, and the other side only for out-of-window session pages in the pass. Two more: a pass into a directory holding an idle project's combined page, and a single project into one holding a session page outside the window. Both runs go ahead and leave the held file alone; checking every project in the pass, or every cached session of a project, would refuse them. Co-Authored-By: Claude Opus 5.5 --- test/test_date_window_view.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py index e79f233c..8f2fd10e 100644 --- a/test/test_date_window_view.py +++ b/test/test_date_window_view.py @@ -269,6 +269,36 @@ def test_an_archived_project_is_not_part_of_a_view( # ---- Where the view goes --------------------------------------------------- +def test_a_page_of_a_project_outside_the_window_does_not_count( + projects: Path, tmp_path: Path +) -> None: + """Only the pages the view writes count: an idle project's combined + page is none of them.""" + dest = tmp_path / "view" + held = dest / "-idle" / "combined_transcripts.html" + held.parent.mkdir(parents=True) + held.write_text("mine", encoding="utf-8") + _window(projects, dest) + assert held.read_text(encoding="utf-8") == "mine" + assert (dest / "-mixed" / "session-s-day2.html").is_file() + + +def test_one_project_counts_only_the_window_s_session_pages( + projects: Path, tmp_path: Path +) -> None: + """Converted on its own, a project checks the session pages of the + window, not every session it has.""" + mixed = projects / "-mixed" + convert_jsonl_to("html", mixed, silent=True) + dest = tmp_path / "view" + held = dest / "session-s-day1.html" + held.parent.mkdir(parents=True) + held.write_text("mine", encoding="utf-8") + convert_jsonl_to("html", mixed, output_root=dest, silent=True, **WINDOW) + assert held.read_text(encoding="utf-8") == "mine" + assert _session_ids(set(_pages(dest))) == INSIDE | {"s-day1"} + + def test_a_view_over_a_full_one_is_refused_and_writes_nothing( projects: Path, tmp_path: Path ) -> None: From ed6fd4c514781294fad8dfb1f118cfb3df01605d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:31:32 +0100 Subject: [PATCH 06/18] Count the window's session pages in a filtered pass's progress line A filtered pass printed each converted project's count of stale session pages, from its plan ("-mixed: 4 sessions"), where it writes the window's (2 in the test's fixture). It now counts those. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/converter.py | 4 ++++ test/test_date_window_view.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 4d0a14fd..d93b31dc 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -7244,6 +7244,10 @@ def _print_project_failed(plan: _ProjectPlan, error: str) -> None: continue plan.needs_work = plan.window.shown plan.outside_window = not plan.window.shown + # The session pages it writes: the window's, not the stale ones. + plan.stats.sessions_regenerated = ( + len(plan.window.session_ids) if generate_individual_sessions else 0 + ) if not force: existing = _first_existing( _window_view_files( diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py index 8f2fd10e..67bd4bb7 100644 --- a/test/test_date_window_view.py +++ b/test/test_date_window_view.py @@ -266,6 +266,16 @@ def test_an_archived_project_is_not_part_of_a_view( assert "-idle" not in (view / "index.html").read_text(encoding="utf-8") +def test_the_progress_line_counts_the_window_s_session_pages( + projects: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _full(projects) + capsys.readouterr() + _window(projects, tmp_path / "view") + out = capsys.readouterr().out + assert f"-mixed: {len(INSIDE)} sessions" in out, out + + # ---- Where the view goes --------------------------------------------------- From 34dbe7083e67584c6fd75d6420aad21dcf5ffb88 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 13:35:29 +0100 Subject: [PATCH 07/18] Give the end-to-end date filter test an output path that does not exist The test took its output path from a NamedTemporaryFile, which creates the file, so its date-filtered run met an existing file at its destination and, as a filtered run now does without --force, refused to write. It keeps the name and removes the placeholder. Co-Authored-By: Claude Opus 5.5 --- test/test_date_filtering.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_date_filtering.py b/test/test_date_filtering.py index 4dc3e448..0aa9dbbd 100644 --- a/test/test_date_filtering.py +++ b/test/test_date_filtering.py @@ -126,6 +126,9 @@ def to_utc_iso(dt: datetime) -> str: # Test conversion with date filtering with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as output_f: output_path = Path(output_f.name) + # Only the name is wanted: a date-filtered run does not write over an + # existing file without force. + output_path.unlink() # Filter for today only result_path = convert_jsonl_to_html( From 365880fd16f4dc13f4e2f3e03335798a4f820304 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 15:45:25 +0100 Subject: [PATCH 08/18] Mark the session pages a windowed -o export writes into the project With a project directory as input and `-o page.html`, only the combined page goes to the given path: the session pages are still written into the project, over the full view's. Their render cache rows were left current, because recording also required the cache update that `-o` turns off, so the next run without a window kept the window's pages. They are now marked stale like any page a view replaces in place. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/converter.py | 2 +- test/test_date_window_view.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index d93b31dc..0bb58a70 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -4417,7 +4417,7 @@ def convert_jsonl_to( render_pool=render_pool, fragment_store=fragment_store, restrict_to_sessions=shown_sessions, - record_rendered=record_window and (update_cache or not windowed), + record_rendered=record_window, ) finally: diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py index 67bd4bb7..2c14db6f 100644 --- a/test/test_date_window_view.py +++ b/test/test_date_window_view.py @@ -392,6 +392,37 @@ def test_one_project_converted_on_its_own_follows_the_same_rule( ) +def test_a_combined_page_exported_elsewhere_still_marks_the_session_pages( + projects: Path, tmp_path: Path +) -> None: + """``-o page.html`` on a project sends only the combined page elsewhere: + the session pages are still written into the project, over the full + view's, so they are left stale like any page a view replaced there.""" + mixed = projects / "-mixed" + _full(projects) + result = CliRunner().invoke( + main, + [ + str(mixed), + "-o", + str(tmp_path / "page.html"), + "--from-date", + "2026-01-02", + "--to-date", + "2026-01-02", + "--force", + ], + ) + assert result.exit_code == 0, result.output + assert "ONLY-DAY-TWO" in (mixed / "session-s-day2.html").read_text(encoding="utf-8") + marked = { + page + for path, page, count, _at in _render_rows(tmp_path / "cache.db") + if Path(path).name == "-mixed" and count == -1 + } + assert marked == {f"session-{sid}.html" for sid in INSIDE} + + def test_one_transcript_file_follows_the_same_rule( projects: Path, tmp_path: Path ) -> None: From baa0fa0589cc1bb3acfc7b19a1a723fb2a1cf7e1 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 15:47:53 +0100 Subject: [PATCH 09/18] Say which pages of a date-window view state the window The index, combined and session pages carry the window in their title; the search and recent sessions pages are written as on any run and do not. Say so, and what those two show, instead of claiming every page states it. Co-Authored-By: Claude Opus 5.5 --- docs/date-window.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/date-window.md b/docs/date-window.md index bf2badb5..dcde6e00 100644 --- a/docs/date-window.md +++ b/docs/date-window.md @@ -1,8 +1,8 @@ # Viewing a date window `--from-date` and `--to-date` turn a run into a view of that window: -every page it writes, and every page and link those pages show, comes -from the window. +its index, combined pages and session pages, and every link they show, +come from the window. ```bash # Yesterday's work across all projects, in a directory of its own @@ -25,8 +25,14 @@ Both bounds take natural language (`"today"`, `"3 days ago"`, - **A session page** is written for each session with a message in the window, and only those. A session that only crosses the window, with messages before and after it but none inside, is not part of it. -- **Every page states its window** in its title, e.g. *(from 2026-01-02 to - 2026-01-02)*, so it is never taken for the whole archive. +- **The index, the combined pages and the session pages state their + window** in their title, e.g. *(from 2026-01-02 to 2026-01-02)*, so they + are never taken for the whole archive. +- **The search page and the recent sessions page** are written as on any + run and do not state the window. Search covers the whole archive (it + needs `claude-code-log serve`), and the recent sessions page lists the + sessions active in the last three days, in the window or not, linking + only the session pages the view wrote. Archived projects, whose transcripts are gone, are not part of a view: there is no source left to take a window of. From d5cc87d7599f8569d4d0b883aa843b53904c8063 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 15:48:25 +0100 Subject: [PATCH 10/18] Give the one-project date-window examples commands that work For a project directory, `-o` names the combined page, not a directory: `-o ~/last-week` wrote a combined page named `last-week` and the window's session pages into the project, over the full view's, and after a full run it was refused for them. The examples now write the combined page alone (`--combined only -o page.html`) or the view in place (`--force`), and the docs say why. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++--- docs/date-window.md | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b4d3cd5..d912bf00 100644 --- a/README.md +++ b/README.md @@ -152,15 +152,17 @@ claude-code-log /path/to/directory -o combined_transcripts.html claude-code-log /path/to/directory --open-browser # Filter by date range (supports natural language) -claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" -o ~/yesterday -claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" -o ~/recent +claude-code-log /path/to/directory --from-date "yesterday" --to-date "today" --combined only -o ~/yesterday.html +claude-code-log /path/to/directory --from-date "3 days ago" --to-date "yesterday" --force ``` A date-filtered run writes a view of its window: only the projects and sessions with messages in it, with the window's totals. It goes into a destination that holds none of the pages it writes, so over the pages of an earlier run it refuses to start: give it another `-o`, or `--force` to -write over them (nothing is removed). See [Viewing a date +write over them (nothing is removed). For one directory, `-o` names the +combined page alone and the session pages go into the directory, hence +`--combined only` above. See [Viewing a date window](https://daaain.github.io/claude-code-log/date-window/). ### Feeding Past Conversations to an LLM diff --git a/docs/date-window.md b/docs/date-window.md index dcde6e00..a100837a 100644 --- a/docs/date-window.md +++ b/docs/date-window.md @@ -8,8 +8,8 @@ come from the window. # Yesterday's work across all projects, in a directory of its own claude-code-log --from-date "yesterday" --to-date "yesterday" -o ~/yesterday -# One project's last week -claude-code-log /path/to/project --from-date "last week" -o ~/last-week +# One project's last week, as one page of its own +claude-code-log /path/to/project --from-date "last week" --combined only -o ~/last-week.html ``` Both bounds take natural language (`"today"`, `"3 days ago"`, @@ -59,6 +59,11 @@ You then have two ways out: - **`-o` / `--output` another directory.** This is the usual way: the full view stays where it is, and the window's view goes next to it. + For one project, `-o` names the combined page alone, and its session + pages are still written into the project's directory, where the full + view's are. To leave those alone, write the combined page only + (`--combined only -o page.html`), or the whole view in place with + `--force`. - **`--force`** writes the window's view over the existing pages. It removes nothing: pages of the full view that the window does not rewrite (session pages outside the window, other projects' pages) stay From c780dbf2d2486218a85c1985ec73ca5241f40cfb Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 17:49:25 +0100 Subject: [PATCH 11/18] List the window's sessions on a date-window view's recent page The recent page of a date-filtered run listed every session active in the last three days by the clock, in the window or not, with whole-session counts, and did not say a window was in force. Under a window it now lists the window's sessions, those of the index's cards however old, newest by their last message in the window and at most as many as on any run. Each row's messages and time range are the window's, as on the index, and its turns, tools and last messages are read from its entries in the window. The page's title and header name the window, and the data names it too, for the empty-page message. Pages written without a window are unchanged byte for byte. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/converter.py | 42 ++++++++-- claude_code_log/html/renderer.py | 9 ++- .../templates/components/recent/recent.js | 5 +- claude_code_log/html/templates/recent.html | 5 +- claude_code_log/recent.py | 79 +++++++++++++++++-- claude_code_log/renderer.py | 12 +-- claude_code_log/utils.py | 9 +++ test/test_date_window_view.py | 30 +++++++ 8 files changed, 168 insertions(+), 23 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 0bb58a70..23a0dd39 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -32,6 +32,7 @@ from .render_pool import RenderPool from .utils import ( + date_window_label, DEFAULT_THEME, output_theme, atomic_write_text, @@ -6955,6 +6956,8 @@ def _write_recent_page( project_summaries: List[Dict[str, Any]], theme: str, session_pages: bool = True, + from_date: Optional[str] = None, + to_date: Optional[str] = None, ) -> None: """Write ``recent.html`` and its ``recent-data.js`` next to the index. @@ -6964,24 +6967,51 @@ def _write_recent_page( follow ``--output`` / ``--expand-paths`` / variant filenames exactly as the index does. Without ``session_pages`` (``--combined only``) there are none: the index names each session's file all the same, - but nothing writes it. A failure here must never fail the conversion: - the page is a convenience next to the index, not part of it. + but nothing writes it. Under a date window the page lists the + window's sessions, those of the index's cards, each with the window's + part of it, and names the window. A failure here must never fail the + conversion: the page is a convenience next to the index, not part of + it. """ - from .recent import RECENT_DATA, RECENT_PAGE, build_recent_payload, recent_data_js + from .recent import ( + RECENT_DATA, + RECENT_PAGE, + RecentWindow, + build_recent_payload, + recent_data_js, + ) links: Dict[str, str] = {} for summary in project_summaries if session_pages else []: for session in summary.get("sessions", []): if session.get("id") and session.get("file"): links[str(session["id"])] = str(session["file"]) + label = date_window_label(from_date, to_date) try: from .html.renderer import generate_recent_html + window = ( + RecentWindow( + label=label, + sessions={ + (str(Path(summary["path"])), str(session["id"])): session + for summary in project_summaries + for session in summary.get("sessions", []) + }, + keep=lambda entries: filter_messages_by_date( + entries, from_date, to_date + ), + ) + if label + else None + ) payload = build_recent_payload( - projects_path, href_for=lambda _project, sid: links.get(sid) + projects_path, + href_for=lambda _project, sid: links.get(sid), + window=window, ) atomic_write_text(index_dir / RECENT_DATA, recent_data_js(payload)) - atomic_write_text(index_dir / RECENT_PAGE, generate_recent_html(theme)) + atomic_write_text(index_dir / RECENT_PAGE, generate_recent_html(theme, label)) except Exception as exc: # pragma: no cover - defensive print(f"Warning: could not write {RECENT_PAGE}: {exc}") @@ -7920,6 +7950,8 @@ def _convert_plan_inline( project_summaries, theme, session_pages=generate_individual_sessions, + from_date=from_date, + to_date=to_date, ) # Count total sessions from project summaries diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index d4bec843..37d2f40e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -2182,7 +2182,9 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: ) -def generate_recent_html(theme: str = DEFAULT_THEME) -> str: +def generate_recent_html( + theme: str = DEFAULT_THEME, window: Optional[str] = None +) -> str: """Render the recent sessions page (``recent.html``). The page holds no data: it loads the sibling ``recent-data.js`` @@ -2190,12 +2192,15 @@ def generate_recent_html(theme: str = DEFAULT_THEME) -> str: every refresh, so it is identical from run to run and no transcript text is ever inlined into it. It has one look in both themes (the minimal theme's tokens); ``theme`` is accepted for symmetry with the - other page generators and recorded in the stamp. + other page generators and recorded in the stamp. ``window`` is a + date-filtered run's window (``date_window_label``), which the page + names: its data is then the window's sessions. """ env = get_template_environment() return str( env.get_template("recent.html").render( library_version=get_library_version(), theme=normalize_theme(theme), + window=window, ) ) diff --git a/claude_code_log/html/templates/components/recent/recent.js b/claude_code_log/html/templates/components/recent/recent.js index 7c739aae..79f8b739 100644 --- a/claude_code_log/html/templates/components/recent/recent.js +++ b/claude_code_log/html/templates/components/recent/recent.js @@ -285,6 +285,7 @@ return { generatedAt: num(raw.generatedAt), windowDays: num(raw.windowDays) || 3, + window: str(raw.window), ttl: num(raw.cacheTtlMinutes) || 60, bgCap: num(raw.bgCapMinutes) || 60, wakeGrace: num(raw.wakeGraceMinutes) || 5, @@ -1283,7 +1284,9 @@ emptyEl, payload.sessions.length ? 'No sessions match this filter.' - : 'No sessions updated in the last ' + plural(payload.windowDays, 'day') + '.' + : payload.window + ? 'No sessions in the date window ' + payload.window + '.' + : 'No sessions updated in the last ' + plural(payload.windowDays, 'day') + '.' ); } visible.forEach(function (v) { diff --git a/claude_code_log/html/templates/recent.html b/claude_code_log/html/templates/recent.html index 692c9ea8..a301c9a9 100644 --- a/claude_code_log/html/templates/recent.html +++ b/claude_code_log/html/templates/recent.html @@ -5,7 +5,7 @@ - Recent sessions + Recent sessions{% if window %} ({{ window }}){% endif %} {#- One look in both themes: the minimal theme's tokens, and the page's own stylesheet. The stored light/dark choice applies before first paint. The Pygments sheets colour code in the rendered Markdown of an open @@ -32,7 +32,8 @@
-

Sessions, last update first

+

Sessions, last update first

{% if window %} +

The sessions of the date window {{ window }}, with what the window holds of each.

{% endif %}
diff --git a/claude_code_log/recent.py b/claude_code_log/recent.py index b52d68bd..ce389b49 100644 --- a/claude_code_log/recent.py +++ b/claude_code_log/recent.py @@ -83,7 +83,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Callable, Iterable, Iterator, Optional, cast +from typing import Any, Callable, Iterable, Iterator, Mapping, Optional, cast from .cache import CacheManager, get_cache_db_path, get_library_version from .background_work import BackgroundWork @@ -738,6 +738,56 @@ def _session_rows(db_path: Path, cutoff_iso: str, limit: int) -> list[sqlite3.Ro conn.close() +@dataclass(frozen=True) +class RecentWindow: + """What a date-filtered run's recent page shows instead of the last days. + + ``sessions`` maps ``(project path, session id)`` to the window's part + of each session the run wrote, as the index lists it: its + ``message_count`` and ``first_timestamp`` / ``last_timestamp``. + ``keep`` narrows a session's entries to the window, so its turns, + tools and last messages are the window's too. ``label`` names the + window (``date_window_label``). + """ + + label: str + sessions: Mapping[tuple[str, str], Mapping[str, Any]] + keep: Callable[[list[TranscriptEntry]], list[TranscriptEntry]] + + +def _window_session_rows(db_path: Path, window: RecentWindow) -> list[sqlite3.Row]: + """The session rows of ``window``'s newest sessions, newest first. + + Newest by the window's own last message, at most :data:`MAX_SESSIONS` + as on any run, and however old: the window, not the clock, decides. + """ + + def last(key: tuple[str, str]) -> str: + return str(window.sessions[key].get("last_timestamp") or "") + + keys = sorted(window.sessions, key=last, reverse=True)[:MAX_SESSIONS] + if not keys or not db_path.exists(): + return [] + conn = sqlite3.connect(f"{db_path.resolve().as_uri()}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + f"""SELECT s.session_id, s.summary, s.ai_title, s.first_timestamp, + s.last_timestamp, s.message_count, s.first_user_message, + s.cwd, p.project_path + FROM sessions s JOIN projects p ON p.id = s.project_id + WHERE s.hidden = 0 + AND s.session_id IN ({", ".join("?" * len(keys))})""", + [sid for _path, sid in keys], + ).fetchall() + except sqlite3.Error: + return [] + finally: + conn.close() + found = {(str(Path(row["project_path"])), row["session_id"]): row for row in rows} + return [found[key] for key in keys if key in found] + + def build_recent_payload( projects_dir: Path, *, @@ -745,6 +795,7 @@ def build_recent_payload( href_for: Optional[Callable[[Path, str], Optional[str]]] = None, db_path: Optional[Path] = None, memo: Optional[RecentMemo] = None, + window: Optional[RecentWindow] = None, ) -> dict[str, Any]: """The page's data: the recently updated sessions across every project. @@ -754,7 +805,10 @@ def build_recent_payload( A link to a page that wasn't written is harmless: the row still shows, and the browser reports a missing file only if it is followed. With a ``memo`` (``watch`` keeps one), sessions unchanged since the - last build aren't read again. + last build aren't read again. With a ``window`` (a date-filtered run), + the sessions are the window's instead, each with only what the window + holds of it, and the payload names the window; no memo is used then, + since its summaries are of whole sessions. """ now = now or datetime.now(timezone.utc) now_ms = int(now.timestamp() * 1000) @@ -762,7 +816,11 @@ def build_recent_payload( # Cached timestamps are ISO 8601 in UTC ("...Z"), so they sort as text. cutoff_iso = cutoff.strftime("%Y-%m-%dT%H:%M:%S") db_path = db_path or get_cache_db_path(projects_dir) - rows = _session_rows(db_path, cutoff_iso, MAX_SESSIONS) + if window is None: + rows = _session_rows(db_path, cutoff_iso, MAX_SESSIONS) + else: + rows = _window_session_rows(db_path, window) + memo = None managers: dict[str, CacheManager] = {} names: dict[str, str] = {} @@ -785,7 +843,8 @@ def build_recent_payload( managers[key] = manager def read(manager: CacheManager = manager) -> SessionSummary: - return read_session(manager.load_session_entries(session_id)) + entries = manager.load_session_entries(session_id) + return read_session(window.keep(entries) if window else entries) summary = ( memo.summary( @@ -831,11 +890,18 @@ def read(manager: CacheManager = manager) -> SessionSummary: "last": info["last"], } ) + if window is not None: + part = window.sessions[(key, session_id)] + sessions[-1].update( + messages=part.get("message_count", 0), + firstAt=_ms(part.get("first_timestamp")), + lastAt=_ms(part.get("last_timestamp")), + ) if memo is not None: memo.keep_only(row["session_id"] for row in rows) - return { + payload: dict[str, Any] = { "v": 1, "generatedAt": now_ms, "windowDays": WINDOW_DAYS, @@ -844,6 +910,9 @@ def read(manager: CacheManager = manager) -> SessionSummary: "wakeGraceMinutes": WAKE_GRACE_MINUTES, "sessions": sessions, } + if window is not None: + payload["window"] = window.label + return payload def payload_json(payload: dict[str, Any]) -> str: diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 0f3d7f92..f67fdd45 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -81,6 +81,7 @@ queued_command_prompt_items, ) from .utils import ( + date_window_label, DEFAULT_THEME, format_duration, output_theme, @@ -5330,14 +5331,9 @@ def title_for_projects_index( pass # Add date range suffix if provided - if from_date or to_date: - date_range_parts: list[str] = [] - if from_date: - date_range_parts.append(f"from {from_date}") - if to_date: - date_range_parts.append(f"to {to_date}") - date_range_str = " ".join(date_range_parts) - title += f" ({date_range_str})" + window = date_window_label(from_date, to_date) + if window: + title += f" ({window})" return title diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index d45b3cf4..e56038c4 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -257,6 +257,15 @@ def read(text: str, name: str) -> tuple[datetime, bool]: return from_dt, to_dt +def date_window_label( + from_date: Optional[str], to_date: Optional[str] +) -> Optional[str]: + """How a page names a run's date window ("from X to Y"), or None.""" + parts = [f"from {from_date}"] if from_date else [] + parts += [f"to {to_date}"] if to_date else [] + return " ".join(parts) or None + + def format_duration(total_seconds: float) -> str: """A short human duration: ``"120 ms"``, ``"4.2s"``, ``"2m 13s"``. diff --git a/test/test_date_window_view.py b/test/test_date_window_view.py index 2c14db6f..c076344a 100644 --- a/test/test_date_window_view.py +++ b/test/test_date_window_view.py @@ -223,6 +223,36 @@ def test_the_pages_written_are_the_pages_linked_and_the_pages_checked( assert (dest / page).read_text(encoding="utf-8") == "mine" +def _recent_payload(index_dir: Path) -> Dict[str, Any]: + text = (index_dir / "recent-data.js").read_text(encoding="utf-8") + start = text.index("__cclRecent(") + len("__cclRecent(") + return json.loads(text[start : text.rindex(");")]) + + +def test_the_recent_page_lists_the_window_s_sessions_with_its_counts( + projects: Path, tmp_path: Path +) -> None: + """Not the sessions active in the last few days: the window's, however + old, each with what the window holds of it. s-span's prompt was on the + first day, so in the window it has none; its title stays the whole + session's, as on the index.""" + _full(projects) + view = tmp_path / "view" + _window(projects, view) + payload = _recent_payload(view) + sessions = {s["id"]: s for s in payload["sessions"]} + assert set(sessions) == INSIDE + assert payload["window"] == "from 2026-01-02 to 2026-01-02" + assert sessions["s-day2"]["messages"] == 2 + span = sessions["s-span"] + assert span["messages"] == 1 + assert span["firstAt"] == span["lastAt"] == 1767351600000 # 2026-01-02 11:00Z + assert span["lastPrompt"] is None + assert span["title"] == "SPAN-ASKED" + page = (view / "recent.html").read_text(encoding="utf-8") + assert "Recent sessions (from 2026-01-02 to 2026-01-02)" in page + + def test_a_session_that_arrives_during_the_pass_is_left_to_the_next( projects: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From 38e22a881ad63035b77a389b2c698d21cd329799 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 8 Nov 2026 17:49:56 +0100 Subject: [PATCH 12/18] Say on a date-window view's search page that it searches everything The search page next to a date-filtered run's index searches the whole archive, as it does on any run: it reads the cache, which holds every session. Written beside a window view, it now says so, naming the window the index shows. It is still written there, so the index's link to it does not dangle. Pages written without a window are unchanged byte for byte. Co-Authored-By: Claude Opus 5.5 --- claude_code_log/converter.py | 3 ++- claude_code_log/html/renderer.py | 7 ++++++- .../html/templates/archive_search.html | 4 +++- test/test_date_window_view.py | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 23a0dd39..cb777a27 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -7942,7 +7942,8 @@ def _convert_plan_inline( from .html.renderer import generate_archive_search_html atomic_write_text( - index_path.parent / "search.html", generate_archive_search_html(theme) + index_path.parent / "search.html", + generate_archive_search_html(theme, date_window_label(from_date, to_date)), ) _write_recent_page( index_path.parent, diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 37d2f40e..fa7e667e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -2146,7 +2146,9 @@ def generate_projects_index_html( return renderer.generate_projects_index(project_summaries, from_date, to_date) -def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: +def generate_archive_search_html( + theme: str = DEFAULT_THEME, window: Optional[str] = None +) -> str: """Render the archive-wide search page. Written on every HTML run, including for people who never start the @@ -2156,6 +2158,8 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: ``theme`` is the run's HTML theme (``utils.THEMES``): like the index, the page is rewritten on every run, so it always follows the run. + ``window`` is a date-filtered run's window (``date_window_label``): + the search still covers the whole archive, and the page says so. """ from ..search import SEARCH_FIELD_HINTS, SEARCH_FIELD_LABELS, SEARCH_FIELDS @@ -2164,6 +2168,7 @@ def generate_archive_search_html(theme: str = DEFAULT_THEME) -> str: env.get_template("archive_search.html").render( library_version=get_library_version(), theme=normalize_theme(theme), + window=window, # The field toggles are rendered from the canonical list, so a # new group appears in the UI without anyone remembering to add # it. Which of them start out *checked* is deliberately not diff --git a/claude_code_log/html/templates/archive_search.html b/claude_code_log/html/templates/archive_search.html index 2dd0bce4..b16400f6 100644 --- a/claude_code_log/html/templates/archive_search.html +++ b/claude_code_log/html/templates/archive_search.html @@ -214,7 +214,9 @@

Search all transcripts

← All projects{% if minimal %}{% include 'components/minimal/scheme_toggle.html' %}{% endif %}
- +{% if window %} +

The index beside this page shows the date window {{ window }}; this search covers the whole archive.

+{% endif %}