From 0523482aa18a898c748b09cacdcf668afafd1dcc Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 23 Apr 2026 08:05:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(home):=20tram=20arrival=20widget=20for=20?= =?UTF-8?q?=D0=A3=D0=BB.=20=D0=90=D0=BD=D1=82=D0=BE=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0-=D0=9E=D0=B2=D1=81=D0=B5=D0=B5=D0=BD=D0=BA=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a live transit widget on the home screen showing upcoming trams at both directions of the stop: toward Новочеркасская (stopID 16226) and toward пр. Большевиков (stopID 16354). - /api/transport proxies the СПб ORGP endpoint /stop/{id}/arriving (DataTables POST format, JSON response with route number + minutes). No auth required, free. - TransportWidget renders two glassmorphism cards with route badges, minutes-to-arrival, wheelchair indicator; imminent (<=2 min) arrivals get a colored highlight. Filters to trams 23/27/39; refreshes every 30s. - Route colors: 23 blue, 27 amber, 39 purple. --- app/api/transport/route.ts | 67 ++++++++++ app/page.tsx | 4 + components/TransportWidget.tsx | 227 +++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 app/api/transport/route.ts create mode 100644 components/TransportWidget.tsx diff --git a/app/api/transport/route.ts b/app/api/transport/route.ts new file mode 100644 index 0000000..daf708d --- /dev/null +++ b/app/api/transport/route.ts @@ -0,0 +1,67 @@ +export const dynamic = 'force-dynamic' +import { NextResponse } from 'next/server' + +const ORGP_BASE = 'https://transport.orgp.spb.ru' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const stopId = searchParams.get('stopId') + if (!stopId || !/^\d+$/.test(stopId)) { + return NextResponse.json({ error: 'stopId required (digits)' }, { status: 400 }) + } + + const body = new URLSearchParams({ + sEcho: '1', + iColumns: '5', + sColumns: 'index,routeNumber,timeToArrive,parkNumber,wheelchair', + iDisplayStart: '0', + iDisplayLength: '-1', + sNames: 'index,routeNumber,timeToArrive,parkNumber,wheelchair', + }) + + try { + const upstream = await fetch( + `${ORGP_BASE}/Portal/transport/stop/${encodeURIComponent(stopId)}/arriving`, + { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + }, + body: body.toString(), + cache: 'no-store', + // @ts-ignore next-internal option for timeout + next: { revalidate: 0 }, + } + ) + + if (!upstream.ok) { + return NextResponse.json( + { error: `upstream_${upstream.status}`, arrivals: [] }, + { status: 502 } + ) + } + + const data = await upstream.json() + const arrivals = Array.isArray(data?.aaData) + ? data.aaData.map((row: any[]) => ({ + route: String(row[1] ?? ''), + minutes: Number(row[2] ?? 0), + park: String(row[3] ?? ''), + wheelchair: Boolean(row[4]), + })) + : [] + + return NextResponse.json({ + stopId, + arrivals, + fetchedAt: new Date().toISOString(), + }) + } catch (e: any) { + return NextResponse.json( + { error: e?.message || 'fetch_failed', arrivals: [] }, + { status: 502 } + ) + } +} diff --git a/app/page.tsx b/app/page.tsx index 804b661..384390e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,6 +9,7 @@ import RoomTabs from '@/components/RoomTabs' import DeviceCard from '@/components/DeviceCard' import CalendarTab from '@/components/CalendarTab' import NotesTab from '@/components/NotesTab' +import TransportWidget from '@/components/TransportWidget' import WeatherAnimation from '@/components/WeatherAnimation' type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings' @@ -502,6 +503,9 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S )} + {/* Transport: tram arrivals at Ул. Антонова-Овсеенко, both directions */} + + {/* Two columns: Events + Notes */}
diff --git a/components/TransportWidget.tsx b/components/TransportWidget.tsx new file mode 100644 index 0000000..1afa232 --- /dev/null +++ b/components/TransportWidget.tsx @@ -0,0 +1,227 @@ +'use client' +import { useEffect, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { ArrowRight, ArrowLeft, Accessibility, Train } from 'lucide-react' + +interface Arrival { + route: string + minutes: number + park: string + wheelchair: boolean +} + +interface Direction { + stopId: string + title: string + subtitle: string + icon: 'right' | 'left' + accent: string + glow: string +} + +const DIRECTIONS: Direction[] = [ + { + stopId: '16226', + title: 'В центр', + subtitle: 'к м. Новочеркасская', + icon: 'right', + accent: 'linear-gradient(135deg, rgba(16,185,129,0.14), rgba(6,182,212,0.06))', + glow: 'rgba(16,185,129,0.35)', + }, + { + stopId: '16354', + title: 'Из центра', + subtitle: 'к пр. Большевиков', + icon: 'left', + accent: 'linear-gradient(135deg, rgba(236,72,153,0.14), rgba(245,158,11,0.06))', + glow: 'rgba(236,72,153,0.35)', + }, +] + +const ALLOWED_ROUTES = new Set(['23', '27', '39']) + +const ROUTE_STYLE: Record = { + '23': { color: '#60a5fa', bg: 'linear-gradient(135deg, #3b82f6, #2563eb)' }, + '27': { color: '#fbbf24', bg: 'linear-gradient(135deg, #f59e0b, #d97706)' }, + '39': { color: '#c084fc', bg: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, +} + +function formatMinutes(m: number): { big: string; unit: string } { + if (m <= 0) return { big: 'сейчас', unit: '' } + if (m === 1) return { big: '1', unit: 'мин' } + return { big: String(m), unit: 'мин' } +} + +function DirectionCard({ dir, arrivals, loading }: { dir: Direction; arrivals: Arrival[]; loading: boolean }) { + const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3) + const ArrowIcon = dir.icon === 'right' ? ArrowRight : ArrowLeft + + return ( +
+ {/* Glow */} +
+ + {/* Header */} +
+
+ +
+
+
+ {dir.title} +
+
+ {dir.subtitle} +
+
+
+ + {/* Arrivals */} +
+ {loading && arrivals.length === 0 ? ( +
+ Загрузка... +
+ ) : sorted.length === 0 ? ( +
+ +
Пока нет данных
+
+ ) : ( + + {sorted.map((arr, idx) => { + const style = ROUTE_STYLE[arr.route] || { color: '#9ca3af', bg: 'linear-gradient(135deg, #6b7280, #4b5563)' } + const time = formatMinutes(arr.minutes) + const isImminent = arr.minutes <= 2 + return ( + + {/* Route badge */} +
+ {arr.route} +
+ + {/* Label */} +
+
+ Трамвай +
+
+ №{arr.park} +
+
+ + {/* Time */} +
+
+ {time.big} +
+ {time.unit && ( +
+ {time.unit} +
+ )} +
+ + {/* Wheelchair */} + {arr.wheelchair && ( + + )} +
+ ) + })} +
+ )} +
+
+ ) +} + +export default function TransportWidget() { + const [data, setData] = useState>({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + const load = async () => { + try { + const results = await Promise.all( + DIRECTIONS.map(d => + fetch(`/api/transport?stopId=${d.stopId}`) + .then(r => r.json()) + .then(j => ({ stopId: d.stopId, arrivals: (j.arrivals as Arrival[]) || [] })) + .catch(() => ({ stopId: d.stopId, arrivals: [] as Arrival[] })) + ) + ) + if (cancelled) return + const map: Record = {} + for (const r of results) { + map[r.stopId] = r.arrivals.filter(a => ALLOWED_ROUTES.has(a.route)) + } + setData(map) + } finally { + if (!cancelled) setLoading(false) + } + } + load() + const t = setInterval(load, 30_000) + return () => { cancelled = true; clearInterval(t) } + }, []) + + return ( +
+ {DIRECTIONS.map(d => ( + + ))} +
+ ) +}