Add a "resolution time" statistic (#151)

* Add a resolution time stat

* Add resolution times to API

* Return None if no resolution time can be calculated

* Optionally calculate hang time for all tickets, not just in-progress ones

* Add avg_hang_time_all_minutes to API

* Store reopenedAt timestamp
This commit is contained in:
Mish 2026-01-27 11:55:07 +00:00 committed by GitHub
parent 6afc28a27c
commit e77a60e1ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 59 additions and 28 deletions

View file

@ -29,6 +29,7 @@ async def stats(req: Request):
for entry in total_stats.helpers_leaderboard[:3]
],
"average_hang_time_minutes": total_stats.avg_hang_time_minutes,
"mean_resolution_time_minutes": total_stats.mean_resolution_time_minutes,
"prev_day_total": prev_day_stats.new_tickets_total,
"prev_day_open": prev_day_stats.new_tickets_still_open,
"prev_day_in_progress": prev_day_stats.new_tickets_in_progress,
@ -41,6 +42,8 @@ async def stats(req: Request):
}
for entry in prev_day_stats.helpers_leaderboard[:3]
],
"prev_day_average_hang_time_minutes": prev_day_stats.avg_hang_time_minutes,
"prev_day_average_hang_time_minutes": prev_day_stats.avg_hang_time_current_minutes,
"prev_day_average_hang_time_all_minutes": prev_day_stats.avg_hang_time_all_minutes,
"prev_day_mean_resolution_time_minutes": prev_day_stats.mean_resolution_time_minutes,
}
)

View file

@ -1,4 +1,5 @@
import logging
from datetime import datetime
from slack_sdk.errors import SlackApiError
@ -29,6 +30,7 @@ class Reopen(Macro):
"status": TicketStatus.OPEN,
"closedBy": {"disconnect": True},
"reopenedBy": {"connect": {"id": helper.id}},
"reopenedAt": datetime.now(),
"closedAt": None,
},
)

View file

