Add weather badge, chore chart, fix controls grid

- Header now shows weather icon + temp from weather.forecast_home_2 next
  to the connection badge (right side of nav bar)
- New ChoreChart widget below TodoWidget on Home view; reads from
  todo.chores HA entity; items prefixed with a name (Becca: / Chris: /
  Arabella:) get per-person color coding matching the calendar palette;
  tap toggles completion
- Controls overlay simplified from grid-cols-12 auto-rows-min to a plain
  grid-cols-2 — removes the uneven gaps between widgets
This commit is contained in:
root
2026-04-16 22:35:00 -05:00
parent f5461db97d
commit 81236d908c
5 changed files with 224 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import { LocksOverlay } from '@/components/locks';
import { ControlsOverlay } from '@/components/controls';
import { CalendarWidget } from '@/components/calendar';
import { TodoWidget } from '@/components/todo';
import { ChoreChart } from '@/components/chores';
import { SettingsPanel, ConnectionModal } from '@/components/settings';
import { CameraOverlay } from '@/components/cameras';
import { CameraFeed } from '@/components/cameras/CameraFeed';
@@ -99,11 +100,16 @@ function DashboardContent() {
<CalendarWidget />
</div>
)}
{config.todoList && (
<div className="flex-1 min-w-0">
<TodoWidget />
<div className="flex-1 min-w-0 flex flex-col gap-3">
{config.todoList && (
<div className="flex-1 min-h-0">
<TodoWidget />
</div>
)}
<div className="flex-1 min-h-0">
<ChoreChart />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,168 @@
import { useEffect, useState, useCallback } from 'react';
import { useHAStore } from '@/stores/haStore';
import { haConnection } from '@/services/homeAssistant/connection';
// Person color mapping (matches calendar eventColors)
const PEOPLE: Record<string, { bg: string; text: string }> = {
becca: { bg: '#14b8a6', text: '#ffffff' },
chris: { bg: '#22c55e', text: '#ffffff' },
arabella: { bg: '#ec4899', text: '#ffffff' },
};
const FALLBACK_COLOR = { bg: '#94a3b8', text: '#ffffff' };
interface ChoreItem {
uid: string;
summary: string;
status: string;
person: string | null;
task: string;
color: { bg: string; text: string };
}
function parseChore(item: { uid: string; summary: string; status: string }): ChoreItem {
// "Becca: Do dishes" → person=Becca, task=Do dishes
// "Take out trash" → person=null, task=Take out trash
const colonIdx = item.summary.indexOf(':');
let person: string | null = null;
let task = item.summary;
let color = FALLBACK_COLOR;
if (colonIdx > 0 && colonIdx < 20) {
const prefix = item.summary.slice(0, colonIdx).trim().toLowerCase();
if (PEOPLE[prefix]) {
person = item.summary.slice(0, colonIdx).trim();
task = item.summary.slice(colonIdx + 1).trim();
color = PEOPLE[prefix];
}
}
// Also check if any person name appears anywhere in summary
if (!person) {
for (const [name, c] of Object.entries(PEOPLE)) {
if (item.summary.toLowerCase().includes(name)) {
person = name.charAt(0).toUpperCase() + name.slice(1);
color = c;
break;
}
}
}
return { uid: item.uid, summary: item.summary, status: item.status, person, task, color };
}
const CHORE_ENTITY = 'todo.chores';
export function ChoreChart() {
const isConnected = useHAStore((s) => s.connectionState === 'connected');
const [chores, setChores] = useState<ChoreItem[]>([]);
const fetchChores = useCallback(async () => {
const conn = haConnection.getConnection();
if (!conn) return;
try {
const result = await conn.sendMessagePromise({
type: 'call_service',
domain: 'todo',
service: 'get_items',
target: { entity_id: CHORE_ENTITY },
return_response: true,
}) as { response?: Record<string, { items: Array<{ uid: string; summary: string; status: string }> }> };
const items = result?.response?.[CHORE_ENTITY]?.items ?? [];
setChores(items.map(parseChore));
} catch {
// Entity may not exist yet
}
}, []);
useEffect(() => {
if (!isConnected) return;
fetchChores();
const id = setInterval(fetchChores, 30000);
return () => clearInterval(id);
}, [isConnected, fetchChores]);
const toggleChore = useCallback(async (uid: string, currentStatus: string) => {
const conn = haConnection.getConnection();
if (!conn) return;
const newStatus = currentStatus === 'completed' ? 'needs_action' : 'completed';
try {
await conn.sendMessagePromise({
type: 'call_service',
domain: 'todo',
service: 'update_item',
target: { entity_id: CHORE_ENTITY },
service_data: { item: uid, status: newStatus },
});
fetchChores();
} catch {
// ignore
}
}, [fetchChores]);
if (!chores.length) {
return (
<div className="widget h-full">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
Chores
</div>
<div className="widget-content flex items-center justify-center">
<p className="text-sm text-gray-500">
Add a <code>todo.chores</code> list in HA
</p>
</div>
</div>
);
}
return (
<div className="widget h-full">
<div className="widget-title">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
Chores
</div>
<div className="widget-content overflow-y-auto space-y-1">
{chores.map((chore) => (
<button
key={chore.uid}
onClick={() => toggleChore(chore.uid, chore.status)}
className="compact-row w-full gap-2 touch-manipulation"
>
{/* Checkbox */}
<div
className="w-5 h-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors"
style={{
borderColor: chore.color.bg,
backgroundColor: chore.status === 'completed' ? chore.color.bg : 'transparent',
}}
>
{chore.status === 'completed' && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{/* Person badge */}
{chore.person && (
<span
className="text-[0.6rem] font-bold px-1.5 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: chore.color.bg, color: chore.color.text }}
>
{chore.person}
</span>
)}
{/* Task text */}
<span className={`text-sm flex-1 text-left truncate ${chore.status === 'completed' ? 'line-through opacity-50' : ''}`}>
{chore.task}
</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ChoreChart } from './ChoreChart';

View File

@@ -26,27 +26,13 @@ export function ControlsOverlay() {
</header>
<div className="flex-1 overflow-auto p-5">
<div className="grid grid-cols-12 auto-rows-min gap-4">
{config.alarm && (
<div className="col-span-12 md:col-span-6">
<AlarmoPanel />
</div>
)}
<div className="grid grid-cols-2 gap-4">
{config.alarm && <AlarmoPanel />}
{config.thermostats.map((thermostat) => (
<div key={thermostat.entityId} className="col-span-12 md:col-span-6">
<ThermostatWidget config={thermostat} />
</div>
<ThermostatWidget key={thermostat.entityId} config={thermostat} />
))}
{config.lights.length > 0 && (
<div className="col-span-12 md:col-span-6">
<LightsWidget />
</div>
)}
{config.locks.length > 0 && (
<div className="col-span-12 md:col-span-6">
<LocksWidget />
</div>
)}
{config.lights.length > 0 && <LightsWidget />}
{config.locks.length > 0 && <LocksWidget />}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useConnectionState, useEntityState, useEntityAttribute } from '@/stores/haStore';
import { useConnectionState, useEntityState, useEntityAttribute, useHAStore } from '@/stores/haStore';
import { useUIStore } from '@/stores/uiStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { env } from '@/config/environment';
@@ -80,6 +80,42 @@ function PackageStatus() {
);
}
const WEATHER_ICONS: Record<string, string> = {
'clear-night': '🌙',
'cloudy': '☁️',
'exceptional': '⚠️',
'fog': '🌫️',
'hail': '🌨️',
'lightning': '⚡',
'lightning-rainy': '⛈️',
'partlycloudy': '⛅',
'pouring': '🌧️',
'rainy': '🌧️',
'snowy': '❄️',
'snowy-rainy': '🌨️',
'sunny': '☀️',
'windy': '💨',
'windy-variant': '💨',
};
function WeatherBadge() {
const weatherEntity = 'weather.forecast_home_2';
const condition = useEntityState(weatherEntity) ?? '';
const entities = useHAStore((s) => s.entities);
const attrs = entities[weatherEntity]?.attributes as Record<string, unknown> | undefined;
const temp = attrs?.temperature as number | undefined;
if (!temp) return null;
const icon = WEATHER_ICONS[condition] || '🌡️';
return (
<div className="flex items-center gap-1.5 text-ink">
<span className="text-lg">{icon}</span>
<span className="text-lg font-bold">{Math.round(temp)}°</span>
</div>
);
}
export function Header() {
const connectionState = useConnectionState();
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
@@ -239,8 +275,9 @@ export function Header() {
</button>
</nav>
{/* Right - Connection Status */}
<div className="flex items-center">
{/* Right - Weather + Connection */}
<div className="flex items-center gap-4">
<WeatherBadge />
<div className="status-badge">
<div className={`status-dot ${getConnectionStatusClass()}`} />
<span>{getConnectionText()}</span>