---
title: "Widgets that show data over time"
description: "Reading Home Assistant history and statistics from a widget, and rendering a different tile at 2x1 than at 3x2."
canonical: https://glasshome.app/blog/widget-data-over-time
date: 2026-08-26
tags: ["Widgets","Developers"]
---
[Part 1](/blog/build-your-first-glasshome-widget) built a tile that answers "what is happening now". This one answers "what has been happening":

That curve is a real day of solar production, from Home Assistant's recorder.

## Three sources, not one

| You want                          | Use                                       | Comes from                           |
| --------------------------------- | ----------------------------------------- | ------------------------------------ |
| The value now                     | `useEntity`, `useEntities`                | Dash's live store, already in memory |
| The last hours or days            | `trackEntityHistory` + `useEntityHistory` | HA's recorder, fetched on demand     |
| Weeks, months, or sums per bucket | `useEntityStatistics`                     | HA's long-term statistics            |

The first is free. The other two are fetches you ask for explicitly. Most widgets want the middle row; reach for statistics when the window would run to thousands of state changes, or when you want HA to do the bucketing.

## Step 1: ask for history, then read it

`useEntityHistory` does not fetch. It reads a slice of Dash's store that exists only if something asked for it, so track on mount and untrack on cleanup:

```tsx
import {
  trackEntityHistory,
  untrackEntityHistory,
  useEntityHistory,
} from "@glasshome/widget-sdk";
import { createMemo, onCleanup, onMount } from "solid-js";

const entityId = () => props.config.entityIds[0] ?? "";
const history = useEntityHistory(entityId);

onMount(() => {
  const id = entityId();
  if (id) trackEntityHistory(id, { startTime: new Date(Date.now() - 24 * 3_600_000) });
});
onCleanup(() => {
  const id = entityId();
  if (id) untrackEntityHistory(id);
});

const points = createMemo(() =>
  (history()?.timeline ?? [])
    .map((entry) => ({ value: Number(entry.state), timestamp: entry.timestamp }))
    .filter((p) => !Number.isNaN(p.value)),
);
```

Skip the `onCleanup` and the entity stays tracked for the life of the page. `history()` gives you `{ timeline, loading, error, lastFetched }`. The timeline is state changes, not evenly spaced samples, so plot against the timestamps.

The `Number(...)` and `filter` matter: entity states are strings, and a briefly `unavailable` sensor puts `NaN` in your series, which turns an SVG path into nothing.

## Step 2: turn the series into a line

`monotoneCubicPath` takes points and returns an SVG path, with no overshoot between samples. Scale your values into the box first:

```tsx
import { monotoneCubicPath } from "@glasshome/widget-sdk";

const path = createMemo(() => {
  const pts = props.points;
  const w = size().width - 24;
  const h = 46;
  if (pts.length < 2 || w <= 0) return "";

  const values = pts.map((p) => p.value);
  const min = Math.min(...values);
  const max = Math.max(...values);
  const span = max - min || 1;            // a flat series would divide by zero
  const t0 = pts[0]?.timestamp ?? 0;
  const tSpan = (pts[pts.length - 1]?.timestamp ?? t0) - t0 || 1;

  return monotoneCubicPath(
    pts.map((p) => ({
      x: ((p.timestamp - t0) / tSpan) * w,
      y: h - ((p.value - min) / span) * h,
    })),
  );
});
```

```tsx
<svg class="mt-1 w-full" height="46" aria-hidden="true">
  <path d={path()} fill="none" stroke="var(--widget-color)" stroke-width="2.5" stroke-linecap="round" />
</svg>
```

`stroke="var(--widget-color)"` takes the tile's tone, so the line matches the widget in both themes without you picking a colour.

## Step 3: one component, two sizes

A 1x1 tile has no room for a chart. Measure the shell instead of shipping two widgets:

```tsx
import { useWidgetDimensions } from "@glasshome/widget-sdk";

const size = useWidgetDimensions();
const roomForChart = () => size().height >= 140 && points().length > 1;
```

```tsx
<Widget.Value value={Math.round(latest())} unit={unit()} />
<Show when={roomForChart()}>
  <Sparkline points={points()} />
</Show>
```

Same code, same config, different tile. `size()` is an accessor, so resizing re-decides.

