From ba079e6d2a299bdedefc0bf4848ce34ed39d9ef4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 13:48:12 +0000 Subject: [PATCH 1/3] Add average resolution time by priority to home page Breaks down average (and p95) resolution time across non-project completed issues (Bugs, New Features, Technical Changes) grouped by Linear priority so urgent work can be compared against lower-priority items at a glance. https://claude.ai/code/session_014XrrEqCFFWBGuwkw5M7A6q --- app.py | 44 +++++++++++++++++ linear/issues.py | 48 +++++++++++++++++++ templates/index.html | 5 ++ .../index_resolution_by_priority.html | 22 +++++++++ 4 files changed, 119 insertions(+) create mode 100644 templates/partials/index_resolution_by_priority.html diff --git a/app.py b/app.py index f5ee0f9..6bad9dd 100644 --- a/app.py +++ b/app.py @@ -32,6 +32,7 @@ get_created_issues, get_open_issues, get_open_issues_for_person, + get_resolution_time_by_priority, get_time_data, ) from linear.projects import get_projects @@ -918,6 +919,41 @@ def _build_leaderboard_context(days: int, _cache_epoch: int) -> dict: } +@lru_cache(maxsize=INDEX_CONTEXT_CACHE_MAXSIZE) +def _build_resolution_by_priority_context(days: int, _cache_epoch: int) -> dict: + with ThreadPoolExecutor(max_workers=INDEX_THREADPOOL_MAX_WORKERS) as executor: + completed_bugs_future = executor.submit(get_completed_issues_summary, 5, "Bug", days) + completed_new_features_future = executor.submit( + get_completed_issues_summary, 5, "New Feature", days + ) + completed_technical_changes_future = executor.submit( + get_completed_issues_summary, 5, "Technical Change", days + ) + + completed_bugs_result = get_future_result_with_timeout(completed_bugs_future, []) + completed_new_features_result = get_future_result_with_timeout( + completed_new_features_future, [] + ) + completed_technical_changes_result = get_future_result_with_timeout( + completed_technical_changes_future, [] + ) + + completed_non_project_issues = [ + issue + for issue in completed_bugs_result + + completed_new_features_result + + completed_technical_changes_result + if not issue.get("project") + ] + + resolution_stats = get_resolution_time_by_priority(completed_non_project_issues) + + return { + "days": days, + "resolution_stats": resolution_stats, + } + + # use a query string parameter for days on the index route @app.route("/") def index(): @@ -933,6 +969,14 @@ def index_priority_stats_partial(): return render_template("partials/index_priority_stats.html", **context) +@app.route("/partials/index/resolution-by-priority") +def index_resolution_by_priority_partial(): + days = request.args.get("days", default=30, type=int) + cache_epoch = int(time.time() / INDEX_CACHE_TTL_SECONDS) + context = _build_resolution_by_priority_context(days, cache_epoch) + return render_template("partials/index_resolution_by_priority.html", **context) + + @app.route("/partials/index/open-items") def index_open_items_partial(): days = request.args.get("days", default=30, type=int) diff --git a/linear/issues.py b/linear/issues.py index 4fe0481..92f497d 100644 --- a/linear/issues.py +++ b/linear/issues.py @@ -376,6 +376,54 @@ def by_platform(issues): ) +PRIORITY_LABELS = { + 1: "Urgent", + 2: "High", + 3: "Medium", + 4: "Low", + 5: "Very Low", +} + + +def by_priority(issues): + priority_issues = {} + for issue in issues: + priority = issue.get("priority") + if priority is None: + continue + priority_issues.setdefault(priority, []).append(issue) + return dict(sorted(priority_issues.items())) + + +def get_resolution_time_by_priority(issues): + """Return average/p95 resolution time (in days) per priority level. + + Only issues with both createdAt and completedAt populated are included. + Priorities with no resolved issues in the window are omitted. + """ + stats = [] + for priority, priority_issues in by_priority(issues).items(): + time_data = get_time_data(priority_issues) + resolved_count = sum( + 1 + for issue in priority_issues + if _parse_linear_datetime(issue.get("completedAt")) + and _parse_linear_datetime(issue.get("createdAt")) + ) + if not resolved_count: + continue + stats.append( + { + "priority": priority, + "label": PRIORITY_LABELS.get(priority, f"P{priority}"), + "count": resolved_count, + "avg_days": time_data["lead"]["avg"], + "p95_days": time_data["lead"]["p95"], + } + ) + return stats + + def _parse_linear_datetime(value): if not value: return None diff --git a/templates/index.html b/templates/index.html index 9a93e96..fd3d185 100644 --- a/templates/index.html +++ b/templates/index.html @@ -29,6 +29,7 @@

