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 => (
+
+ ))}
+
+ )
+}