diff --git a/api/cache.py b/api/cache.py index f31e836c..7dd90f3d 100644 --- a/api/cache.py +++ b/api/cache.py @@ -67,7 +67,20 @@ # ALWAYS_SKIP. Cached manifests that observed lockfiles (or that came # from include_all=true scans) would no longer match a fresh scan; # bumping forces every repo to re-cache and drops the orphans. -_MANIFEST_SCHEMA_VERSION = 2 +# v3: DirNode gained descendants_ext_breakdown and the manifest gained +# top-level busyness thresholds. Pre-v3 cached manifests lack both, so +# consumers (street view, commit-pane busyness label, scene tree color) +# would mis-render until a fresh scan; bumping forces the re-cache. +# v4: a latent bug in cache_load_git_history dropped each commit's authors +# + subject when reconstructing from the git-history cache. It never bit +# until the v3 bump invalidated the manifest blobs and forced a re-scan that +# rebuilds commits FROM that cache — which then cached author-less manifests +# (fireflies/header crash on commit.authors). The loader is fixed; this bump +# discards the polluted v3 blobs so a normal load re-scans correctly. +# v5: each CommitEntry gained same_day_total (commits sharing its date), +# baked at wrap time so the commit pane + scene tree-color read one field +# instead of recomputing the per-day grouping. Pre-v5 blobs lack it. +_MANIFEST_SCHEMA_VERSION = 5 # Composite cache version: invalidates when EITHER the manifest # schema OR the git-history cache shape changes. Stored as a string # in the cache file's `version` field; the loader's equality check @@ -235,11 +248,26 @@ def cache_load_git_history( date = c.get("date") files = c.get("files") sha = c.get("sha") + authors = c.get("authors") + subject = c.get("subject") + # Reconstruct the FULL CommitEntry — authors + subject are part of the + # shape (v9/v11) and manifest consumers (fireflies iterate authors, + # the commit pane shows subject) break without them. Drop any commit + # missing/malformed on any field rather than emit a partial entry. if (isinstance(date, str) and isinstance(files, int) and not isinstance(files, bool) and isinstance(sha, str) - and _SHA_HEX_RE.fullmatch(sha) is not None): - commits.append({"date": date, "files": files, "sha": sha}) + and _SHA_HEX_RE.fullmatch(sha) is not None + and isinstance(authors, list) + and all(isinstance(a, str) for a in authors) + and isinstance(subject, str)): + commits.append({ + "date": date, + "files": files, + "sha": sha, + "authors": authors, + "subject": subject, + }) return created, modified, commits diff --git a/api/scan.py b/api/scan.py index 52cbbab6..6c4adc16 100755 --- a/api/scan.py +++ b/api/scan.py @@ -34,8 +34,10 @@ ) from .media import probe_media_dims from .types import ( + BusynessThresholds, CommitEntry, DirNode, + ExtBreakdownEntry, FileEntry, FileNode, GitMeta, @@ -409,7 +411,10 @@ def _collect_git_metadata( tracked = _collect_tracked_set(root) _log(f" {len(tracked)} tracked entries (files + dirs)") - if use_cache and head_sha: + # Always write the cache (only `head_sha` is required to key it) — + # `use_cache` gates the READ above, not the write. A skip-cache scan + # still refreshes the cache so the next normal run is up to date. + if head_sha: try: cache_save_git_history(root, head_sha, created, modified, commits) except OSError: @@ -804,26 +809,27 @@ def _populate_file_metadata( raise _log(f" read {len(miss_paths)}/{len(miss_paths)} files") - if use_cache: - # Union-merge: start from the loaded cache (preserves entries - # for files not visited this scan, e.g. when .codecityignore flips) - # and overwrite with current values for everything we did visit. - for node in nodes: - entry: FileEntry = { - "size": node["size"], - "mtime": _node_mtime(node), - "lines": node["lines"], - "binary": node["binary"], - "ext": node["extension"], - } - if "media_width" in node and "media_height" in node: - entry["media_width"] = node["media_width"] - entry["media_height"] = node["media_height"] - cache_entries[node["path"]] = entry - try: - cache_save_files(abs_root, cache_entries) - except OSError: - pass + # Always write the file-stat cache — `use_cache` gates only the READ. + # On a warm scan cache_entries holds the loaded cache, so this is a + # union-merge (preserves entries for files not visited this scan, e.g. + # when .codecityignore flips); on a skip-cache scan it starts empty and + # records exactly the files this fresh scan visited. + for node in nodes: + entry: FileEntry = { + "size": node["size"], + "mtime": _node_mtime(node), + "lines": node["lines"], + "binary": node["binary"], + "ext": node["extension"], + } + if "media_width" in node and "media_height" in node: + entry["media_width"] = node["media_width"] + entry["media_height"] = node["media_height"] + cache_entries[node["path"]] = entry + try: + cache_save_files(abs_root, cache_entries) + except OSError: + pass def _iter_file_nodes(tree: DirNode) -> Iterator[FileNode]: @@ -901,6 +907,7 @@ class _DirFrame: "pending_entries", "files", "subdirs", "descendants_count", "descendants_file_count", "descendants_dir_count", "descendants_size", + "ext_breakdown", ) def __init__(self, abs_dir: str, rel_dir: str) -> None: @@ -921,6 +928,9 @@ def __init__(self, abs_dir: str, rel_dir: str) -> None: self.descendants_file_count = 0 self.descendants_dir_count = 0 self.descendants_size = 0 + # ext (lowercase, "(none)" if absent) -> [count, total_size], over + # all descendant files. Merged up from child frames as they pop. + self.ext_breakdown: dict[str, list[int]] = {} def _build_tree( @@ -971,6 +981,13 @@ def _build_tree( top.descendants_count += 1 top.descendants_file_count += 1 top.descendants_size += node["size"] + ext = (node["extension"] or "(none)").lower() + bucket = top.ext_breakdown.get(ext) + if bucket is None: + top.ext_breakdown[ext] = [1, node["size"]] + else: + bucket[0] += 1 + bucket[1] += node["size"] heartbeat.tick() elif entry.is_dir(follow_symlinks=False): # Descend by pushing a new frame; rollup happens when it @@ -982,6 +999,13 @@ def _build_tree( # (root) or attach to the parent. finished = stack.pop() children: list[FileNode | DirNode] = [*finished.files, *finished.subdirs] + # Sort by count desc, then ext asc for deterministic output. + ext_breakdown_out: list[ExtBreakdownEntry] = [ + {"ext": ext, "count": cnt, "size": size} + for ext, (cnt, size) in sorted( + finished.ext_breakdown.items(), key=lambda kv: (-kv[1][0], kv[0]) + ) + ] node_out: DirNode = { "name": finished.name, "type": NodeKind.DIRECTORY, @@ -994,6 +1018,7 @@ def _build_tree( "descendants_file_count": finished.descendants_file_count, "descendants_dir_count": finished.descendants_dir_count, "descendants_size": finished.descendants_size, + "descendants_ext_breakdown": ext_breakdown_out, "children": children, } if not stack: @@ -1004,6 +1029,14 @@ def _build_tree( parent.descendants_file_count += node_out["descendants_file_count"] parent.descendants_dir_count += 1 + node_out["descendants_dir_count"] parent.descendants_size += node_out["descendants_size"] + # Merge the child's per-extension breakdown up into the parent. + for ext, (cnt, size) in finished.ext_breakdown.items(): + bucket = parent.ext_breakdown.get(ext) + if bucket is None: + parent.ext_breakdown[ext] = [cnt, size] + else: + bucket[0] += cnt + bucket[1] += size # ── Public entry ───────────────────────────────────────────────────────────── @@ -1038,6 +1071,27 @@ def _walk(node: dict) -> None: return h.hexdigest() +def _compute_busyness(commits: list[CommitEntry]) -> BusynessThresholds: + """Repo-relative per-day commit-count thresholds. avg = median commits/day + (over days with >= 1 commit); busy = 75th percentile, clamped to avg+1 so + the three bands stay distinct. Both the scene tree-color gradient and the + commit pane's label read these, so a busy day looks consistent in both. + Returns {avg:1, busy:1} for an empty history so consumers needn't guard.""" + if not commits: + return {"avg": 1, "busy": 1} + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + counts = sorted(per_day.values()) + + def _quantile(p: float) -> int: + return counts[min(len(counts) - 1, int(len(counts) * p))] + + avg = _quantile(0.5) + busy = max(_quantile(0.75), avg + 1) + return {"avg": avg, "busy": busy} + + def _wrap_manifest( root_abs: str, tree: DirNode, sig: Any, tree_signature: str, repo_info: RepoInfo, commits: list[CommitEntry], @@ -1048,6 +1102,7 @@ def _wrap_manifest( has placeholder lines/binary set by _force_skeleton_placeholders; final has the real per-file metadata). The envelope shape is the same either way.""" + _annotate_same_day_totals(commits) return { "root": root_abs, "scanned_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -1056,9 +1111,22 @@ def _wrap_manifest( "tree": tree, "repo": repo_info, "commits": commits, + "busyness": _compute_busyness(commits), } +def _annotate_same_day_totals(commits: list[CommitEntry]) -> None: + """In-place: set each commit's same_day_total to the number of commits + sharing its calendar date. A derived aggregate (like busyness), so it's + baked at wrap time rather than during git collection — both the commit + pane's badge and the scene tree-color read this one field (#35).""" + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + for c in commits: + c["same_day_total"] = per_day[c["date"]] + + def _force_skeleton_placeholders(node: DirNode | FileNode) -> None: """In-place: set every FileNode under `node` to lines=1, binary=False so the skeleton renders with uniform-height buildings.""" diff --git a/api/server.py b/api/server.py index f01432c2..5c06343d 100644 --- a/api/server.py +++ b/api/server.py @@ -748,13 +748,15 @@ def _run_scan() -> None: try: _stream_events(handler, _events(), cancel_event) - # Only cache on successful completion AND only when use_cache. + # Always write the cache on a successful scan — `use_cache` only + # controls whether we READ from it. A skip-cache (no_cache) scan still + # persists its fresh result, so the next normal load is served the + # up-to-date manifest instead of a stale one. final_manifest = state["final_manifest"] scan_target = state["scan_target"] sig = state["sig"] if ( - use_cache - and final_manifest is not None + final_manifest is not None and scan_target is not None and sig is not None ): diff --git a/api/tests/test_cache.py b/api/tests/test_cache.py index bc88baf6..61a866ce 100644 --- a/api/tests/test_cache.py +++ b/api/tests/test_cache.py @@ -119,6 +119,37 @@ def test_hit_on_matching_head(self) -> None: result = cache_mod.cache_load_git_history(root, "abc123") self.assertEqual(result, (created, modified, [])) + def test_round_trips_full_commit_entries(self) -> None: + # The loader must reconstruct the WHOLE CommitEntry — authors + + # subject included. A previous bug dropped them, so commits loaded + # from a warm cache had no `authors`, crashing the fireflies + # consumer (`for author of c.authors`). Round-trip a populated commit. + root = Path("/some/repo") + commits = [ + { + "date": "2024-01-01", + "files": 3, + "sha": "a" * 40, + "authors": ["Alice", "Bob"], + "subject": "Initial commit", + }, + { + "date": "2024-01-02", + "files": 1, + "sha": "b" * 40, + "authors": [], + "subject": "Empty-authors edge case", + }, + ] + cache_mod.cache_save_git_history(root, "head1", {}, {}, commits) + result = cache_mod.cache_load_git_history(root, "head1") + assert result is not None + _, _, loaded = result + self.assertEqual(loaded, commits) + # Every loaded commit carries an iterable authors list. + for c in loaded: + self.assertIsInstance(c["authors"], list) + def test_miss_on_different_head(self) -> None: root = Path("/some/repo") cache_mod.cache_save_git_history(root, "abc123", {}, {}, []) @@ -178,8 +209,10 @@ def test_git_history_cache_round_trips_commits(self): """Round-trip a small commits list through the cache.""" root = Path("/some/repo") commits: list[CommitEntry] = [ - {"date": "2024-01-01", "files": 3, "sha": "a" * 40}, - {"date": "2024-02-15", "files": 7, "sha": "b" * 40}, + {"date": "2024-01-01", "files": 3, "sha": "a" * 40, + "authors": ["Alice"], "subject": "first"}, + {"date": "2024-02-15", "files": 7, "sha": "b" * 40, + "authors": ["Bob", "Carol"], "subject": "second"}, ] cache_mod.cache_save_git_history( root, head_sha="abc", @@ -208,26 +241,41 @@ def test_git_history_cache_drops_malformed_commits(self): "created": {}, "modified": {}, "commits": [ - {"date": "2024-01-01", "files": 3, "sha": sha_a}, # valid + {"date": "2024-01-01", "files": 3, "sha": sha_a, + "authors": ["Alice"], "subject": "ok"}, # valid "not a dict", # dropped: not a dict - {"date": 12345, "files": 5, "sha": sha_a}, # dropped: date not str - {"date": "2024-02-01", "sha": sha_a}, # dropped: missing files - {"date": "2024-03-01", "files": True, "sha": sha_a}, # dropped: files is bool - {"files": 4, "sha": sha_a}, # dropped: missing date - {"date": "2024-04-01", "files": 7}, # dropped: missing sha - {"date": "2024-05-01", "files": 2, "sha": "short"}, # dropped: sha too short - {"date": "2024-05-15", "files": 4, "sha": "Z" * 40}, # sha contains non-hex characters - {"date": "2024-06-01", "files": 1, "sha": sha_b}, # valid + {"date": 12345, "files": 5, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: date not str + {"date": "2024-02-01", "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: missing files + {"date": "2024-03-01", "files": True, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: files is bool + {"files": 4, "sha": sha_a, + "authors": [], "subject": "x"}, # dropped: missing date + {"date": "2024-04-01", "files": 7, + "authors": [], "subject": "x"}, # dropped: missing sha + {"date": "2024-05-01", "files": 2, "sha": "short", + "authors": [], "subject": "x"}, # dropped: sha too short + {"date": "2024-05-15", "files": 4, "sha": "Z" * 40, + "authors": [], "subject": "x"}, # dropped: non-hex sha + {"date": "2024-07-01", "files": 1, "sha": sha_a, + "authors": "Alice", "subject": "x"}, # dropped: authors not a list + {"date": "2024-08-01", "files": 1, "sha": sha_a, + "authors": ["Alice"]}, # dropped: missing subject + {"date": "2024-06-01", "files": 1, "sha": sha_b, + "authors": ["Bob"], "subject": "ok2"}, # valid ], }), encoding="utf-8") loaded = cache_mod.cache_load_git_history(root, "abc") self.assertIsNotNone(loaded) assert loaded is not None _created, _modified, commits = loaded - # Only the two well-formed entries survive. + # Only the two well-formed entries survive (with authors + subject). self.assertEqual(commits, [ - {"date": "2024-01-01", "files": 3, "sha": sha_a}, - {"date": "2024-06-01", "files": 1, "sha": sha_b}, + {"date": "2024-01-01", "files": 3, "sha": sha_a, + "authors": ["Alice"], "subject": "ok"}, + {"date": "2024-06-01", "files": 1, "sha": sha_b, + "authors": ["Bob"], "subject": "ok2"}, ]) def test_git_history_rejects_old_version(self): diff --git a/api/tests/test_scan.py b/api/tests/test_scan.py index 4e131973..50bb81d5 100644 --- a/api/tests/test_scan.py +++ b/api/tests/test_scan.py @@ -15,6 +15,8 @@ import pytest from api.scan import ( + _annotate_same_day_totals, + _compute_busyness, _extension, _is_binary, compute_tree_signature, @@ -198,6 +200,51 @@ def test_mostly_control_chars_is_binary(self): self.assertTrue(_is_binary(p)) +class ComputeBusynessTests(unittest.TestCase): + @staticmethod + def _commits(*per_day: int) -> list: + # Build commits across distinct dates with the given per-day counts. + out = [] + for day, n in enumerate(per_day, start=1): + for _ in range(n): + out.append({"date": f"2026-01-{day:02d}", "files": 1, + "sha": "0" * 40, "authors": [], "subject": ""}) + return out + + def test_empty_history(self): + self.assertEqual(_compute_busyness([]), {"avg": 1, "busy": 1}) + + def test_percentile_bands(self): + # Per-day counts sorted: [1, 1, 2, 5] + # avg = q(0.50) = counts[floor(4*0.5)=2] = 2 + # busy = max(q(0.75)=counts[floor(4*0.75)=3]=5, avg+1=3) = 5 + self.assertEqual(_compute_busyness(self._commits(1, 1, 2, 5)), + {"avg": 2, "busy": 5}) + + def test_busy_clamped_to_avg_plus_one(self): + # Uniform 2-per-day → median 2, 75th pct 2 → busy clamps to 3. + self.assertEqual(_compute_busyness(self._commits(2, 2, 2, 2)), + {"avg": 2, "busy": 3}) + + +class AnnotateSameDayTotalsTests(unittest.TestCase): + def test_sets_per_day_group_size_on_each_commit(self): + # Three commits on 01-01, one on 01-02. + commits = [ + {"date": "2026-01-01", "files": 1, "sha": "0" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-01", "files": 1, "sha": "1" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-01", "files": 1, "sha": "2" * 40, "authors": [], "subject": ""}, + {"date": "2026-01-02", "files": 1, "sha": "3" * 40, "authors": [], "subject": ""}, + ] + _annotate_same_day_totals(commits) + self.assertEqual([c["same_day_total"] for c in commits], [3, 3, 3, 1]) + + def test_empty_history_is_a_noop(self): + commits: list = [] + _annotate_same_day_totals(commits) + self.assertEqual(commits, []) + + class ScanTreeIntegrationTests(_CacheRedirectMixin, unittest.TestCase): @classmethod def setUpClass(cls): @@ -223,6 +270,72 @@ def test_counts_roll_up_correctly(self): self.assertEqual(tree["descendants_count"], 15) self.assertGreater(tree["descendants_size"], 0) + def test_ext_breakdown_rolls_up(self): + m = _final_manifest(str(FIXTURE)) + tree = m["tree"] + breakdown = tree["descendants_ext_breakdown"] + self.assertIsInstance(breakdown, list) + for entry in breakdown: + self.assertEqual(set(entry.keys()), {"ext", "count", "size"}) + # Per-ext counts/sizes partition the descendant files exactly. + self.assertEqual( + sum(e["count"] for e in breakdown), tree["descendants_file_count"] + ) + self.assertEqual( + sum(e["size"] for e in breakdown), tree["descendants_size"] + ) + # Sorted by count descending. + counts = [e["count"] for e in breakdown] + self.assertEqual(counts, sorted(counts, reverse=True)) + # Extension keys are lowercase, dot-prefixed; the fixture has .md files. + self.assertIn(".md", {e["ext"] for e in breakdown}) + + def test_ext_breakdown_leaf_dir_only_counts_own_files(self): + # A nested directory's breakdown must cover only its own subtree, + # not the whole repo. + m = _final_manifest(str(FIXTURE)) + + def _find_dir_with_files(node): + if node["type"] != "directory": + return None + if node["children_file_count"] > 0 and node["path"] != ".": + return node + for child in node["children"]: + found = _find_dir_with_files(child) + if found: + return found + return None + + sub = _find_dir_with_files(m["tree"]) + if sub is not None: + self.assertEqual( + sum(e["count"] for e in sub["descendants_ext_breakdown"]), + sub["descendants_file_count"], + ) + + def test_busyness_present_in_manifest(self): + m = _final_manifest(str(FIXTURE)) + b = m["busyness"] + self.assertEqual(set(b.keys()), {"avg", "busy"}) + self.assertIsInstance(b["avg"], int) + self.assertIsInstance(b["busy"], int) + # Bands stay distinct: busy is always at least avg + 1. + self.assertGreaterEqual(b["busy"], b["avg"] + 1) + + def test_same_day_total_baked_on_every_commit(self): + m = _final_manifest(str(FIXTURE)) + commits = m["commits"] + self.assertGreater(len(commits), 0) + # Recompute the per-day grouping independently and assert each + # commit's baked same_day_total matches (>= 1, includes self). + per_day: dict[str, int] = {} + for c in commits: + per_day[c["date"]] = per_day.get(c["date"], 0) + 1 + for c in commits: + self.assertIn("same_day_total", c) + self.assertEqual(c["same_day_total"], per_day[c["date"]]) + self.assertGreaterEqual(c["same_day_total"], 1) + def test_signature_present_and_stable(self): m1 = _final_manifest(str(FIXTURE)) m2 = _final_manifest(str(FIXTURE)) diff --git a/api/tests/test_server_manifest.py b/api/tests/test_server_manifest.py index 6a6c6629..8e3fd283 100644 --- a/api/tests/test_server_manifest.py +++ b/api/tests/test_server_manifest.py @@ -612,12 +612,12 @@ def test_warm_cache_emits_one_final(self) -> None: self.assertEqual(len(manifest_events), 1) self.assertEqual(manifest_events[0]["phase"], "final") - def test_no_cache_skips_lookup_and_save(self) -> None: + def test_no_cache_skips_lookup_but_still_writes(self) -> None: with TemporaryDirectory() as td: self._make_tiny_repo(td) # Warm the cache first. self._http.request_stream(self.server_port, f"/api/manifest?src={td}") - # no_cache=true should NOT serve from cache. + # no_cache=true should NOT serve from cache (forces a fresh scan). _, events = self._http.request_stream( self.server_port, f"/api/manifest?src={td}&no_cache=true", ) @@ -625,9 +625,10 @@ def test_no_cache_skips_lookup_and_save(self) -> None: self.assertEqual( len(manifest_events), 2, "no_cache should force a fresh scan", ) - # Delete the cache file and verify no_cache also skipped - # the save — the cache should remain absent after this - # request. + # Clear the cache, then run another no_cache scan. `use_cache` + # gates only the READ — a fresh scan must STILL write its result, + # so the next normal load is served up-to-date data instead of a + # stale (or absent) manifest. import shutil shutil.rmtree(self.cache_root, ignore_errors=True) self.cache_root.mkdir(parents=True, exist_ok=True) @@ -635,11 +636,10 @@ def test_no_cache_skips_lookup_and_save(self) -> None: self.server_port, f"/api/manifest?src={td}&no_cache=true", ) manifests_dir = self.cache_root / "manifests" - if manifests_dir.exists(): - self.assertEqual( - list(manifests_dir.iterdir()), [], - "no_cache=true must not write to the manifest cache", - ) + self.assertTrue( + manifests_dir.exists() and list(manifests_dir.iterdir()), + "no_cache=true must still WRITE the manifest cache (read-only flag)", + ) def test_skeleton_has_placeholder_lines(self) -> None: import subprocess diff --git a/api/types.py b/api/types.py index 86e08acb..76f6afef 100644 --- a/api/types.py +++ b/api/types.py @@ -57,6 +57,17 @@ class FileNode(TypedDict): media_height: NotRequired[int] +class ExtBreakdownEntry(TypedDict): + """One file-extension bucket within a directory's descendant breakdown. + `ext` is the lowercase extension (e.g. ".ts") or "(none)" for files with + no extension. Computed once during the tree walk so the UI's street view + reads it instead of re-walking the subtree on every selection.""" + + ext: str + count: int + size: int + + class DirNode(TypedDict): """One directory in the manifest tree. children may be empty.""" @@ -72,6 +83,10 @@ class DirNode(TypedDict): descendants_file_count: int descendants_dir_count: int descendants_size: int + # Per-extension counts/sizes aggregated over ALL descendant files, + # sorted by count desc (ext asc tiebreak). Baked here so the street + # view reads it directly. Empty list for directories with no files. + descendants_ext_breakdown: list[ExtBreakdownEntry] TreeNode = FileNode | DirNode @@ -100,13 +115,30 @@ class CommitEntry(TypedDict): distinct authors for this commit — primary (git's %an) at index 0, Co-authored-by trailer names following. Emails stripped (privacy). subject is git %s — the first line of the commit message only; - body is fetched lazily via /api/commit.""" + body is fetched lazily via /api/commit. same_day_total is the number + of commits sharing this commit's calendar date (>= 1, includes self), + baked once at manifest-wrap so the commit pane's busyness badge and the + scene tree-color both read one consistent value instead of each + recomputing the per-day grouping.""" date: str # "YYYY-MM-DD" files: int sha: str authors: list[str] subject: str + same_day_total: int + + +class BusynessThresholds(TypedDict): + """Repo-relative per-day commit-count thresholds (commits/day), computed + once from the commit history. A day with >= busy commits reads as "Busy", + >= avg as "Average", else "Quiet". The scene tree-color gradient and the + commit pane's label both read these, so a busy day looks consistent in + both. `avg` is the median commits/day (over days with >= 1 commit); `busy` + is the 75th percentile, clamped to avg+1 so the bands stay distinct.""" + + avg: int + busy: int class Manifest(TypedDict): @@ -120,6 +152,7 @@ class Manifest(TypedDict): tree: DirNode repo: RepoInfo commits: list[CommitEntry] + busyness: BusynessThresholds display_root: NotRequired[str] diff --git a/app/index.html b/app/index.html index c9781b22..1071b648 100644 --- a/app/index.html +++ b/app/index.html @@ -10,26 +10,7 @@