@@ -38,13 +38,217 @@ export async function fetchZones(
3838 // error_description, throw'им TimeModeUnavailableError. Просто wrap без
3939 // error_description → fallback на items или [].
4040 if ( ! Array . isArray ( res . data ) ) {
41- const data = res . data ;
42- if ( data ?. error_description ) {
43- throw new TimeModeUnavailableError ( data . error_description , mode ) ;
41+ const data = res . data ;
42+
43+ if ( data ?. error_description ) {
44+ throw new TimeModeUnavailableError ( data . error_description , mode ) ;
45+ }
46+
47+ const items = Array . isArray ( data ?. items ) ? data . items : [ ] ;
48+
49+ return dedupeZonesByNearestTime (
50+ items as unknown as ApiZoneLike [ ] ,
51+ mode . kind === 'now' ? undefined : mode . at ,
52+ ) . map ( ( item ) => normalizeZoneFields ( item , mode . kind ) ) as unknown as ZoneMapItem [ ] ;
53+ }
54+
55+ return dedupeZonesByNearestTime (
56+ res . data as unknown as ApiZoneLike [ ] ,
57+ mode . kind === 'now' ? undefined : mode . at ,
58+ ) . map ( ( item ) => normalizeZoneFields ( item , mode . kind ) ) as unknown as ZoneMapItem [ ] ;
59+ }
60+
61+ type ApiZoneLike = Record < string , unknown > ;
62+
63+ function parseDateMs ( value : unknown ) : number | null {
64+ if ( typeof value !== 'string' ) return null ;
65+
66+ const ms = Date . parse ( value ) ;
67+
68+ return Number . isFinite ( ms ) ? ms : null ;
69+ }
70+
71+ function getApiZoneTimeMs ( item : ApiZoneLike ) : number | null {
72+ return (
73+ parseDateMs ( item . occupancy_updated_at ) ??
74+ parseDateMs ( item . observed_at ) ??
75+ parseDateMs ( item . at ) ??
76+ parseDateMs ( item . forecasted_at ) ??
77+ parseDateMs ( item . forecast_at ) ??
78+ parseDateMs ( item . predicted_for )
79+ ) ;
80+ }
81+
82+ function getConfidenceLevel ( confidence : number ) : 'very_low' | 'low' | 'medium' | 'high' {
83+ if ( confidence < 0.4 ) return 'very_low' ;
84+ if ( confidence < 0.6 ) return 'low' ;
85+ if ( confidence < 0.8 ) return 'medium' ;
86+ return 'high' ;
87+ }
88+
89+ function normalizeZoneFields (
90+ item : ApiZoneLike ,
91+ modeKind : TimeMode [ 'kind' ] = 'now' ,
92+ ) : ApiZoneLike {
93+ const normalized : ApiZoneLike = { ...item } ;
94+
95+ const capacityRaw = normalized [ 'capacity' ] ;
96+ const capacity =
97+ typeof capacityRaw === 'number' && Number . isFinite ( capacityRaw ) ? capacityRaw : 0 ;
98+
99+ const freeCountRaw =
100+ modeKind === 'future'
101+ ? normalized [ 'predicted_free_count' ] ??
102+ normalized [ 'forecasted_free_count' ] ??
103+ normalized [ 'free_count' ] ??
104+ normalized [ 'current_free_count' ]
105+ : normalized [ 'free_count' ] ??
106+ normalized [ 'predicted_free_count' ] ??
107+ normalized [ 'forecasted_free_count' ] ??
108+ normalized [ 'current_free_count' ] ;
109+
110+ const freeCount =
111+ typeof freeCountRaw === 'number' && Number . isFinite ( freeCountRaw ) ? freeCountRaw : 0 ;
112+
113+ const occupiedRaw =
114+ modeKind === 'future'
115+ ? normalized [ 'predicted_occupied' ] ??
116+ normalized [ 'forecasted_occupied' ] ??
117+ normalized [ 'occupied' ]
118+ : normalized [ 'occupied' ] ??
119+ normalized [ 'predicted_occupied' ] ??
120+ normalized [ 'forecasted_occupied' ] ;
121+
122+ const occupied =
123+ typeof occupiedRaw === 'number' && Number . isFinite ( occupiedRaw )
124+ ? occupiedRaw
125+ : Math . max ( 0 , capacity - freeCount ) ;
126+
127+ const confidenceRaw =
128+ modeKind === 'future'
129+ ? normalized [ 'forecast_confidence' ] ??
130+ normalized [ 'prediction_confidence' ] ??
131+ normalized [ 'predicted_confidence' ] ??
132+ normalized [ 'model_confidence' ] ??
133+ normalized [ 'forecast_probability' ] ??
134+ normalized [ 'confidence' ]
135+ : normalized [ 'confidence' ] ??
136+ normalized [ 'forecast_confidence' ] ??
137+ normalized [ 'prediction_confidence' ] ??
138+ normalized [ 'predicted_confidence' ] ??
139+ normalized [ 'model_confidence' ] ;
140+
141+ const confidenceNumber =
142+ typeof confidenceRaw === 'number'
143+ ? confidenceRaw
144+ : typeof confidenceRaw === 'string'
145+ ? Number ( confidenceRaw )
146+ : 0 ;
147+
148+ const confidence = Number . isFinite ( confidenceNumber )
149+ ? confidenceNumber > 1
150+ ? confidenceNumber / 100
151+ : confidenceNumber
152+ : 0 ;
153+
154+ const observedAt =
155+ normalized [ 'observed_at' ] ??
156+ normalized [ 'occupancy_updated_at' ] ??
157+ normalized [ 'ingested_at' ] ??
158+ null ;
159+
160+ const forecastTargetAt =
161+ normalized [ 'displayed_at' ] ??
162+ normalized [ 'predicted_for' ] ??
163+ normalized [ 'forecasted_at' ] ??
164+ normalized [ 'forecast_at' ] ??
165+ normalized [ 'at' ] ??
166+ null ;
167+
168+ const forecastCreatedAt =
169+ normalized [ 'forecast_created_at' ] ??
170+ normalized [ 'generated_at' ] ??
171+ normalized [ 'model_run_at' ] ??
172+ normalized [ 'created_at' ] ??
173+ normalized [ 'ingested_at' ] ??
174+ null ;
175+
176+ normalized [ 'capacity' ] = capacity ;
177+ normalized [ 'free_count' ] = freeCount ;
178+ normalized [ 'occupied' ] = occupied ;
179+ normalized [ 'confidence' ] = confidence ;
180+
181+ if ( modeKind === 'future' ) {
182+ // Важно: occupancy_updated_at для прогноза — это НЕ время, на которое прогноз.
183+ // Это время создания/генерации прогноза.
184+ normalized [ 'occupancy_updated_at' ] = forecastCreatedAt ;
185+ normalized [ 'forecast_created_at' ] = forecastCreatedAt ;
186+ normalized [ 'displayed_at' ] = forecastTargetAt ;
187+ } else {
188+ normalized [ 'occupancy_updated_at' ] = observedAt ;
189+ normalized [ 'displayed_at' ] = observedAt ;
190+ }
191+
192+ if ( ! normalized [ 'confidence_level' ] ) {
193+ normalized [ 'confidence_level' ] = getConfidenceLevel ( confidence ) ;
194+ }
195+
196+ if ( normalized [ 'is_active' ] === undefined ) {
197+ normalized [ 'is_active' ] = true ;
198+ }
199+
200+ return normalized ;
201+ }
202+
203+ function dedupeZonesByNearestTime < T extends ApiZoneLike > ( items : T [ ] , targetAt ?: string ) : T [ ] {
204+ const targetMs = targetAt ? Date . parse ( targetAt ) : NaN ;
205+ const hasTarget = Number . isFinite ( targetMs ) ;
206+
207+ const byZoneId = new Map < number , T > ( ) ;
208+
209+ for ( const rawItem of items ) {
210+ if ( typeof rawItem . zone_id !== 'number' ) continue ;
211+
212+ const prev = byZoneId . get ( rawItem . zone_id ) ;
213+
214+ if ( ! prev ) {
215+ byZoneId . set ( rawItem . zone_id , rawItem ) ;
216+ continue ;
217+ }
218+
219+ if ( ! hasTarget ) continue ;
220+
221+ const prevTimeMs = getApiZoneTimeMs ( prev ) ;
222+ const itemTimeMs = getApiZoneTimeMs ( rawItem ) ;
223+
224+ if ( itemTimeMs === null ) continue ;
225+
226+ if ( prevTimeMs === null || Math . abs ( itemTimeMs - targetMs ) < Math . abs ( prevTimeMs - targetMs ) ) {
227+ byZoneId . set ( rawItem . zone_id , rawItem ) ;
44228 }
45- return Array . isArray ( data ?. items ) ? data . items : [ ] ;
46229 }
47- return res . data ;
230+
231+ return [ ...byZoneId . values ( ) ] ;
232+ }
233+
234+ function extractSingleZoneFromResponse ( data : unknown , targetAt ?: string ) : ApiZoneLike | null {
235+ if ( Array . isArray ( data ) ) {
236+ return dedupeZonesByNearestTime ( data as ApiZoneLike [ ] , targetAt ) [ 0 ] ?? null ;
237+ }
238+
239+ if ( data && typeof data === 'object' && 'items' in data ) {
240+ const items = ( data as { items ?: unknown } ) . items ;
241+
242+ if ( Array . isArray ( items ) ) {
243+ return dedupeZonesByNearestTime ( items as ApiZoneLike [ ] , targetAt ) [ 0 ] ?? null ;
244+ }
245+ }
246+
247+ if ( data && typeof data === 'object' ) {
248+ return data as ApiZoneLike ;
249+ }
250+
251+ return null ;
48252}
49253
50254// CARD-01 + Phase 3 Plan 05 / TIME-07: полная Zone для модального окна.
@@ -67,24 +271,89 @@ export async function fetchZoneById(
67271 mode : TimeMode = { kind : 'now' } ,
68272) : Promise < Zone > {
69273 if ( mode . kind === 'now' ) {
70- const res = await apiClient . get < Zone > ( `/zones/${ id } ` , { signal } ) ;
71- return res . data ;
274+ const res = await apiClient . get < Zone > ( `/zones/${ id } ` , { signal} ) ;
275+ return normalizeZoneFields ( res . data as unknown as ApiZoneLike , 'now' ) as unknown as Zone ;
72276 }
73277 // past/future: dispatch через timeModeAdapter, override view='card' и
74278 // zone_id=:id (вместо bbox для card-context).
75- const { endpoint, extraParams } = timeModeAdapter ( mode ) ;
76- const res = await apiClient . get < Zone | { error_description ?: string } > ( endpoint , {
77- params : { ...extraParams , view : 'card' , zone_id : String ( id ) } ,
78- signal,
79- } ) ;
80- // Q4 wrap-shape: backend сообщил, что mode на это время недоступен.
279+ const { endpoint, extraParams} = timeModeAdapter ( mode ) ;
280+
281+ const [ modeRes , baseRes ] = await Promise . all ( [
282+ apiClient . get < Zone | Zone [ ] | { error_description ?: string ; items ?: Zone [ ] } > ( endpoint , {
283+ params : { ...extraParams , view : 'card' , zone_id : String ( id ) } ,
284+ signal,
285+ } ) ,
286+ apiClient . get < Zone > ( `/zones/${ id } ` , { signal} ) ,
287+ ] ) ;
288+
81289 if (
82- res . data &&
83- typeof res . data === 'object' &&
84- 'error_description' in res . data &&
85- res . data . error_description
290+ modeRes . data &&
291+ typeof modeRes . data === 'object' &&
292+ ! Array . isArray ( modeRes . data ) &&
293+ 'error_description' in modeRes . data &&
294+ modeRes . data . error_description
86295 ) {
87- throw new TimeModeUnavailableError ( res . data . error_description , mode ) ;
296+ throw new TimeModeUnavailableError ( modeRes . data . error_description , mode ) ;
297+ }
298+
299+ const dynamicZone = extractSingleZoneFromResponse ( modeRes . data , mode . at ) ;
300+
301+ if ( ! dynamicZone ) {
302+ return normalizeZoneFields ( baseRes . data as unknown as ApiZoneLike ) as unknown as Zone ;
88303 }
89- return res . data as Zone ;
304+
305+ const baseZone = baseRes . data as unknown as ApiZoneLike ;
306+
307+ const merged = {
308+ ...baseZone ,
309+ ...dynamicZone ,
310+
311+ geometry : dynamicZone [ 'geometry' ] ?? baseZone [ 'geometry' ] ,
312+ zone_type : dynamicZone [ 'zone_type' ] ?? baseZone [ 'zone_type' ] ,
313+ location_type : dynamicZone [ 'location_type' ] ?? baseZone [ 'location_type' ] ,
314+ pay : dynamicZone [ 'pay' ] ?? baseZone [ 'pay' ] ,
315+ is_private : dynamicZone [ 'is_private' ] ?? baseZone [ 'is_private' ] ,
316+ is_accessible : dynamicZone [ 'is_accessible' ] ?? baseZone [ 'is_accessible' ] ,
317+ image_polygon : dynamicZone [ 'image_polygon' ] ?? baseZone [ 'image_polygon' ] ,
318+ camera_id : dynamicZone [ 'camera_id' ] ?? baseZone [ 'camera_id' ] ,
319+ partner_id : dynamicZone [ 'partner_id' ] ?? baseZone [ 'partner_id' ] ,
320+ created_by_user_id : dynamicZone [ 'created_by_user_id' ] ?? baseZone [ 'created_by_user_id' ] ,
321+
322+ forecast_created_at :
323+ dynamicZone [ 'forecast_created_at' ] ??
324+ dynamicZone [ 'generated_at' ] ??
325+ dynamicZone [ 'model_run_at' ] ??
326+ dynamicZone [ 'created_at' ] ??
327+ dynamicZone [ 'ingested_at' ] ??
328+ null ,
329+
330+ displayed_at :
331+ dynamicZone [ 'displayed_at' ] ??
332+ dynamicZone [ 'predicted_for' ] ??
333+ dynamicZone [ 'forecasted_at' ] ??
334+ dynamicZone [ 'forecast_at' ] ??
335+ dynamicZone [ 'at' ] ??
336+ null ,
337+
338+ forecast_confidence :
339+ dynamicZone [ 'forecast_confidence' ] ??
340+ dynamicZone [ 'prediction_confidence' ] ??
341+ dynamicZone [ 'predicted_confidence' ] ??
342+ dynamicZone [ 'model_confidence' ] ??
343+ dynamicZone [ 'confidence' ] ??
344+ null ,
345+
346+ created_at : dynamicZone [ 'created_at' ] ?? baseZone [ 'created_at' ] ,
347+ updated_at : dynamicZone [ 'updated_at' ] ?? baseZone [ 'updated_at' ] ,
348+ } ;
349+
350+ console . log ( '[forecast card merge]' , {
351+ baseConfidence : baseZone [ 'confidence' ] ,
352+ dynamicConfidence : dynamicZone [ 'confidence' ] ,
353+ dynamicForecastConfidence : dynamicZone [ 'forecast_confidence' ] ,
354+ mergedForecastConfidence : merged [ 'forecast_confidence' ] ,
355+ modeKind : mode . kind ,
356+ } ) ;
357+
358+ return normalizeZoneFields ( merged , mode . kind ) as unknown as Zone ;
90359}
0 commit comments