feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management

- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates.
- Added vehicle icons, status labels, and filtering options for routes.
- Created ScheduleCenterPage to display schedule versions, conflicts, and gaps.
- Introduced responsive design for both pages with dedicated CSS styles.
- Integrated API calls for fetching routes and vehicle data.
This commit is contained in:
2026-09-18 02:58:20 +03:00
parent 4552062b0a
commit 863ac603bc
16 changed files with 1070 additions and 37 deletions

View File

@@ -65,6 +65,38 @@ class CitiesController {
}
}
async updateCity(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор города.' });
}
const { name, slug, emblem, center_lat, center_lon } = req.body;
const validationError = validateCity({ name, slug, emblem, center_lat, center_lon });
if (validationError) return res.status(400).json({ error: validationError });
try {
const result = await pool.query(`
UPDATE cities
SET name = $1, slug = $2, emblem = $3, center_lat = $4, center_lon = $5
WHERE id = $6
RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at
`, [
String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(),
center_lat === '' || center_lat === undefined ? null : Number(center_lat),
center_lon === '' || center_lon === undefined ? null : Number(center_lon),
cityId
]);
if (!result.rows.length) return res.status(404).json({ error: 'Город не найден.' });
res.json(result.rows[0]);
} catch (error) {
if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' });
logger.error('Error updating city', { cityId, message: error.message });
res.status(500).json({ error: 'Не удалось сохранить изменения города.' });
}
}
async uploadCityAvatar(req, res) {
const cityId = Number(req.params.cityId);
if (!Number.isInteger(cityId) || cityId <= 0) {

View File

@@ -0,0 +1,48 @@
const logger = require('../config/logger');
const { buildScheduleCenter, formatMinskDate, parseDate } = require('../services/scheduleCenterReport');
const SCHEDULE_CENTER_QUERY = `
SELECT
r.id AS route_id,
r.route_number,
r.name AS route_name,
r.color,
s.id AS schedule_id,
s.day_type,
s.valid_from,
s.valid_until,
GREATEST(
COALESCE(cardinality(s.departure_times), 0),
CASE WHEN jsonb_typeof(s.trips) = 'array' THEN jsonb_array_length(s.trips) ELSE 0 END
)::integer AS departure_count
FROM routes r
LEFT JOIN schedules s ON s.route_id = r.id
WHERE r.city_id = $1 AND r.is_active = true
ORDER BY r.route_number, r.name, s.day_type, s.valid_from NULLS FIRST, s.id
`;
class ScheduleCenterController {
constructor({ pool, report = buildScheduleCenter } = {}) {
this.pool = pool || require('../config/database');
this.report = report;
}
async getOverview(req, res) {
const targetDate = req.query.date || formatMinskDate();
if (!parseDate(targetDate)) {
return res.status(400).json({ error: 'Дата должна быть в формате ГГГГ-ММ-ДД.' });
}
try {
const { rows } = await this.pool.query(SCHEDULE_CENTER_QUERY, [req.city.id]);
return res.json(this.report(rows, targetDate));
} catch (error) {
logger.error('Error building schedule center report', { message: error.message, cityId: req.city?.id });
return res.status(500).json({ error: 'Не удалось загрузить центр расписаний. Повторите попытку.' });
}
}
}
module.exports = new ScheduleCenterController();
module.exports.ScheduleCenterController = ScheduleCenterController;
module.exports.SCHEDULE_CENTER_QUERY = SCHEDULE_CENTER_QUERY;

View File

@@ -15,6 +15,7 @@ const { createPassengerTelemetryRateLimiter } = require('../middleware/passenger
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { resolveCity } = require('../middleware/city');
const qualityController = require('../controllers/qualityController');
const scheduleCenterController = require('../controllers/scheduleCenterController');
// City catalogue is global. Every transport-data endpoint below receives a
// verified city context from X-City-Id (or falls back to the legacy Polotsk city).
@@ -23,6 +24,7 @@ router.use(resolveCity);
router.use('/schedule-import', require('./scheduleImport'));
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController));
// Routes
router.get('/routes', routesController.getAllRoutes.bind(routesController));

View File

@@ -7,6 +7,7 @@ const router = express.Router();
router.get('/', citiesController.getAllCities.bind(citiesController));
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController));
router.put('/:cityId', authenticateToken, requireAdmin, citiesController.updateCity.bind(citiesController));
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
module.exports = router;

View File

