feat(voice): hero TimerHomeWidget + timer cancel/adjust by label
All checks were successful
Deploy / deploy (push) Successful in 3m25s

UI:
- Replace Notes column on Home bento with TimerHomeWidget. Shows all
  active timers as stacked cards with big 30px countdowns, per-timer
  +1/-1 minute buttons and cancel. Colors: indigo default, amber in
  last 10s, red when expired. Empty state suggests voice command.
- Existing chip TimerWidget (bottom-right) kept for ambient view on
  other tabs — redundant on Home, but harmless.

API:
- /api/voice/timer accepts cookie OR bearer (browser widget cancel
  works with user's auth_token cookie; Python script uses bearer).
- New action 'adjust' — shifts endsAt by delta_seconds. Clamps so
  endsAt never goes into the past.
- Cancel now supports {label} in addition to {id} (fuzzy substring
  match, most-recently-started wins). Emits timer_cancel with id+label
  so clients can refresh.
- findByLabel / adjustTimer helpers in lib/timers.ts.
This commit is contained in:
Cosmo
2026-04-23 13:51:25 +00:00
parent 7fb05181e6
commit 0c677df558
4 changed files with 378 additions and 95 deletions

View File

@@ -13,6 +13,7 @@ import TransportWidget from '@/components/TransportWidget'
import WeatherAnimation from '@/components/WeatherAnimation'
import VoiceOverlay from '@/components/VoiceOverlay'
import TimerWidget from '@/components/TimerWidget'
import TimerHomeWidget from '@/components/TimerHomeWidget'
type Tab = 'home' | 'devices' | 'calendar' | 'notes' | 'settings'
@@ -623,7 +624,7 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div>
)}
{/* ───── Events + Notes row ───── */}
{/* ───── Events + Timers row ───── */}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 14 }}>
{/* Events — today + tomorrow in one card */}
@@ -705,64 +706,8 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div>
</div>
{/* Notes */}
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StickyNote size={13} color="var(--text-secondary)" />
<span style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Заметки</span>
</div>
{pinnedNotes.length === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', color: 'var(--text-tertiary)' }}>
<div>
<StickyNote size={22} style={{ margin: '0 auto 6px', opacity: 0.4 }} />
<div style={{ fontSize: 12 }}>Заметки появятся здесь</div>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{pinnedNotes.map(note => {
const doneCount = note.items?.filter((i: any) => i.done).length || 0
const totalCount = note.items?.length || 0
return (
<div key={note.id} style={{
padding: '10px 12px', borderRadius: 12,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
borderLeft: `3px solid ${note.color}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
{note.type === 'shopping' ? <ShoppingCart size={12} color={note.color} /> : <FileText size={12} color={note.color} />}
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
{note.type === 'shopping' && totalCount > 0 && (
<span style={{ fontSize: 11, color: note.color, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{doneCount}/{totalCount}</span>
)}
</div>
{note.type === 'shopping' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{(note.items || []).filter((i: any) => !i.done).slice(0, 4).map((item: any) => (
<div key={item.id} style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 5, height: 5, borderRadius: 2, background: note.color, opacity: 0.6, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.text}</span>
</div>
))}
{(note.items || []).filter((i: any) => !i.done).length > 4 && (
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginLeft: 11 }}>
+{(note.items || []).filter((i: any) => !i.done).length - 4} ещё
</div>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical' as any }}>
{note.text || 'Пустая заметка'}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Timers (replaces Notes on Home) */}
<TimerHomeWidget />
</div>
{/* Weather day detail modal */}