Files
smart-home-tablet/components/FocusCard.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

427 lines
14 KiB
TypeScript
Raw 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, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Umbrella, Wind, ThermometerSun, TramFront, Calendar as CalendarIcon,
Receipt, Sparkles, Moon, Sun,
} from 'lucide-react'
// ——————————————————————————————
// Types
// ——————————————————————————————
export interface FocusWeather {
temp: string
desc: string
feelsLike?: string
}
export interface FocusTram {
route: string
minutes: number
direction: string
}
export interface FocusEvent {
id: string
title: string
start: string
allDay?: boolean
ownerName?: string
color?: string
}
export interface FocusCountdown {
label: string
date: string // ISO YYYY-MM-DD
}
export interface FocusBill {
title: string
amount: string
daysLeft: number
}
interface Props {
weather: FocusWeather | null
tramNext?: FocusTram | null
nextEvent?: FocusEvent | null
countdowns?: FocusCountdown[]
bills?: FocusBill[]
}
// ——————————————————————————————
// Focus state machine
// ——————————————————————————————
type FocusKind =
| { kind: 'morning-outfit'; tempNow: string; feels: string; advice: string; Icon: any; accent: string }
| { kind: 'tram-imminent'; route: string; minutes: number; direction: string }
| { kind: 'event-upcoming'; title: string; inMinutes: number; owner?: string; color?: string }
| { kind: 'countdown'; label: string; days: number }
| { kind: 'bill-due'; title: string; amount: string; daysLeft: number }
| { kind: 'night'; hour: number }
| { kind: 'quiet'; greeting: string }
function pickFocus(p: Props, hour: number): FocusKind {
// 1. Bill due today / tomorrow — всегда приоритет
const bill = p.bills?.find(b => b.daysLeft <= 1)
if (bill) return { kind: 'bill-due', ...bill }
// 2. Ближайшее событие ≤30 минут
if (p.nextEvent && !p.nextEvent.allDay) {
const start = new Date(p.nextEvent.start).getTime()
const inMin = Math.round((start - Date.now()) / 60_000)
if (inMin >= -5 && inMin <= 30) {
return {
kind: 'event-upcoming',
title: p.nextEvent.title,
inMinutes: inMin,
owner: p.nextEvent.ownerName,
color: p.nextEvent.color,
}
}
}
// 3. Трамвай в рабочий час, ≤3 мин
const rushHour = (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 20)
if (rushHour && p.tramNext && p.tramNext.minutes >= 0 && p.tramNext.minutes <= 3) {
return { kind: 'tram-imminent', ...p.tramNext }
}
// 4. Утро (7-10) → одевалка
if (hour >= 7 && hour < 11 && p.weather) {
const t = parseInt(p.weather.temp, 10)
const descLower = p.weather.desc?.toLowerCase() || ''
const rain = /дожд|ливен|грозa|морос/.test(descLower)
const snow = /снег|метел/.test(descLower)
const advice =
rain ? 'возьми зонт' :
snow ? 'шапка и зимняя обувь' :
t <= -10 ? 'пуховик, шапка, перчатки' :
t < 0 ? 'шапка и перчатки' :
t < 7 ? 'тёплая куртка' :
t < 15 ? 'лёгкая куртка' :
t < 22 ? 'свитер или рубашка' : 'футболка'
const Icon = rain ? Umbrella : snow ? Wind : t < 0 ? Wind : ThermometerSun
const accent =
rain ? 'var(--data-cool)' :
snow ? 'var(--data-info)' :
t < 0 ? 'var(--data-info)' :
t >= 22 ? 'var(--data-warm)' : 'var(--data-warm)'
return {
kind: 'morning-outfit',
tempNow: p.weather.temp,
feels: p.weather.feelsLike || '',
advice, Icon, accent,
}
}
// 5. Ближайший countdown (≤14 дней)
const cd = (p.countdowns || [])
.map(c => {
const target = new Date(c.date + 'T00:00:00').getTime()
const days = Math.ceil((target - Date.now()) / 86_400_000)
return { label: c.label, days }
})
.filter(c => c.days >= 0 && c.days <= 14)
.sort((a, b) => a.days - b.days)[0]
if (cd) return { kind: 'countdown', ...cd }
// 6. Ночь
if (hour >= 22 || hour < 5) return { kind: 'night', hour }
// 7. Тихо — приветствие
const greeting =
hour >= 5 && hour < 12 ? 'Доброе утро' :
hour >= 12 && hour < 17 ? 'Добрый день' :
hour >= 17 && hour < 22 ? 'Добрый вечер' : 'Доброй ночи'
return { kind: 'quiet', greeting }
}
// ——————————————————————————————
// Presentations
// ——————————————————————————————
function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontSize: 11, fontWeight: 700, letterSpacing: '0.14em',
textTransform: 'uppercase', color: 'var(--text-tertiary)',
}}>
{children}
</div>
)
}
function FocusShell({
eyebrow,
accent,
children,
}: {
eyebrow: React.ReactNode
accent: string
children: React.ReactNode
}) {
return (
<div
className="focus-card grain"
style={{
padding: '26px 28px',
display: 'flex', flexDirection: 'column',
minHeight: 220,
background: `linear-gradient(180deg, color-mix(in srgb, ${accent} 10%, var(--surface-1)), var(--surface-1))`,
border: `1px solid color-mix(in srgb, ${accent} 22%, var(--border-subtle))`,
}}
>
{/* Top eyebrow */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
{eyebrow}
</div>
{children}
</div>
)
}
// ——————————————————————————————
// Individual states
// ——————————————————————————————
function MorningOutfit(f: Extract<FocusKind, { kind: 'morning-outfit' }>) {
return (
<FocusShell
accent={f.accent}
eyebrow={<>
<f.Icon size={16} color={f.accent} strokeWidth={2} />
<Eyebrow>Собираясь на улицу</Eyebrow>
</>}
>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 14, marginBottom: 16 }}>
<div className="num-display" style={{ fontSize: 96, color: 'var(--text-primary)' }}>
{f.tempNow}°
</div>
{f.feels && (
<div style={{ fontSize: 15, color: 'var(--text-secondary)' }}>
ощущается <span className="num" style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{f.feels}°</span>
</div>
)}
</div>
<div style={{
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
letterSpacing: '-0.3px', lineHeight: 1.3,
}}>
{f.advice}
</div>
</FocusShell>
)
}
function TramImminent(f: Extract<FocusKind, { kind: 'tram-imminent' }>) {
const accent = 'var(--data-hot)'
return (
<FocusShell
accent={accent}
eyebrow={<>
<TramFront size={16} color={accent} strokeWidth={2} />
<Eyebrow>Трамвай подходит</Eyebrow>
</>}
>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 14 }}>
<div className="num-display" style={{ fontSize: 96, color: accent }}>
{f.minutes <= 0 ? 'сейчас' : f.minutes}
</div>
{f.minutes > 0 && (
<div style={{ fontSize: 24, fontWeight: 600, color: 'var(--text-secondary)' }}>мин</div>
)}
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
Маршрут {f.route} · {f.direction}
</div>
</FocusShell>
)
}
function EventUpcoming(f: Extract<FocusKind, { kind: 'event-upcoming' }>) {
const accent = f.color || 'var(--accent)'
const timeWord = f.inMinutes < 0 ? 'сейчас' : f.inMinutes === 0 ? 'сейчас' : `через ${f.inMinutes}м`
return (
<FocusShell
accent={accent}
eyebrow={<>
<CalendarIcon size={16} color={accent} strokeWidth={2} />
<Eyebrow>{timeWord}</Eyebrow>
</>}
>
<div style={{
fontSize: 32, fontWeight: 800, color: 'var(--text-primary)',
letterSpacing: '-0.8px', lineHeight: 1.15, marginBottom: 14,
}}>
{f.title}
</div>
{f.owner && (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px', borderRadius: 14,
background: `color-mix(in srgb, ${accent} 16%, var(--surface-2))`,
border: `1px solid color-mix(in srgb, ${accent} 28%, var(--border-subtle))`,
color: accent, fontSize: 13, fontWeight: 700, alignSelf: 'flex-start',
}}>
{f.owner}
</div>
)}
</FocusShell>
)
}
function CountdownView(f: Extract<FocusKind, { kind: 'countdown' }>) {
const accent = f.days <= 3 ? 'var(--data-hot)' : f.days <= 7 ? 'var(--data-warm)' : 'var(--data-violet)'
const word =
f.days === 0 ? 'сегодня' :
f.days === 1 ? 'завтра' :
f.days < 5 ? `${f.days} дня` : `${f.days} дней`
return (
<FocusShell
accent={accent}
eyebrow={<>
<Sparkles size={16} color={accent} strokeWidth={2} />
<Eyebrow>До события</Eyebrow>
</>}
>
<div className="num-display" style={{
fontSize: f.days === 0 ? 56 : 96,
color: 'var(--text-primary)', marginBottom: 12,
}}>
{f.days === 0 ? 'сегодня' : f.days}
{f.days > 0 && <span style={{ fontSize: 24, fontWeight: 600, color: 'var(--text-secondary)', marginLeft: 10, letterSpacing: 0 }}>
{f.days < 5 ? 'дня' : 'дней'}
</span>}
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>
{f.label}
</div>
</FocusShell>
)
}
function BillDue(f: Extract<FocusKind, { kind: 'bill-due' }>) {
const accent = 'var(--data-danger)'
const word = f.daysLeft === 0 ? 'сегодня' : f.daysLeft === 1 ? 'завтра' : `через ${f.daysLeft}д`
return (
<FocusShell
accent={accent}
eyebrow={<>
<Receipt size={16} color={accent} strokeWidth={2} />
<Eyebrow>К оплате {word}</Eyebrow>
</>}
>
<div className="num-display" style={{
fontSize: 72, color: accent, marginBottom: 8, letterSpacing: '-0.03em',
}}>
{f.amount}
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>
{f.title}
</div>
</FocusShell>
)
}
function NightView() {
const accent = 'var(--data-violet)'
return (
<FocusShell
accent={accent}
eyebrow={<>
<Moon size={16} color={accent} strokeWidth={2} />
<Eyebrow>Тихое время</Eyebrow>
</>}
>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, gap: 8 }}>
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.5px' }}>
Спокойной ночи
</div>
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
Дом в режиме ожидания. Касание разблокировать.
</div>
</div>
</FocusShell>
)
}
function QuietView({ greeting, weather }: { greeting: string; weather: FocusWeather | null }) {
const accent = 'var(--accent)'
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 30_000)
return () => clearInterval(t)
}, [])
return (
<FocusShell
accent={accent}
eyebrow={<>
<Sun size={16} color={accent} strokeWidth={2} />
<Eyebrow>{now.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}</Eyebrow>
</>}
>
<div style={{ fontSize: 32, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.5px', marginBottom: 12 }}>
{greeting}
</div>
{weather && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
<div className="num-display" style={{ fontSize: 56, color: 'var(--text-primary)' }}>
{weather.temp}°
</div>
<div style={{ fontSize: 15, color: 'var(--text-secondary)', fontWeight: 500 }}>
{weather.desc}
</div>
</div>
)}
</FocusShell>
)
}
// ——————————————————————————————
// Root
// ——————————————————————————————
export default function FocusCard(props: Props) {
const [hour, setHour] = useState(() => new Date().getHours())
useEffect(() => {
const t = setInterval(() => setHour(new Date().getHours()), 60_000)
return () => clearInterval(t)
}, [])
const focus = useMemo(() => pickFocus(props, hour), [props, hour])
const key = focus.kind + (
focus.kind === 'morning-outfit' ? focus.advice :
focus.kind === 'tram-imminent' ? `${focus.route}-${focus.minutes}` :
focus.kind === 'event-upcoming' ? focus.title :
focus.kind === 'countdown' ? focus.label :
focus.kind === 'bill-due' ? focus.title :
focus.kind === 'quiet' ? focus.greeting : ''
)
return (
<AnimatePresence mode="wait">
<motion.div
key={key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
{focus.kind === 'morning-outfit' && <MorningOutfit {...focus} />}
{focus.kind === 'tram-imminent' && <TramImminent {...focus} />}
{focus.kind === 'event-upcoming' && <EventUpcoming {...focus} />}
{focus.kind === 'countdown' && <CountdownView {...focus} />}
{focus.kind === 'bill-due' && <BillDue {...focus} />}
{focus.kind === 'night' && <NightView />}
{focus.kind === 'quiet' && <QuietView greeting={focus.greeting} weather={props.weather} />}
</motion.div>
</AnimatePresence>
)
}