Skip to content

Commit e54ff20

Browse files
committed
Add integrity metrics trend indicators and affecting commitments display
- Add TrendDirection enum and trend fields to IntegrityMetrics - Add calculate_integrity_metrics_with_trends() to compare 30-day periods - Add get_affecting_commitments() to show commitments impacting score - Update IntegrityHandler to display affecting commitments in dashboard - Update DataPanel to show trend arrows in integrity view - Update snapshots for integrity dashboard tests - Add comprehensive tests for trend calculation and affecting commitments
1 parent 1ec62d3 commit e54ff20

File tree

9 files changed

+928
-172
lines changed

9 files changed

+928
-172
lines changed

src/jdo/commands/handlers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,9 @@ def _show_dashboard(self, metrics: dict[str, Any]) -> HandlerResult:
16851685
total_at_risk = metrics.get("total_at_risk", 0)
16861686
total_abandoned = metrics.get("total_abandoned", 0)
16871687

1688+
# Affecting commitments
1689+
affecting = metrics.get("affecting_commitments", [])
1690+
16881691
lines = [
16891692
"Integrity Dashboard",
16901693
"=" * 40,
@@ -1709,6 +1712,15 @@ def _show_dashboard(self, metrics: dict[str, Any]) -> HandlerResult:
17091712
lines.append(f" Abandoned: {total_abandoned}")
17101713
lines.append("")
17111714

1715+
# Add affecting commitments
1716+
if affecting:
1717+
lines.append("Recent issues affecting score:")
1718+
for item in affecting:
1719+
deliverable = item.get("deliverable", "Untitled")[:40]
1720+
reason = item.get("reason", "unknown")
1721+
lines.append(f" • {deliverable} ({reason})")
1722+
lines.append("")
1723+
17121724
# Grade color hint for TUI
17131725
grade_colors = {
17141726
"A+": "green",
@@ -1734,6 +1746,7 @@ def _show_dashboard(self, metrics: dict[str, Any]) -> HandlerResult:
17341746
"data": {
17351747
**metrics,
17361748
"grade_color": grade_colors.get(grade, "white"),
1749+
"affecting_commitments": affecting,
17371750
},
17381751
},
17391752
draft_data=None,

src/jdo/integrity/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
from __future__ import annotations
44

55
from jdo.integrity.service import (
6+
AffectingCommitment,
67
AtRiskResult,
78
IntegrityService,
89
RecoveryResult,
910
RiskSummary,
1011
)
1112

12-
__all__ = ["AtRiskResult", "IntegrityService", "RecoveryResult", "RiskSummary"]
13+
__all__ = [
14+
"AffectingCommitment",
15+
"AtRiskResult",
16+
"IntegrityService",
17+
"RecoveryResult",
18+
"RiskSummary",
19+
]

src/jdo/integrity/service.py

Lines changed: 271 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
from jdo.models.cleanup_plan import CleanupPlan, CleanupPlanStatus
1212
from jdo.models.commitment import Commitment, CommitmentStatus
13-
from jdo.models.integrity_metrics import IntegrityMetrics
13+
from jdo.models.integrity_metrics import (
14+
TREND_THRESHOLD,
15+
IntegrityMetrics,
16+
TrendDirection,
17+
)
1418
from jdo.models.stakeholder import Stakeholder
1519
from jdo.models.task import Task, TaskStatus
1620
from jdo.models.task_history import TaskEventType, TaskHistoryEntry
@@ -25,6 +29,11 @@
2529
ACCURACY_DECAY_DAYS = 7 # Weight halves every 7 days
2630
ACCURACY_MAX_AGE_DAYS = 90 # Maximum age for history consideration
2731

32+
# Constants for trend calculation
33+
TREND_PERIOD_DAYS = 30 # Period for comparing metrics (30 days)
34+
AFFECTING_SCORE_DAYS = 30 # Days to look back for affecting commitments
35+
MAX_AFFECTING_COMMITMENTS = 5 # Maximum commitments to show in affecting list
36+
2837

2938
@dataclass
3039
class RiskSummary:
@@ -117,6 +126,14 @@ class RecoveryResult:
117126
notification_still_needed: bool
118127

119128

129+
@dataclass
130+
class AffectingCommitment:
131+
"""A commitment that negatively affected the integrity score."""
132+
133+
commitment: Commitment
134+
reason: str # Why it affected the score (e.g., "completed late", "abandoned")
135+
136+
120137
class IntegrityService:
121138
"""Service for managing the Honor-Your-Word integrity protocol.
122139
@@ -668,3 +685,256 @@ def _calculate_estimation_accuracy(self, session: Session) -> tuple[float, int]:
668685
return 1.0, tasks_with_estimates
669686

670687
return weighted_accuracy / total_weight, tasks_with_estimates
688+
689+
def calculate_integrity_metrics_with_trends(self, session: Session) -> IntegrityMetrics:
690+
"""Calculate integrity metrics including trend indicators.
691+
692+
Compares current 30-day period with previous 30-day period to
693+
determine if metrics are improving, declining, or stable.
694+
695+
Args:
696+
session: Database session
697+
698+
Returns:
699+
IntegrityMetrics with trend fields populated
700+
"""
701+
# Get current metrics
702+
current = self.calculate_integrity_metrics(session)
703+
704+
# Calculate previous period metrics for comparison
705+
now = datetime.now(UTC)
706+
cutoff = now - timedelta(days=TREND_PERIOD_DAYS)
707+
prev_cutoff = cutoff - timedelta(days=TREND_PERIOD_DAYS)
708+
709+
# Calculate on-time rate for previous period
710+
prev_on_time_rate = self._calculate_period_on_time_rate(session, prev_cutoff, cutoff)
711+
712+
# Calculate cleanup rate for previous period
713+
prev_cleanup_rate = self._calculate_period_cleanup_rate(session, prev_cutoff, cutoff)
714+
715+
# Calculate notification timeliness for previous period
716+
prev_notification = self._calculate_period_notification_timeliness(
717+
session, prev_cutoff, cutoff
718+
)
719+
720+
# Determine trends
721+
on_time_trend = self._determine_trend(prev_on_time_rate, current.on_time_rate)
722+
notification_trend = self._determine_trend(
723+
prev_notification, current.notification_timeliness
724+
)
725+
cleanup_trend = self._determine_trend(prev_cleanup_rate, current.cleanup_completion_rate)
726+
727+
# Calculate overall trend from composite score
728+
prev_composite = (
729+
prev_on_time_rate * 0.35
730+
+ prev_notification * 0.25
731+
+ prev_cleanup_rate * 0.25
732+
+ current.estimation_accuracy * 0.10 # Use current, no history
733+
+ min(current.current_streak_weeks * 2, 5) / 100
734+
) * 100
735+
overall_trend = self._determine_trend(prev_composite, current.composite_score)
736+
737+
# Return metrics with trends
738+
return IntegrityMetrics(
739+
on_time_rate=current.on_time_rate,
740+
notification_timeliness=current.notification_timeliness,
741+
cleanup_completion_rate=current.cleanup_completion_rate,
742+
current_streak_weeks=current.current_streak_weeks,
743+
total_completed=current.total_completed,
744+
total_on_time=current.total_on_time,
745+
total_at_risk=current.total_at_risk,
746+
total_abandoned=current.total_abandoned,
747+
estimation_accuracy=current.estimation_accuracy,
748+
tasks_with_estimates=current.tasks_with_estimates,
749+
on_time_trend=on_time_trend,
750+
notification_trend=notification_trend,
751+
cleanup_trend=cleanup_trend,
752+
overall_trend=overall_trend,
753+
)
754+
755+
def _determine_trend(self, prev_value: float, curr_value: float) -> TrendDirection:
756+
"""Determine trend direction between two values.
757+
758+
Args:
759+
prev_value: Previous period value
760+
curr_value: Current period value
761+
762+
Returns:
763+
TrendDirection indicating if value is improving, declining, or stable
764+
"""
765+
diff = curr_value - prev_value
766+
if diff > TREND_THRESHOLD:
767+
return TrendDirection.UP
768+
if diff < -TREND_THRESHOLD:
769+
return TrendDirection.DOWN
770+
return TrendDirection.STABLE
771+
772+
def _calculate_period_on_time_rate(
773+
self, session: Session, start: datetime, end: datetime
774+
) -> float:
775+
"""Calculate on-time rate for a specific period.
776+
777+
Args:
778+
session: Database session
779+
start: Period start datetime
780+
end: Period end datetime
781+
782+
Returns:
783+
On-time rate (0.0-1.0), defaults to 1.0 if no data
784+
"""
785+
# Get completed commitments in period
786+
period_completed = session.exec(
787+
select(func.count())
788+
.select_from(Commitment)
789+
.where(
790+
Commitment.status == CommitmentStatus.COMPLETED,
791+
Commitment.completed_at >= start, # type: ignore[operator]
792+
Commitment.completed_at < end, # type: ignore[operator]
793+
)
794+
).one()
795+
796+
if period_completed == 0:
797+
return 1.0 # Clean slate for period
798+
799+
period_on_time = session.exec(
800+
select(func.count())
801+
.select_from(Commitment)
802+
.where(
803+
Commitment.status == CommitmentStatus.COMPLETED,
804+
Commitment.completed_at >= start, # type: ignore[operator]
805+
Commitment.completed_at < end, # type: ignore[operator]
806+
Commitment.completed_on_time == True, # noqa: E712
807+
)
808+
).one()
809+
810+
return period_on_time / period_completed
811+
812+
def _calculate_period_cleanup_rate(
813+
self, session: Session, start: datetime, end: datetime
814+
) -> float:
815+
"""Calculate cleanup completion rate for a specific period.
816+
817+
Args:
818+
session: Database session
819+
start: Period start datetime
820+
end: Period end datetime
821+
822+
Returns:
823+
Cleanup rate (0.0-1.0), defaults to 1.0 if no data
824+
"""
825+
period_plans = session.exec(
826+
select(func.count())
827+
.select_from(CleanupPlan)
828+
.where(
829+
CleanupPlan.created_at >= start, # type: ignore[operator]
830+
CleanupPlan.created_at < end, # type: ignore[operator]
831+
)
832+
).one()
833+
834+
if period_plans == 0:
835+
return 1.0 # Clean slate for period
836+
837+
period_completed = session.exec(
838+
select(func.count())
839+
.select_from(CleanupPlan)
840+
.where(
841+
CleanupPlan.status == CleanupPlanStatus.COMPLETED,
842+
CleanupPlan.created_at >= start, # type: ignore[operator]
843+
CleanupPlan.created_at < end, # type: ignore[operator]
844+
)
845+
).one()
846+
847+
return period_completed / period_plans
848+
849+
def _calculate_period_notification_timeliness(
850+
self, session: Session, start: datetime, end: datetime
851+
) -> float:
852+
"""Calculate notification timeliness for a specific period.
853+
854+
Args:
855+
session: Database session
856+
start: Period start datetime
857+
end: Period end datetime
858+
859+
Returns:
860+
Timeliness score (0.0-1.0), defaults to 1.0 if no data
861+
"""
862+
# Get commitments marked at-risk in period
863+
at_risk_in_period = session.exec(
864+
select(Commitment).where(
865+
Commitment.marked_at_risk_at.is_not(None), # type: ignore[union-attr]
866+
Commitment.marked_at_risk_at >= start, # type: ignore[operator]
867+
Commitment.marked_at_risk_at < end, # type: ignore[operator]
868+
)
869+
).all()
870+
871+
if not at_risk_in_period:
872+
return 1.0 # Clean slate for period
873+
874+
total_score = 0.0
875+
for commitment in at_risk_in_period:
876+
if commitment.marked_at_risk_at is None or commitment.due_date is None:
877+
continue
878+
879+
marked_date = commitment.marked_at_risk_at.date()
880+
days_before_due = (commitment.due_date - marked_date).days
881+
882+
if days_before_due >= 7:
883+
score = 1.0
884+
elif days_before_due <= 0:
885+
score = 0.0
886+
else:
887+
score = days_before_due / 7.0
888+
889+
total_score += score
890+
891+
return total_score / len(at_risk_in_period) if at_risk_in_period else 1.0
892+
893+
def get_affecting_commitments(self, session: Session) -> list[AffectingCommitment]:
894+
"""Get recent commitments that negatively affected the integrity score.
895+
896+
Returns commitments from the last 30 days where:
897+
- completed_on_time = False (late completion)
898+
- status = abandoned
899+
900+
Args:
901+
session: Database session
902+
903+
Returns:
904+
List of AffectingCommitment with reason for each
905+
"""
906+
now = datetime.now(UTC)
907+
cutoff = now - timedelta(days=AFFECTING_SCORE_DAYS)
908+
909+
# Get late completions
910+
late_completions = session.exec(
911+
select(Commitment)
912+
.where(
913+
Commitment.status == CommitmentStatus.COMPLETED,
914+
Commitment.completed_on_time == False, # noqa: E712
915+
Commitment.completed_at >= cutoff, # type: ignore[operator]
916+
)
917+
.order_by(Commitment.completed_at.desc()) # type: ignore[union-attr]
918+
.limit(MAX_AFFECTING_COMMITMENTS)
919+
).all()
920+
921+
result: list[AffectingCommitment] = [
922+
AffectingCommitment(commitment=c, reason="completed late") for c in late_completions
923+
]
924+
925+
# Get abandoned commitments
926+
remaining_slots = MAX_AFFECTING_COMMITMENTS - len(result)
927+
if remaining_slots > 0:
928+
abandoned = session.exec(
929+
select(Commitment)
930+
.where(
931+
Commitment.status == CommitmentStatus.ABANDONED,
932+
Commitment.updated_at >= cutoff, # type: ignore[operator]
933+
)
934+
.order_by(Commitment.updated_at.desc()) # type: ignore[arg-type]
935+
.limit(remaining_slots)
936+
).all()
937+
938+
result.extend(AffectingCommitment(commitment=c, reason="abandoned") for c in abandoned)
939+
940+
return result

0 commit comments

Comments
 (0)