> **Call it from inside \`\<Widget>\`:** `useWidgetDimensions` throws if you call it in the widget function itself. Put it in a child component that renders inside `<Widget>`, like `PowerContent` below.

## Longer windows: statistics

For a month of data, ask Home Assistant for pre-bucketed values instead of every state change:

```tsx
const stats = useEntityStatistics(
  () => props.config.entityIds[0] ?? "",
  () => ({ startTime: new Date(Date.now() - 30 * 24 * 3_600_000), period: "day" }),
);
```

You get buckets with `start`, `end` and the statistics that entity carries. It's a Solid resource, so `stats.loading` and `stats.error` are there, and it re-fetches when the id or options change.

Rule of thumb: `sum` for metered things (energy, water), `mean` for sampled ones (temperature, power draw).

## The whole widget

```tsx
// src/my-power/index.tsx
import {
  defineConfig,
  defineWidget,
  field,
  type Infer,
  monotoneCubicPath,
  trackEntityHistory,
  untrackEntityHistory,
  useEntities,
  useEntityHistory,
  useWidgetDimensions,
  useWidgetEntityGroup,
  Widget,
} from "@glasshome/widget-sdk";
import { Icon } from "@iconify-icon/solid";
import { createMemo, onCleanup, onMount, Show } from "solid-js";

const configSchema = defineConfig({
  title: field.title(),
  entityIds: field.entity("sensor"),
  hours: field.number({ title: "Hours of history", default: 24, min: 1, max: 72 }),
});

type Config = Infer<typeof configSchema>;

function Sparkline(props: { points: { value: number; timestamp: number }[] }) {
  const size = useWidgetDimensions();

  const path = createMemo(() => {
    const pts = props.points;
    const w = size().width - 24;
    const h = 46;
    if (pts.length < 2 || w <= 0) return "";
    const values = pts.map((p) => p.value);
    const min = Math.min(...values);
    const max = Math.max(...values);
    const span = max - min || 1;
    const t0 = pts[0]?.timestamp ?? 0;
    const tSpan = (pts[pts.length - 1]?.timestamp ?? t0) - t0 || 1;
    return monotoneCubicPath(
      pts.map((p) => ({
        x: ((p.timestamp - t0) / tSpan) * w,
        y: h - ((p.value - min) / span) * h,
      })),
    );
  });

  return (
    <svg class="mt-1 w-full" height="46" aria-hidden="true">
      <path d={path()} fill="none" stroke="var(--widget-color)" stroke-width="2.5" stroke-linecap="round" />
    </svg>
  );
}

// Inside <Widget>, so it may measure the shell.
function PowerContent(props: { config: Config; latest: () => number; unit: () => string }) {
  const size = useWidgetDimensions();
  const entityId = () => props.config.entityIds[0] ?? "";
  const history = useEntityHistory(entityId);

  onMount(() => {
    const id = entityId();
    if (id)
      trackEntityHistory(id, {
        startTime: new Date(Date.now() - props.config.hours * 3_600_000),
      });
  });
  onCleanup(() => {
    const id = entityId();
    if (id) untrackEntityHistory(id);
  });

  const points = createMemo(() =>
    (history()?.timeline ?? [])
      .map((entry) => ({ value: Number(entry.state), timestamp: entry.timestamp }))
      .filter((p) => !Number.isNaN(p.value)),
  );

  const roomForChart = () => size().height >= 140 && points().length > 1;

  return (
    <Widget.Content>
      <Widget.Icon icon={<Icon icon="mdi:solar-power-variant" />} />
      <div class="flex min-w-0 flex-col gap-1">
        <Widget.Title>{props.config.title || "Power"}</Widget.Title>
        <Widget.Value value={Math.round(props.latest())} unit={props.unit()} />
        <Show when={roomForChart()}>
          <Sparkline points={points()} />
        </Show>
      </div>
    </Widget.Content>
  );
}

function MyPowerWidget(props: { config: Config }) {
  const entities = useEntities(() => props.config.entityIds);

  const { aggregatedData, emptyState, hasEntities } = useWidgetEntityGroup({
    entities,
    aggregationMode: () => "sensor",
    emptyStateConfig: {
      icon: <Icon icon="mdi:flash" width={32} />,
      title: "No sensor yet",
      message: "Hold to pick one",
    },
  });

  const sensor = createMemo(() => {
    const data = aggregatedData();
    return data && "numericValue" in data ? data : undefined;
  });

  return (
    <Widget variant="classic-glass" tone="accent" emptyState={emptyState()} loading={!hasEntities()}>
      <Show when={hasEntities()}>
        <PowerContent
          config={props.config}
          latest={() => sensor()?.numericValue ?? 0}
          unit={() => sensor()?.unit ?? ""}
        />
      </Show>
    </Widget>
  );
}

export default defineWidget<Config>({
  manifest: {
    name: "My Power",
    description: "A sensor value with its recent history",
    icon: "mdi:solar-power-variant",
    minSize: { w: 1, h: 1 },
    maxSize: { w: 4, h: 4 },
    defaultSize: { w: 2, h: 2 },
    sdkVersion: "^1.11.2",
    capabilities: [{ domain: "sensor", access: "read" }],
  },
  configSchema,
  component: MyPowerWidget,
});
```

Note the capability: `read` on `sensor`, because this widget changes nothing.

> **A trap when you render previews:** The demo home only has history for its energy entities, so point your examples at `sensor.solar_power` or `sensor.home_power`. Anything else renders a tile with no line.