diff --git a/CHANGELOG.md b/CHANGELOG.md index 1969a69..8790958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- **Address search** перестал «перепрыгивать» в другой город при выборе подсказки. Координаты выбранной подсказки теперь берутся из `SuggestResult.coords` (их кладёт `ymaps3.search` в `suggestAddresses`). Раньше после клика делался повторный resolve по `sug.uri`, в котором хранился только `title` без региона из `subtitle` — Yandex без региона возвращал первый попавшийся объект (например, «Ломоносова 9 Санкт-Петербург» уходил в Великий Новгород). Заодно удалены ставшие мёртвыми `useResolveCoordinates` и `geocodeByUri` (`shared/lib/yandex/geocoder.ts`); экземпляр `isResolving` в загрузочном статусе Desktop-варианта тоже снят. + ## [1.0.0-mvp] — Phase 5 verification complete Final MVP release. Merge from `feat/mvp-rewrite` → `main`. diff --git a/nginx-security-headers.conf b/nginx-security-headers.conf index 8ce5703..1a73e97 100644 --- a/nginx-security-headers.conf +++ b/nginx-security-headers.conf @@ -16,7 +16,9 @@ # грузится fetch()'ем; MSW service-worker (mock) переотправляет его как # fetch → правило connect-src, не script-src. Без этого — белый экран. # - *.parktrack.live — prod (api.parktrack.live) + dev (api.dev.parktrack.live) -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://suggest-maps.yandex.ru; connect-src 'self' https://*.parktrack.live https://api.parktrack.live https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net https://suggest-maps.yandex.ru https://search-maps.yandex.ru https://geocode-maps.yandex.ru https://api.routing.yandex.net; style-src 'self' 'unsafe-inline' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; img-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net; worker-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net; frame-ancestors 'self' https://parktrack.live https://*.parktrack.live;" always; -add_header X-Content-Type-Options "nosniff" always; +# - http://localhost:8000 / http://127.0.0.1:8000 — локальный api-server (dev/demo +# compose-сборка с VITE_API_BASE_URL=http://localhost:8000); 'self' покрывает +# только same-origin (порт 80/13000), на :8000 нужен явный allow. +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://*.yastatic.net https://suggest-maps.yandex.ru https://cdn.jsdelivr.net; connect-src 'self' http://localhost:8000 http://127.0.0.1:8000 https://*.parktrack.live https://api.parktrack.live https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net https://*.yastatic.net https://suggest-maps.yandex.ru https://search-maps.yandex.ru https://geocode-maps.yandex.ru https://api.routing.yandex.net https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://*.yastatic.net https://cdn.jsdelivr.net; img-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://*.maps.yandex.net https://yastatic.net https://*.yastatic.net; worker-src 'self' data: blob: https://api-maps.yandex.ru https://*.api-maps.yandex.ru https://yastatic.net https://*.yastatic.net; frame-ancestors 'self' https://parktrack.live https://*.parktrack.live;" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/src/entities/filters/model/filter-storage.ts b/src/entities/filters/model/filter-storage.ts index 35a9f48..c4e90bb 100644 --- a/src/entities/filters/model/filter-storage.ts +++ b/src/entities/filters/model/filter-storage.ts @@ -36,6 +36,12 @@ export function readFiltersFromStorage(): Partial { const hnf = ssGet('hideNoFree'); if (hnf !== null) r.hideNoFree = hnf === '1'; + const mfc = ssGet('minFreeCount'); + if (mfc !== null) { + const n = Number(mfc); + if (Number.isFinite(n) && n >= 0) r.minFreeCount = Math.floor(n); + } + const mc = ssGet('minConf'); if (mc !== null) { const n = Number(mc); @@ -89,6 +95,7 @@ export function writeFilterToStorage( case 'hideInactive': serialized = (value as boolean) ? '1' : '0'; break; + case 'minFreeCount': case 'minConf': serialized = String(value as number); break; diff --git a/src/entities/filters/model/filter.types.ts b/src/entities/filters/model/filter.types.ts index 63c30df..09c9a9a 100644 --- a/src/entities/filters/model/filter.types.ts +++ b/src/entities/filters/model/filter.types.ts @@ -14,6 +14,7 @@ export const ALL_LOCATION_TYPES: readonly LocationType[] = [ export interface ZoneFilters { hideNoFree: boolean; // FILTER-01 default false + minFreeCount: number; minConf: number; // FILTER-02 default 0 (no min) maxPay: number | null; // FILTER-03 default null (no max) hidePrivate: boolean; // FILTER-04 default false @@ -24,6 +25,7 @@ export interface ZoneFilters { export const DEFAULT_FILTERS: ZoneFilters = { hideNoFree: false, + minFreeCount: 0, minConf: 0, maxPay: null, hidePrivate: false, @@ -32,10 +34,10 @@ export const DEFAULT_FILTERS: ZoneFilters = { hideInactive: true, }; -// FILTER-09: сколько фильтров не в дефолте (для badge-count «Активно: N»). export function countActive(f: ZoneFilters): number { let n = 0; if (f.hideNoFree !== DEFAULT_FILTERS.hideNoFree) n++; + if (f.minFreeCount !== DEFAULT_FILTERS.minFreeCount) n++; if (f.minConf !== DEFAULT_FILTERS.minConf) n++; if (f.maxPay !== DEFAULT_FILTERS.maxPay) n++; if (f.hidePrivate !== DEFAULT_FILTERS.hidePrivate) n++; @@ -43,4 +45,4 @@ export function countActive(f: ZoneFilters): number { if (f.locationType.length !== 0) n++; if (f.hideInactive !== DEFAULT_FILTERS.hideInactive) n++; return n; -} +} \ No newline at end of file diff --git a/src/entities/zone/api/zone.api.ts b/src/entities/zone/api/zone.api.ts index c23ed1d..a88ef0a 100644 --- a/src/entities/zone/api/zone.api.ts +++ b/src/entities/zone/api/zone.api.ts @@ -38,13 +38,217 @@ export async function fetchZones( // error_description, throw'им TimeModeUnavailableError. Просто wrap без // error_description → fallback на items или []. if (!Array.isArray(res.data)) { - const data = res.data; - if (data?.error_description) { - throw new TimeModeUnavailableError(data.error_description, mode); + const data = res.data; + + if (data?.error_description) { + throw new TimeModeUnavailableError(data.error_description, mode); + } + + const items = Array.isArray(data?.items) ? data.items : []; + + return dedupeZonesByNearestTime( + items as unknown as ApiZoneLike[], + mode.kind === 'now' ? undefined : mode.at, + ).map((item) => normalizeZoneFields(item, mode.kind)) as unknown as ZoneMapItem[]; + } + + return dedupeZonesByNearestTime( + res.data as unknown as ApiZoneLike[], + mode.kind === 'now' ? undefined : mode.at, + ).map((item) => normalizeZoneFields(item, mode.kind)) as unknown as ZoneMapItem[]; +} + +type ApiZoneLike = Record; + +function parseDateMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + + const ms = Date.parse(value); + + return Number.isFinite(ms) ? ms : null; +} + +function getApiZoneTimeMs(item: ApiZoneLike): number | null { + return ( + parseDateMs(item.occupancy_updated_at) ?? + parseDateMs(item.observed_at) ?? + parseDateMs(item.at) ?? + parseDateMs(item.forecasted_at) ?? + parseDateMs(item.forecast_at) ?? + parseDateMs(item.predicted_for) + ); +} + +function getConfidenceLevel(confidence: number): 'very_low' | 'low' | 'medium' | 'high' { + if (confidence < 0.4) return 'very_low'; + if (confidence < 0.6) return 'low'; + if (confidence < 0.8) return 'medium'; + return 'high'; +} + +function normalizeZoneFields( + item: ApiZoneLike, + modeKind: TimeMode['kind'] = 'now', +): ApiZoneLike { + const normalized: ApiZoneLike = { ...item }; + + const capacityRaw = normalized['capacity']; + const capacity = + typeof capacityRaw === 'number' && Number.isFinite(capacityRaw) ? capacityRaw : 0; + + const freeCountRaw = + modeKind === 'future' + ? normalized['predicted_free_count'] ?? + normalized['forecasted_free_count'] ?? + normalized['free_count'] ?? + normalized['current_free_count'] + : normalized['free_count'] ?? + normalized['predicted_free_count'] ?? + normalized['forecasted_free_count'] ?? + normalized['current_free_count']; + + const freeCount = + typeof freeCountRaw === 'number' && Number.isFinite(freeCountRaw) ? freeCountRaw : 0; + + const occupiedRaw = + modeKind === 'future' + ? normalized['predicted_occupied'] ?? + normalized['forecasted_occupied'] ?? + normalized['occupied'] + : normalized['occupied'] ?? + normalized['predicted_occupied'] ?? + normalized['forecasted_occupied']; + + const occupied = + typeof occupiedRaw === 'number' && Number.isFinite(occupiedRaw) + ? occupiedRaw + : Math.max(0, capacity - freeCount); + + const confidenceRaw = + modeKind === 'future' + ? normalized['forecast_confidence'] ?? + normalized['prediction_confidence'] ?? + normalized['predicted_confidence'] ?? + normalized['model_confidence'] ?? + normalized['forecast_probability'] ?? + normalized['confidence'] + : normalized['confidence'] ?? + normalized['forecast_confidence'] ?? + normalized['prediction_confidence'] ?? + normalized['predicted_confidence'] ?? + normalized['model_confidence']; + + const confidenceNumber = + typeof confidenceRaw === 'number' + ? confidenceRaw + : typeof confidenceRaw === 'string' + ? Number(confidenceRaw) + : 0; + +const confidence = Number.isFinite(confidenceNumber) + ? confidenceNumber > 1 + ? confidenceNumber / 100 + : confidenceNumber + : 0; + + const observedAt = + normalized['observed_at'] ?? + normalized['occupancy_updated_at'] ?? + normalized['ingested_at'] ?? + null; + + const forecastTargetAt = + normalized['displayed_at'] ?? + normalized['predicted_for'] ?? + normalized['forecasted_at'] ?? + normalized['forecast_at'] ?? + normalized['at'] ?? + null; + + const forecastCreatedAt = + normalized['forecast_created_at'] ?? + normalized['generated_at'] ?? + normalized['model_run_at'] ?? + normalized['created_at'] ?? + normalized['ingested_at'] ?? + null; + + normalized['capacity'] = capacity; + normalized['free_count'] = freeCount; + normalized['occupied'] = occupied; + normalized['confidence'] = confidence; + + if (modeKind === 'future') { + // Важно: occupancy_updated_at для прогноза — это НЕ время, на которое прогноз. + // Это время создания/генерации прогноза. + normalized['occupancy_updated_at'] = forecastCreatedAt; + normalized['forecast_created_at'] = forecastCreatedAt; + normalized['displayed_at'] = forecastTargetAt; + } else { + normalized['occupancy_updated_at'] = observedAt; + normalized['displayed_at'] = observedAt; + } + + if (!normalized['confidence_level']) { + normalized['confidence_level'] = getConfidenceLevel(confidence); + } + + if (normalized['is_active'] === undefined) { + normalized['is_active'] = true; + } + + return normalized; +} + +function dedupeZonesByNearestTime(items: T[], targetAt?: string): T[] { + const targetMs = targetAt ? Date.parse(targetAt) : NaN; + const hasTarget = Number.isFinite(targetMs); + + const byZoneId = new Map(); + + for (const rawItem of items) { + if (typeof rawItem.zone_id !== 'number') continue; + + const prev = byZoneId.get(rawItem.zone_id); + + if (!prev) { + byZoneId.set(rawItem.zone_id, rawItem); + continue; + } + + if (!hasTarget) continue; + + const prevTimeMs = getApiZoneTimeMs(prev); + const itemTimeMs = getApiZoneTimeMs(rawItem); + + if (itemTimeMs === null) continue; + + if (prevTimeMs === null || Math.abs(itemTimeMs - targetMs) < Math.abs(prevTimeMs - targetMs)) { + byZoneId.set(rawItem.zone_id, rawItem); } - return Array.isArray(data?.items) ? data.items : []; } - return res.data; + + return [...byZoneId.values()]; +} + +function extractSingleZoneFromResponse(data: unknown, targetAt?: string): ApiZoneLike | null { + if (Array.isArray(data)) { + return dedupeZonesByNearestTime(data as ApiZoneLike[], targetAt)[0] ?? null; + } + + if (data && typeof data === 'object' && 'items' in data) { + const items = (data as { items?: unknown }).items; + + if (Array.isArray(items)) { + return dedupeZonesByNearestTime(items as ApiZoneLike[], targetAt)[0] ?? null; + } + } + + if (data && typeof data === 'object') { + return data as ApiZoneLike; + } + + return null; } // CARD-01 + Phase 3 Plan 05 / TIME-07: полная Zone для модального окна. @@ -67,24 +271,89 @@ export async function fetchZoneById( mode: TimeMode = { kind: 'now' }, ): Promise { if (mode.kind === 'now') { - const res = await apiClient.get(`/zones/${id}`, { signal }); - return res.data; + const res = await apiClient.get(`/zones/${id}`, {signal}); + return normalizeZoneFields(res.data as unknown as ApiZoneLike, 'now') as unknown as Zone; } // past/future: dispatch через timeModeAdapter, override view='card' и // zone_id=:id (вместо bbox для card-context). - const { endpoint, extraParams } = timeModeAdapter(mode); - const res = await apiClient.get(endpoint, { - params: { ...extraParams, view: 'card', zone_id: String(id) }, - signal, - }); - // Q4 wrap-shape: backend сообщил, что mode на это время недоступен. + const {endpoint, extraParams} = timeModeAdapter(mode); + + const [modeRes, baseRes] = await Promise.all([ + apiClient.get(endpoint, { + params: {...extraParams, view: 'card', zone_id: String(id)}, + signal, + }), + apiClient.get(`/zones/${id}`, {signal}), + ]); + if ( - res.data && - typeof res.data === 'object' && - 'error_description' in res.data && - res.data.error_description + modeRes.data && + typeof modeRes.data === 'object' && + !Array.isArray(modeRes.data) && + 'error_description' in modeRes.data && + modeRes.data.error_description ) { - throw new TimeModeUnavailableError(res.data.error_description, mode); + throw new TimeModeUnavailableError(modeRes.data.error_description, mode); + } + + const dynamicZone = extractSingleZoneFromResponse(modeRes.data, mode.at); + + if (!dynamicZone) { + return normalizeZoneFields(baseRes.data as unknown as ApiZoneLike) as unknown as Zone; } - return res.data as Zone; + + const baseZone = baseRes.data as unknown as ApiZoneLike; + + const merged = { + ...baseZone, + ...dynamicZone, + + geometry: dynamicZone['geometry'] ?? baseZone['geometry'], + zone_type: dynamicZone['zone_type'] ?? baseZone['zone_type'], + location_type: dynamicZone['location_type'] ?? baseZone['location_type'], + pay: dynamicZone['pay'] ?? baseZone['pay'], + is_private: dynamicZone['is_private'] ?? baseZone['is_private'], + is_accessible: dynamicZone['is_accessible'] ?? baseZone['is_accessible'], + image_polygon: dynamicZone['image_polygon'] ?? baseZone['image_polygon'], + camera_id: dynamicZone['camera_id'] ?? baseZone['camera_id'], + partner_id: dynamicZone['partner_id'] ?? baseZone['partner_id'], + created_by_user_id: dynamicZone['created_by_user_id'] ?? baseZone['created_by_user_id'], + + forecast_created_at: + dynamicZone['forecast_created_at'] ?? + dynamicZone['generated_at'] ?? + dynamicZone['model_run_at'] ?? + dynamicZone['created_at'] ?? + dynamicZone['ingested_at'] ?? + null, + + displayed_at: + dynamicZone['displayed_at'] ?? + dynamicZone['predicted_for'] ?? + dynamicZone['forecasted_at'] ?? + dynamicZone['forecast_at'] ?? + dynamicZone['at'] ?? + null, + + forecast_confidence: + dynamicZone['forecast_confidence'] ?? + dynamicZone['prediction_confidence'] ?? + dynamicZone['predicted_confidence'] ?? + dynamicZone['model_confidence'] ?? + dynamicZone['confidence'] ?? + null, + + created_at: dynamicZone['created_at'] ?? baseZone['created_at'], + updated_at: dynamicZone['updated_at'] ?? baseZone['updated_at'], +}; + + console.log('[forecast card merge]', { + baseConfidence: baseZone['confidence'], + dynamicConfidence: dynamicZone['confidence'], + dynamicForecastConfidence: dynamicZone['forecast_confidence'], + mergedForecastConfidence: merged['forecast_confidence'], + modeKind: mode.kind, +}); + + return normalizeZoneFields(merged, mode.kind) as unknown as Zone; } diff --git a/src/features/address-search/index.ts b/src/features/address-search/index.ts index 7d013cd..5703cb6 100644 --- a/src/features/address-search/index.ts +++ b/src/features/address-search/index.ts @@ -1,4 +1,3 @@ export { useAddressSuggest } from './model/useAddressSuggest'; export type { UseAddressSuggestResult } from './model/useAddressSuggest'; -export { useResolveCoordinates } from './model/useResolveCoordinates'; export { useDestination } from './model/useDestination'; diff --git a/src/features/address-search/model/useAddressSuggest.test.tsx b/src/features/address-search/model/useAddressSuggest.test.tsx index 65d29ba..132be80 100644 --- a/src/features/address-search/model/useAddressSuggest.test.tsx +++ b/src/features/address-search/model/useAddressSuggest.test.tsx @@ -1,9 +1,13 @@ // Phase 4 / SEARCH-01..02 / D-01..D-03 (TDD RED): // Tests for useAddressSuggest hook — debounce 300ms, min length 2, retry false, // queryKey on debounced text. mocks suggestAddresses. +// +// Fix 2026-05-26: hook теперь читает ?bbox через nuqs для viewport bias → +// нужен NuqsTestingAdapter в обёртке. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; import type { ReactNode } from 'react'; import { useAddressSuggest } from './useAddressSuggest'; @@ -13,10 +17,12 @@ vi.mock('@/shared/lib/yandex', async () => { }); import { suggestAddresses } from '@/shared/lib/yandex'; -function makeWrapper() { +function makeWrapper(initialUrl = '') { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 0 } } }); return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); } diff --git a/src/features/address-search/model/useAddressSuggest.ts b/src/features/address-search/model/useAddressSuggest.ts index 6adee46..9a111b9 100644 --- a/src/features/address-search/model/useAddressSuggest.ts +++ b/src/features/address-search/model/useAddressSuggest.ts @@ -5,11 +5,20 @@ // - на 429 / 5xx — error прокинут в caller (toast в widget) // - AbortSignal автоматически от TanStack Query при смене queryKey (cancellation на typing) // - retry:false — на 429 ждём пользовательского нового ввода (или 60s manual retry в widget) +// +// Fix 2026-05-26 (viewport bias): передаём текущий ?bbox в suggestAddresses → +// ymaps3.search получает `bounds` и ранжирует улицы/POI внутри viewport'а +// выше. В подсказках первыми идут адреса рядом с тем, что юзер видит сейчас. +// bbox в queryKey округлён до 1 знака (~11км) — чтобы микропан не инвалидил +// кэш на каждом дрейфе карты, при этом смена района перезагружает подсказки. import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useDebounce } from 'use-debounce'; +import { useQueryState } from 'nuqs'; import { suggestAddresses, type SuggestResult } from '@/shared/lib/yandex'; import { ROUTING_SEARCH_DEBOUNCE_MS, SUGGEST_MIN_QUERY_LENGTH } from '@/shared/config'; +import { parseAsBbox } from '@/shared/lib/url'; +import type { Bbox } from '@/shared/lib/geo'; export interface UseAddressSuggestResult { text: string; @@ -19,14 +28,23 @@ export interface UseAddressSuggestResult { error: unknown; } +// Округляем bbox до ~0.1° (~11 км по широте) для queryKey — viewport bias не +// требует точности, а кэш не должен инвалидироваться на каждом мелком пане. +function coarseBbox(b: Bbox | null): Bbox | null { + if (!b) return null; + return b.map((v) => Math.round(v * 10) / 10) as Bbox; +} + export function useAddressSuggest(): UseAddressSuggestResult { const [text, setText] = useState(''); + const [bbox] = useQueryState('bbox', parseAsBbox); const [debounced] = useDebounce(text, ROUTING_SEARCH_DEBOUNCE_MS); const trimmed = debounced.trim(); const enabled = trimmed.length >= SUGGEST_MIN_QUERY_LENGTH; + const coarse = coarseBbox(bbox); const query = useQuery({ - queryKey: ['suggest', trimmed] as const, - queryFn: ({ signal }) => suggestAddresses(trimmed, signal), + queryKey: ['suggest', trimmed, coarse] as const, + queryFn: ({ signal }) => suggestAddresses(trimmed, signal, bbox ?? undefined), enabled, retry: false, staleTime: 60_000, diff --git a/src/features/address-search/model/useResolveCoordinates.ts b/src/features/address-search/model/useResolveCoordinates.ts deleted file mode 100644 index fe98a00..0000000 --- a/src/features/address-search/model/useResolveCoordinates.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Phase 4 / SEARCH-03 / Pitfall 1: -// Suggest НЕ возвращает coords inline — резолв через Geocoder по uri. -// useMutation pattern: каждый выбор suggestion = ОДИН call. -import { useMutation } from '@tanstack/react-query'; -import { geocodeByUri } from '@/shared/lib/yandex'; - -export function useResolveCoordinates() { - const mutation = useMutation({ - mutationFn: ({ uri, signal }: { uri: string; signal?: AbortSignal }) => { - // signal optional т.к. mutation обычно не-cancelable, но allow для test - const ctrl = signal ?? new AbortController().signal; - return geocodeByUri(uri, ctrl); - }, - }); - return { - resolve: (uri: string) => mutation.mutateAsync({ uri }), - isPending: mutation.isPending, - error: mutation.error, - }; -} diff --git a/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts b/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts index ec5f43f..eebe5bb 100644 --- a/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts +++ b/src/features/filter-zones/lib/applyClientCandidateFilters.test.ts @@ -40,6 +40,7 @@ const baseCandidate: RouteCandidate = { const baseFilters: ZoneFilters = { hideNoFree: false, + minFreeCount: 0, minConf: 0, maxPay: null, hidePrivate: false, diff --git a/src/features/filter-zones/lib/applyClientCandidateFilters.ts b/src/features/filter-zones/lib/applyClientCandidateFilters.ts index 08d493a..0951e47 100644 --- a/src/features/filter-zones/lib/applyClientCandidateFilters.ts +++ b/src/features/filter-zones/lib/applyClientCandidateFilters.ts @@ -11,9 +11,11 @@ export function applyClientCandidateFilters( candidates: RouteCandidate[], f: ZoneFilters, ): RouteCandidate[] { + const minFreeCount = Math.max(f.hideNoFree ? 1 : 0, f.minFreeCount); + return candidates.filter((c) => { // hideNoFree (FILTER-01) - if (f.hideNoFree && c.current_free_count === 0) return false; + if (minFreeCount > 0 && c.current_free_count < minFreeCount) return false; // minConf (FILTER-02) — safety-net if (f.minConf > 0 && c.current_confidence < f.minConf) return false; // maxPay (FILTER-03) — safety-net diff --git a/src/features/filter-zones/lib/applyClientFilters.ts b/src/features/filter-zones/lib/applyClientFilters.ts index d5151c0..b66e189 100644 --- a/src/features/filter-zones/lib/applyClientFilters.ts +++ b/src/features/filter-zones/lib/applyClientFilters.ts @@ -1,13 +1,23 @@ -// D-12 client-side: minConf и maxPay применяются на клиенте как safety-net. -// Server-side эквиваленты (min_confidence, max_pay) тоже отправляются — если backend -// их понимает, double-filter без эффекта. Если backend отвечает 400 — fallback OK. import type { ZoneMapItem } from '@/entities/zone'; import type { ZoneFilters } from '@/entities/filters'; +function getFreeCount(zone: ZoneMapItem): number { + const raw = + (zone as unknown as Record)['free_count'] ?? + (zone as unknown as Record)['predicted_free_count'] ?? + (zone as unknown as Record)['forecasted_free_count'] ?? + (zone as unknown as Record)['current_free_count']; + + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; +} + export function applyClientFilters(zones: ZoneMapItem[], f: ZoneFilters): ZoneMapItem[] { + const minFreeCount = Math.max(f.hideNoFree ? 1 : 0, f.minFreeCount); + return zones.filter((z) => { + if (minFreeCount > 0 && getFreeCount(z) < minFreeCount) return false; if (f.minConf > 0 && z.confidence < f.minConf) return false; if (f.maxPay !== null && z.pay > f.maxPay) return false; return true; }); -} +} \ No newline at end of file diff --git a/src/features/filter-zones/lib/buildServerQuery.ts b/src/features/filter-zones/lib/buildServerQuery.ts index f51ff82..cb1e2c0 100644 --- a/src/features/filter-zones/lib/buildServerQuery.ts +++ b/src/features/filter-zones/lib/buildServerQuery.ts @@ -8,7 +8,11 @@ import { ALL_LOCATION_TYPES, type ZoneFilters } from '@/entities/filters'; export function buildServerQuery(f: ZoneFilters): Record { const q: Record = {}; - if (f.hideNoFree) q.min_free_count = '1'; + const minFreeCount = Math.max(f.hideNoFree ? 1 : 0, f.minFreeCount); + + if (minFreeCount > 0) { + q.min_free_count = String(minFreeCount); + } if (f.minConf > 0) q.min_confidence = String(f.minConf); if (f.maxPay !== null) q.max_pay = String(f.maxPay); if (f.hidePrivate) q.include_private = 'false'; diff --git a/src/features/filter-zones/model/useFilters.ts b/src/features/filter-zones/model/useFilters.ts index 7364889..961cf3e 100644 --- a/src/features/filter-zones/model/useFilters.ts +++ b/src/features/filter-zones/model/useFilters.ts @@ -23,6 +23,10 @@ export function useFilters() { 'fNoFree', parseAsBoolean.withDefault(DEFAULT_FILTERS.hideNoFree), ); + const [minFreeCount, _setMinFreeCount] = useQueryState( + 'fMinFree', + parseAsInteger.withDefault(DEFAULT_FILTERS.minFreeCount), + ); const [minConf, _setMinConf] = useQueryState( 'fMinConf', parseAsFloat.withDefault(DEFAULT_FILTERS.minConf), @@ -47,6 +51,7 @@ export function useFilters() { const filters: ZoneFilters = { hideNoFree, + minFreeCount, minConf, maxPay, hidePrivate, @@ -62,6 +67,14 @@ export function useFilters() { }, [_setHideNoFree], ); + const setMinFreeCount = useCallback( + (v: number) => { + const safeValue = Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0; + _setMinFreeCount(safeValue); + writeFilterToStorage('minFreeCount', safeValue); + }, + [_setMinFreeCount], + ); const setMinConf = useCallback( (v: number) => { _setMinConf(v); @@ -107,6 +120,7 @@ export function useFilters() { const resetAll = useCallback(() => { setHideNoFree(DEFAULT_FILTERS.hideNoFree); + setMinFreeCount(DEFAULT_FILTERS.minFreeCount); setMinConf(DEFAULT_FILTERS.minConf); setMaxPay(DEFAULT_FILTERS.maxPay); setHidePrivate(DEFAULT_FILTERS.hidePrivate); @@ -115,6 +129,7 @@ export function useFilters() { setHideInactive(DEFAULT_FILTERS.hideInactive); }, [ setHideNoFree, + setMinFreeCount, setMinConf, setMaxPay, setHidePrivate, @@ -127,6 +142,7 @@ export function useFilters() { filters, activeCount: countActive(filters), setHideNoFree, + setMinFreeCount, setMinConf, setMaxPay, setHidePrivate, diff --git a/src/features/filter-zones/model/useFiltersHydration.ts b/src/features/filter-zones/model/useFiltersHydration.ts index fef6d02..3d6c9a0 100644 --- a/src/features/filter-zones/model/useFiltersHydration.ts +++ b/src/features/filter-zones/model/useFiltersHydration.ts @@ -12,6 +12,7 @@ export function useFiltersHydration(): void { const { filters, setHideNoFree, + setMinFreeCount, setMinConf, setMaxPay, setHidePrivate, @@ -33,6 +34,9 @@ export function useFiltersHydration(): void { if (stored.hideNoFree !== undefined && stored.hideNoFree !== filters.hideNoFree) { setHideNoFree(stored.hideNoFree); } + if (stored.minFreeCount !== undefined && stored.minFreeCount !== filters.minFreeCount) { + setMinFreeCount(stored.minFreeCount); + } if (stored.minConf !== undefined && stored.minConf !== filters.minConf) { setMinConf(stored.minConf); } diff --git a/src/index.css b/src/index.css index 25bd976..5b00a44 100644 --- a/src/index.css +++ b/src/index.css @@ -29,18 +29,31 @@ } } -/* Phase 5 D-05 (RESP-07): map controls offset выше открытого bottom-sheet'а. - --bottom-sheet-offset устанавливается MobileLayout useEffect'ом в - зависимости от состояния sheets (filters/time/results/selectedZone). - Default 20px когда все sheets закрыты. Селектор-fallback ниже целит - ymaps3 controls внутри map-controls-shifted-container, потому что - YMapControls сам не принимает className prop (typed reactify - обёртка из @yandex/ymaps3-types). */ +:root { + --bottom-sheet-offset: 20px; + } + .map-controls-shifted-container [class*='ymaps3-controls'] { bottom: var(--bottom-sheet-offset, 20px) !important; transition: bottom 200ms ease; } +/* Центруем узкие контролы относительно большого 3D-компаса. + Двигаем не внутреннюю иконку, а внешний слот контрола внутри YMapControls. */ +.map-controls-shifted-container [class*='ymaps3--controls'] > *:has(.ymaps3--zoom-control), +.map-controls-shifted-container [class*='ymaps3--controls'] > *:has(.ymaps3--geolocation-control) { + margin-right: 8px !important; + margin-bottom: 11px; +} + +.ymaps3--controls.ymaps3--controls_bottom.ymaps3--controls_left.ymaps3--controls_horizontal { + display: none; +} + +.ymaps3--button, .ymaps3--control-button, .ymaps3--control, .ymaps3--control__background { + border-radius: 12px !important; +} + /* Fix 2026-05-16: +/- зум-контрол должен уходить ЗА мобильную карточку. Попытка через z-index на самом контроле ([class*='ymaps3-controls']) НЕ сработала: имя класса контрола в текущей сборке Yandex другое, а ymaps3 diff --git a/src/pages/map/ui/DesktopLayout.tsx b/src/pages/map/ui/DesktopLayout.tsx index df25ff5..c820ee8 100644 --- a/src/pages/map/ui/DesktopLayout.tsx +++ b/src/pages/map/ui/DesktopLayout.tsx @@ -35,13 +35,8 @@ const MapCanvas = lazy(() => export function DesktopLayout() { const mapRef = useRef(null); - // D-12 «Указать вручную» → focus search-input (передаётся через WTPCTAButton.onManualEntry). - const searchAnchorRef = useRef(null); - const handleManualEntry = () => { - const input = - searchAnchorRef.current?.querySelector('input[role="searchbox"]'); - input?.focus(); - }; + // 2026-05-26: searchAnchorRef + handleManualEntry удалены — кнопку «Указать + // вручную» из PreFlightDialog убрали, фокусить инпут больше неоткуда. return ( @@ -58,10 +53,8 @@ export function DesktopLayout() { ~50px vertical space карты, единый pattern с mobile FiltersFAB. */}
- -
- -
+ +
{/* Phase 4 / CO-03: DestPromptBanner — ниже flex-row */} diff --git a/src/pages/map/ui/MobileLayout.tsx b/src/pages/map/ui/MobileLayout.tsx index 2789e20..1cb71ab 100644 --- a/src/pages/map/ui/MobileLayout.tsx +++ b/src/pages/map/ui/MobileLayout.tsx @@ -62,11 +62,8 @@ export function MobileLayout() { document.documentElement.style.setProperty('--bottom-sheet-offset', offset); }, [filtersOpen, timeSheetOpen, resultsSheetOpen, selectedZoneId]); - // D-12 «Указать вручную» → focus search-input. - const handleManualEntry = () => { - const input = document.querySelector('input[role="searchbox"]'); - input?.focus(); - }; + // 2026-05-26: handleManualEntry удалён — кнопка «Указать вручную» из + // PreFlightDrawer убрана, фокусить инпут больше неоткуда. return ( @@ -92,7 +89,6 @@ export function MobileLayout() {