@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
from statistics import fmean
from typing import TypedDict
from nephthys.utils.env import env
@ -21,17 +22,29 @@ class OverallStatsResult:
tickets_in_progress: int
helpers_leaderboard: list[LeaderboardEntry]
avg_hang_time_minutes: float | None
mean_resolution_time_minutes: float | None
def calculate_avg_hang_time(tickets: list[Ticket]) -> float | None:
def calculate_hang_times(
tickets: list[Ticket], include_closed_tickets: bool
) -> list[float]:
hang_times = []
for tkt in tickets:
if tkt.status == TicketStatus.CLOSED:
if not include_closed_tickets and tkt.status == TicketStatus.CLOSED:
continue
if not tkt.assignedAt:
continue
hang_times.append((tkt.assignedAt - tkt.createdAt).total_seconds() / 60)
return sum(hang_times) / len(hang_times) if hang_times else None
return hang_times
def calculate_resolution_times(tickets: list[Ticket]) -> list[float]:
resolution_times = []
for tkt in tickets:
if not tkt.closedAt:
continue
resolution_times.append((tkt.closedAt - tkt.createdAt).total_seconds() / 60)
return resolution_times
async def calculate_overall_stats() -> OverallStatsResult:
@ -56,18 +69,27 @@ async def calculate_overall_stats() -> OverallStatsResult:
reverse=True,
)
hang_times = calculate_hang_times(tickets, include_closed_tickets=False)
resolution_times = calculate_resolution_times(tickets)
return OverallStatsResult(
tickets_total=total,
tickets_open=total_open,
tickets_closed=total_closed,
tickets_in_progress=total_in_progress,
helpers_leaderboard=helpers_leaderboard,
avg_hang_time_minutes=calculate_avg_hang_time(tickets),
avg_hang_time_minutes=fmean(hang_times) if hang_times else None,
mean_resolution_time_minutes=fmean(resolution_times)
if resolution_times
else None,
)
@dataclass
class DailyStatsResult:
"""Processed statistics for a time interval
(usually 24h but doesn't have to be)"""
new_tickets_total: int
new_tickets_now_closed: int
new_tickets_still_open: int
@ -76,45 +98,39 @@ class DailyStatsResult:
closed_today_from_today: int
assigned_today_in_progress: int
helpers_leaderboard: list[LeaderboardEntry]
avg_hang_time_minutes: float | None
# Mean time to response for tickets created today and currently in-progress
avg_hang_time_current_minutes: float | None
# Mean time to response for all tickets created today
avg_hang_time_all_minutes: float | None
# Mean time to resolution for tickets created today
mean_resolution_time_minutes: float | None
async def calculate_daily_stats(
start_time: datetime, end_time: datetime
) -> DailyStatsResult:
tickets = await env.db.ticket.find_many() or []
tickets_created_today = [t for t in tickets if start_time <= t.createdAt < end_time]
users_with_closed_tickets = await env.db.user.find_many(
include={"closedTickets": True},
where={"helper": True, "closedTickets": {"some": {}}},
)
new_tickets_total = len(
[t for t in tickets if start_time <= t.createdAt < end_time]
)
new_tickets_total = len(tickets_created_today)
new_tickets_now_closed = len(
[
t
for t in tickets
for t in tickets_created_today
if t.status == TicketStatus.CLOSED
and t.closedAt
and start_time <= t.closedAt < end_time
and start_time <= t.createdAt < end_time
]
)
new_tickets_still_open = len(
[
t
for t in tickets
if start_time <= t.createdAt < end_time and t.status == TicketStatus.OPEN
]
[t for t in tickets_created_today if t.status == TicketStatus.OPEN]
)
new_tickets_in_progress = len(
[
t
for t in tickets
if start_time <= t.createdAt < end_time
and t.status == TicketStatus.IN_PROGRESS
]
[t for t in tickets_created_today if t.status == TicketStatus.IN_PROGRESS]
)
tickets_closed_today = [
t
@ -152,9 +168,16 @@ async def calculate_daily_stats(
reverse=True,
)
hang_time = calculate_avg_hang_time(
[t for t in tickets if start_time <= t.createdAt < end_time]
hang_times_current = calculate_hang_times(
tickets_created_today, include_closed_tickets=False
)
hang_times_all = calculate_hang_times(
tickets_created_today, include_closed_tickets=True
)
resolution_times = calculate_resolution_times(tickets_created_today)
hang_time_current = fmean(hang_times_current) if hang_times_current else None
hang_time_all = fmean(hang_times_all) if hang_times_all else None
resolution_time = fmean(resolution_times) if resolution_times else None
return DailyStatsResult(
closed_today=closed_today,
@ -165,5 +188,7 @@ async def calculate_daily_stats(
new_tickets_now_closed=new_tickets_now_closed,
new_tickets_in_progress=new_tickets_in_progress,
new_tickets_still_open=new_tickets_still_open,
avg_hang_time_minutes=hang_time,
avg_hang_time_current_minutes=hang_time_current,
avg_hang_time_all_minutes=hang_time_all,
mean_resolution_time_minutes=resolution_time,
)

View file

@ -35,8 +35,8 @@ async def get_leaderboard_view():
else "No hang time data available"
)
avg_prev_day_hang_time_str = (
f"{prev_day.avg_hang_time_minutes:.2f} minutes"
if prev_day.avg_hang_time_minutes is not None
f"{prev_day.avg_hang_time_current_minutes:.2f} minutes"
if prev_day.avg_hang_time_current_minutes is not None
else "No hang time data available"
)

View file

@ -58,8 +58,9 @@ model Ticket {
openedBy User @relation("OpenedTickets", fields: [openedById], references: [id])
openedById Int
reopenedBy User? @relation("ReopenedTickets", fields: [reopenedById], references: [id])
reopenedBy User? @relation("ReopenedTickets", fields: [reopenedById], references: [id])
reopenedById Int?
reopenedAt DateTime?
closedBy User? @relation("ClosedTickets", fields: [closedById], references: [id])
closedById Int?