diff --git a/app/api/calendar/route.ts b/app/api/calendar/route.ts new file mode 100644 index 0000000..aa76a49 --- /dev/null +++ b/app/api/calendar/route.ts @@ -0,0 +1,75 @@ +export const dynamic = 'force-dynamic' +import { NextResponse } from 'next/server' +import { google } from 'googleapis' + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const range = searchParams.get('range') || 'today' + + const clientId = process.env.GOOGLE_CLIENT_ID + const clientSecret = process.env.GOOGLE_CLIENT_SECRET + const refreshToken = process.env.GOOGLE_REFRESH_TOKEN + const svetaCalendarId = process.env.SVETA_CALENDAR_ID + + if (!clientId || !clientSecret || !refreshToken) { + return NextResponse.json({ events: [], error: 'not_configured' }) + } + + const auth = new google.auth.OAuth2(clientId, clientSecret) + auth.setCredentials({ refresh_token: refreshToken }) + + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + let timeMin = todayStart.toISOString() + let timeMax: string + + if (range === 'today') { + timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString() + } else if (range === 'week') { + timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString() + } else { + timeMax = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59).toISOString() + timeMin = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() + } + + const calendarClient = google.calendar({ version: 'v3', auth }) + + const calendars = [ + { id: 'daniilklimov25@gmail.com', owner: 'daniil', color: '#6366f1', name: 'Даниил' }, + ...(svetaCalendarId ? [{ id: svetaCalendarId, owner: 'sveta', color: '#ec4899', name: 'Света' }] : []) + ] + + const results = await Promise.allSettled( + calendars.map(cal => + calendarClient.events.list({ + calendarId: cal.id, + timeMin, + timeMax, + singleEvents: true, + orderBy: 'startTime', + maxResults: 100, + }).then(r => ({ ...cal, events: r.data.items || [] })) + ) + ) + + const allEvents = results + .filter(r => r.status === 'fulfilled') + .flatMap(r => { + const val = (r as PromiseFulfilledResult).value + return val.events.map((e: any) => ({ + id: e.id, + title: e.summary || '(без названия)', + start: e.start?.dateTime || e.start?.date, + end: e.end?.dateTime || e.end?.date, + allDay: !e.start?.dateTime, + description: e.description || null, + location: e.location || null, + owner: val.owner, + ownerName: val.name, + color: val.color, + })) + }) + .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) + + return NextResponse.json({ events: allEvents, fetchedAt: new Date().toISOString() }) +} diff --git a/app/page.tsx b/app/page.tsx index 1af7451..54986db 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,8 +5,9 @@ import Sidebar from '@/components/Sidebar' import TopBar from '@/components/TopBar' import RoomTabs from '@/components/RoomTabs' import DeviceCard from '@/components/DeviceCard' +import CalendarTab from '@/components/CalendarTab' -type Tab = 'home' | 'rooms' | 'sensors' | 'settings' +type Tab = 'home' | 'devices' | 'calendar' | 'settings' interface WeatherData { temp: string @@ -27,6 +28,17 @@ interface HaStates { [key: string]: { state: string; attributes?: Record; _mock?: boolean } } +interface CalendarEvent { + id: string + title: string + start: string + end: string + allDay: boolean + owner: string + ownerName: string + color: string +} + const ROOMS = [ { id: 'living', name: 'Гостиная', emoji: '🛋️', deviceCount: 3 }, { id: 'bedroom', name: 'Спальня', emoji: '🛏️', deviceCount: 2 }, @@ -90,6 +102,162 @@ const DEVICES_BY_ROOM: Record([]) + const [calLoading, setCalLoading] = useState(true) + + useEffect(() => { + fetch('/api/calendar?range=today') + .then(r => r.json()) + .then(d => setTodayEvents(d.events || [])) + .catch(() => setTodayEvents([])) + .finally(() => setCalLoading(false)) + }, []) + + return ( +
+ + {/* Today Widget */} +
+
+ 📅 Сегодня +
+ + {calLoading ? ( +
Загрузка...
+ ) : todayEvents.length === 0 ? ( +
+ Свободный день 🎉 +
+ ) : ( +
+ {todayEvents.map(ev => ( +
+
+
+
+ {ev.title} +
+
+ {ev.allDay ? 'Весь день' : `${formatEventTime(ev.start)} — ${formatEventTime(ev.end)}`} + {ev.ownerName} +
+
+
+ ))} +
+ )} +
+ + {/* Weather Widget */} + {weather && ( +
+
+ 🌤️ Погода +
+ + {/* Current */} +
+ {getWeatherEmoji(weather.desc)} +
+
{weather.temp}°C
+
{weather.desc}
+
+ 💧 {weather.humidity}% · 💨 {weather.windSpeed} км/ч · Ощущается {weather.feelsLike}° +
+
+
+ + {/* Forecast */} + {weather.forecast && weather.forecast.length > 0 && ( +
+ {weather.forecast.slice(0, 3).map(day => { + const d = new Date(day.date) + const label = d.toLocaleDateString('ru-RU', { weekday: 'short' }) + return ( +
+
{label}
+
{getWeatherEmoji(day.desc)}
+
{day.maxTemp}°
+
{day.minTemp}°
+
+ ) + })} +
+ )} +
+ )} + + {/* Sensors Widget */} + {sensors && ( +
+
+ 📊 Датчики квартиры +
+
+ {[ + { label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' }, + { label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' }, + { label: 'PM2.5', value: `${sensors.pm25} μg`, icon: '💨' }, + ].map(s => ( +
+
{s.icon}
+
{s.value}
+
{s.label}
+
+ ))} +
+
+ )} +
+ ) +} + export default function HomePage() { const [tab, setTab] = useState('home') const [activeRoom, setActiveRoom] = useState('living') @@ -97,7 +265,6 @@ export default function HomePage() { const [sensors, setSensors] = useState(null) const [haStates, setHaStates] = useState({}) - // Load weather useEffect(() => { const load = async () => { try { @@ -111,7 +278,6 @@ export default function HomePage() { return () => clearInterval(t) }, []) - // Load HA states + sensors const loadHA = useCallback(async () => { try { const r = await fetch('/api/ha') @@ -142,63 +308,50 @@ export default function HomePage() { } return ( -
+
-
+
- {tab === 'home' && ( + {tab === 'home' && } + + {tab === 'devices' && ( <> - -
+
{devicesInRoom.length === 0 ? ( -
+
🏠 Устройства не добавлены
) : ( -
+
{devicesInRoom.map(device => ( )} - {tab === 'rooms' && ( -
- 🏠 - Управление комнатами - Скоро -
- )} - - {tab === 'sensors' && sensors && ( -
-

Датчики

-
- {[ - { label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' }, - { label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' }, - { label: 'PM2.5', value: `${sensors.pm25} μg/m³`, icon: '💨' }, - ].map(s => ( -
- {s.icon} -
{s.value}
-
{s.label}
-
- ))} -
-
- )} - - {tab === 'sensors' && !sensors && ( -
- Загрузка датчиков... -
- )} + {tab === 'calendar' && } {tab === 'settings' && ( -
+
⚙️ Настройки Скоро diff --git a/components/CalendarTab.tsx b/components/CalendarTab.tsx new file mode 100644 index 0000000..e714d0f --- /dev/null +++ b/components/CalendarTab.tsx @@ -0,0 +1,285 @@ +'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 { + const groups: Record = {} + 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 ( +
+
+
+
+ {event.title} +
+
+ + {event.allDay ? 'Весь день' : `${formatTime(event.start)} — ${formatTime(event.end)}`} + + {event.ownerName} +
+ {event.location && ( +
📍 {event.location}
+ )} +
+
+ ) +} + +function TimelineView({ range }: { range: 'today' | 'week' }) { + const [events, setEvents] = useState([]) + 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 ( +
+ Загрузка... +
+ ) + + if (events.length === 0) return ( +
+ 🎉 + Свободный день! +
+ ) + + const grouped = groupByDate(events) + + return ( +
+ {Object.entries(grouped).map(([dateKey, dayEvents]) => ( +
+
+ {formatDayHeader(dateKey)} +
+ {dayEvents.map(ev => )} +
+ ))} +
+ ) +} + +function MonthView() { + const [year, setYear] = useState(() => new Date().getFullYear()) + const [month, setMonth] = useState(() => new Date().getMonth()) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedDay, setSelectedDay] = useState(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 = {} + 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 ( +
+ {/* Month header */} +
+ + {monthName} + +
+ + {/* Day of week headers */} +
+ {['Пн','Вт','Ср','Чт','Пт','Сб','Вс'].map(d => ( +
{d}
+ ))} +
+ + {/* Calendar grid */} + {loading ? ( +
Загрузка...
+ ) : ( +
+ {Array.from({ length: totalCells }).map((_, idx) => { + const dayNum = idx - startDow + 1 + if (dayNum < 1 || dayNum > lastDay.getDate()) { + return
+ } + 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 ( + + ) + })} +
+ )} + + {/* Selected day events */} + {selectedDay && ( +
+
+ {formatDayHeader(selectedDay)} +
+ {selectedEvents.length === 0 ? ( +
+ Событий нет 🎉 +
+ ) : ( + selectedEvents.map(ev => ) + )} +
+ )} +
+ ) +} + +export default function CalendarTab() { + const [view, setView] = useState<'today' | 'week' | 'month'>('today') + + const viewOptions: { id: typeof view; label: string }[] = [ + { id: 'today', label: 'Сегодня' }, + { id: 'week', label: 'Неделя' }, + { id: 'month', label: 'Месяц' }, + ] + + return ( +
+ {/* View switcher */} +
+
+ {viewOptions.map(opt => ( + + ))} +
+
+ + {(view === 'today' || view === 'week') && } + {view === 'month' && } +
+ ) +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 098eed9..14661f0 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -1,19 +1,19 @@ 'use client' -import { Home, LayoutGrid, Thermometer, Settings } from 'lucide-react' +import { Home, Cpu, CalendarDays, Settings } from 'lucide-react' -type Tab = 'home' | 'rooms' | 'sensors' | 'settings' +type Tab = 'home' | 'devices' | 'calendar' | 'settings' interface SidebarProps { active: Tab onChange: (tab: Tab) => void } -const navItems: { id: Tab; icon: any }[] = [ - { id: 'home', icon: Home }, - { id: 'rooms', icon: LayoutGrid }, - { id: 'sensors', icon: Thermometer }, - { id: 'settings', icon: Settings }, +const navItems: { id: Tab; icon: any; label: string }[] = [ + { id: 'home', icon: Home, label: 'Главная' }, + { id: 'devices', icon: Cpu, label: 'Устройства' }, + { id: 'calendar', icon: CalendarDays, label: 'Календарь' }, + { id: 'settings', icon: Settings, label: 'Настройки' }, ] export default function Sidebar({ active, onChange }: SidebarProps) { @@ -56,12 +56,13 @@ export default function Sidebar({ active, onChange }: SidebarProps) {
{/* Nav items */} - {navItems.map(({ id, icon: Icon }) => { + {navItems.map(({ id, icon: Icon, label }) => { const isActive = active === id return (