Files
smart-home-tablet/components/CalendarTab.tsx
Cosmo fe2745f138
Some checks failed
Deploy to VM / deploy (push) Failing after 4s
fix: remove Today tab from calendar, default to Week view
2026-04-22 13:14:01 +00:00

285 lines
11 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 { useState, useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface CalendarEvent {
id: string
title: string
start: string
end: string
allDay: boolean
description: string | null
location: string | null
owner: string
ownerName: string
color: string
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
function formatDayHeader(dateStr: string): string {
const d = new Date(dateStr + (dateStr.length === 10 ? 'T00:00:00' : ''))
return d.toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })
}
function groupByDate(events: CalendarEvent[]): Record<string, CalendarEvent[]> {
const groups: Record<string, CalendarEvent[]> = {}
for (const ev of events) {
const dateKey = ev.start.substring(0, 10)
if (!groups[dateKey]) groups[dateKey] = []
groups[dateKey].push(ev)
}
return groups
}
function EventCard({ event }: { event: CalendarEvent }) {
return (
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '10px 14px',
borderRadius: 12,
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.06)',
marginBottom: 8,
}}>
<div style={{ width: 3, borderRadius: 2, background: event.color, alignSelf: 'stretch', minHeight: 36, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{event.title}
</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>
{event.allDay ? 'Весь день' : `${formatTime(event.start)}${formatTime(event.end)}`}
</span>
<span style={{ color: event.color, fontWeight: 500 }}>{event.ownerName}</span>
</div>
{event.location && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2, opacity: 0.7 }}>📍 {event.location}</div>
)}
</div>
</div>
)
}
function TimelineView({ range }: { range: 'today' | 'week' }) {
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`/api/calendar?range=${range}`)
.then(r => r.json())
.then(d => setEvents(d.events || []))
.catch(() => setEvents([]))
.finally(() => setLoading(false))
}, [range])
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-secondary)' }}>
Загрузка...
</div>
)
if (events.length === 0) return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, gap: 8, color: 'var(--text-secondary)' }}>
<span style={{ fontSize: 40 }}>🎉</span>
<span style={{ fontSize: 15 }}>Свободный день!</span>
</div>
)
const grouped = groupByDate(events)
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 24px' }}>
{Object.entries(grouped).map(([dateKey, dayEvents]) => (
<div key={dateKey} style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 10, textTransform: 'capitalize', letterSpacing: '0.02em' }}>
{formatDayHeader(dateKey)}
</div>
{dayEvents.map(ev => <EventCard key={ev.id} event={ev} />)}
</div>
))}
</div>
)
}
function MonthView() {
const [year, setYear] = useState(() => new Date().getFullYear())
const [month, setMonth] = useState(() => new Date().getMonth())
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [selectedDay, setSelectedDay] = useState<string | null>(null)
useEffect(() => {
setLoading(true)
fetch('/api/calendar?range=month')
.then(r => r.json())
.then(d => setEvents(d.events || []))
.catch(() => setEvents([]))
.finally(() => setLoading(false))
}, [year, month])
const today = new Date()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const startDow = (firstDay.getDay() + 6) % 7 // Mon=0
const totalCells = Math.ceil((startDow + lastDay.getDate()) / 7) * 7
const monthName = firstDay.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })
const eventsByDate: Record<string, CalendarEvent[]> = {}
for (const ev of events) {
const dk = ev.start.substring(0, 10)
if (!eventsByDate[dk]) eventsByDate[dk] = []
eventsByDate[dk].push(ev)
}
const prevMonth = () => {
if (month === 0) { setYear(y => y - 1); setMonth(11) }
else setMonth(m => m - 1)
setSelectedDay(null)
}
const nextMonth = () => {
if (month === 11) { setYear(y => y + 1); setMonth(0) }
else setMonth(m => m + 1)
setSelectedDay(null)
}
const selectedEvents = selectedDay ? (eventsByDate[selectedDay] || []) : []
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Month header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button onClick={prevMonth} style={{ padding: 8, borderRadius: 10, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-primary)' }}>
<ChevronLeft size={18} />
</button>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
<button onClick={nextMonth} style={{ padding: 8, borderRadius: 10, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-primary)' }}>
<ChevronRight size={18} />
</button>
</div>
{/* Day of week headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4, textAlign: 'center' }}>
{['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => (
<div key={d} style={{ fontSize: 11, color: 'var(--text-secondary)', fontWeight: 600, paddingBottom: 4 }}>{d}</div>
))}
</div>
{/* Calendar grid */}
{loading ? (
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 14 }}>Загрузка...</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
{Array.from({ length: totalCells }).map((_, idx) => {
const dayNum = idx - startDow + 1
if (dayNum < 1 || dayNum > lastDay.getDate()) {
return <div key={idx} style={{ height: 44 }} />
}
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`
const hasEvents = !!eventsByDate[dateStr]
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === dayNum
const isSelected = selectedDay === dateStr
const dayEvents = eventsByDate[dateStr] || []
return (
<button
key={idx}
onClick={() => setSelectedDay(isSelected ? null : dateStr)}
style={{
height: 44,
borderRadius: 10,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
background: isSelected ? 'rgba(0,212,255,0.15)' : isToday ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.02)',
border: isSelected ? '1px solid rgba(0,212,255,0.4)' : isToday ? '1px solid rgba(255,255,255,0.15)' : '1px solid rgba(255,255,255,0.04)',
color: isToday ? '#00d4ff' : 'var(--text-primary)',
fontSize: 13,
fontWeight: isToday ? 700 : 400,
touchAction: 'manipulation',
}}
>
<span>{dayNum}</span>
{hasEvents && (
<div style={{ display: 'flex', gap: 2 }}>
{dayEvents.slice(0, 3).map((ev, i) => (
<div key={i} style={{ width: 5, height: 5, borderRadius: '50%', background: ev.color }} />
))}
</div>
)}
</button>
)
})}
</div>
)}
{/* Selected day events */}
{selectedDay && (
<div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 10, textTransform: 'capitalize' }}>
{formatDayHeader(selectedDay)}
</div>
{selectedEvents.length === 0 ? (
<div style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 14, padding: '16px 0' }}>
Событий нет 🎉
</div>
) : (
selectedEvents.map(ev => <EventCard key={ev.id} event={ev} />)
)}
</div>
)}
</div>
)
}
export default function CalendarTab() {
const [view, setView] = useState<'week' | 'month'>('week')
const viewOptions: { id: 'week' | 'month'; label: string }[] = [
{ id: 'week', label: 'Неделя' },
{ id: 'month', label: 'Месяц' },
]
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* View switcher */}
<div style={{ padding: '12px 20px 0', flexShrink: 0 }}>
<div style={{ display: 'inline-flex', background: 'rgba(255,255,255,0.05)', borderRadius: 12, padding: 3, border: '1px solid rgba(255,255,255,0.08)' }}>
{viewOptions.map(opt => (
<button
key={opt.id}
onClick={() => setView(opt.id)}
style={{
padding: '7px 18px',
borderRadius: 10,
fontSize: 13,
fontWeight: 500,
background: view === opt.id ? 'rgba(0,212,255,0.15)' : 'transparent',
border: view === opt.id ? '1px solid rgba(0,212,255,0.25)' : '1px solid transparent',
color: view === opt.id ? '#00d4ff' : 'var(--text-secondary)',
transition: 'all 0.2s',
touchAction: 'manipulation',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{view === 'week' && <TimelineView range={view} />}
{view === 'month' && <MonthView />}
</div>
)
}