Files
smart-home-tablet/components/TransportWidget.tsx
Cosmo e328055851
All checks were successful
Deploy / deploy (push) Successful in 3m8s
feat(design): FocusCard hero, CountdownCard, data-* palette, swipe, touch-targets
Big design pass across Home + tokens + components.

— globals.css: new data-* palette (cool/warm/hot/good/info/rose/violet/mood)
  with theme-aware variants, .grain overlay utility, .num-display
  typography helper, .hit-zone 44px wrapper, .eyebrow label, .focus-card
  base, focus-visible outline-offset 3px, space/touch scale vars.
— FocusCard.tsx: context engine — пять состояний (morning-outfit,
  tram-imminent, event-upcoming, countdown, bill-due, night, quiet).
  Auto-rotates by hour + live data. 96px display numbers, accent-mixed
  surfaces, grain overlay.
— CountdownCard.tsx + /api/countdowns: rotating 8s list, persistent
  /data/tablet-countdowns.json, full CRUD. Default seeded with Токио.
— HomeTab: replaced plain Weather hero with FocusCard, added Row 4
  with CountdownCard. Pulls trams + countdowns for the Focus context.
— Swipe between tabs: pointer-level detection on <main>, data-swipe-ignore
  bails out inside modals + note swipe-to-delete + voice overlay.
— Touch-target sweep: TopBar HA dot → 44px hit-zone, sensor chip 44px
  min-height, forecast day buttons 92px min, DeviceCard toggle 60x36,
  CalendarTab prev/next/close/list all 44x44, NotesTab buttons 44x44,
  TimerHomeWidget + 44x44, WeatherDayModal chevrons 48x48, close 48.
— Hardcoded hex → data-* tokens: TopBar sensors, TransportWidget routes
  (via color-mix), DeviceCard full rewrite (per-kind accent, glass
  removed in favor of color-mix surfaces + proper mock-state treatment),
  NotesTab palette refreshed to match dark theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:24:23 +00:00

231 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowRight, Train } from 'lucide-react'
interface Arrival {
route: string
minutes: number
park: string
wheelchair: boolean
}
interface Direction {
stopId: string
short: string
sub: string
}
const DIRECTIONS: Direction[] = [
{ stopId: '16226', short: 'Лента', sub: 'в центр' },
{ stopId: '16354', short: 'Дыбенко', sub: 'от центра' },
]
// Маршрут-специфичные цвета — соответствуют реальной окраске маршрутов
// (но в правильной семантике: good=23, info=27, danger=39)
const ROUTES: { num: string; color: string; bg: string }[] = [
{ num: '23', color: 'var(--data-good)', bg: 'linear-gradient(135deg, var(--data-good), color-mix(in srgb, var(--data-good) 75%, black))' },
{ num: '27', color: 'var(--data-info)', bg: 'linear-gradient(135deg, var(--data-info), color-mix(in srgb, var(--data-info) 75%, black))' },
{ num: '39', color: 'var(--data-danger)', bg: 'linear-gradient(135deg, var(--data-danger), color-mix(in srgb, var(--data-danger) 75%, black))' },
]
function Cell({ arrivals, color }: { arrivals: Arrival[]; color: string }) {
const sorted = [...arrivals].sort((a, b) => a.minutes - b.minutes).slice(0, 3)
if (sorted.length === 0) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '10px 14px', borderRadius: 12,
background: 'var(--surface-2)',
border: '1px dashed var(--border-subtle)',
color: 'var(--text-tertiary)', fontSize: 13, fontWeight: 500,
minHeight: 56,
}}></div>
)
}
const [first, ...rest] = sorted
const imminent = first.minutes <= 2
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 12px', borderRadius: 12,
background: imminent ? `color-mix(in srgb, ${color} 10%, var(--surface-2))` : 'var(--surface-2)',
border: `1px solid ${imminent ? color : 'var(--border-subtle)'}`,
minHeight: 56, minWidth: 0, overflow: 'hidden',
transition: 'all 0.3s ease',
}}>
{/* Primary time — big */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 3, flexShrink: 0 }}>
<div style={{
fontSize: first.minutes <= 0 ? 16 : 28,
fontWeight: 800, letterSpacing: '-1px', lineHeight: 1,
color: imminent ? color : 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums',
}}>
{first.minutes <= 0 ? 'сейчас' : first.minutes}
</div>
{first.minutes > 0 && (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>мин</div>
)}
</div>
{rest.length > 0 && (
<>
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--hairline)', margin: '4px 0' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 9, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
затем
</div>
<div style={{
fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{rest.map(r => r.minutes <= 0 ? 'сейчас' : `${r.minutes}м`).join(' · ')}
</div>
</div>
</>
)}
</div>
)
}
export default function TransportWidget() {
const [data, setData] = useState<Record<string, Arrival[]>>({})
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<string, Arrival[]> = {}
for (const r of results) map[r.stopId] = r.arrivals
setData(map)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const t = setInterval(load, 30_000)
return () => { cancelled = true; clearInterval(t) }
}, [])
return (
<div className="card-hero" style={{
padding: '18px 20px',
display: 'flex', flexDirection: 'column', gap: 14,
minWidth: 0,
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 36, height: 36, borderRadius: 11,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-secondary)', flexShrink: 0,
}}>
<Train size={17} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{
fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 2,
}}>
Трамвай
</div>
<div style={{
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
letterSpacing: '-0.2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
Ул. Антонова-Овсеенко
</div>
</div>
</div>
{/* Column headers */}
<div style={{
display: 'grid',
gridTemplateColumns: '52px minmax(0, 1fr) minmax(0, 1fr)',
gap: 10,
paddingBottom: 6,
borderBottom: '1px solid var(--hairline)',
}}>
<div />
{DIRECTIONS.map(d => (
<div key={d.stopId} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '0 4px' }}>
<ArrowRight size={12} color="var(--text-tertiary)" style={{ flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.short}
</div>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginTop: 1, fontWeight: 500 }}>
{d.sub}
</div>
</div>
</div>
))}
</div>
{/* Rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1 }}>
{ROUTES.map(route => (
<div key={route.num} style={{
display: 'grid',
gridTemplateColumns: '52px minmax(0, 1fr) minmax(0, 1fr)',
gap: 10,
alignItems: 'stretch',
minWidth: 0,
}}>
<div style={{
background: route.bg,
boxShadow: `0 6px 16px -4px color-mix(in srgb, ${route.color} 35%, transparent)`,
borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 800, fontSize: 20,
letterSpacing: '-1px',
minHeight: 56,
}}>
{route.num}
</div>
{DIRECTIONS.map(d => {
const arrivals = (data[d.stopId] || []).filter(a => a.route === route.num)
return (
<AnimatePresence key={d.stopId} mode="wait">
<motion.div
key={`${route.num}-${d.stopId}-${arrivals.map(a => a.minutes).join(',')}`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<Cell arrivals={arrivals} color={route.color} />
</motion.div>
</AnimatePresence>
)
})}
</div>
))}
</div>
{loading && Object.keys(data).length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', textAlign: 'center', marginTop: 'auto' }}>
Загрузка расписания...
</div>
)}
</div>
)
}