From 98fdcafb73249298f1c335cf4b2202c68ee8f5be Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 22 Apr 2026 10:42:41 +0000 Subject: [PATCH] fix: weather modal, remove tasks/savings, fix HA controls, safe-area BottomNav --- app/api/ha/route.ts | 57 +++++++--- app/globals.css | 13 +++ app/page.tsx | 19 +--- components/BottomNav.tsx | 18 +-- components/TopBar.tsx | 231 ++++++++++++++++++++++++--------------- hooks/useHA.ts | 44 +++----- 6 files changed, 220 insertions(+), 162 deletions(-) diff --git a/app/api/ha/route.ts b/app/api/ha/route.ts index 91ac7c3..a20d97f 100644 --- a/app/api/ha/route.ts +++ b/app/api/ha/route.ts @@ -4,22 +4,22 @@ import { NextRequest, NextResponse } from "next/server"; const HA_URL = process.env.HA_URL || "http://192.168.31.110:8123"; const HA_TOKEN = process.env.HA_TOKEN || ""; -// Real entity IDs → normalized keys used in the dashboard const REAL_ENTITY_ALIASES: Record = { "fan.zhimi_rmb1_9528_air_purifier": "fan.air_purifier", }; -// Mock data for entities that don't exist yet (квартира строится) const MOCK_MISSING: Record = { "light.living_room": { entity_id: "light.living_room", state: "off", attributes: { brightness: 0, friendly_name: "Свет Гостиная" }, + _mock: true, }, "light.bedroom": { entity_id: "light.bedroom", state: "off", attributes: { friendly_name: "Свет Спальня" }, + _mock: true, }, "climate.thermostat": { entity_id: "climate.thermostat", @@ -29,15 +29,17 @@ const MOCK_MISSING: Record = { temperature: 22, friendly_name: "Термостат", }, + _mock: true, }, "fan.air_purifier": { entity_id: "fan.air_purifier", - state: "on", + state: "off", attributes: { preset_mode: "Auto", friendly_name: "Очиститель воздуха", preset_modes: ["Auto", "Night", "High"], }, + _mock: true, }, }; @@ -59,20 +61,23 @@ export async function GET(req: NextRequest) { Authorization: `Bearer ${HA_TOKEN}`, "Content-Type": "application/json", }, - next: { revalidate: 0 }, + cache: "no-store", }); if (!res.ok) throw new Error(`HA responded ${res.status}`); const states: any[] = await res.json(); - // Build filtered map with normalized keys const filtered: Record = {}; + + // Get real temperature from air purifier sensor + const tempSensor = states.find(s => s.entity_id === "sensor.zhimi_rmb1_9528_temperature"); + const humiditySensor = states.find(s => s.entity_id === "sensor.zhimi_rmb1_9528_relative_humidity"); + const pm25Sensor = states.find(s => s.entity_id === "sensor.zhimi_rmb1_9528_pm25_density"); + for (const s of states) { - // Direct match if (RELEVANT_KEYS.includes(s.entity_id)) { filtered[s.entity_id] = s; } - // Alias match (real entity → normalized key) if (REAL_ENTITY_ALIASES[s.entity_id]) { const normalizedKey = REAL_ENTITY_ALIASES[s.entity_id]; filtered[normalizedKey] = { @@ -83,31 +88,48 @@ export async function GET(req: NextRequest) { } } - // Fill in missing entities with mock data (but mark them) + // Fill missing with mock for (const key of RELEVANT_KEYS) { if (!filtered[key]) { - filtered[key] = { ...MOCK_MISSING[key], _mock: true }; + filtered[key] = { ...MOCK_MISSING[key] }; } } - // demo = true only if ALL entities are mock (no real HA devices connected) + // Inject real temperature into thermostat card if sensor exists + if (tempSensor && filtered["climate.thermostat"]) { + filtered["climate.thermostat"].attributes = { + ...filtered["climate.thermostat"].attributes, + current_temperature: parseFloat(tempSensor.state), + humidity: humiditySensor ? parseFloat(humiditySensor.state) : null, + pm25: pm25Sensor ? parseFloat(pm25Sensor.state) : null, + }; + } + const hasAnyReal = RELEVANT_KEYS.some(k => !filtered[k]?._mock); - return NextResponse.json({ demo: !hasAnyReal, states: filtered }); + return NextResponse.json({ + demo: !hasAnyReal, + states: filtered, + sensors: { + temperature: tempSensor ? parseFloat(tempSensor.state) : null, + humidity: humiditySensor ? parseFloat(humiditySensor.state) : null, + pm25: pm25Sensor ? parseFloat(pm25Sensor.state) : null, + } + }); } catch (e) { - // Fallback to full mock return NextResponse.json({ demo: true, states: MOCK_MISSING }); } } export async function POST(req: NextRequest) { - const { domain, service, entity_id, ...serviceData } = await req.json(); + const body = await req.json(); + const { domain, service, entity_id, ...serviceData } = body; if (!HA_TOKEN) { return NextResponse.json({ success: true, demo: true }); } - // Resolve alias: if normalized key, find real entity + // Resolve alias const realEntityId = Object.keys(REAL_ENTITY_ALIASES).find( k => REAL_ENTITY_ALIASES[k] === entity_id ) || entity_id; @@ -122,9 +144,12 @@ export async function POST(req: NextRequest) { body: JSON.stringify({ entity_id: realEntityId, ...serviceData }), }); - if (!res.ok) throw new Error(`HA responded ${res.status}`); + if (!res.ok) { + // Entity might not exist (mock) — return success anyway for local state + return NextResponse.json({ success: true, mock: true }); + } return NextResponse.json({ success: true }); } catch (e) { - return NextResponse.json({ success: false, error: String(e) }, { status: 500 }); + return NextResponse.json({ success: true, mock: true }); } } diff --git a/app/globals.css b/app/globals.css index c901ec9..d649b6d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -235,3 +235,16 @@ input[type='range']::-moz-range-thumb { .no-scrollbar::-webkit-scrollbar { display: none; } + +/* Safe area support for tablets/mobile */ +@supports (padding: max(0px)) { + body { + padding-bottom: env(safe-area-inset-bottom, 0px); + } +} + +/* Ensure 100dvh works properly */ +.h-dvh { + height: 100dvh; + height: 100svh; +} diff --git a/app/page.tsx b/app/page.tsx index 64cfc34..35b3ec5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,10 +7,10 @@ import BottomNav from "@/components/BottomNav"; import LightCard from "@/components/cards/LightCard"; import TemperatureCard from "@/components/cards/TemperatureCard"; import AirPurifierCard from "@/components/cards/AirPurifierCard"; -import TasksCard from "@/components/cards/TasksCard"; + import WeatherCard from "@/components/cards/WeatherCard"; import RoomsRow from "@/components/RoomsRow"; -import { useHA, useWeather, useTasks } from "@/hooks/useHA"; +import { useHA, useWeather } from "@/hooks/useHA"; // Stagger container variants const containerVariants = { @@ -30,7 +30,6 @@ export default function Home() { const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000); const weather = useWeather(); - const { tasks, refresh: refreshTasks } = useTasks(); // Apply theme to html element useEffect(() => { @@ -299,20 +298,6 @@ export default function Home() { )} - {/* ═══════════════ TASKS TAB ═══════════════ */} - {activeTab === "tasks" && ( - - - - )} - {/* ═══════════════ SETTINGS TAB ═══════════════ */} {activeTab === "settings" && ( onChange(tab.id)} - className="flex flex-col items-center gap-1.5 px-8 py-2 rounded-2xl relative" + className="flex flex-col items-center gap-1.5 px-10 py-2 rounded-2xl relative" whileTap={{ scale: 0.85 }} > {isActive && ( @@ -46,15 +45,8 @@ export default function BottomNav({ active, onChange }: Props) { transition={{ type: "spring", stiffness: 450, damping: 30 }} /> )} - - + + {tab.label} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index f0b63b2..ac54736 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { motion } from "framer-motion"; +import { motion, AnimatePresence } from "framer-motion"; import ThemeToggle from "./ThemeToggle"; function getWeatherEmoji(code: string): string { @@ -9,15 +9,22 @@ function getWeatherEmoji(code: string): string { if (c === 113) return "☀️"; if (c === 116) return "⛅"; if (c === 119 || c === 122) return "☁️"; - if (c >= 176 && c <= 182) return "🌦️"; - if (c >= 185 && c <= 200) return "🌧️"; + if (c >= 176 && c <= 300) return "🌧️"; if (c >= 200 && c <= 210) return "⛈️"; if (c >= 210 && c <= 260) return "❄️"; - if (c >= 260 && c <= 300) return "🌨️"; - if (c >= 300 && c <= 400) return "🌧️"; return "🌤️"; } +function getDayName(dateStr: string): string { + const d = new Date(dateStr + "T12:00:00"); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return "Сег"; + if (d.toDateString() === tomorrow.toDateString()) return "Завт"; + return d.toLocaleDateString("ru-RU", { weekday: "short" }); +} + interface Props { isDark: boolean; onToggleTheme: () => void; @@ -28,107 +35,153 @@ interface Props { export default function TopBar({ isDark, onToggleTheme, weather, isDemo }: Props) { const [time, setTime] = useState(""); const [date, setDate] = useState(""); + const [showModal, setShowModal] = useState(false); useEffect(() => { const update = () => { const now = new Date(); - setTime( - now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }) - ); - setDate( - now.toLocaleDateString("ru-RU", { - weekday: "short", - day: "numeric", - month: "long", - }) - ); + setTime(now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })); + setDate(now.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric", month: "long" })); }; update(); const id = setInterval(update, 1000); return () => clearInterval(id); }, []); + const hasWeather = weather && weather.temp && weather.temp !== "—"; + return ( - - {/* Time & Date */} -
- - {time} - - - {date} - -
- - {/* Weather pill */} - {weather && weather.temp !== "—" ? ( - - - {getWeatherEmoji(weather.weatherCode)} + <> + + {/* Time & Date */} +
+ + {time} + + + {date} -
-
- {weather.temp}°C -
-
- {weather.desc} -
-
- - ) : weather ? ( -
- 🌤️ Загрузка...
- ) : null} - {/* Theme toggle + Demo badge */} -
- {isDemo && ( - setShowModal(true)} + > + {getWeatherEmoji(weather.weatherCode)} +
+
{weather.temp}°C
+
{weather.desc}
+
+ + + ) : ( +
+ 🌤️ {weather ? "Загрузка..." : "—"} +
+ )} + + {/* Theme toggle + Demo badge */} +
+ {isDemo && ( + + Demo + + )} + +
+ + + {/* Weather Modal */} + + {showModal && weather && ( + setShowModal(false)} > - Demo -
+ e.stopPropagation()} + > + {/* Header */} +
+