@@ -0,0 +1,194 @@
const DAY_TYPES = [
{ value: 'weekday', label: 'Будние дни', shortLabel: 'Пн–Пт' },
{ value: 'saturday', label: 'Суббота', shortLabel: 'Сб' },
{ value: 'sunday', label: 'Воскресенье', shortLabel: 'Вс' },
];
const DAY_TYPE_BY_VALUE = new Map(DAY_TYPES.map((dayType) => [dayType.value, dayType]));
function toDateKey(value) {
if (!value) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
return null;
}
function parseDate(date) {
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
const parsed = new Date(`${date}T00:00:00.000Z`);
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date ? null : parsed;
}
function addDays(date, amount) {
const parsed = parseDate(date);
parsed.setUTCDate(parsed.getUTCDate() + amount);
return parsed.toISOString().slice(0, 10);
}
function formatMinskDate(date = new Date()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(date);
}
function versionState(version, targetDate) {
if (version.valid_from && version.valid_until && version.valid_until < version.valid_from) return 'invalid';
if ((!version.valid_from || version.valid_from <= targetDate)
&& (!version.valid_until || version.valid_until >= targetDate)) return 'active';
if (version.valid_from && version.valid_from > targetDate) return 'upcoming';
return 'past';
}
function overlaps(left, right) {
return (!left.valid_until || !right.valid_from || left.valid_until >= right.valid_from)
&& (!right.valid_until || !left.valid_from || right.valid_until >= left.valid_from);
}
function buildCoverageGaps(versions, targetDate, windowEnd) {
const periods = versions
.filter((version) => version.state !== 'invalid')
.filter((version) => !version.valid_until || version.valid_until >= targetDate)
.filter((version) => !version.valid_from || version.valid_from <= windowEnd)
.map((version) => ({
start: version.valid_from && version.valid_from > targetDate ? version.valid_from : targetDate,
end: version.valid_until && version.valid_until < windowEnd ? version.valid_until : windowEnd,
}))
.sort((left, right) => left.start.localeCompare(right.start));
const gaps = [];
let cursor = targetDate;
for (const period of periods) {
if (period.start > cursor) {
gaps.push({ starts_on: cursor, ends_on: addDays(period.start, -1) });
}
if (period.end >= cursor) cursor = addDays(period.end, 1);
if (cursor > windowEnd) break;
}
if (cursor <= windowEnd) gaps.push({ starts_on: cursor, ends_on: windowEnd });
return gaps;
}
function makeIssue({ code, severity, route, dayType, description, versionIds = [], range = null }) {
const idParts = [code, route.route_id, dayType?.value || 'all', ...versionIds, range?.starts_on || ''];
return {
id: idParts.join(':'),
code,
severity,
title: code === 'schedule_overlap' ? 'Пересекаются версии'
: code === 'invalid_period' ? 'Некорректный период версии'
: 'Нет действующей версии',
description,
route_id: route.route_id,
route_label: `№${route.route_number} · ${route.route_name}`,
day_type: dayType?.value || null,
day_type_label: dayType?.label || null,
version_ids: versionIds,
range,
};
}
function createRoute(row) {
return {
route_id: row.route_id,
route_number: row.route_number,
route_name: row.route_name,
color: row.color || '#525252',
day_types: DAY_TYPES.map((dayType) => ({
...dayType,
versions: [],
gaps: [],
status: 'gap',
})),
};
}
function buildScheduleCenter(rows, targetDate = formatMinskDate(), { windowDays = 90 } = {}) {
if (!parseDate(targetDate)) throw new Error('Invalid target date');
const windowEnd = addDays(targetDate, windowDays);
const routesById = new Map();
for (const row of rows) {
if (!routesById.has(row.route_id)) routesById.set(row.route_id, createRoute(row));
if (!row.schedule_id || !DAY_TYPE_BY_VALUE.has(row.day_type)) continue;
const route = routesById.get(row.route_id);
const dayType = route.day_types.find((item) => item.value === row.day_type);
const version = {
id: row.schedule_id,
valid_from: toDateKey(row.valid_from),
valid_until: toDateKey(row.valid_until),
departure_count: Number(row.departure_count || 0),
state: 'past',
has_conflict: false,
};
version.state = versionState(version, targetDate);
dayType.versions.push(version);
}
const routes = [...routesById.values()]
.sort((left, right) => String(left.route_number).localeCompare(String(right.route_number), 'ru', { numeric: true })
|| left.route_name.localeCompare(right.route_name, 'ru'));
const issues = [];
let active = 0;
let upcoming = 0;
let conflicts = 0;
let gaps = 0;
for (const route of routes) {
for (const dayType of route.day_types) {
dayType.versions.sort((left, right) => (left.valid_from || '').localeCompare(right.valid_from || '') || left.id - right.id);
for (const version of dayType.versions) {
if (version.state === 'active') active += 1;
if (version.state === 'upcoming') upcoming += 1;
if (version.state !== 'invalid') continue;
issues.push(makeIssue({
code: 'invalid_period', severity: 'critical', route, dayType, versionIds: [version.id],
description: `Версия №${version.id}: дата окончания раньше даты начала.`,
}));
}
for (let index = 0; index < dayType.versions.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < dayType.versions.length; otherIndex += 1) {
const left = dayType.versions[index];
const right = dayType.versions[otherIndex];
if (left.state === 'invalid' || right.state === 'invalid' || !overlaps(left, right)) continue;
left.has_conflict = true;
right.has_conflict = true;
conflicts += 1;
issues.push(makeIssue({
code: 'schedule_overlap', severity: 'critical', route, dayType, versionIds: [left.id, right.id],
description: `Версии №${left.id} и №${right.id} действуют одновременно.`,
}));
}
}
dayType.gaps = buildCoverageGaps(dayType.versions, targetDate, windowEnd);
if (dayType.versions.some((version) => version.state === 'active')) dayType.status = 'active';
else if (dayType.versions.some((version) => version.state === 'upcoming')) dayType.status = 'upcoming';
for (const gap of dayType.gaps) {
gaps += 1;
issues.push(makeIssue({
code: 'schedule_gap', severity: 'warning', route, dayType, range: gap,
description: gap.starts_on === gap.ends_on
? `На ${gap.starts_on} нет версии расписания.`
: `С ${gap.starts_on} по ${gap.ends_on} нет версии расписания.`,
}));
}
}
}
return {
target_date: targetDate,
window_end: windowEnd,
summary: { routes: routes.length, active, upcoming, conflicts, gaps },
routes,
issues: issues.sort((left, right) => left.severity.localeCompare(right.severity)
|| left.route_label.localeCompare(right.route_label, 'ru') || left.id.localeCompare(right.id)),
};
}
module.exports = { DAY_TYPES, addDays, buildScheduleCenter, formatMinskDate, parseDate };