Priority Bug Stats

#}
+
{% endblock %} @@ -117,6 +118,10 @@

Priority Bug Stats

// renderPlatformChart // ); loadSection('open-items', `/partials/index/open-items?days=${days}`); + loadSection( + 'resolution-by-priority', + `/partials/index/resolution-by-priority?days=${days}` + ); loadSection('leaderboard', `/partials/index/leaderboard?days=${days}`); {% endblock %} diff --git a/templates/partials/index_resolution_by_priority.html b/templates/partials/index_resolution_by_priority.html new file mode 100644 index 0000000..f48aa57 --- /dev/null +++ b/templates/partials/index_resolution_by_priority.html @@ -0,0 +1,22 @@ +

Average Resolution Time by Priority

+

+ Non-project items completed in the last {{ days }} day{{ '' if days == 1 else 's' }}. +

+{% if resolution_stats %} +
+ {% for stat in resolution_stats %} +
+
+
{{ stat.label }}
+

{{ stat.avg_days }}d

+ {{ stat.count }} resolved · p95 {{ stat.p95_days }}d +
+
+ {% endfor %} +
+{% else %} +
No resolved non-project items in this window.
+{% endif %} +
From 35b0a20682a6c747a475a8629aa33344a9f039b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 14:09:36 +0000 Subject: [PATCH 2/3] Move resolution-by-priority section to top of home page https://claude.ai/code/session_014XrrEqCFFWBGuwkw5M7A6q --- templates/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/index.html b/templates/index.html index fd3d185..70ac7c1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -28,8 +28,8 @@

Priority Bug Stats

#} -
+
{% endblock %} @@ -117,11 +117,11 @@

Priority Bug Stats

