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:
@@ -6,6 +6,7 @@ import { LocksOverlay } from '@/components/locks';
|
|||||||
import { ControlsOverlay } from '@/components/controls';
|
import { ControlsOverlay } from '@/components/controls';
|
||||||
import { CalendarWidget } from '@/components/calendar';
|
import { CalendarWidget } from '@/components/calendar';
|
||||||
import { TodoWidget } from '@/components/todo';
|
import { TodoWidget } from '@/components/todo';
|
||||||
|
import { ChoreChart } from '@/components/chores';
|
||||||
import { SettingsPanel, ConnectionModal } from '@/components/settings';
|
import { SettingsPanel, ConnectionModal } from '@/components/settings';
|
||||||
import { CameraOverlay } from '@/components/cameras';
|
import { CameraOverlay } from '@/components/cameras';
|
||||||
import { CameraFeed } from '@/components/cameras/CameraFeed';
|
import { CameraFeed } from '@/components/cameras/CameraFeed';
|
||||||
@@ -99,11 +100,16 @@ function DashboardContent() {
|
|||||||
<CalendarWidget />
|
<CalendarWidget />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
{config.todoList && (
|
{config.todoList && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-h-0">
|
||||||
<TodoWidget />
|
<TodoWidget />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ChoreChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
168
src/components/chores/ChoreChart.tsx
Normal file
168
src/components/chores/ChoreChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/chores/index.ts
Normal file
1
src/components/chores/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ChoreChart } from './ChoreChart';
|
||||||
@@ -26,27 +26,13 @@ export function ControlsOverlay() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-5">
|
<div className="flex-1 overflow-auto p-5">
|
||||||
<div className="grid grid-cols-12 auto-rows-min gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{config.alarm && (
|
{config.alarm && <AlarmoPanel />}
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<AlarmoPanel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config.thermostats.map((thermostat) => (
|
{config.thermostats.map((thermostat) => (
|
||||||
<div key={thermostat.entityId} className="col-span-12 md:col-span-6">
|
<ThermostatWidget key={thermostat.entityId} config={thermostat} />
|
||||||
<ThermostatWidget config={thermostat} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{config.lights.length > 0 && (
|
{config.lights.length > 0 && <LightsWidget />}
|
||||||
<div className="col-span-12 md:col-span-6">
|
{config.locks.length > 0 && <LocksWidget />}
|
||||||
<LightsWidget />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{config.locks.length > 0 && (
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<LocksWidget />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useUIStore } from '@/stores/uiStore';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
import { useSettingsStore } from '@/stores/settingsStore';
|
||||||
import { env } from '@/config/environment';
|
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() {
|
export function Header() {
|
||||||
const connectionState = useConnectionState();
|
const connectionState = useConnectionState();
|
||||||
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
|
const cameraOverlayOpen = useUIStore((state) => state.cameraOverlayOpen);
|
||||||
@@ -239,8 +275,9 @@ export function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right - Connection Status */}
|
{/* Right - Weather + Connection */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-4">
|
||||||
|
<WeatherBadge />
|
||||||
<div className="status-badge">
|
<div className="status-badge">
|
||||||
<div className={`status-dot ${getConnectionStatusClass()}`} />
|
<div className={`status-dot ${getConnectionStatusClass()}`} />
|
||||||
<span>{getConnectionText()}</span>
|
<span>{getConnectionText()}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user