# Observation chart

> A record's latest vital signs with the flag against each reference range and a sparkline of the recent readings.

- Block: [observations](https://bettersupabase.com/blocks/observations.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/observation-chart

## Install the block

```bash
npx better-supabase add observations
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button empty skeleton table @reui/frame
```

From `apps/examples/nextjs/src/features/observations/components/observation-chart.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/observations/components/observation-chart.tsx):

```tsx
// oxlint-disable-next-line typescript/require-await -- a Server Action must be async, and refresh() returns nothing to await
async function retry() {
  "use server";
  refresh();
}

const FLAG_VARIANT = {
  normal: "success-light",
  low: "warning-light",
  high: "warning-light",
  critical_low: "destructive",
  critical_high: "destructive",
} as const satisfies Record<ObservationFlag, string>;

const WIDTH = 120;
const HEIGHT = 32;

/** The values as an SVG polyline, scaled to the box. */
function Sparkline({
  values,
  label,
}: {
  readonly values: readonly number[];
  readonly label: string;
}) {
  if (values.length < 2) {
    return null;
  }

  const low = Math.min(...values);
  const span = Math.max(...values) - low || 1;
  const step = WIDTH / (values.length - 1);

  const points = values
    .map(
      (value, index) =>
        `${(index * step).toFixed(1)},${(HEIGHT - 2 - ((value - low) / span) * (HEIGHT - 4)).toFixed(1)}`,
    )
    .join(" ");

  return (
    <>
      <span className="sr-only">{label}</span>
      <svg
        aria-hidden="true"
        viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
        className="h-8 w-30 text-primary"
      >
        <polyline
          points={points}
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinejoin="round"
          strokeLinecap="round"
        />
      </svg>
    </>
  );
}

/**
 * Render inside `<Suspense>`: a record's latest vital signs, each with its flag
 * against the reference range and a sparkline of its recent values.
 */
export async function ObservationChart() {
  const [state, t, format] = await Promise.all([
    getVitals(),
    getExtracted("observations"),
    getFormatter(),
  ]);

  if (state.kind === "no-organization") {
    return null;
  }

  if (state.kind === "error") {
    return (
      <Alert variant="destructive" data-recipe="observation-chart">
        <TriangleAlertIcon />
        <AlertTitle>{t("The vital signs didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const { customer, rows } = state.data;

  const flagLabel = (flag: ObservationFlag): string => {
    switch (flag) {
      case "normal": {
        return t("Normal");
      }

      case "low": {
        return t("Low");
      }

      case "high": {
        return t("High");
      }

      case "critical_low": {
        return t("Critically low");
      }

      case "critical_high": {
        return t("Critically high");
      }

      default: {
        const unknown: never = flag;

        return unknown;
      }
    }
  };

  return (
    <Frame spacing="sm" data-recipe="observation-chart">
      <FrameHeader className="gap-0.5">
        <FrameTitle>{t("Vital signs")}</FrameTitle>
        <FrameDescription>
          {customer === null
            ? t("The latest readings of a customer, against their ranges.")
            : t("The latest readings for {name}, against their ranges.", {
                name: customer.name,
              })}
        </FrameDescription>
      </FrameHeader>
      <FramePanel>
        {rows.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HeartPulseIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No readings yet")}</EmptyTitle>
              <EmptyDescription>
                {t(
                  "Recorded vital signs appear here with their flag and recent trend.",
                )}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <a
                className="text-sm underline underline-offset-4"
                href="https://bettersupabase.com/docs/blocks/observations#record"
              >
                {t("Read how to record an observation")}
              </a>
            </EmptyContent>
          </Empty>
        ) : (
          <Table aria-label={t("Latest vital signs")}>
            <TableHeader>
              <TableRow>
                <TableHead>{t("Measurement")}</TableHead>
                <TableHead>{t("Latest")}</TableHead>
                <TableHead>{t("Flag")}</TableHead>
                <TableHead>{t("Trend")}</TableHead>
                <TableHead>{t("Observed")}</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {rows.map((row) => (
                <TableRow key={row.key}>
                  <TableCell className="font-medium">{row.title}</TableCell>
                  <TableCell className="tabular-nums">
                    {row.unit === null ? row.value : `${row.value} ${row.unit}`}
                  </TableCell>
                  <TableCell>
                    {row.flag === null ? null : (
                      <Badge variant={FLAG_VARIANT[row.flag]}>
                        {flagLabel(row.flag)}
                      </Badge>
                    )}
                  </TableCell>
                  <TableCell>
                    <Sparkline
                      values={row.series}
                      label={t("{title}: the last {count, number} readings", {
                        title: row.title,
                        count: row.series.length,
                      })}
                    />
                  </TableCell>
                  <TableCell className="text-muted-foreground">
                    {format.dateTime(new Date(row.observedAt), {
                      dateStyle: "medium",
                      timeStyle: "short",
                    })}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        )}
      </FramePanel>
    </Frame>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Observation chart" in a React web app that uses shadcn/ui components. A record's latest vital signs with the flag against each reference range and a sparkline of the recent readings.

It uses the better-supabase `observations` block (Observations). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add observations` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button empty skeleton table @reui/frame`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/observations/components/observation-chart.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/observation-chart.md
- Block docs: https://bettersupabase.com/docs/blocks/observations
```