// `/partials/index/priority-stats?days=${days}`, // renderPlatformChart // ); - loadSection('open-items', `/partials/index/open-items?days=${days}`); loadSection( 'resolution-by-priority', `/partials/index/resolution-by-priority?days=${days}` ); + loadSection('open-items', `/partials/index/open-items?days=${days}`); loadSection('leaderboard', `/partials/index/leaderboard?days=${days}`); {% endblock %} From d8bdb8befa7f286c4ae94fa6e61d750e49e5a46f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 14:14:48 +0000 Subject: [PATCH 3/3] Rename "New Feature" label to "Feature Request" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linear label was renamed, so update every query site plus the matching local variable names in app.py and jobs.py. The "New Features" heading in the weekly changelog prose stays — it's a category label for the LLM-generated summary, not a Linear label. Also narrow resolution-by-priority to Bug + Feature Request only (dropping Technical Change) so the home-page metric reflects the two user-facing item types. https://claude.ai/code/session_014XrrEqCFFWBGuwkw5M7A6q --- app.py | 58 +++++++++++++++++++++++++-------------------------------- jobs.py | 6 +++--- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/app.py b/app.py index 6bad9dd..b09bbb6 100644 --- a/app.py +++ b/app.py @@ -523,7 +523,7 @@ def get_future_result_with_timeout( def _build_leaderboard_entries( days: int, completed_bugs: list, - completed_new_features: list, + completed_feature_requests: list, completed_technical_changes: list, merged_reviews: dict, merged_authored_prs: dict, @@ -569,7 +569,7 @@ def resolve_slug(*identities: str | None) -> str | None: count_breakdown_by_slug: dict[str, dict[str, int]] = {} count_breakdown_by_external: dict[str, dict[str, int]] = {} - completed_work = completed_bugs + completed_new_features + completed_technical_changes + completed_work = completed_bugs + completed_feature_requests + completed_technical_changes for issue in completed_work: assignee = issue.get("assignee") @@ -782,8 +782,8 @@ def _build_priority_stats_context(days: int, _cache_epoch: int) -> dict: created_priority_future = executor.submit(get_created_issues, 2, "Bug", days) completed_priority_future = executor.submit(get_completed_issues_summary, 2, "Bug", days) completed_bugs_future = executor.submit(get_completed_issues_summary, 5, "Bug", days) - completed_new_features_future = executor.submit( - get_completed_issues_summary, 5, "New Feature", days + completed_feature_requests_future = executor.submit( + get_completed_issues_summary, 5, "Feature Request", days ) completed_technical_changes_future = executor.submit( get_completed_issues_summary, 5, "Technical Change", days @@ -796,11 +796,11 @@ def _build_priority_stats_context(days: int, _cache_epoch: int) -> dict: ] completed_bugs_result = get_future_result_with_timeout(completed_bugs_future, []) completed_bugs = [issue for issue in completed_bugs_result if not issue.get("project")] - completed_new_features_result = get_future_result_with_timeout( - completed_new_features_future, [] + completed_feature_requests_result = get_future_result_with_timeout( + completed_feature_requests_future, [] ) - completed_new_features = [ - issue for issue in completed_new_features_result if not issue.get("project") + completed_feature_requests = [ + issue for issue in completed_feature_requests_result if not issue.get("project") ] completed_technical_changes_result = get_future_result_with_timeout( completed_technical_changes_future, [] @@ -811,13 +811,13 @@ def _build_priority_stats_context(days: int, _cache_epoch: int) -> dict: time_data = get_time_data(completed_priority_bugs) fixes_per_day = ( - len(completed_bugs + completed_new_features + completed_technical_changes) / days + len(completed_bugs + completed_feature_requests + completed_technical_changes) / days if days else 0 ) total_completed_issues = len( - completed_bugs + completed_new_features + completed_technical_changes + completed_bugs + completed_feature_requests + completed_technical_changes ) if total_completed_issues: priority_percentage = int( @@ -847,16 +847,16 @@ def _build_open_items_context(days: int, _cache_epoch: int) -> dict: with ThreadPoolExecutor(max_workers=INDEX_THREADPOOL_MAX_WORKERS) as executor: open_priority_future = executor.submit(get_open_issues, 2, "Bug") open_bugs_future = executor.submit(get_open_issues, 5, "Bug") - open_new_features_future = executor.submit(get_open_issues, 5, "New Feature") + open_feature_requests_future = executor.submit(get_open_issues, 5, "Feature Request") open_technical_changes_future = executor.submit(get_open_issues, 5, "Technical Change") open_priority_bugs = get_future_result_with_timeout(open_priority_future, []) open_bugs_result = get_future_result_with_timeout(open_bugs_future, []) - open_new_features_result = get_future_result_with_timeout(open_new_features_future, []) + open_feature_requests_result = get_future_result_with_timeout(open_feature_requests_future, []) open_technical_changes_result = get_future_result_with_timeout( open_technical_changes_future, [] ) - open_work = open_bugs_result + open_new_features_result + open_technical_changes_result + open_work = open_bugs_result + open_feature_requests_result + open_technical_changes_result return { "days": days, @@ -877,8 +877,8 @@ def _build_open_items_context(days: int, _cache_epoch: int) -> dict: def _build_leaderboard_context(days: int, _cache_epoch: int) -> dict: with ThreadPoolExecutor(max_workers=INDEX_THREADPOOL_MAX_WORKERS) as executor: completed_bugs_future = executor.submit(get_completed_issues_summary, 5, "Bug", days) - completed_new_features_future = executor.submit( - get_completed_issues_summary, 5, "New Feature", days + completed_feature_requests_future = executor.submit( + get_completed_issues_summary, 5, "Feature Request", days ) completed_technical_changes_future = executor.submit( get_completed_issues_summary, 5, "Technical Change", days @@ -888,11 +888,11 @@ def _build_leaderboard_context(days: int, _cache_epoch: int) -> dict: completed_bugs_result = get_future_result_with_timeout(completed_bugs_future, []) completed_bugs = [issue for issue in completed_bugs_result if not issue.get("project")] - completed_new_features_result = get_future_result_with_timeout( - completed_new_features_future, [] + completed_feature_requests_result = get_future_result_with_timeout( + completed_feature_requests_future, [] ) - completed_new_features = [ - issue for issue in completed_new_features_result if not issue.get("project") + completed_feature_requests = [ + issue for issue in completed_feature_requests_result if not issue.get("project") ] completed_technical_changes_result = get_future_result_with_timeout( completed_technical_changes_future, [] @@ -907,7 +907,7 @@ def _build_leaderboard_context(days: int, _cache_epoch: int) -> dict: leaderboard_entries = _build_leaderboard_entries( days=days, completed_bugs=completed_bugs, - completed_new_features=completed_new_features, + completed_feature_requests=completed_feature_requests, completed_technical_changes=completed_technical_changes, merged_reviews=merged_reviews, merged_authored_prs=merged_authored_prs, @@ -923,26 +923,18 @@ def _build_leaderboard_context(days: int, _cache_epoch: int) -> dict: def _build_resolution_by_priority_context(days: int, _cache_epoch: int) -> dict: with ThreadPoolExecutor(max_workers=INDEX_THREADPOOL_MAX_WORKERS) as executor: completed_bugs_future = executor.submit(get_completed_issues_summary, 5, "Bug", days) - completed_new_features_future = executor.submit( - get_completed_issues_summary, 5, "New Feature", days - ) - completed_technical_changes_future = executor.submit( - get_completed_issues_summary, 5, "Technical Change", days + completed_feature_requests_future = executor.submit( + get_completed_issues_summary, 5, "Feature Request", days ) completed_bugs_result = get_future_result_with_timeout(completed_bugs_future, []) - completed_new_features_result = get_future_result_with_timeout( - completed_new_features_future, [] - ) - completed_technical_changes_result = get_future_result_with_timeout( - completed_technical_changes_future, [] + completed_feature_requests_result = get_future_result_with_timeout( + completed_feature_requests_future, [] ) completed_non_project_issues = [ issue - for issue in completed_bugs_result - + completed_new_features_result - + completed_technical_changes_result + for issue in completed_bugs_result + completed_feature_requests_result if not issue.get("project") ] diff --git a/jobs.py b/jobs.py index 7c4c8d8..5c05885 100644 --- a/jobs.py +++ b/jobs.py @@ -426,7 +426,7 @@ def normalize_identity(value: str | None) -> str: items = ( get_completed_issues(5, "Bug", days) - + get_completed_issues(5, "New Feature", days) + + get_completed_issues(5, "Feature Request", days) + get_completed_issues(5, "Technical Change", days) ) items = [item for item in items if not item.get("project")] @@ -506,7 +506,7 @@ def post_stale(): cr_prs = get_prs_with_changes_requested_by_reviewer() stale_issues = get_stale_issues_by_assignee( get_open_issues(5, "Bug") - + get_open_issues(5, "New Feature") + + get_open_issues(5, "Feature Request") + get_open_issues(5, "Technical Change"), 7, ) @@ -729,7 +729,7 @@ def post_weekly_changelog(): issues = ( get_completed_issues(5, "Bug", 7) - + get_completed_issues(5, "New Feature", 7) + + get_completed_issues(5, "Feature Request", 7) + get_completed_issues(5, "Technical Change", 7) ) if not issues: