diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..56f3b6bf
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..694b1719
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+.DS_Store
+.playwright-cli/
+.cache/
+backend/node_modules/
+backend/.cache/
+backend/.env
+backend/package-lock.json
+client/node_modules/
+client/dist/
+client/.env
+client/package-lock.json
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 00000000..acd9e4a4
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,8 @@
+{
+ "tabWidth": 4,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 00000000..8eae064d
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,8 @@
+PORT=3001
+CLIENT_ORIGIN=http://localhost:5173
+CLIENT_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173,http://localhost:4174,http://127.0.0.1:4174
+YANDEX_RASP_API_KEY=
+YANDEX_RASP_API_BASE=https://api.rasp.yandex-net.ru/v3.0
+STATION_CACHE_MAX_AGE_HOURS=24
+STATION_SEARCH_LIMIT=12
+NEAREST_STATION_LIMIT=8
diff --git a/backend/config.js b/backend/config.js
new file mode 100644
index 00000000..f807e288
--- /dev/null
+++ b/backend/config.js
@@ -0,0 +1,26 @@
+import "dotenv/config";
+
+export const PORT = Number(process.env.PORT || 3001);
+export const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || "http://localhost:5173";
+export const CLIENT_ORIGINS = String(process.env.CLIENT_ORIGINS || CLIENT_ORIGIN)
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter(Boolean);
+export const YANDEX_RASP_API_KEY = process.env.YANDEX_RASP_API_KEY || "";
+export const YANDEX_RASP_API_BASE =
+ process.env.YANDEX_RASP_API_BASE || "https://api.rasp.yandex-net.ru/v3.0";
+export const STATION_CACHE_MAX_AGE_HOURS = Number(process.env.STATION_CACHE_MAX_AGE_HOURS || 24);
+export const STATION_SEARCH_LIMIT = Number(process.env.STATION_SEARCH_LIMIT || 12);
+export const NEAREST_STATION_LIMIT = Number(process.env.NEAREST_STATION_LIMIT || 8);
+
+export const RAIL_STATION_TYPES = [
+ "station",
+ "platform",
+ "stop",
+ "checkpoint",
+ "post",
+ "crossing",
+ "overtaking_point",
+ "train_station",
+ "unknown",
+];
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 00000000..c5919291
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "suburban-backend",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "node --watch server.js",
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2"
+ }
+}
diff --git a/backend/server.js b/backend/server.js
new file mode 100644
index 00000000..a6d5633e
--- /dev/null
+++ b/backend/server.js
@@ -0,0 +1,128 @@
+import cors from "cors";
+import express from "express";
+import { CLIENT_ORIGINS, NEAREST_STATION_LIMIT, PORT, STATION_SEARCH_LIMIT } from "./config.js";
+import { searchNearbyStationsFromIndex, searchStationsByName } from "./services/stationIndex.js";
+import { getStationSchedule, searchRoutesBetweenStations } from "./services/yandexRasp.js";
+
+const app = express();
+
+function isAllowedOrigin(origin) {
+ if (!origin) {
+ return true;
+ }
+
+ if (CLIENT_ORIGINS.includes(origin)) {
+ return true;
+ }
+
+ return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
+}
+
+app.use(
+ cors({
+ origin(origin, callback) {
+ if (isAllowedOrigin(origin)) {
+ callback(null, true);
+ return;
+ }
+
+ callback(new Error(`CORS blocked for origin: ${origin}`));
+ },
+ }),
+);
+app.use(express.json());
+
+function sendError(response, error, fallbackMessage) {
+ console.error("[api]", error);
+ response.status(error.statusCode || 500).json({
+ error: error.message || fallbackMessage,
+ });
+}
+
+app.get("/api/ping", (_request, response) => {
+ response.json({
+ ok: true,
+ now: new Date().toISOString(),
+ });
+});
+
+app.get("/api/stations/suggest", async (request, response) => {
+ try {
+ const searchText = String(request.query.searchText || "");
+ const limit = Number(request.query.limit || STATION_SEARCH_LIMIT);
+ const stations = await searchStationsByName(searchText, limit);
+
+ response.json({
+ stations,
+ });
+ } catch (error) {
+ sendError(response, error, "Не удалось выполнить поиск станций.");
+ }
+});
+
+app.get("/api/stations/nearby", async (request, response) => {
+ try {
+ const latitude = Number(request.query.lat);
+ const longitude = Number(request.query.lng);
+ const distance = Number(request.query.distance || 15);
+ const limit = Number(request.query.limit || NEAREST_STATION_LIMIT);
+
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
+ response.status(400).json({
+ error: "Для поиска по карте нужны координаты lat и lng.",
+ });
+ return;
+ }
+
+ const stations = await searchNearbyStationsFromIndex(latitude, longitude, distance, limit);
+
+ response.json({
+ stations,
+ });
+ } catch (error) {
+ sendError(response, error, "Не удалось получить станции рядом с выбранной точкой.");
+ }
+});
+
+app.get("/api/stations/:stationCode/schedule", async (request, response) => {
+ try {
+ const stationCode = String(request.params.stationCode || "");
+ const date = String(request.query.date || "");
+
+ if (!stationCode) {
+ response.status(400).json({
+ error: "Не передан код станции.",
+ });
+ return;
+ }
+
+ const schedule = await getStationSchedule(stationCode, date);
+ response.json(schedule);
+ } catch (error) {
+ sendError(response, error, "Не удалось загрузить расписание по станции.");
+ }
+});
+
+app.get("/api/routes/search", async (request, response) => {
+ try {
+ const fromStationCode = String(request.query.from || "");
+ const toStationCode = String(request.query.to || "");
+ const date = String(request.query.date || "");
+
+ if (!fromStationCode || !toStationCode) {
+ response.status(400).json({
+ error: "Для поиска маршрута нужно выбрать станции отправления и прибытия.",
+ });
+ return;
+ }
+
+ const routes = await searchRoutesBetweenStations(fromStationCode, toStationCode, date);
+ response.json(routes);
+ } catch (error) {
+ sendError(response, error, "Не удалось загрузить маршруты между станциями.");
+ }
+});
+
+app.listen(PORT, () => {
+ console.log(`Backend started on http://localhost:${PORT}`);
+});
diff --git a/backend/services/stationIndex.js b/backend/services/stationIndex.js
new file mode 100644
index 00000000..bee66eee
--- /dev/null
+++ b/backend/services/stationIndex.js
@@ -0,0 +1,362 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ RAIL_STATION_TYPES,
+ STATION_CACHE_MAX_AGE_HOURS,
+ STATION_SEARCH_LIMIT,
+} from "../config.js";
+import { normalizeText } from "../utils/normalizeText.js";
+import { requestYandex } from "./yandexRasp.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const cacheDirectory = path.join(__dirname, "..", ".cache");
+const cacheFilePath = path.join(cacheDirectory, "suburban-stations.json");
+const cacheMaxAgeMs = STATION_CACHE_MAX_AGE_HOURS * 60 * 60 * 1000;
+
+let stationIndex = null;
+let stationIndexPromise = null;
+
+const SEARCH_SCORE = {
+ exactTitle: 120,
+ exactAlternativeTitle: 100,
+ titlePrefix: 70,
+ alternativeTitlePrefix: 60,
+ settlementExactMatch: 50,
+ titleWordBoundaryPrefix: 40,
+ regionExactMatch: 20,
+ suburbanTransport: 12,
+ stationTypePriority: 8,
+};
+
+const PREFERRED_STATION_TYPES = new Set(["train_station", "station"]);
+
+function getStationSearchFields(station) {
+ return {
+ title: normalizeText(station.title),
+ popularTitle: normalizeText(station.popularTitle),
+ shortTitle: normalizeText(station.shortTitle),
+ settlement: normalizeText(station.settlement),
+ region: normalizeText(station.region),
+ };
+}
+
+function startsWithWholeWord(value, query) {
+ return value.startsWith(`${query} `) || value.startsWith(`${query}-`);
+}
+
+function getStationTypePriority(station) {
+ if (PREFERRED_STATION_TYPES.has(station.stationType)) {
+ return SEARCH_SCORE.stationTypePriority;
+ }
+
+ return 0;
+}
+
+function getTransportPriority(station) {
+ if (station.transportType === "suburban") {
+ return SEARCH_SCORE.suburbanTransport;
+ }
+
+ return 0;
+}
+
+function getSourcePriority(station) {
+ if (station.transportType === "suburban") {
+ return 2;
+ }
+
+ if (station.direction) {
+ return 1;
+ }
+
+ return 0;
+}
+
+function isRailOrSuburbanStation(station) {
+ if (!station?.codes?.yandex_code) {
+ return false;
+ }
+
+ if (station.transport_type === "suburban" || station.transport_type === "train") {
+ return true;
+ }
+
+ if (station.direction) {
+ return true;
+ }
+
+ return RAIL_STATION_TYPES.includes(station.station_type);
+}
+
+function buildStationRecord(station, settlementTitle, regionTitle, countryTitle) {
+ const stationCode = station.codes?.yandex_code;
+
+ return {
+ code: stationCode,
+ title: station.title,
+ popularTitle: station.popular_title || "",
+ shortTitle: station.short_title || "",
+ stationType: station.station_type,
+ transportType: station.transport_type,
+ direction: station.direction || "",
+ lat: station.latitude ?? null,
+ lng: station.longitude ?? null,
+ settlement: settlementTitle || "",
+ region: regionTitle || "",
+ country: countryTitle || "",
+ displayLabel: [station.title, settlementTitle, regionTitle].filter(Boolean).join(", "),
+ searchKey: normalizeText(
+ [
+ station.title,
+ station.popular_title,
+ station.short_title,
+ settlementTitle,
+ regionTitle,
+ countryTitle,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ),
+ };
+}
+
+function calculateDistanceKm(fromLatitude, fromLongitude, toLatitude, toLongitude) {
+ const earthRadiusKm = 6371;
+ const latitudeDelta = ((toLatitude - fromLatitude) * Math.PI) / 180;
+ const longitudeDelta = ((toLongitude - fromLongitude) * Math.PI) / 180;
+ const latitudeOne = (fromLatitude * Math.PI) / 180;
+ const latitudeTwo = (toLatitude * Math.PI) / 180;
+
+ const haversine =
+ Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2) +
+ Math.cos(latitudeOne) *
+ Math.cos(latitudeTwo) *
+ Math.sin(longitudeDelta / 2) *
+ Math.sin(longitudeDelta / 2);
+
+ const angularDistance = 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
+ return earthRadiusKm * angularDistance;
+}
+
+function getSearchScore(station, normalizedQuery) {
+ const searchFields = getStationSearchFields(station);
+ const hasExactAlternativeTitle =
+ searchFields.popularTitle === normalizedQuery ||
+ searchFields.shortTitle === normalizedQuery;
+ const hasAlternativeTitlePrefix =
+ searchFields.popularTitle.startsWith(normalizedQuery) ||
+ searchFields.shortTitle.startsWith(normalizedQuery);
+ let score = 0;
+
+ if (searchFields.title === normalizedQuery) {
+ score += SEARCH_SCORE.exactTitle;
+ }
+
+ if (hasExactAlternativeTitle) {
+ score += SEARCH_SCORE.exactAlternativeTitle;
+ }
+
+ if (startsWithWholeWord(searchFields.title, normalizedQuery)) {
+ score += SEARCH_SCORE.titleWordBoundaryPrefix;
+ }
+
+ if (searchFields.title.startsWith(normalizedQuery)) {
+ score += SEARCH_SCORE.titlePrefix;
+ }
+
+ if (hasAlternativeTitlePrefix) {
+ score += SEARCH_SCORE.alternativeTitlePrefix;
+ }
+
+ if (searchFields.settlement === normalizedQuery) {
+ score += SEARCH_SCORE.settlementExactMatch;
+ }
+
+ if (searchFields.region === normalizedQuery) {
+ score += SEARCH_SCORE.regionExactMatch;
+ }
+
+ score += getTransportPriority(station);
+ score += getStationTypePriority(station);
+
+ return score;
+}
+
+function flattenStationsList(payload) {
+ const stationMap = new Map();
+
+ for (const country of payload.countries || []) {
+ for (const region of country.regions || []) {
+ for (const settlement of region.settlements || []) {
+ for (const station of settlement.stations || []) {
+ if (!isRailOrSuburbanStation(station)) {
+ continue;
+ }
+
+ const stationRecord = buildStationRecord(
+ station,
+ settlement.title,
+ region.title,
+ country.title,
+ );
+
+ if (!stationRecord.code) {
+ continue;
+ }
+
+ const existingStation = stationMap.get(stationRecord.code);
+
+ if (!existingStation) {
+ stationMap.set(stationRecord.code, stationRecord);
+ continue;
+ }
+
+ const currentPriority = getSourcePriority(existingStation);
+ const nextPriority = getSourcePriority(stationRecord);
+
+ if (nextPriority > currentPriority) {
+ stationMap.set(stationRecord.code, stationRecord);
+ }
+ }
+ }
+ }
+ }
+
+ return [...stationMap.values()].sort((leftStation, rightStation) => {
+ if (leftStation.transportType !== rightStation.transportType) {
+ return leftStation.transportType === "suburban" ? -1 : 1;
+ }
+
+ return leftStation.displayLabel.localeCompare(rightStation.displayLabel, "ru");
+ });
+}
+
+async function readCacheFile() {
+ try {
+ const rawFile = await fs.readFile(cacheFilePath, "utf8");
+ const parsedFile = JSON.parse(rawFile);
+
+ if (!parsedFile?.createdAt || !Array.isArray(parsedFile?.stations)) {
+ return null;
+ }
+
+ const ageMs = Date.now() - new Date(parsedFile.createdAt).getTime();
+ if (ageMs > cacheMaxAgeMs) {
+ return null;
+ }
+
+ return parsedFile.stations;
+ } catch {
+ return null;
+ }
+}
+
+async function writeCacheFile(stations) {
+ await fs.mkdir(cacheDirectory, { recursive: true });
+ await fs.writeFile(
+ cacheFilePath,
+ JSON.stringify(
+ {
+ createdAt: new Date().toISOString(),
+ stations,
+ },
+ null,
+ 2,
+ ),
+ "utf8",
+ );
+}
+
+async function downloadStationsList() {
+ const payload = await requestYandex("stations_list/", {
+ lang: "ru_RU",
+ format: "json",
+ });
+
+ const stations = flattenStationsList(payload);
+ await writeCacheFile(stations);
+ return stations;
+}
+
+export async function getStationIndex() {
+ if (stationIndex) {
+ return stationIndex;
+ }
+
+ if (!stationIndexPromise) {
+ stationIndexPromise = (async () => {
+ const cachedStations = await readCacheFile();
+
+ if (cachedStations) {
+ stationIndex = cachedStations;
+ return cachedStations;
+ }
+
+ const downloadedStations = await downloadStationsList();
+ stationIndex = downloadedStations;
+ return downloadedStations;
+ })();
+ }
+
+ try {
+ return await stationIndexPromise;
+ } finally {
+ stationIndexPromise = null;
+ }
+}
+
+export async function searchStationsByName(searchText, limit = STATION_SEARCH_LIMIT) {
+ const normalizedQuery = normalizeText(searchText);
+
+ if (!normalizedQuery) {
+ return [];
+ }
+
+ const stations = await getStationIndex();
+
+ return stations
+ .filter((station) => station.searchKey.includes(normalizedQuery))
+ .map((station) => ({
+ station,
+ score: getSearchScore(station, normalizedQuery),
+ }))
+ .sort((leftMatch, rightMatch) => {
+ if (leftMatch.score !== rightMatch.score) {
+ return rightMatch.score - leftMatch.score;
+ }
+
+ return leftMatch.station.displayLabel.localeCompare(
+ rightMatch.station.displayLabel,
+ "ru",
+ );
+ })
+ .slice(0, limit)
+ .map((match) => match.station);
+}
+
+export async function searchNearbyStationsFromIndex(latitude, longitude, distanceKm, limit) {
+ const stations = await getStationIndex();
+
+ const closestStations = stations
+ .filter((station) => station.lat != null && station.lng != null)
+ .map((station) => ({
+ ...station,
+ distance: calculateDistanceKm(latitude, longitude, station.lat, station.lng),
+ }))
+ .sort((leftStation, rightStation) => leftStation.distance - rightStation.distance);
+
+ const stationsInsideRadius = closestStations.filter(
+ (station) => station.distance <= distanceKm,
+ );
+ const resultStations =
+ stationsInsideRadius.length > 0
+ ? stationsInsideRadius.slice(0, limit)
+ : closestStations.slice(0, limit);
+
+ return resultStations.map((station) => ({
+ ...station,
+ stationTypeName: "",
+ typeChoices: {},
+ }));
+}
diff --git a/backend/services/yandexRasp.js b/backend/services/yandexRasp.js
new file mode 100644
index 00000000..afd1ad76
--- /dev/null
+++ b/backend/services/yandexRasp.js
@@ -0,0 +1,236 @@
+import { NEAREST_STATION_LIMIT, YANDEX_RASP_API_BASE, YANDEX_RASP_API_KEY } from "../config.js";
+import { createHttpError, fetchJson } from "../utils/fetchJson.js";
+
+function ensureApiKey() {
+ if (!YANDEX_RASP_API_KEY) {
+ throw createHttpError(
+ 503,
+ "Не указан ключ Яндекс.Расписаний. Добавьте YANDEX_RASP_API_KEY в backend/.env.",
+ );
+ }
+}
+
+function buildUrl(pathname, params) {
+ const url = new URL(pathname, `${YANDEX_RASP_API_BASE}/`);
+ url.searchParams.set("apikey", YANDEX_RASP_API_KEY);
+
+ for (const [parameterName, parameterValue] of Object.entries(params || {})) {
+ if (parameterValue === undefined || parameterValue === null || parameterValue === "") {
+ continue;
+ }
+
+ url.searchParams.set(parameterName, String(parameterValue));
+ }
+
+ return url;
+}
+
+export async function requestYandex(pathname, params) {
+ ensureApiKey();
+ const normalizedPathname = pathname.replace(/^\//, "");
+ const requestUrl = buildUrl(normalizedPathname, params);
+ return fetchJson(requestUrl, `yandex:${pathname}`);
+}
+
+function mapStationInfo(station) {
+ return {
+ code: station.code || station.codes?.yandex_code || "",
+ title: station.title || "",
+ popularTitle: station.popular_title || "",
+ shortTitle: station.short_title || "",
+ stationType: station.station_type || "",
+ stationTypeName: station.station_type_name || "",
+ transportType: station.transport_type || "",
+ direction: station.direction || "",
+ settlement: station.settlement || "",
+ region: station.region || "",
+ lat: station.lat ?? station.latitude ?? null,
+ lng: station.lng ?? station.longitude ?? null,
+ displayLabel: [station.title, station.settlement, station.region]
+ .filter(Boolean)
+ .join(", "),
+ };
+}
+
+function normalizeScheduleEntry(rawEntry) {
+ return {
+ key: [
+ rawEntry.thread?.uid || rawEntry.thread?.title || "thread",
+ rawEntry.arrival || "",
+ rawEntry.departure || "",
+ ].join("|"),
+ arrival: rawEntry.arrival || "",
+ departure: rawEntry.departure || "",
+ isFuzzy: Boolean(rawEntry.is_fuzzy),
+ days: rawEntry.days || "",
+ exceptDays: rawEntry.except_days || "",
+ stops: rawEntry.stops || "",
+ platform: rawEntry.platform || "",
+ terminal: rawEntry.terminal || "",
+ thread: {
+ uid: rawEntry.thread?.uid || "",
+ title: rawEntry.thread?.title || "",
+ shortTitle: rawEntry.thread?.short_title || "",
+ number: rawEntry.thread?.number || "",
+ carrier: rawEntry.thread?.carrier?.title || "",
+ transportType: rawEntry.thread?.transport_type || "",
+ transportSubtype: rawEntry.thread?.transport_subtype?.title || "",
+ expressType: rawEntry.thread?.express_type || "",
+ },
+ };
+}
+
+function mergeScheduleResponses(arrivalResponse, departureResponse) {
+ const mergedEntries = new Map();
+
+ for (const responseChunk of [arrivalResponse, departureResponse]) {
+ for (const rawEntry of responseChunk.schedule || []) {
+ const normalizedEntry = normalizeScheduleEntry(rawEntry);
+ const existingEntry = mergedEntries.get(normalizedEntry.key);
+
+ if (!existingEntry) {
+ mergedEntries.set(normalizedEntry.key, normalizedEntry);
+ continue;
+ }
+
+ mergedEntries.set(normalizedEntry.key, {
+ ...existingEntry,
+ ...normalizedEntry,
+ arrival: existingEntry.arrival || normalizedEntry.arrival,
+ departure: existingEntry.departure || normalizedEntry.departure,
+ platform: existingEntry.platform || normalizedEntry.platform,
+ terminal: existingEntry.terminal || normalizedEntry.terminal,
+ });
+ }
+ }
+
+ return [...mergedEntries.values()].sort((leftEntry, rightEntry) => {
+ const leftTime = Date.parse(leftEntry.departure || leftEntry.arrival || 0);
+ const rightTime = Date.parse(rightEntry.departure || rightEntry.arrival || 0);
+ return leftTime - rightTime;
+ });
+}
+
+function formatTicketInfo(ticketInfo) {
+ const firstPlace = ticketInfo?.places?.[0];
+
+ if (!firstPlace?.price?.whole) {
+ return "";
+ }
+
+ const currencySymbol = firstPlace.currency === "RUB" ? "₽" : firstPlace.currency;
+ return `от ${firstPlace.price.whole} ${currencySymbol}`;
+}
+
+function mapRouteSegment(rawSegment, isInterval = false) {
+ return {
+ key: [
+ rawSegment.thread?.uid || rawSegment.thread?.title || "route",
+ rawSegment.departure || "",
+ rawSegment.arrival || "",
+ isInterval ? "interval" : "regular",
+ ].join("|"),
+ isInterval,
+ departure: rawSegment.departure || "",
+ arrival: rawSegment.arrival || "",
+ departurePlatform: rawSegment.departure_platform || "",
+ arrivalPlatform: rawSegment.arrival_platform || "",
+ departureTerminal: rawSegment.departure_terminal || "",
+ arrivalTerminal: rawSegment.arrival_terminal || "",
+ duration: rawSegment.duration || 0,
+ startDate: rawSegment.start_date || "",
+ stops: rawSegment.stops || "",
+ days: rawSegment.days || "",
+ hasTransfers: Boolean(rawSegment.has_transfers),
+ ticketText: formatTicketInfo(rawSegment.tickets_info),
+ from: mapStationInfo(rawSegment.from || {}),
+ to: mapStationInfo(rawSegment.to || {}),
+ thread: {
+ uid: rawSegment.thread?.uid || "",
+ title: rawSegment.thread?.title || "",
+ shortTitle: rawSegment.thread?.short_title || "",
+ number: rawSegment.thread?.number || "",
+ carrier: rawSegment.thread?.carrier?.title || "",
+ vehicle: rawSegment.thread?.vehicle || "",
+ transportType: rawSegment.thread?.transport_type || "",
+ transportSubtype: rawSegment.thread?.transport_subtype?.title || "",
+ intervalDensity: rawSegment.thread?.interval?.density || "",
+ },
+ };
+}
+
+export async function getNearbyStations(
+ latitude,
+ longitude,
+ distance,
+ limit = NEAREST_STATION_LIMIT,
+) {
+ const response = await requestYandex("nearest_stations/", {
+ lang: "ru_RU",
+ format: "json",
+ lat: latitude,
+ lng: longitude,
+ distance,
+ limit,
+ transport_types: "suburban",
+ station_types:
+ "station,platform,stop,train_station,checkpoint,post,crossing,overtaking_point,unknown",
+ });
+
+ return (response.stations || []).map((station) => ({
+ ...mapStationInfo(station),
+ distance: station.distance || 0,
+ typeChoices: station.type_choices || {},
+ }));
+}
+
+export async function getStationSchedule(stationCode, date) {
+ const requestParams = {
+ station: stationCode,
+ date,
+ lang: "ru_RU",
+ format: "json",
+ transport_types: "suburban",
+ };
+
+ const [arrivalResponse, departureResponse] = await Promise.all([
+ requestYandex("schedule/", { ...requestParams, event: "arrival" }),
+ requestYandex("schedule/", { ...requestParams, event: "departure" }),
+ ]);
+
+ return {
+ date: departureResponse.date || arrivalResponse.date || date,
+ station: mapStationInfo(departureResponse.station || arrivalResponse.station || {}),
+ directions: departureResponse.directions || arrivalResponse.directions || [],
+ entries: mergeScheduleResponses(arrivalResponse, departureResponse),
+ };
+}
+
+export async function searchRoutesBetweenStations(fromStationCode, toStationCode, date) {
+ const response = await requestYandex("search/", {
+ from: fromStationCode,
+ to: toStationCode,
+ date,
+ lang: "ru_RU",
+ format: "json",
+ transport_types: "suburban",
+ transfers: "true",
+ limit: 50,
+ });
+
+ const intervalSegments = (response.interval_segments || []).map((segment) =>
+ mapRouteSegment(segment, true),
+ );
+ const regularSegments = (response.segments || []).map((segment) => mapRouteSegment(segment));
+
+ return {
+ search: response.search || {},
+ from: mapStationInfo(response.search?.from || {}),
+ to: mapStationInfo(response.search?.to || {}),
+ segments: [...regularSegments, ...intervalSegments].sort((leftSegment, rightSegment) => {
+ const leftTime = Date.parse(leftSegment.departure || 0);
+ const rightTime = Date.parse(rightSegment.departure || 0);
+ return leftTime - rightTime;
+ }),
+ };
+}
diff --git a/backend/utils/fetchJson.js b/backend/utils/fetchJson.js
new file mode 100644
index 00000000..e3bde035
--- /dev/null
+++ b/backend/utils/fetchJson.js
@@ -0,0 +1,37 @@
+export function createHttpError(statusCode, message) {
+ const error = new Error(message);
+ error.statusCode = statusCode;
+ return error;
+}
+
+export async function fetchJson(url, requestLabel, options = {}) {
+ let response;
+
+ try {
+ response = await fetch(url, options);
+ } catch (error) {
+ console.error(`[${requestLabel}] network error`, error);
+ throw createHttpError(502, "Не удалось связаться с API Яндекс.Расписаний.");
+ }
+
+ const responseText = await response.text();
+
+ if (!response.ok) {
+ console.error(`[${requestLabel}] bad response`, response.status, responseText);
+ throw createHttpError(
+ response.status,
+ "Яндекс.Расписания вернули ошибку. Проверьте ключ и параметры запроса.",
+ );
+ }
+
+ if (!responseText) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(responseText);
+ } catch (error) {
+ console.error(`[${requestLabel}] invalid json`, error, responseText);
+ throw createHttpError(502, "API Яндекс.Расписаний вернуло некорректный JSON.");
+ }
+}
diff --git a/backend/utils/normalizeText.js b/backend/utils/normalizeText.js
new file mode 100644
index 00000000..9eff34bb
--- /dev/null
+++ b/backend/utils/normalizeText.js
@@ -0,0 +1,8 @@
+export function normalizeText(value) {
+ return String(value || "")
+ .toLowerCase()
+ .replace(/ё/g, "е")
+ .replace(/[^\p{L}\p{N}\s-]/gu, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
diff --git a/client/.env.example b/client/.env.example
new file mode 100644
index 00000000..6dca0fde
--- /dev/null
+++ b/client/.env.example
@@ -0,0 +1,3 @@
+VITE_API_BASE_URL=http://localhost:3001/api
+VITE_FAVORITES_STORAGE_KEY=suburban_lab_favorites
+VITE_THEME_STORAGE_KEY=suburban_lab_theme
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 00000000..fc45b2ad
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Прибывалка для электричек
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 00000000..c5dcd71e
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "suburban-client",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "ol": "^10.6.1",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@rollup/rollup-linux-x64-gnu": "^4.60.1",
+ "@vitejs/plugin-react": "^4.4.1",
+ "vite": "^6.2.1"
+ }
+}
diff --git a/client/public/favicon.svg b/client/public/favicon.svg
new file mode 100644
index 00000000..5ada8615
--- /dev/null
+++ b/client/public/favicon.svg
@@ -0,0 +1,11 @@
+
diff --git a/client/src/App.jsx b/client/src/App.jsx
new file mode 100644
index 00000000..f59164b6
--- /dev/null
+++ b/client/src/App.jsx
@@ -0,0 +1,232 @@
+import { useEffect, useState } from "react";
+import { loadNearbyStations, loadRoutesBetweenStations, loadStationSchedule } from "./api/client";
+import { AppHeader } from "./components/AppHeader";
+import { FavoritesPanel } from "./components/FavoritesPanel";
+import { RouteSearchPanel } from "./components/RouteSearchPanel";
+import { StationSchedulePanel } from "./components/StationSchedulePanel";
+import { StationSearchPanel } from "./components/StationSearchPanel";
+import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./config/app";
+import { MAP_POINT_SEARCH_DISTANCE } from "./config/map";
+import { useFavorites } from "./hooks/useFavorites";
+import { todayDateValue } from "./utils/dates";
+import { readStoredValue, writeStoredValue } from "./utils/storage";
+
+
+function getSavedTheme() {
+ const savedTheme = readStoredValue(THEME_STORAGE_KEY, DEFAULT_THEME);
+ return savedTheme === "dark" ? "dark" : DEFAULT_THEME;
+}
+
+function openSection(sectionId) {
+ window.location.hash = `#${sectionId}`;
+}
+
+export default function App() {
+ const [theme, setTheme] = useState(getSavedTheme);
+ const [selectedStation, setSelectedStation] = useState(null);
+ const [pickedMapPoint, setPickedMapPoint] = useState(null);
+ const [stationDate, setStationDate] = useState(todayDateValue());
+ const [scheduleEntries, setScheduleEntries] = useState([]);
+ const [scheduleLoading, setScheduleLoading] = useState(false);
+ const [scheduleError, setScheduleError] = useState("");
+
+ const [nearbyStations, setNearbyStations] = useState([]);
+ const [nearbyStationsLoading, setNearbyStationsLoading] = useState(false);
+ const [nearbyStationsError, setNearbyStationsError] = useState("");
+
+ const [routeFromStation, setRouteFromStation] = useState(null);
+ const [routeToStation, setRouteToStation] = useState(null);
+ const [routeDate, setRouteDate] = useState(todayDateValue());
+ const [routeSegments, setRouteSegments] = useState([]);
+ const [routeLoading, setRouteLoading] = useState(false);
+ const [routeError, setRouteError] = useState("");
+
+ const { favoriteStations, addFavorite, removeFavorite, hasFavorite } = useFavorites();
+
+ useEffect(() => {
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light";
+ writeStoredValue(THEME_STORAGE_KEY, theme);
+ }, [theme]);
+
+ useEffect(() => {
+ if (!selectedStation?.code) {
+ setScheduleEntries([]);
+ setScheduleError("");
+ return;
+ }
+
+ let isCancelled = false;
+
+ async function fetchSchedule() {
+ setScheduleLoading(true);
+ setScheduleError("");
+
+ try {
+ const response = await loadStationSchedule(selectedStation.code, stationDate);
+
+ if (isCancelled) {
+ return;
+ }
+
+ setScheduleEntries(response.entries || []);
+ } catch (error) {
+ if (!isCancelled) {
+ setScheduleError(error.message);
+ }
+ } finally {
+ if (!isCancelled) {
+ setScheduleLoading(false);
+ }
+ }
+ }
+
+ void fetchSchedule();
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [selectedStation, stationDate]);
+
+ function selectStation(station) {
+ setSelectedStation(station);
+
+ if (station?.lat != null && station?.lng != null) {
+ setPickedMapPoint({
+ lat: station.lat,
+ lng: station.lng,
+ });
+ }
+ }
+
+ async function handleMapPointPick(point) {
+ setPickedMapPoint(point);
+ setNearbyStationsLoading(true);
+ setNearbyStationsError("");
+
+ try {
+ const response = await loadNearbyStations(
+ point.lat,
+ point.lng,
+ MAP_POINT_SEARCH_DISTANCE,
+ );
+
+ setNearbyStations(response.stations || []);
+ } catch (error) {
+ setNearbyStationsError(error.message);
+ } finally {
+ setNearbyStationsLoading(false);
+ }
+ }
+
+ function handleFavoriteToggle(station) {
+ if (station && hasFavorite(station.code)) {
+ removeFavorite(station.code);
+ return;
+ }
+
+ if (station) {
+ addFavorite(station);
+ }
+ }
+
+ async function handleRouteSubmit(event) {
+ event.preventDefault();
+
+ if (!routeFromStation?.code || !routeToStation?.code) {
+ setRouteError("Выберите обе станции, прежде чем запускать поиск.");
+ return;
+ }
+
+ setRouteLoading(true);
+ setRouteError("");
+
+ try {
+ const response = await loadRoutesBetweenStations(
+ routeFromStation.code,
+ routeToStation.code,
+ routeDate,
+ );
+
+ setRouteSegments(response.segments || []);
+ } catch (error) {
+ setRouteError(error.message);
+ } finally {
+ setRouteLoading(false);
+ }
+ }
+
+ function handleFavoritePick(station) {
+ selectStation(station);
+ openSection("station-panel");
+ }
+
+ function handleFavoriteUseAsRouteFrom(station) {
+ setRouteFromStation(station);
+ openSection("route-panel");
+ }
+
+ function handleFavoriteUseAsRouteTo(station) {
+ setRouteToStation(station);
+ openSection("route-panel");
+ }
+
+ function handleThemeToggle() {
+ setTheme((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/api/client.js b/client/src/api/client.js
new file mode 100644
index 00000000..f47eb98b
--- /dev/null
+++ b/client/src/api/client.js
@@ -0,0 +1,63 @@
+import { API_BASE_URL } from "../config/app";
+
+async function requestJson(pathname, params = {}) {
+ const requestUrl = new URL(pathname, `${API_BASE_URL}/`);
+
+ for (const [parameterName, parameterValue] of Object.entries(params)) {
+ if (parameterValue === undefined || parameterValue === null || parameterValue === "") {
+ continue;
+ }
+
+ requestUrl.searchParams.set(parameterName, String(parameterValue));
+ }
+
+ let response;
+
+ try {
+ response = await fetch(requestUrl);
+ } catch (error) {
+ console.error("API network error", requestUrl.toString(), error);
+ throw new Error(
+ `Не удалось подключиться к серверу. Проверьте, что backend запущен по адресу ${API_BASE_URL}.`,
+ );
+ }
+
+ const responseText = await response.text();
+ let payload = {};
+
+ if (responseText) {
+ try {
+ payload = JSON.parse(responseText);
+ } catch (error) {
+ console.error("API response parse error", requestUrl.toString(), responseText);
+ throw new Error("Сервер вернул некорректный ответ.");
+ }
+ }
+
+ if (!response.ok) {
+ console.error("API request failed", response.status, payload);
+ throw new Error(payload.error || "Не удалось выполнить запрос.");
+ }
+
+ return payload;
+}
+
+export async function suggestStations(searchText, limit = 8) {
+ return requestJson("stations/suggest", { searchText, limit });
+}
+
+export async function loadNearbyStations(lat, lng, distance, limit = 8) {
+ return requestJson("stations/nearby", { lat, lng, distance, limit });
+}
+
+export async function loadStationSchedule(stationCode, date) {
+ return requestJson(`stations/${stationCode}/schedule`, { date });
+}
+
+export async function loadRoutesBetweenStations(fromStationCode, toStationCode, date) {
+ return requestJson("routes/search", {
+ from: fromStationCode,
+ to: toStationCode,
+ date,
+ });
+}
diff --git a/client/src/assets/moon.svg b/client/src/assets/moon.svg
new file mode 100644
index 00000000..f1c4ce79
--- /dev/null
+++ b/client/src/assets/moon.svg
@@ -0,0 +1,3 @@
+
diff --git a/client/src/assets/sun.svg b/client/src/assets/sun.svg
new file mode 100644
index 00000000..8ed99ea8
--- /dev/null
+++ b/client/src/assets/sun.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/client/src/components/AppHeader.jsx b/client/src/components/AppHeader.jsx
new file mode 100644
index 00000000..11bdecc3
--- /dev/null
+++ b/client/src/components/AppHeader.jsx
@@ -0,0 +1,41 @@
+import moonIconUrl from "../assets/moon.svg";
+import sunIconUrl from "../assets/sun.svg";
+import { APP_NAME } from "../config/app";
+
+export function AppHeader({ theme, onThemeToggle }) {
+ const nextThemeLabel = theme === "dark" ? "Включить светлую тему" : "Включить тёмную тему";
+ const themeIconUrl = theme === "dark" ? moonIconUrl : sunIconUrl;
+
+ return (
+
+
+
+
Пригородные поезда
+
{APP_NAME}
+
+ Поиск станции, расписание и маршруты по Самаре и области.
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/EmptyState.jsx b/client/src/components/EmptyState.jsx
new file mode 100644
index 00000000..e6548925
--- /dev/null
+++ b/client/src/components/EmptyState.jsx
@@ -0,0 +1,8 @@
+export function EmptyState({ title, text }) {
+ return (
+
+ );
+}
diff --git a/client/src/components/FavoriteButton.jsx b/client/src/components/FavoriteButton.jsx
new file mode 100644
index 00000000..eb387b98
--- /dev/null
+++ b/client/src/components/FavoriteButton.jsx
@@ -0,0 +1,14 @@
+export function FavoriteButton({ active, onClick }) {
+ return (
+
+ );
+}
diff --git a/client/src/components/FavoritesPanel.jsx b/client/src/components/FavoritesPanel.jsx
new file mode 100644
index 00000000..33444024
--- /dev/null
+++ b/client/src/components/FavoritesPanel.jsx
@@ -0,0 +1,82 @@
+import { formatStationLabel } from "../utils/formatters";
+import { EmptyState } from "./EmptyState";
+
+export function FavoritesPanel({
+ favoriteStations,
+ onPickStation,
+ onRemoveFavorite,
+ onUseAsRouteFrom,
+ onUseAsRouteTo,
+ selectedStationCode,
+}) {
+ function renderEmptyState() {
+ return (
+
+ );
+ }
+
+ function renderFavoriteList() {
+ return (
+
+ {favoriteStations.map((station) => (
+
+
+
{station.title}
+
{formatStationLabel(station)}
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+
+
+
Избранное
+
Сохранённые станции
+
Список станций, которые вы открываете чаще.
+
+
{favoriteStations.length}
+
+ {favoriteStations.length === 0 ? renderEmptyState() : renderFavoriteList()}
+
+ );
+}
diff --git a/client/src/components/LoadingBlock.jsx b/client/src/components/LoadingBlock.jsx
new file mode 100644
index 00000000..8800381c
--- /dev/null
+++ b/client/src/components/LoadingBlock.jsx
@@ -0,0 +1,8 @@
+export function LoadingBlock({ text = "Загрузка..." }) {
+ return (
+
+
+ {text}
+
+ );
+}
diff --git a/client/src/components/RouteCard.jsx b/client/src/components/RouteCard.jsx
new file mode 100644
index 00000000..815b3bac
--- /dev/null
+++ b/client/src/components/RouteCard.jsx
@@ -0,0 +1,74 @@
+import { formatDateTime } from "../utils/dates";
+import { formatDuration, formatStopsLabel, formatTransportLabel } from "../utils/formatters";
+
+export function RouteCard({ segment }) {
+ return (
+
+
+
+
{segment.thread.shortTitle || segment.thread.title}
+
+ {segment.thread.number ? `№ ${segment.thread.number}` : "Без номера"}
+ {segment.thread.transportSubtype ? ` • ${segment.thread.transportSubtype}` : ""}
+ {segment.hasTransfers ? " • с пересадками" : " • прямой"}
+
+
+
+ {segment.ticketText ? (
+
+ {segment.ticketText}
+
+ ) : null}
+ {segment.isInterval ? (
+
+ интервальный
+
+ ) : null}
+
+
+
+
+
+ {segment.from.title}
+ {formatDateTime(segment.departure)}
+
+ {segment.departurePlatform
+ ? `платформа ${segment.departurePlatform}`
+ : "платформа не указана"}
+
+
+
{formatDuration(segment.duration)}
+
+ {segment.to.title}
+ {formatDateTime(segment.arrival)}
+
+ {segment.arrivalPlatform
+ ? `платформа ${segment.arrivalPlatform}`
+ : "платформа не указана"}
+
+
+
+
+
+
+
- Перевозчик
+ - {segment.thread.carrier || "не указан"}
+
+
+
- Остановки
+ - {formatStopsLabel(segment.stops)}
+
+
+
- Дни следования
+ - {segment.days || segment.thread.intervalDensity || "нет данных"}
+
+
+
- Транспорт
+ -
+ {segment.thread.vehicle || formatTransportLabel(segment.thread.transportType)}
+
+
+
+
+ );
+}
diff --git a/client/src/components/RouteSearchPanel.jsx b/client/src/components/RouteSearchPanel.jsx
new file mode 100644
index 00000000..aaa659bc
--- /dev/null
+++ b/client/src/components/RouteSearchPanel.jsx
@@ -0,0 +1,77 @@
+import { EmptyState } from "./EmptyState";
+import { LoadingBlock } from "./LoadingBlock";
+import { RouteCard } from "./RouteCard";
+import { StationAutocomplete } from "./StationAutocomplete";
+
+export function RouteSearchPanel({
+ fromStation,
+ toStation,
+ selectedDate,
+ onFromStationSelect,
+ onToStationSelect,
+ onDateChange,
+ onSubmit,
+ isLoading,
+ errorText,
+ routeSegments,
+}) {
+ const visibleRouteSegments = routeSegments.slice(0, 12);
+
+ return (
+
+
+
+
Маршрут между станциями
+
Расписание электричек между двумя станциями
+
+
+
+
+
+ {isLoading ? : null}
+ {errorText ? {errorText}
: null}
+ {!isLoading && !errorText && routeSegments.length > 12 ? (
+ Показаны первые 12 вариантов из {routeSegments.length}.
+ ) : null}
+
+ {!isLoading && !errorText && routeSegments.length === 0 ? (
+
+ ) : null}
+
+ {routeSegments.length > 0 ? (
+
+ {visibleRouteSegments.map((segment) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/components/ScheduleCard.jsx b/client/src/components/ScheduleCard.jsx
new file mode 100644
index 00000000..2ae7bc44
--- /dev/null
+++ b/client/src/components/ScheduleCard.jsx
@@ -0,0 +1,53 @@
+import { formatDateTime, formatTime } from "../utils/dates";
+import { formatStopsLabel } from "../utils/formatters";
+
+export function ScheduleCard({ entry }) {
+ return (
+
+
+
+
+
+ Прибытие
+ {formatTime(entry.arrival)}
+ {formatDateTime(entry.arrival)}
+
+
+ Отправление
+ {formatTime(entry.departure)}
+ {formatDateTime(entry.departure)}
+
+
+
+
+
+
- Платформа
+ - {entry.platform || "не указана"}
+
+
+
- Терминал
+ - {entry.terminal || "не указан"}
+
+
+
- Остановки
+ - {formatStopsLabel(entry.stops)}
+
+
+
- Дни следования
+ - {entry.days || entry.exceptDays || "нет данных"}
+
+
+
+ );
+}
diff --git a/client/src/components/StationAutocomplete.jsx b/client/src/components/StationAutocomplete.jsx
new file mode 100644
index 00000000..635f84a9
--- /dev/null
+++ b/client/src/components/StationAutocomplete.jsx
@@ -0,0 +1,121 @@
+import { useEffect, useState } from "react";
+import { suggestStations } from "../api/client";
+import { formatStationLabel } from "../utils/formatters";
+import { LoadingBlock } from "./LoadingBlock";
+
+export function StationAutocomplete({ label, inputId, placeholder, selectedStation, onSelect }) {
+ const [inputValue, setInputValue] = useState("");
+ const [stationSuggestions, setStationSuggestions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorText, setErrorText] = useState("");
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ useEffect(() => {
+ setInputValue(selectedStation ? formatStationLabel(selectedStation) : "");
+ setStationSuggestions([]);
+ setIsDropdownOpen(false);
+ }, [selectedStation]);
+
+ useEffect(() => {
+ const trimmedValue = inputValue.trim();
+
+ if (trimmedValue.length < 2) {
+ setStationSuggestions([]);
+ setIsDropdownOpen(false);
+ setErrorText("");
+ return undefined;
+ }
+
+ let isCancelled = false;
+ const timeoutId = setTimeout(async () => {
+ setIsLoading(true);
+ setErrorText("");
+
+ try {
+ const response = await suggestStations(trimmedValue);
+
+ if (isCancelled) {
+ return;
+ }
+
+ setStationSuggestions(response.stations || []);
+ setIsDropdownOpen(true);
+ } catch (error) {
+ if (isCancelled) {
+ return;
+ }
+
+ setErrorText(error.message);
+ } finally {
+ if (!isCancelled) {
+ setIsLoading(false);
+ }
+ }
+ }, 350);
+
+ return () => {
+ isCancelled = true;
+ clearTimeout(timeoutId);
+ };
+ }, [inputValue]);
+
+ function handleSuggestionClick(station) {
+ onSelect(station);
+ setInputValue(formatStationLabel(station));
+ setIsDropdownOpen(false);
+ }
+
+ function handleClearClick() {
+ setInputValue("");
+ setStationSuggestions([]);
+ setIsDropdownOpen(false);
+ onSelect(null);
+ }
+
+ return (
+
+
+
+ setInputValue(event.target.value)}
+ placeholder={placeholder}
+ autoComplete="off"
+ />
+ {inputValue ? (
+
+ ) : null}
+
+
+ {isLoading ?
: null}
+ {errorText ?
{errorText}
: null}
+
+ {isDropdownOpen && stationSuggestions.length > 0 ? (
+
+ {stationSuggestions.map((station) => (
+ -
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/components/StationMap.jsx b/client/src/components/StationMap.jsx
new file mode 100644
index 00000000..00d2d8b6
--- /dev/null
+++ b/client/src/components/StationMap.jsx
@@ -0,0 +1,136 @@
+import { useEffect, useRef } from "react";
+import Feature from "ol/Feature.js";
+import Map from "ol/Map.js";
+import View from "ol/View.js";
+import { Point } from "ol/geom.js";
+import TileLayer from "ol/layer/Tile.js";
+import VectorLayer from "ol/layer/Vector.js";
+import { fromLonLat, toLonLat } from "ol/proj.js";
+import OSM from "ol/source/OSM.js";
+import VectorSource from "ol/source/Vector.js";
+import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style.js";
+import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from "../config/map";
+import { getVectorSourceByLayerName } from "../utils/map";
+
+const MARKERS_LAYER_NAME = "station-markers";
+
+const defaultMarkerStyle = new Style({
+ image: new CircleStyle({
+ radius: 6,
+ fill: new Fill({ color: "#8b2332" }),
+ stroke: new Stroke({ color: "#fff7ef", width: 2 }),
+ }),
+});
+
+const activeMarkerStyle = new Style({
+ image: new CircleStyle({
+ radius: 8,
+ fill: new Fill({ color: "#0d6efd" }),
+ stroke: new Stroke({ color: "#ffffff", width: 3 }),
+ }),
+});
+
+const pickedPointStyle = new Style({
+ image: new CircleStyle({
+ radius: 7,
+ fill: new Fill({ color: "#dc3545" }),
+ stroke: new Stroke({ color: "#ffffff", width: 3 }),
+ }),
+});
+
+function hasCoordinates(point) {
+ return point?.lng != null && point?.lat != null;
+}
+
+function makePointFeature(longitude, latitude, style) {
+ const feature = new Feature({
+ geometry: new Point(fromLonLat([longitude, latitude])),
+ });
+
+ feature.setStyle(style);
+ return feature;
+}
+
+export function StationMap({ nearbyStations, selectedStation, pickedPoint, onMapPointPick }) {
+ const mapElementRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+
+ useEffect(() => {
+ if (!mapElementRef.current || mapInstanceRef.current) {
+ return;
+ }
+
+ const markerLayer = new VectorLayer({
+ source: new VectorSource(),
+ });
+ markerLayer.set("name", MARKERS_LAYER_NAME);
+
+ const map = new Map({
+ target: mapElementRef.current,
+ layers: [
+ new TileLayer({
+ source: new OSM(),
+ }),
+ markerLayer,
+ ],
+ view: new View({
+ center: fromLonLat(DEFAULT_MAP_CENTER),
+ zoom: DEFAULT_MAP_ZOOM,
+ }),
+ });
+
+ map.on("click", (event) => {
+ const [longitude, latitude] = toLonLat(event.coordinate);
+ onMapPointPick({
+ lat: Number(latitude.toFixed(6)),
+ lng: Number(longitude.toFixed(6)),
+ });
+ });
+
+ mapInstanceRef.current = map;
+ }, [onMapPointPick]);
+
+ useEffect(() => {
+ const source = getVectorSourceByLayerName(mapInstanceRef.current, MARKERS_LAYER_NAME);
+
+ if (!source) {
+ return;
+ }
+
+ source.clear();
+ const renderedCodes = new Set();
+
+ if (hasCoordinates(pickedPoint)) {
+ source.addFeature(makePointFeature(pickedPoint.lng, pickedPoint.lat, pickedPointStyle));
+ }
+
+ for (const station of nearbyStations) {
+ if (!hasCoordinates(station)) {
+ continue;
+ }
+
+ const markerStyle =
+ station.code === selectedStation?.code ? activeMarkerStyle : defaultMarkerStyle;
+ source.addFeature(makePointFeature(station.lng, station.lat, markerStyle));
+ renderedCodes.add(station.code);
+ }
+
+ if (selectedStation?.code && hasCoordinates(selectedStation) && !renderedCodes.has(selectedStation.code)) {
+ source.addFeature(makePointFeature(selectedStation.lng, selectedStation.lat, activeMarkerStyle));
+ }
+
+ if (hasCoordinates(selectedStation) && mapInstanceRef.current) {
+ mapInstanceRef.current.getView().animate({
+ center: fromLonLat([selectedStation.lng, selectedStation.lat]),
+ zoom: 12,
+ });
+ } else if (hasCoordinates(pickedPoint) && mapInstanceRef.current) {
+ mapInstanceRef.current.getView().animate({
+ center: fromLonLat([pickedPoint.lng, pickedPoint.lat]),
+ zoom: 12,
+ });
+ }
+ }, [nearbyStations, pickedPoint, selectedStation]);
+
+ return ;
+}
diff --git a/client/src/components/StationSchedulePanel.jsx b/client/src/components/StationSchedulePanel.jsx
new file mode 100644
index 00000000..b531f2ed
--- /dev/null
+++ b/client/src/components/StationSchedulePanel.jsx
@@ -0,0 +1,60 @@
+import { EmptyState } from "./EmptyState";
+import { LoadingBlock } from "./LoadingBlock";
+import { ScheduleCard } from "./ScheduleCard";
+
+export function StationSchedulePanel({
+ selectedStation,
+ selectedDate,
+ onDateChange,
+ isLoading,
+ errorText,
+ scheduleEntries,
+}) {
+ const hasSelectedStation = Boolean(selectedStation?.code);
+
+ return (
+
+
+
+
Расписание по станции
+
Все проходящие электрички через выбранную станцию
+
+
+
+
+ {!hasSelectedStation ? (
+
+ ) : null}
+
+ {hasSelectedStation && isLoading ? : null}
+ {hasSelectedStation && errorText ? {errorText}
: null}
+
+ {hasSelectedStation && !isLoading && !errorText && scheduleEntries.length === 0 ? (
+
+ ) : null}
+
+ {hasSelectedStation && scheduleEntries.length > 0 ? (
+
+ {scheduleEntries.map((entry) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/client/src/components/StationSearchPanel.jsx b/client/src/components/StationSearchPanel.jsx
new file mode 100644
index 00000000..7c22d6ce
--- /dev/null
+++ b/client/src/components/StationSearchPanel.jsx
@@ -0,0 +1,121 @@
+import { MAP_POINT_SEARCH_DISTANCE } from "../config/map";
+import {
+ formatDistance,
+ formatStationLabel,
+ formatStationTypeLabel,
+ formatTransportLabel,
+} from "../utils/formatters";
+import { EmptyState } from "./EmptyState";
+import { FavoriteButton } from "./FavoriteButton";
+import { LoadingBlock } from "./LoadingBlock";
+import { StationAutocomplete } from "./StationAutocomplete";
+import { StationMap } from "./StationMap";
+
+export function StationSearchPanel({
+ selectedStation,
+ onStationSelect,
+ onFavoriteToggle,
+ isFavorite,
+ onMapPointPick,
+ pickedPoint,
+ nearbyStations,
+ nearbyStationsLoading,
+ nearbyStationsError,
+}) {
+ return (
+
+
+
+
Поиск станции
+
Выберите станцию по названию или по карте
+
+ {selectedStation ? (
+
onFavoriteToggle(selectedStation)}
+ />
+ ) : null}
+
+
+
+
+
+
+ {selectedStation ? (
+
+
+
+
+
- Код станции
+ - {selectedStation.code}
+
+
+
- Тип
+ - {formatStationTypeLabel(selectedStation.stationType)}
+
+
+
- Транспорт
+ - {formatTransportLabel(selectedStation.transportType)}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
Клик по карте ищет ближайшие станции в радиусе {MAP_POINT_SEARCH_DISTANCE} км.
+
+
+
+ {nearbyStationsLoading ?
: null}
+ {nearbyStationsError ?
{nearbyStationsError}
: null}
+ {!nearbyStationsLoading && !nearbyStationsError && pickedPoint && nearbyStations.length === 0 ? (
+
+ ) : null}
+
+ {nearbyStations.length > 0 ? (
+
+ {nearbyStations.map((station) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/client/src/config/app.js b/client/src/config/app.js
new file mode 100644
index 00000000..7b23591d
--- /dev/null
+++ b/client/src/config/app.js
@@ -0,0 +1,10 @@
+export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3001/api";
+
+export const FAVORITES_STORAGE_KEY =
+ import.meta.env.VITE_FAVORITES_STORAGE_KEY || "suburban_lab_favorites";
+
+export const THEME_STORAGE_KEY = import.meta.env.VITE_THEME_STORAGE_KEY || "suburban_lab_theme";
+
+export const APP_NAME = "Прибывалка для электричек";
+
+export const DEFAULT_THEME = import.meta.env.VITE_DEFAULT_THEME || "light";
diff --git a/client/src/config/map.js b/client/src/config/map.js
new file mode 100644
index 00000000..e32eb0e7
--- /dev/null
+++ b/client/src/config/map.js
@@ -0,0 +1,3 @@
+export const DEFAULT_MAP_CENTER = [50.121252, 53.185568];
+export const DEFAULT_MAP_ZOOM = 11;
+export const MAP_POINT_SEARCH_DISTANCE = 25;
diff --git a/client/src/hooks/useFavorites.js b/client/src/hooks/useFavorites.js
new file mode 100644
index 00000000..693391f4
--- /dev/null
+++ b/client/src/hooks/useFavorites.js
@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import { FAVORITES_STORAGE_KEY } from "../config/app";
+import { readStoredList, writeStoredList } from "../utils/storage";
+
+function normalizeFavorites(items) {
+ return [...items].sort((leftItem, rightItem) =>
+ `${leftItem.title} ${leftItem.region}`.localeCompare(
+ `${rightItem.title} ${rightItem.region}`,
+ "ru",
+ ),
+ );
+}
+
+function hasFavoriteInList(items, stationCode) {
+ return items.some((station) => station.code === stationCode);
+}
+
+export function useFavorites() {
+ const [favoriteStations, setFavoriteStations] = useState(() =>
+ normalizeFavorites(readStoredList(FAVORITES_STORAGE_KEY)),
+ );
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return undefined;
+ }
+
+ function handleStorageChange(event) {
+ if (event.key && event.key !== FAVORITES_STORAGE_KEY) {
+ return;
+ }
+
+ setFavoriteStations(normalizeFavorites(readStoredList(FAVORITES_STORAGE_KEY)));
+ }
+
+ window.addEventListener("storage", handleStorageChange);
+ return () => window.removeEventListener("storage", handleStorageChange);
+ }, []);
+
+ function addFavorite(station) {
+ setFavoriteStations((currentStations) => {
+ if (hasFavoriteInList(currentStations, station.code)) {
+ return currentStations;
+ }
+
+ const nextStations = normalizeFavorites([...currentStations, station]);
+ writeStoredList(FAVORITES_STORAGE_KEY, nextStations);
+ return nextStations;
+ });
+ }
+
+ function removeFavorite(stationCode) {
+ setFavoriteStations((currentStations) => {
+ const nextStations = currentStations.filter((station) => station.code !== stationCode);
+ writeStoredList(FAVORITES_STORAGE_KEY, nextStations);
+ return nextStations;
+ });
+ }
+
+ function hasFavorite(stationCode) {
+ return hasFavoriteInList(favoriteStations, stationCode);
+ }
+
+ return {
+ favoriteStations,
+ addFavorite,
+ removeFavorite,
+ hasFavorite,
+ };
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
new file mode 100644
index 00000000..2d3bef59
--- /dev/null
+++ b/client/src/main.jsx
@@ -0,0 +1,6 @@
+import ReactDOM from "react-dom/client";
+import "ol/ol.css";
+import App from "./App";
+import "./styles/app.css";
+
+ReactDOM.createRoot(document.getElementById("root")).render();
diff --git a/client/src/styles/app.css b/client/src/styles/app.css
new file mode 100644
index 00000000..8d54e52d
--- /dev/null
+++ b/client/src/styles/app.css
@@ -0,0 +1,810 @@
+:root {
+ color-scheme: light;
+ --page-bg: #f3f6fa;
+ --surface: #ffffff;
+ --surface-muted: #f8fafc;
+ --surface-strong: #ffffff;
+ --border: #d7e0ea;
+ --text-main: #1f2937;
+ --text-soft: #6b7280;
+ --primary: #0d6efd;
+ --primary-dark: #0b5ed7;
+ --danger: #dc3545;
+ --success: #198754;
+ --input-bg: #ffffff;
+ --input-border: #c6d0db;
+ --accent-surface: #eef5ff;
+ --accent-border: #cfe2ff;
+ --accent-strong-border: #9ec5fe;
+ --accent-text: #0d6efd;
+ --hero-bg: linear-gradient(135deg, #17396d 0%, #1565df 56%, #8ebdff 100%);
+ --hero-text: #ffffff;
+ --hero-soft: rgba(255, 255, 255, 0.8);
+ --hero-chip-bg: rgba(255, 255, 255, 0.14);
+ --hero-chip-border: rgba(255, 255, 255, 0.24);
+ --shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
+ --radius-lg: 18px;
+ --radius-md: 14px;
+ --radius-sm: 10px;
+}
+
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --page-bg: #09121f;
+ --surface: #0f1a2b;
+ --surface-muted: #132237;
+ --surface-strong: #18283f;
+ --border: #27405f;
+ --text-main: #edf3ff;
+ --text-soft: #9eb1ca;
+ --primary: #6aa6ff;
+ --primary-dark: #4d8ef0;
+ --danger: #ff7f8d;
+ --success: #48d597;
+ --input-bg: #0d1726;
+ --input-border: #35506f;
+ --accent-surface: rgba(106, 166, 255, 0.14);
+ --accent-border: rgba(106, 166, 255, 0.28);
+ --accent-strong-border: rgba(106, 166, 255, 0.55);
+ --accent-text: #92beff;
+ --hero-bg: linear-gradient(135deg, #0d2346 0%, #15345f 56%, #2d5f9f 100%);
+ --hero-text: #f7fbff;
+ --hero-soft: rgba(247, 251, 255, 0.76);
+ --hero-chip-bg: rgba(8, 18, 33, 0.3);
+ --hero-chip-border: rgba(190, 219, 255, 0.16);
+ --shadow: 0 18px 32px rgba(0, 0, 0, 0.28);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ font-family: "Segoe UI", Roboto, Arial, sans-serif;
+ color: var(--text-main);
+ background: var(--page-bg);
+ transition:
+ background-color 0.25s ease,
+ color 0.25s ease;
+}
+
+a {
+ color: inherit;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+.app-shell {
+ width: min(1280px, calc(100% - 24px));
+ margin: 0 auto;
+ padding: 16px 0 40px;
+}
+
+.page-header {
+ margin-bottom: 20px;
+ border: 1px solid var(--hero-chip-border);
+ border-radius: 22px;
+ background: var(--hero-bg);
+ box-shadow: 0 18px 36px rgba(16, 31, 57, 0.18);
+ color: var(--hero-text);
+}
+
+.page-header__inner {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 22px;
+}
+
+.page-header__label,
+.section-kicker {
+ margin: 0 0 6px;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.page-header__label {
+ color: var(--hero-soft);
+}
+
+.section-kicker {
+ color: var(--primary);
+}
+
+.page-header__main h1,
+.panel h2 {
+ margin: 0;
+ font-size: clamp(1.55rem, 3vw, 2.2rem);
+ line-height: 1.15;
+}
+
+.page-header__text {
+ margin: 8px 0 0;
+ color: var(--hero-soft);
+ line-height: 1.5;
+ max-width: 700px;
+}
+
+.page-header__controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-left: auto;
+ flex-wrap: nowrap;
+}
+
+.theme-switch {
+ display: inline-grid;
+ place-items: center;
+ width: 54px;
+ height: 54px;
+ padding: 0;
+ border: 1px solid var(--hero-chip-border);
+ border-radius: 16px;
+ background: var(--hero-chip-bg);
+ color: var(--hero-text);
+ transition:
+ transform 0.2s ease,
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+}
+
+.theme-switch:hover {
+ transform: translateY(-1px);
+}
+
+.theme-switch--light {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.theme-switch--dark {
+ background: rgba(6, 14, 28, 0.34);
+}
+
+.theme-switch__icon {
+ display: inline-grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+}
+
+.theme-switch__icon svg {
+ width: 24px;
+ height: 24px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.theme-switch--light .theme-switch__icon {
+ color: #fff4b0;
+}
+
+.theme-switch--dark .theme-switch__icon {
+ color: #d9e6ff;
+}
+
+.page-header__nav {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 10px;
+ justify-content: flex-start;
+}
+
+.page-header__nav a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ padding: 0 14px;
+ border: 1px solid var(--hero-chip-border);
+ border-radius: 999px;
+ background: var(--hero-chip-bg);
+ color: var(--hero-text);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.layout {
+ display: grid;
+ gap: 20px;
+}
+
+.layout__main,
+.layout__aside {
+ display: grid;
+ gap: 20px;
+}
+
+.layout__aside {
+ align-content: start;
+}
+
+.panel {
+ border: 1px solid var(--border);
+ border-radius: 22px;
+ background: var(--surface);
+ box-shadow: var(--shadow);
+ padding: 20px;
+}
+
+.panel--compact {
+ padding: 18px;
+}
+
+.panel__heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 18px;
+}
+
+.panel-grid {
+ display: grid;
+ gap: 16px;
+ align-items: start;
+}
+
+.panel-card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--surface-muted);
+ padding: 16px;
+ min-height: 100%;
+}
+
+.field-label,
+.date-field span {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+.station-autocomplete,
+.date-field {
+ width: 100%;
+}
+
+.station-autocomplete__control {
+ position: relative;
+}
+
+input {
+ width: 100%;
+ min-height: 46px;
+ border: 1px solid var(--input-border);
+ border-radius: 12px;
+ background: var(--input-bg);
+ padding: 10px 14px;
+ color: var(--text-main);
+}
+
+input:focus {
+ outline: 0;
+ border-color: #8ab4ff;
+ box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
+}
+
+.icon-button {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ background: var(--input-bg);
+ color: var(--text-main);
+ transform: translateY(-50%);
+}
+
+.suggestions-list,
+.plain-list {
+ margin: 12px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.suggestions-list {
+ display: grid;
+ gap: 8px;
+}
+
+.suggestions-list__item,
+.nearby-list__item {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface-strong);
+ padding: 12px 14px;
+ text-align: left;
+}
+
+.suggestions-list__item span,
+.nearby-list__item span,
+.station-summary__header p,
+.favorite-card p,
+.schedule-card__subtitle,
+.route-card__header p {
+ display: block;
+ margin-top: 4px;
+ color: var(--text-soft);
+}
+
+.loading-block {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+ padding: 10px 12px;
+ border: 1px solid var(--accent-border);
+ border-radius: 999px;
+ background: var(--accent-surface);
+ color: var(--accent-text);
+}
+
+.loading-block__dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: pulse 1s infinite ease-in-out;
+}
+
+.field-error {
+ margin: 12px 0 0;
+ color: var(--danger);
+}
+
+.empty-state {
+ border: 1px dashed var(--border);
+ border-radius: 14px;
+ padding: 16px;
+ background: var(--surface-muted);
+}
+
+.empty-state h3 {
+ margin: 0 0 8px;
+ font-size: 1rem;
+}
+
+.empty-state p,
+.map-note p,
+.plain-list li {
+ margin: 0;
+ line-height: 1.55;
+ color: var(--text-soft);
+}
+
+.plain-list {
+ display: grid;
+ gap: 10px;
+}
+
+.map-note {
+ margin-bottom: 12px;
+}
+
+.station-summary {
+ margin-top: 14px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface-strong);
+ padding: 14px;
+}
+
+.station-summary__header h3,
+.favorite-card h3,
+.schedule-card h3,
+.route-card h3 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.station-summary__meta,
+.schedule-card__meta,
+.route-card__meta {
+ display: grid;
+ gap: 10px;
+ margin: 14px 0 0;
+}
+
+.station-summary__meta div,
+.schedule-card__meta div,
+.route-card__meta div {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface-muted);
+}
+
+.station-summary__meta dt,
+.schedule-card__meta dt,
+.route-card__meta dt {
+ margin-bottom: 4px;
+ font-size: 0.82rem;
+ color: var(--text-soft);
+}
+
+.station-summary__meta dd,
+.schedule-card__meta dd,
+.route-card__meta dd {
+ margin: 0;
+}
+
+.station-map {
+ height: 340px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ overflow: hidden;
+ background: #dde7f0;
+}
+
+.nearby-list,
+.favorites-list,
+.schedule-list,
+.route-list {
+ display: grid;
+ gap: 12px;
+}
+
+.nearby-list {
+ margin-top: 14px;
+}
+
+.route-list {
+ margin-top: 16px;
+}
+
+.nearby-list__item.is-selected {
+ border-color: var(--accent-strong-border);
+ background: var(--accent-surface);
+}
+
+.date-field {
+ min-width: 172px;
+}
+
+.schedule-card,
+.route-card,
+.favorite-card {
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface);
+ padding: 16px;
+}
+
+.schedule-card__header,
+.route-card__header,
+.favorite-card,
+.favorite-card__actions {
+ display: flex;
+ gap: 12px;
+}
+
+.schedule-card__header,
+.route-card__header,
+.favorite-card {
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.schedule-card__carrier {
+ max-width: 220px;
+ text-align: right;
+ color: var(--text-soft);
+}
+
+.schedule-times,
+.route-card__timeline,
+.route-form {
+ display: grid;
+ gap: 12px;
+}
+
+.route-form {
+ margin-bottom: 12px;
+}
+
+.schedule-times {
+ margin: 16px 0;
+}
+
+.schedule-times div,
+.route-card__timeline div,
+.route-card__duration {
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface-muted);
+}
+
+.schedule-times span,
+.route-card__timeline span,
+.schedule-times small,
+.route-card__timeline small {
+ display: block;
+ margin-top: 4px;
+ color: var(--text-soft);
+}
+
+.schedule-times strong,
+.route-card__timeline strong {
+ font-size: 1.1rem;
+}
+
+.route-card__duration {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.route-card__badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ align-items: flex-start;
+}
+
+.route-card__badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 32px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ white-space: nowrap;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.route-card__badge--price {
+ background: var(--accent-surface);
+ color: var(--accent-text);
+ border: 1px solid var(--accent-border);
+}
+
+.route-card__badge--interval {
+ background: #f3f4f6;
+ color: var(--text-soft);
+ border: 1px solid var(--border);
+}
+
+.primary-button,
+.secondary-button,
+.favorite-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 10px 16px;
+ border-radius: 12px;
+ border: 1px solid transparent;
+ font-weight: 600;
+}
+
+.route-form .primary-button {
+ width: 100%;
+}
+
+.primary-button {
+ background: var(--primary);
+ color: #fff;
+}
+
+.primary-button:hover,
+.page-header__nav a:hover {
+ background: var(--primary-dark);
+ color: #fff;
+ border-color: var(--primary-dark);
+}
+
+.secondary-button {
+ border-color: var(--border);
+ background: var(--surface-strong);
+ color: var(--text-main);
+}
+
+.secondary-button--danger {
+ color: var(--danger);
+}
+
+.favorite-button {
+ flex-shrink: 0;
+ min-width: auto;
+ min-height: 42px;
+ padding: 8px 14px;
+ background: var(--accent-surface);
+ border-color: var(--accent-strong-border);
+ color: var(--accent-text);
+ white-space: nowrap;
+}
+
+.favorite-button.is-active {
+ background: var(--accent-surface);
+ border-color: var(--accent-strong-border);
+}
+
+.favorite-button__icon {
+ font-size: 1.05rem;
+ line-height: 1;
+}
+
+.favorite-button__text {
+ line-height: 1;
+}
+
+.favorite-card {
+ flex-direction: column;
+ background: linear-gradient(
+ 180deg,
+ var(--accent-surface) 0,
+ var(--accent-surface) 56px,
+ var(--surface) 56px
+ );
+}
+
+.favorite-card.is-current {
+ border-color: var(--accent-strong-border);
+ box-shadow: inset 0 0 0 1px var(--accent-strong-border);
+}
+
+.favorite-card__actions {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ align-items: stretch;
+}
+
+.favorite-card__actions .secondary-button {
+ width: 100%;
+ min-height: 42px;
+ padding: 8px 10px;
+ font-size: 0.95rem;
+ white-space: nowrap;
+}
+
+.panel--favorites {
+ background: linear-gradient(180deg, var(--surface-strong) 0, var(--surface) 100%);
+}
+
+.favorites-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 18px;
+}
+
+.favorites-text {
+ margin: 8px 0 0;
+ color: var(--text-soft);
+ line-height: 1.5;
+}
+
+.favorites-count {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 52px;
+ height: 52px;
+ padding: 0 14px;
+ border-radius: 16px;
+ border: 1px solid var(--accent-border);
+ background: var(--accent-surface);
+ color: var(--accent-text);
+ font-size: 1.15rem;
+ font-weight: 700;
+}
+
+.results-note {
+ margin: 12px 0 0;
+ color: var(--text-soft);
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 0.35;
+ transform: scale(0.92);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@media (min-width: 768px) {
+ .panel-grid,
+ .route-form,
+ .schedule-times,
+ .station-summary__meta {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .schedule-card__meta,
+ .route-card__meta {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .route-card__timeline {
+ grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr);
+ align-items: stretch;
+ }
+}
+
+@media (min-width: 1100px) {
+ .layout {
+ grid-template-columns: minmax(0, 2.2fr) minmax(320px, 0.9fr);
+ align-items: start;
+ }
+}
+
+@media (max-width: 767px) {
+ .page-header__inner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .page-header__controls,
+ .page-header__nav {
+ width: 100%;
+ }
+
+ .page-header__controls {
+ justify-content: space-between;
+ align-items: flex-start;
+ }
+
+ .page-header__nav {
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ }
+
+ .panel__heading,
+ .favorites-head,
+ .schedule-card__header,
+ .route-card__header {
+ flex-direction: column;
+ }
+
+ .theme-switch {
+ flex: 0 0 auto;
+ }
+
+ .favorite-button {
+ width: 100%;
+ }
+
+ .route-card__badges {
+ justify-content: flex-start;
+ }
+
+ .schedule-card__carrier {
+ max-width: none;
+ text-align: left;
+ }
+}
diff --git a/client/src/utils/dates.js b/client/src/utils/dates.js
new file mode 100644
index 00000000..97c6c4bc
--- /dev/null
+++ b/client/src/utils/dates.js
@@ -0,0 +1,27 @@
+export function todayDateValue() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+export function formatDateTime(isoString) {
+ if (!isoString) {
+ return "—";
+ }
+
+ return new Intl.DateTimeFormat("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoString));
+}
+
+export function formatTime(isoString) {
+ if (!isoString) {
+ return "—";
+ }
+
+ return new Intl.DateTimeFormat("ru-RU", {
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoString));
+}
diff --git a/client/src/utils/formatters.js b/client/src/utils/formatters.js
new file mode 100644
index 00000000..18a974e9
--- /dev/null
+++ b/client/src/utils/formatters.js
@@ -0,0 +1,78 @@
+export function formatStationLabel(station) {
+ if (!station) {
+ return "";
+ }
+
+ return [station.title, station.settlement, station.region].filter(Boolean).join(", ");
+}
+
+export function formatDistance(distanceKm) {
+ if (!distanceKm && distanceKm !== 0) {
+ return "";
+ }
+
+ return `${distanceKm.toFixed(1)} км`;
+}
+
+export function formatDuration(totalSeconds) {
+ if (!totalSeconds) {
+ return "—";
+ }
+
+ const totalMinutes = Math.round(totalSeconds / 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+
+ if (hours && minutes) {
+ return `${hours} ч ${minutes} мин`;
+ }
+
+ if (hours) {
+ return `${hours} ч`;
+ }
+
+ return `${minutes} мин`;
+}
+
+export function formatStopsLabel(stopsText) {
+ if (!stopsText) {
+ return "остановки не указаны";
+ }
+
+ return stopsText;
+}
+
+export function formatTransportLabel(transportType) {
+ if (!transportType) {
+ return "электричка";
+ }
+
+ if (transportType === "suburban") {
+ return "электричка";
+ }
+
+ if (transportType === "train") {
+ return "поезд";
+ }
+
+ return transportType;
+}
+
+export function formatStationTypeLabel(stationType) {
+ if (!stationType) {
+ return "не указан";
+ }
+
+ const labels = {
+ train_station: "вокзал",
+ station: "станция",
+ platform: "платформа",
+ stop: "остановочный пункт",
+ checkpoint: "пункт",
+ post: "пост",
+ crossing: "разъезд",
+ overtaking_point: "обгонный пункт",
+ };
+
+ return labels[stationType] || stationType;
+}
diff --git a/client/src/utils/map.js b/client/src/utils/map.js
new file mode 100644
index 00000000..c4cd4db9
--- /dev/null
+++ b/client/src/utils/map.js
@@ -0,0 +1,15 @@
+export function getVectorSourceByLayerName(mapObject, layerName) {
+ if (!mapObject) {
+ return null;
+ }
+
+ const vectorLayer = mapObject
+ .getAllLayers()
+ .find((layer) => layer.get("name") === layerName);
+
+ if (!vectorLayer) {
+ return null;
+ }
+
+ return vectorLayer.getSource();
+}
diff --git a/client/src/utils/storage.js b/client/src/utils/storage.js
new file mode 100644
index 00000000..3aa422ce
--- /dev/null
+++ b/client/src/utils/storage.js
@@ -0,0 +1,85 @@
+function getStorageSafe(storageName) {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const storage = window[storageName];
+ const testKey = "__storage_test__";
+ storage.setItem(testKey, testKey);
+ storage.removeItem(testKey);
+ return storage;
+ } catch {
+ return null;
+ }
+}
+
+export function canUseStorage() {
+ return Boolean(getStorageSafe("localStorage"));
+}
+
+export function readStoredList(storageKey) {
+ const storage = getStorageSafe("localStorage");
+
+ if (!storage) {
+ return [];
+ }
+
+ try {
+ const rawValue = storage.getItem(storageKey);
+
+ if (!rawValue) {
+ return [];
+ }
+
+ const parsedValue = JSON.parse(rawValue);
+ return Array.isArray(parsedValue) ? parsedValue : [];
+ } catch (error) {
+ console.error("Не удалось прочитать localStorage", error);
+ return [];
+ }
+}
+
+export function readStoredValue(storageKey, fallbackValue = "") {
+ const storage = getStorageSafe("localStorage");
+
+ if (!storage) {
+ return fallbackValue;
+ }
+
+ try {
+ const rawValue = storage.getItem(storageKey);
+ return rawValue ?? fallbackValue;
+ } catch (error) {
+ console.error("Не удалось прочитать localStorage", error);
+ return fallbackValue;
+ }
+}
+
+export function writeStoredList(storageKey, items) {
+ const storage = getStorageSafe("localStorage");
+
+ if (!storage) {
+ return;
+ }
+
+ try {
+ storage.setItem(storageKey, JSON.stringify(items));
+ } catch (error) {
+ console.error("Не удалось записать localStorage", error);
+ }
+}
+
+export function writeStoredValue(storageKey, value) {
+ const storage = getStorageSafe("localStorage");
+
+ if (!storage) {
+ return;
+ }
+
+ try {
+ storage.setItem(storageKey, String(value));
+ } catch (error) {
+ console.error("Не удалось записать localStorage", error);
+ }
+}
diff --git a/client/vite.config.js b/client/vite.config.js
new file mode 100644
index 00000000..9780efaa
--- /dev/null
+++ b/client/vite.config.js
@@ -0,0 +1,9 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ },
+});