11module Api
22 class BurndownController < ApplicationController
3- STARTED_STATUSES = %w[ in_progress partially_implemented implemented abandoned ] . freeze
4- COMPLETED_STATUSES = %w[ implemented ] . freeze
3+ STARTED_STATUSES = %w[ in_progress completed abandoned ] . freeze
4+ COMPLETED_STATUSES = %w[ completed ] . freeze
5+ ABANDONED_STATUSES = %w[ abandoned ] . freeze
56
67 def show
78 government = Government . find ( params [ :government_id ] )
89 commitments = government . commitments
910
11+ if params [ :source_type ] . present?
12+ commitments = commitments . joins ( :sources ) . where ( sources : { source_type : params [ :source_type ] } ) . distinct
13+ end
14+
1015 policy_area = nil
1116 if params [ :policy_area_slug ] . present?
1217 policy_area = PolicyArea . find_by! ( slug : params [ :policy_area_slug ] )
@@ -38,46 +43,65 @@ def show
3843 first_change_by_commitment [ sc . commitment_id ] ||= sc . previous_status
3944 end
4045
41- # For commitments already in a started status without status_change records,
42- # use their earliest evidence date instead of date_promised
43- earliest_evidence_dates = earliest_evidence_dates_for ( commitment_ids )
46+ # Gather all real-world evidence dates per commitment
47+ # (from CommitmentMatch → Bill/StatcanDataset and from CriterionAssessment → Source)
48+ all_evidence_dates = evidence_dates_for ( commitment_ids )
4449
45- # Build sorted events
50+ # Build sorted events, tracking effective state per commitment
51+ # to avoid double-counting from duplicate status change records
4652 events = [ ]
53+ effective_state = { }
4754
4855 commitment_records . each do |c |
4956 initial = first_change_by_commitment [ c . id ] || c . status
57+ effective_state [ c . id ] = initial
5058 scope_date = c . date_promised || c . created_at . to_date
5159
5260 # Scope always enters on date_promised
53- events << { date : scope_date , delta_scope : 1 , delta_started : 0 , delta_completed : 0 }
54-
55- # Started/completed enter on evidence date (if no status_change drove it)
56- next if first_change_by_commitment . key? ( c . id )
61+ events << { date : scope_date , delta_scope : 1 , delta_started : 0 , delta_completed : 0 , delta_abandoned : 0 }
5762
63+ # Add initial state event for all commitments that start in a non-default state
5864 if STARTED_STATUSES . include? ( initial )
59- evidence_date = earliest_evidence_dates [ c . id ] || scope_date
65+ evidence_date = all_evidence_dates [ c . id ] &. min || scope_date
6066 events << { date : evidence_date , delta_scope : 0 , delta_started : 1 ,
61- delta_completed : COMPLETED_STATUSES . include? ( initial ) ? 1 : 0 }
67+ delta_completed : COMPLETED_STATUSES . include? ( initial ) ? 1 : 0 ,
68+ delta_abandoned : ABANDONED_STATUSES . include? ( initial ) ? 1 : 0 }
6269 end
6370 end
6471
6572 status_changes . each do |sc |
66- ds = 0
67- dc = 0
73+ # Use effective state to calculate deltas, skipping duplicate transitions
74+ current = effective_state [ sc . commitment_id ]
75+ next if sc . new_status == current
6876
69- was_started = STARTED_STATUSES . include? ( sc . previous_status )
77+ was_started = STARTED_STATUSES . include? ( current )
7078 now_started = STARTED_STATUSES . include? ( sc . new_status )
79+ ds = 0
7180 ds = 1 if now_started && !was_started
7281 ds = -1 if !now_started && was_started
7382
74- was_completed = COMPLETED_STATUSES . include? ( sc . previous_status )
83+ was_completed = COMPLETED_STATUSES . include? ( current )
7584 now_completed = COMPLETED_STATUSES . include? ( sc . new_status )
85+ dc = 0
7686 dc = 1 if now_completed && !was_completed
7787 dc = -1 if !now_completed && was_completed
7888
79- events << { date : sc . changed_at . to_date , delta_scope : 0 ,
80- delta_started : ds , delta_completed : dc }
89+ was_abandoned = ABANDONED_STATUSES . include? ( current )
90+ now_abandoned = ABANDONED_STATUSES . include? ( sc . new_status )
91+ da = 0
92+ da = 1 if now_abandoned && !was_abandoned
93+ da = -1 if !now_abandoned && was_abandoned
94+
95+ # Use the latest real-world evidence date before the job ran,
96+ # falling back to the job run date if no evidence dates exist
97+ job_date = sc . changed_at . to_date
98+ dates = all_evidence_dates [ sc . commitment_id ] || [ ]
99+ event_date = dates . select { |d | d <= job_date } . max || dates . min || job_date
100+
101+ events << { date : event_date , delta_scope : 0 ,
102+ delta_started : ds , delta_completed : dc , delta_abandoned : da }
103+
104+ effective_state [ sc . commitment_id ] = sc . new_status
81105 end
82106
83107 events . sort_by! { |e | e [ :date ] }
@@ -86,24 +110,26 @@ def show
86110 scope = 0
87111 started = 0
88112 completed = 0
113+ abandoned = 0
89114 series = [ ]
90115 current_date = nil
91116
92117 events . each do |e |
93118 if current_date && e [ :date ] != current_date
94- series << { date : current_date . iso8601 , scope : scope , started : started , completed : completed }
119+ series << { date : current_date . iso8601 , scope : scope , started : started , completed : completed , abandoned : abandoned }
95120 end
96121 current_date = e [ :date ]
97122 scope += e [ :delta_scope ]
98123 started += e [ :delta_started ]
99124 completed += e [ :delta_completed ]
125+ abandoned += e [ :delta_abandoned ]
100126 end
101127
102- series << { date : current_date . iso8601 , scope : scope , started : started , completed : completed } if current_date
128+ series << { date : current_date . iso8601 , scope : scope , started : started , completed : completed , abandoned : abandoned } if current_date
103129
104130 # Also emit today if last event was before today
105131 if current_date && current_date < Date . current
106- series << { date : Date . current . iso8601 , scope : scope , started : started , completed : completed }
132+ series << { date : Date . current . iso8601 , scope : scope , started : started , completed : completed , abandoned : abandoned }
107133 end
108134
109135 render json : {
@@ -119,21 +145,28 @@ def show
119145
120146 private
121147
122- def earliest_evidence_dates_for ( commitment_ids )
123- # Find the earliest real-world date from each commitment's matched evidence.
124- # For Bills: use the earliest milestone date (first reading, latest_activity, etc.)
125- # For StatcanDatasets: use last_synced_at
126- matches = CommitmentMatch . where ( commitment_id : commitment_ids ) . includes ( :matchable )
148+ def evidence_dates_for ( commitment_ids )
149+ result = Hash . new { |h , k | h [ k ] = [ ] }
127150
128- result = { }
129- matches . each do |cm |
151+ # Real-world dates from matched evidence (Bills, StatcanDatasets)
152+ CommitmentMatch . where ( commitment_id : commitment_ids ) . includes ( :matchable ) . each do |cm |
130153 date = evidence_date_for ( cm . matchable )
131- next unless date
132-
133- existing = result [ cm . commitment_id ]
134- result [ cm . commitment_id ] = date if existing . nil? || date < existing
154+ result [ cm . commitment_id ] << date if date
135155 end
136156
157+ # Real-world dates from criterion assessment sources
158+ CriterionAssessment
159+ . joins ( criterion : :commitment )
160+ . joins ( "LEFT JOIN sources ON sources.id = criterion_assessments.source_id" )
161+ . where ( criteria : { commitment_id : commitment_ids } )
162+ . where . not ( sources : { date : nil } )
163+ . pluck ( "criteria.commitment_id" , "sources.date" )
164+ . each do |cid , date |
165+ result [ cid ] << date
166+ end
167+
168+ # Deduplicate
169+ result . each_value ( &:uniq! )
137170 result
138171 end
139172
@@ -146,6 +179,8 @@ def evidence_date_for(matchable)
146179 ] . compact . min &.to_date
147180 when StatcanDataset
148181 matchable . last_synced_at &.to_date
182+ when Entry
183+ matchable . published_at &.to_date
149184 end
150185 end
151186 end
0 commit comments