View File

@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildScheduleCenter, parseDate } = require('../src/services/scheduleCenterReport');
test('schedule center separates active and future versions and detects gaps and overlaps', () => {
const report = buildScheduleCenter([
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 1, day_type: 'weekday', valid_from: null, valid_until: null, departure_count: 12 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 2, day_type: 'saturday', valid_from: '2026-10-01', valid_until: null, departure_count: 6 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 3, day_type: 'sunday', valid_from: null, valid_until: '2026-09-30', departure_count: 5 },
{ route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 4, day_type: 'sunday', valid_from: '2026-09-25', valid_until: null, departure_count: 5 },
], '2026-09-18');
assert.deepEqual(report.summary, { routes: 1, active: 2, upcoming: 2, conflicts: 1, gaps: 1 });
assert.equal(report.window_end, '2026-12-17');
const saturday = report.routes[0].day_types.find((dayType) => dayType.value === 'saturday');
assert.equal(saturday.status, 'upcoming');
assert.deepEqual(saturday.gaps, [{ starts_on: '2026-09-18', ends_on: '2026-09-30' }]);
assert.equal(report.issues.find((issue) => issue.code === 'schedule_overlap').route_id, 7);
assert.deepEqual(report.issues.find((issue) => issue.code === 'schedule_overlap').version_ids, [3, 4]);
});
test('schedule center reports uncovered day types for routes without versions', () => {
const report = buildScheduleCenter([
{ route_id: 3, route_number: '2', route_name: 'Рынок — Больница', color: '#dc2626', schedule_id: null },
], '2026-09-18', { windowDays: 7 });
assert.equal(report.summary.gaps, 3);
assert.equal(report.issues.filter((issue) => issue.code === 'schedule_gap').length, 3);
assert.equal(report.routes[0].day_types.every((dayType) => dayType.status === 'gap'), true);
});
test('date validation accepts calendar dates only', () => {
assert.ok(parseDate('2026-02-28'));
assert.equal(parseDate('2026-02-29'), null);
assert.equal(parseDate('18.09.2026'), null);
});