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:
14
src/App.tsx
14
src/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user