feat(ui): add necromancer score metric (fixes #45)#280
Conversation
|
Tip 👋 Hey @Diwakar-odds — Miku's on it. Here are the most useful commands for this PR:
Note 🎓 This repo is part of ECSOC26. Run Note ⭐ Star this repo to unlock all commands. Say 📖 All commands🤖 AI-powered — 14 commands
🔧 Issue & PR management — 18 commands
🎉 Community & utility — 9 commands
|
PR Analysis & Label RequestThis PR introduces the Necromancer Score (Issue #45) to the TUI dashboards. It calculates deep historical patterns (resurrecting 6-month dormant projects) and seamlessly integrates these visual metrics across all three main dashboard components. ECSoC26 Label Justification:
Please review when you have a moment. Thank you! |
|
| Filename | Overview |
|---|---|
| termstory/insights.py | Adds necromancer scoring logic, with the project-level helper depending on enough session history to see dormant gaps. |
| termstory/tui.py | Displays the new metric in dashboard headers, but filtered views can miss valid revivals because they pass only the visible sessions. |
| tests/test_tui.py | Updates async TUI tests and mocks avatar fetching for the changed dashboard rendering. |
Reviews (2): Last reviewed commit: "fix(ui): resolve test failures and marku..." | Re-trigger Greptile
| days_diff = (session_dt - last_seen_dt).days | ||
|
|
||
| # Check for resurrection: > 180 days dead + meaningful session | ||
| if days_diff > 180: |
There was a problem hiding this comment.
Exact Threshold Revivals Dropped
When a project has been inactive for exactly 180 days, this strict check returns no resurrection even though the feature is described as counting 180+ day dormancy. The TUI then shows a necromancer score that is too low for that boundary case, while the related project necromancer calculation treats the same threshold inclusively.
| if days_diff > 180: | |
| if days_diff >= 180: |
bitflicker64
left a comment
There was a problem hiding this comment.
Hey @Diwakar-odds, picking up #45 and #38 back-to-back is good momentum. But this PR has the same three blockers as #282 and a few of its own, so it needs another pass before merge.
What's blocking
-
CI is red on all four Python versions (
MarkupError: auto closing tag ('[/]') has nothing to closeintest_tui_landing_page_after_onboarding). The cause is a markup bug in the new header line, not a logic bug: the f-string attermstory/tui.py:934ends with...[/dim][/]— that's one extra[/]because the wrapping[/]from the previous pattern was retained. Compare to the line it replaces (PROJECTS:row) which balances cleanly. Same bug class as the DAILY CLASS header in #282. Fix: drop the trailing[/], or drop the leading[bold cyan]from theNECROMANCER:segment if you intended the structure from #282. Three sites have the bug (lines 934, 1167, 1366). -
Duplicate logic with
calculate_project_necromancer_score. Acalculate_project_necromancer_score(sessions, projects) -> Dict[str, Any]already exists onmainattermstory/insights.py:578(line 621 on the PR head). It's wired intoformatter.pyviaformat_necromancer_scoreand used incli.py. The PR adds a near-duplicate under a slightly different name (calculate_necromancer_score, singular "project" dropped, noprojectsparameter, returnsint). Two implementations of the same metric will drift. Greptile flagged a related concern but didn't surface the duplicate itself. -
The two implementations disagree on the threshold. This new helper uses
days_diff > 180(strict, off-by-one for exactly 180 days, which is what the issue describes). The existingcalculate_project_necromancer_scoreusesgap >= 180 * 24 * 3600(inclusive). Pick one and make the codebase consistent — preferably use the existing helper since it already has tests and a formatter.
Suggested fix shape
- Delete this new
calculate_necromancer_score. Reuse the existingcalculate_project_necromancer_scorefrom the formatter path (.get("score", 0)) in the three header sites instead. - Fix the trailing
[/]in all three header lines. - If you want the "meaningful session" filter (>5 mins OR >10 commands) on top of the existing helper, add it as a post-filter in the formatter or wrap it in a thin helper that calls the existing one — don't fork the implementation.
Smaller notes
- New helper doesn't filter
is_legacysessions; the existing one does. Same drift concern. last_seen_by_project[s.project_id] = datetime.fromtimestamp(end_ts)updates even for sessions you skip (e.g. None project_id above), but that's fine here because thecontinuehappens before the update. Worth a comment for the next reader.- Wrapped header at
tui.py:1167replacesCOMMITS:withNECROMANCER:—total_commitsis still computed but no longer rendered. Same regression as #282. Add a row, don't replace. NECROMANCER:is 11 chars while most other labels in the column are 9 (PROJECTS:,COMMITS:). The extra spaces after the label compensate, but the row will look misaligned if you ever shorten the label. Minor.- No tests for the new helper. At minimum add a unit test that constructs a session with a 180-day gap and asserts the score.
Happy to approve once the duplicate is removed (or the two are reconciled), the markup is fixed, and CI is green.
| break | ||
| return streak | ||
|
|
||
| def calculate_necromancer_score(sessions: List[Session]) -> int: |
There was a problem hiding this comment.
Duplicate of calculate_project_necromancer_score at termstory/insights.py:621 on this branch. The existing one is already wired into formatter.py:format_necromancer_score and cli.py, has tests in tests/test_insights.py, and returns the dict shape the formatter expects. Two implementations of the same metric will drift — recommended path is to delete this helper and pull .get("score", 0) from the existing one in the three header sites.
| for s in sorted_sessions: | ||
| if s.project_id is None: | ||
| continue | ||
|
|
There was a problem hiding this comment.
Threshold off-by-one. Issue #45 says "6+ months"; this strict > 180 excludes exactly-180-day revivals. The existing calculate_project_necromancer_score uses gap >= 180 * 24 * 3600 (inclusive) and matches the issue text. Reconcile to >= 180, or just reuse the existing helper and drop this one entirely.
| header_lines.append(f"[bold cyan]{avatar_lines[7]}[/] [bold cyan]FOCUS SCORE:[/] [bold green]{fs:.1f}/10.0[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[8]}[/] [bold cyan]PEAK VELOCITY:[/] [dim]{peak_velocity}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]PROJECTS:[/] [dim]{active_projects_count}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim][/]") |
There was a problem hiding this comment.
Root cause of the CI failure (MarkupError: auto closing tag ('[/]') has nothing to close on all 4 Python versions). The f-string ends with ...[/dim][/] — that's an extra [/] because the wrapping [/] from the previous pattern was kept. The line it replaces (PROJECTS:[/] ... [dim]{active_projects_count}[/]) balances cleanly with one [/] at the end. Fix: drop the trailing [/], leaving [dim]{necro_score} dead projects revived[/dim].
| header_lines.append(f"[bold cyan]{avatar_lines[7]}[/] [bold cyan]FOCUS SCORE:[/] [bold green]{fs:.1f}/10.0[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[8]}[/] [bold cyan]PEAK VELOCITY:[/] [dim]{peak_velocity}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]COMMITS:[/] [dim]{total_commits}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim][/]") |
There was a problem hiding this comment.
Same extra [/] markup bug. Fix mirrors line 934.
| header_lines.append(f"[bold cyan]{avatar_lines[7]}[/] [bold cyan]FOCUS SCORE:[/] [bold green]{fs:.1f}/10.0[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[8]}[/] [bold cyan]PEAK TIME:[/] [dim]{peak_velocity}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]PROJECTS:[/] [dim]{len(projects)}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim][/]") |
There was a problem hiding this comment.
Same extra [/] markup bug. Fix mirrors line 934.
| header_lines.append(f"[bold cyan]{avatar_lines[7]}[/] [bold cyan]FOCUS SCORE:[/] [bold green]{fs:.1f}/10.0[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[8]}[/] [bold cyan]PEAK VELOCITY:[/] [dim]{peak_velocity}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]COMMITS:[/] [dim]{total_commits}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim][/]") |
There was a problem hiding this comment.
Same regression as PR #282 — wrapped view loses total_commits because the header row is replaced instead of added. total_commits is still computed above this line. Add a second header_lines.append instead of swapping.
|
I've resolved the test suite failures and the UI rendering bugs (the missing closing tags that swallowed the error). The tests for the Necromancer score and the daily/monthly wrapped views now pass consistently. Let me know if further changes are needed! |
| operator = get_operator_handle() | ||
| fs = calculate_focus_score(sessions) | ||
| tod = calculate_time_of_day_distribution(sessions) | ||
| necro_score = calculate_project_necromancer_score(sessions, projects) |
There was a problem hiding this comment.
Filtered History Undercounts This calculates the necromancer value from the sessions currently being rendered. In daily or monthly views, that list is already limited to the selected date range, while
calculate_project_necromancer_score only detects revivals by comparing consecutive sessions present in that list. If a project was last touched 200 days ago and revived today, the daily view contains only today's session, so the header reports 0 dead projects revived even though the revival meets the 180+ day rule. This metric needs access to the prior activity history for the displayed projects, not just the visible session slice.
|
Thanks for the follow-up. I re-reviewed the current head ( pytest tests/test_insights.py::test_calculate_project_necromancer_score \
tests/test_tui.py::test_tui_landing_page_after_onboarding \
tests/test_tui.py::test_wrapped_view_generation_and_layout -qThat subset passes (
Suggested fix shape: remove the unused helper, compute |
bitflicker64
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Request changes — CI-breaking type misuse + large unrelated TUI churn; reuses the wrong return shape for an existing metric.
Intent
Surface a “necromancer” (dead-project revival) score in TUI headers for issue #45.
Critical
-
necro_scoreis a dict, interpolated into Rich markup
calculate_project_necromancer_score(...)returns{"score": int, "resurrections": [...]}. Embedding{necro_score}stringifies that dict; list brackets in the string are interpreted as Rich tags →MarkupError: auto closing tag ('[/]') has nothing to close(matches the known failure mode for this suite).Use the integer:
necro = calculate_project_necromancer_score(sessions, projects) necro_score = necro["score"]
-
Dead / duplicate helper — this PR also adds unused
calculate_necromancer_scoreininsights.pywhile main already has the richercalculate_project_necromancer_score. Delete the new int-only duplicate; call the existing API.
Warnings
- Large rewrite of
render_time_summary(try-wrap, removegenerate_daily_chronicleimport, discard expressions likelen({s.date_str...})andsum(len(s.commits)...)) is unrelated noise for a metric PR — shrink the diff. - Header replaces a row instead of adding capacity → loses PROJECTS / COMMITS in some views (same class of regression as #281/#282). Prefer adding a row or a dedicated stats line.
- Threshold/semantics differ between the unused helper (
> 180days + “meaningful session”) and the existing function (gap >= 180 days, no meaningfulness filter). Pick one definition (issue #45) and stick to the shared helper. - No focused unit tests for the score itself (only TUI churn).
Suggestions
- Wire
necro["score"]only; keep diff to insights (if needed) + 3 header call sites + small pure tests. - Don’t invent a second necromancer implementation.
Verdict
Request changes — fix dict-in-markup crash and drop the duplicate helper before re-review.
| header_lines.append(f"[bold cyan]{avatar_lines[6]}[/] [bold cyan]ACTIVE REPOS:[/] [bold]{active_projects_count} Workspaces[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[7]}[/] [bold cyan]FOCUS SCORE:[/] [bold green]{fs:.1f}/10.0[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[8]}[/] [bold cyan]PEAK VELOCITY:[/] [dim]{peak_velocity}[/]") | ||
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim]") |
There was a problem hiding this comment.
Critical: necro_score is the full dict from calculate_project_necromancer_score. str(dict) can include [...] which Rich parses as markup and blows up TUI tests.
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score} dead projects revived[/dim]") | |
| header_lines.append(f"[bold cyan]{avatar_lines[9]}[/] [bold cyan]NECROMANCER:[/] [dim]{necro_score['score']} dead projects revived[/dim]") |
(Or better: assign necro_score = calculate_project_necromancer_score(... )["score"] once above and keep {necro_score}.) Same fix needed at the other two header sites.
| break | ||
| return streak | ||
|
|
||
| def calculate_necromancer_score(sessions: List[Session]) -> int: |
There was a problem hiding this comment.
Blocking: This new calculate_necromancer_score is unused and duplicates (with different thresholds/semantics) the existing calculate_project_necromancer_score later in this file. Delete it and call the existing helper from the TUI.
| proj_name = "Other" | ||
| project_seconds[proj_name] += s.duration_seconds | ||
| len({s.date_str for s in sessions if s.start_time}) | ||
|
|
There was a problem hiding this comment.
This expression result is discarded (len({...}) with no assignment). Looks like leftover from the large re-indent. Please drop dead code and keep the PR scoped to the metric.
|
/triage |
|
miku ✅ Marked as triaged ( |
|
:miku /check-star |
|
miku @Diwakar-odds still hasn't starred this repo. Applied Star this repo first, then run |
Summary
Introduces the Necromancer Score metric for user behavior analysis.
Motivation
Closes #45.
Changes
termstory/insights.py:calculate_necromancer_scoreto tally meaningful sessions resurrecting 180+ day dormant projects.termstory/tui.py:Acceptance Criteria
Impact & Side Effects
No breaking changes. Adds visual metrics to the TUI.
How to Test
termstory guiand open any dashboard timeframe view to verify the headers.NECROMANCERmetric correctly.Quality Checklist