Cabin dashboard with i99dash/react
A four-tile dashboard that combines car status, climate, AQI, and now-playing — using i99dash/react hooks. Shows the per-family fallback pattern + how `client.has` lets you hide tiles on hosts that don't ship a family.
A real "everything but the kitchen sink" tile layout — four read-only families on one screen, each gracefully degrading on hosts that don't ship them. The full source is < 150 lines.
┌──────────────────────┬──────────────────────┐
│ 🚗 88% 🔒 │ 🌡 21°C → 22°C │
│ Tesla S · parked │ AC · auto · 40% │
├──────────────────────┼──────────────────────┤
│ 💨 AQI 42 · pm 12 │ 🎵 Track / Artist │
│ ambient 800 lux │ bluetooth · 40% │
└──────────────────────┴──────────────────────┘Prerequisites
- A scaffolded React mini-app (Next.js / Vite / CRA — anything).
i99dash≥ 0.3 andi99dash/react≥ 0.1.
Manifest
{
"id": "cabin_dashboard",
"name": { "en": "Cabin Dashboard" },
"version": "1.0.0",
"url": "https://miniapps.i99dash.app/cabin/",
"icon": "./assets/icon.svg",
"category": "vehicle"
}App root — provider + capability check
// app/page.tsx (Next.js App Router)
'use client';
import { useEffect, useState } from 'react';
import { MiniAppProvider } from 'i99dash/react';
import { createClientOrSSR, type MiniAppClient } from 'i99dash';
import { Dashboard } from './Dashboard';
export default function Page() {
// Start null on both server and client first paint — matches the
// SSR tree, then the effect produces a single real client on mount.
const [client, setClient] = useState<MiniAppClient | null>(null);
useEffect(() => setClient(createClientOrSSR()), []);
return (
<MiniAppProvider client={client}>
<Dashboard />
</MiniAppProvider>
);
}Capability-aware layout
The dashboard hides tiles for families the host doesn't ship. Compute the capability set once at mount; render conditionally.
// app/Dashboard.tsx
'use client';
import { useEffect, useState } from 'react';
import { useClient } from 'i99dash/react';
import { CarTile } from './tiles/CarTile';
import { ClimateTile } from './tiles/ClimateTile';
import { EnvironmentTile } from './tiles/EnvironmentTile';
import { MediaTile } from './tiles/MediaTile';
export function Dashboard() {
const client = useClient();
const [caps, setCaps] = useState<Set<string>>(new Set());
useEffect(() => {
if (!client) return;
client.capabilities().then((c) => setCaps(new Set(c.families)));
}, [client]);
return (
<div className="grid">
{/* `car.status` (no `.read`) is the legacy family ID the host
reports back — distinct from the manifest scope `car.status.read`.
Other families use the same string for both. */}
{caps.has('car.status') && <CarTile />}
{caps.has('climate.read') && <ClimateTile />}
{caps.has('vehicle.environment') && <EnvironmentTile />}
{caps.has('media.read') && <MediaTile />}
</div>
);
}Tiles — one per family
Each tile owns its own subscription via the matching hook. Hooks
return { data, error }; the fallback keeps the layout stable
during SSR / before the first event.
Car
import { useCarStatus } from 'i99dash/react';
import type { CarStatus } from 'i99dash';
const fallback = {
deviceId: '',
brand: 'byd',
at: '1970-01-01T00:00:00.000Z',
staleness: 'very_stale',
batteryPct: 0,
doorsLocked: true,
isMoving: false,
} satisfies CarStatus;
export function CarTile() {
const { data } = useCarStatus({ fallback });
return (
<div className="tile">
<div className="row">
<span>🚗 {data?.batteryPct}%</span>
<span>{data?.doorsLocked ? '🔒' : '🔓'}</span>
</div>
<small>{data?.isMoving ? 'driving' : 'parked'}</small>
</div>
);
}Climate
import { useClimate } from 'i99dash/react';
export function ClimateTile() {
const { data } = useClimate();
if (!data) return <div className="tile tile--idle">…</div>;
return (
<div className="tile">
<div className="row">
🌡 {data.cabinTempC.toFixed(0)}°C → {data.setpointC.toFixed(0)}°C
</div>
<small>
{data.mode === 'off' ? 'off' : `AC · ${data.mode} · ${Math.round(data.fanSpeed * 100)}%`}
</small>
</div>
);
}Environment
import { useVehicleEnvironment } from 'i99dash/react';
export function EnvironmentTile() {
const { data } = useVehicleEnvironment();
if (!data) return <div className="tile tile--idle">…</div>;
return (
<div className="tile">
<div className="row">
💨 AQI {data.aqi ?? '—'} · pm {data.pm25 ?? '—'}
</div>
<small>ambient {data.ambientLightLux ?? '—'} lux</small>
</div>
);
}Media
import { useMedia } from 'i99dash/react';
export function MediaTile() {
const { data } = useMedia();
if (!data || data.source === 'none')
return <div className="tile tile--idle">— silence —</div>;
return (
<div className="tile">
<div className="row">🎵 {data.title ?? '(untitled)'} / {data.artist ?? ''}</div>
<small>
{data.source} · {Math.round(data.volume * 100)}%
</small>
</div>
);
}What you get for free
- One bridge subscription per tile, refcounted. N hooks rendering the same family share the underlying sub.
- Page Visibility pause + catch-up. Backgrounded tab costs near-zero CPU; first visible frame has the latest payload.
- Per-family throttle — host bucket sizes are family-specific (e.g. media 15 / 33 ms for volume scrubbing, climate 4 / 250 ms for setpoint changes). Latest event always wins.
- Schema validation. Malformed payloads drop with a dev
console.warn; your render never crashes on a host bug. - SSR-safe. Server renders the fallback; client re-renders with real data after hydration.
Adding a new family later
If you want to add system.read (units / brightness) or
connectivity.read (network type) later, render a tile that uses
useSystem() / useConnectivity(). No extra wiring. Each family
is structurally identical from the React side.
Related
i99dash/react— every hook on one page.- Subscriptions guide — what the hooks do under the hood.
- Now-playing widget recipe — same media surface, no React.
App icons + cover image + screenshots
How to ship a real icon (and optional cover image / screenshots) with your bundle so the catalog renders them at a versioned CDN URL.
Real-time car status widget
End-to-end recipe — scaffold, wire `client.car.read` + `client.car.subscribe`, render door / battery / lock state with live updates, ship it. ~30 minutes.