Skip to content

Commit 943d292

Browse files
committed
bugfix: forecast data display fix
1 parent 805756f commit 943d292

6 files changed

Lines changed: 407 additions & 73 deletions

File tree

src/entities/zone/api/zone.api.ts

Lines changed: 288 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)