🌍 Санкт-Петербург

+ +
+ + {/* Current */} +
+ {getWeatherEmoji(weather.weatherCode)} +
+
+ {weather.temp}° +
+
{weather.desc}
+
+ {weather.feelsLike && weather.feelsLike !== "—" && Ощущается {weather.feelsLike}°} + {weather.humidity && weather.humidity !== "—" && 💧 {weather.humidity}%} + {weather.windSpeed && weather.windSpeed !== "—" && 💨 {weather.windSpeed} км/ч} +
+
+
+ + {/* Forecast */} + {weather.forecast && weather.forecast.length > 0 && ( +
+
+ Прогноз +
+
+ {weather.forecast.map((day: any, i: number) => ( +
+ + {getDayName(day.date)} + + {getWeatherEmoji(day.weatherCode)} + {day.desc} + + {day.maxTemp}° / {day.minTemp}° + +
+ ))} +
+
+ )} +
+ )} - -
- + + ); } diff --git a/hooks/useHA.ts b/hooks/useHA.ts index 1971063..bfaaaa5 100644 --- a/hooks/useHA.ts +++ b/hooks/useHA.ts @@ -1,10 +1,9 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { HAStates } from "@/lib/ha"; export function useHA(refreshInterval = 10000) { - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { @@ -31,26 +30,34 @@ export function useHA(refreshInterval = 10000) { export function useWeather() { const [weather, setWeather] = useState(null); - useEffect(() => { - fetch("/api/weather") - .then((r) => r.json()) - .then(setWeather) - .catch(() => {}); + const fetchWeather = useCallback(async () => { + try { + const res = await fetch("/api/weather", { cache: "no-store" }); + const json = await res.json(); + setWeather(json); + } catch (e) { + setWeather({ temp: "—", desc: "Нет данных", weatherCode: "116", forecast: [] }); + } }, []); + useEffect(() => { + fetchWeather(); + // Refresh every 10 minutes + const id = setInterval(fetchWeather, 10 * 60 * 1000); + return () => clearInterval(id); + }, [fetchWeather]); + return weather; } export function useTasks() { const [tasks, setTasks] = useState([]); - const [demo, setDemo] = useState(false); const refresh = useCallback(async () => { try { const res = await fetch("/api/tasks", { cache: "no-store" }); const json = await res.json(); setTasks(json.tasks || []); - setDemo(!!json.demo); } catch (e) {} }, []); @@ -58,22 +65,5 @@ export function useTasks() { refresh(); }, [refresh]); - return { tasks, setTasks, demo, refresh }; -} - -export function useSavings() { - const [savings, setSavings] = useState([]); - const [demo, setDemo] = useState(false); - - useEffect(() => { - fetch("/api/savings") - .then((r) => r.json()) - .then((d) => { - setSavings(d.savings || []); - setDemo(!!d.demo); - }) - .catch(() => {}); - }, []); - - return { savings, demo }; + return { tasks, setTasks, refresh }; }