Wristkit / Documentation
Activity Card
Shows today's Move, Exercise and Steps rings, with every state covered.
- Move
- 544/ 600 kcal
- Exercise
- 80/ 30 min
- Steps
- 6,480/ 8,000 steps
Sample data · configurable demo goals
Overview
TodayActivityCard renders three activity rings (Move in kcal, Exercise in minutes and Steps) with real data from your Supabase database. It also handles every state the component can be in, from loading to fully synced.
How to install it
There is no CLI. You copy the files below into your project:
- The eight files in the bundle further down this page go into
components/wristkit/today-activity-card/andlib/wristkit/(paths are shown on each tab) - The route handler at Installation goes into
app/api/wristkit-sync/route.ts - The SQL migrations from the same page go into Supabase
Make sure you have these peer dependencies in your project: drizzle-orm, postgres, zod.
Usage
import { TodayActivityCard, loadTodayActivity } from "@/components/wristkit/today-activity-card";
export default async function Page() {
const state = await loadTodayActivity({ tz: "America/Sao_Paulo" });
return <TodayActivityCard state={state} />;
}loadTodayActivity() is a server side helper. It reads from your Supabase database and returns the right state. Pass it straight to the component, so you never have to think about the state machine yourself.
Options
loadTodayActivity({
tz?: string, // IANA timezone for the day boundary and label. Default "UTC".
goals?: {
kcal?: number, // default 600
exerciseMinutes?: number, // default 30
steps?: number, // default 8000
},
});Props
type Props = {
state: TodayState;
className?: string;
};States
The component covers every state below. For the reasoning behind the contract, see Component states.
ok
Activity is available for today. The rings show progress toward configured goals, with real numbers and a synced status.
- Move
- 544/ 600 kcal
- Exercise
- 80/ 30 min
- Steps
- 6,480/ 8,000 steps
loading
Activity is being loaded. Short arcs travel around fixed ring tracks and the values show pulsing placeholders. Motion stops when reduced motion is enabled.
- Move
- —kcal
- Exercise
- —min
- Steps
- —steps
empty
Nothing has been synced yet. The ring tracks are dotted. Values show as —. The footer invites the user to install the Shortcut.
- Move
- —kcal
- Exercise
- —min
- Steps
- —steps
stale
Data exists but it is older than 24 hours. The arcs are dimmed, the status shows stale, and the footer reports the time since the last sync.
- Move
- 412/ 600 kcal
- Exercise
- 18/ 30 min
- Steps
- 4,280/ 8,000 steps
error
Something went wrong during sync. The card shows a short, generic message — it never echoes the underlying error or code so you don't leak details about your auth or your database.
- Move
- —kcal
- Exercise
- —min
- Steps
- —steps
Rings only
A rendering example using the ActivityRings export from states.tsx, with sample data and no card chrome.
Customization
The component imports styles.css, included in the file bundle. It follows Entrepta color and typography tokens when available, with standalone defaults for other projects. Customize the --wk-* variables in that stylesheet to match your site.
The examples use sample data and configured goals. Goals in this version are not imported from Apple Health.
Files to copy
Drop each file in its destination path. Copy with the button on the right.
import type * as React from "react";
import type { TodayState } from "./load";
import { loadTodayActivity } from "./load";
import {
TodayActivityCardEmpty,
TodayActivityCardError,
TodayActivityCardLoading,
TodayActivityCardOk,
TodayActivityCardStale,
} from "./states";
export { loadTodayActivity };
export type { TodayData, TodayState } from "./load";
export function TodayActivityCard({
state,
className,
}: {
state: TodayState;
className?: string;
}): React.JSX.Element {
switch (state.kind) {
case "loading":
return <TodayActivityCardLoading className={className} />;
case "empty":
return <TodayActivityCardEmpty className={className} />;
case "error":
return <TodayActivityCardError className={className} />;
case "stale":
return <TodayActivityCardStale className={className} data={state.data} />;
case "ok":
return <TodayActivityCardOk className={className} data={state.data} />;
}
}
import type * as React from "react";
import type { TodayData } from "./load";
import "./styles.css";
const METRICS = [
{ id: "move", label: "Move", value: "kcal", goal: "kcalGoal", unit: "kcal" },
{
id: "exercise",
label: "Exercise",
value: "exerciseMinutes",
goal: "exerciseGoal",
unit: "min",
},
{ id: "steps", label: "Steps", value: "steps", goal: "stepsGoal", unit: "steps" },
] as const;
type DisplayKind = "loading" | "empty" | "error" | "stale" | "ok";
export function ActivityRings({ data, kind = "ok" }: { data?: TodayData; kind?: DisplayKind }) {
return (
<svg className="wk-rings" viewBox="0 0 200 200" role="img" aria-label="Activity rings">
<title>Activity rings</title>
{METRICS.map((metric, index) => {
const radius = 84 - index * 23;
const value = data?.[metric.value] ?? 0;
const goal = data?.[metric.goal] ?? 0;
const progress =
goal > 0 && Number.isFinite(value) ? Math.max(0, Math.min(value / goal, 1)) : 0;
return (
<g key={metric.id} className={`wk-ring wk-ring--${metric.id}`}>
<circle className="wk-ring-track" cx="100" cy="100" r={radius} />
{/* A zero-length dash with round caps still paints a dot, so an
empty ring draws no value arc at all. */}
{kind === "loading" || progress > 0 ? (
<circle
className="wk-ring-value"
cx="100"
cy="100"
r={radius}
pathLength="100"
strokeDasharray={kind === "loading" ? "18 82" : `${progress * 100} 100`}
transform="rotate(-90 100 100)"
/>
) : null}
</g>
);
})}
<path className="wk-ring-center" d="M94 99h12m-5-5 5 5-5 5" />
</svg>
);
}
function ActivityPanel({
kind,
data,
className,
}: { kind: DisplayKind; data?: TodayData; className?: string }) {
const status = kind === "ok" ? "synced" : kind;
const notes: Record<DisplayKind, React.ReactNode> = {
ok: "Up to date",
loading: "Syncing your activity…",
empty: "No data yet. Run the Shortcut on your iPhone.",
error: "Something went wrong. We couldn't load today's activity.",
stale: `Last sync ${data?.hoursSinceSync ?? 0}h ago. Run the Shortcut to update.`,
};
return (
<section
className={`wk-activity ${className ?? ""}`}
data-state={kind}
aria-label="Today's activity"
aria-busy={kind === "loading"}
>
<header className="wk-activity-header">
<span className="wk-activity-label">
<span aria-hidden>↗</span> Today / Activity
</span>
<span className="wk-activity-status">
<span aria-hidden className="wk-status-dot" />
{status}
</span>
</header>
<div className="wk-activity-body">
<ActivityRings kind={kind} data={data} />
<dl className="wk-metrics">
{METRICS.map((metric) => (
<div key={metric.id} className={`wk-metric wk-ring--${metric.id}`}>
<dt>
<span className="wk-metric-dot" aria-hidden />
{metric.label}
</dt>
<dd>
<span className="wk-metric-value">
{data ? Math.round(data[metric.value]).toLocaleString("en-US") : "—"}
</span>
<span className="wk-metric-goal">
{data ? `/ ${data[metric.goal].toLocaleString("en-US")} ` : ""}
{metric.unit}
</span>
</dd>
</div>
))}
</dl>
</div>
<footer className="wk-activity-footer">
<span aria-live="polite">{notes[kind]}</span>
{kind === "ok" && data ? (
<time dateTime={data.lastSyncIso}>Synced {data.lastSyncLabel}</time>
) : null}
{kind === "empty" ? <span>Install Shortcut to connect.</span> : null}
{kind === "error" ? <span>Please try again later.</span> : null}
</footer>
</section>
);
}
export function TodayActivityCardLoading({ className }: { className?: string }) {
return <ActivityPanel kind="loading" className={className} />;
}
export function TodayActivityCardEmpty({ className }: { className?: string }) {
return <ActivityPanel kind="empty" className={className} />;
}
export function TodayActivityCardError({ className }: { className?: string }) {
return <ActivityPanel kind="error" className={className} />;
}
export function TodayActivityCardStale({
data,
className,
}: { data: TodayData; className?: string }) {
return <ActivityPanel kind="stale" data={data} className={className} />;
}
export function TodayActivityCardOk({ data, className }: { data: TodayData; className?: string }) {
return <ActivityPanel kind="ok" data={data} className={className} />;
}
/* Portable fallbacks; Entrepta tokens take precedence when present. */
.wk-activity,
.wk-rings-only {
--wk-bg: var(--bg-card, #0b0b0e);
--wk-text: var(--fg-primary, #fafafa);
--wk-muted: var(--fg-muted, #8a8a92);
--wk-border: var(--border-subtle, #27272a);
--wk-move: var(--fg-brand, #7c6bff);
--wk-exercise: var(--status-success, #10b981);
--wk-steps: var(--status-warning, #f59e0b);
color: var(--wk-text);
font-family: var(--font-mono, ui-monospace, monospace);
}
.wk-activity {
container-type: inline-size;
position: relative;
border: 1px solid var(--wk-border);
border-radius: 20px;
padding: 24px;
overflow: hidden;
background: radial-gradient(
ellipse at 0 0,
color-mix(in srgb, var(--wk-move) 8%, transparent),
transparent 65%
), var(--wk-bg);
box-shadow: var(--shadow-card, none);
}
.wk-activity-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.wk-activity-label,
.wk-activity-status,
.wk-metric dt,
.wk-metric-goal,
.wk-activity-footer {
font-size: var(--text-mono-sm, 12px);
line-height: var(--text-mono-sm--line-height, 1.4);
}
.wk-activity-label {
color: var(--wk-muted);
}
.wk-activity-label > span {
color: var(--fg-brand-text, var(--wk-move));
margin-right: 8px;
}
.wk-activity-status {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 9px;
border-radius: 100px;
color: var(--status-success-fg, #34d399);
background: var(--status-success-soft, #10b9811f);
}
.wk-status-dot,
.wk-metric-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.wk-activity-body {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1fr);
gap: 28px;
align-items: center;
padding: 32px 0;
min-height: 252px;
}
.wk-rings {
display: block;
width: 100%;
max-width: 224px;
margin: auto;
overflow: visible;
}
.wk-ring--move {
--wk-ring: var(--wk-move);
}
.wk-ring--exercise {
--wk-ring: var(--wk-exercise);
}
.wk-ring--steps {
--wk-ring: var(--wk-steps);
}
.wk-ring circle {
fill: none;
stroke: var(--wk-ring);
stroke-width: 14;
}
.wk-ring-track {
opacity: 0.14;
}
.wk-ring-value {
stroke-linecap: round;
}
.wk-ring-center {
fill: none;
stroke: var(--wk-muted);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.wk-metrics {
margin: 0;
min-width: 0;
}
.wk-metric {
padding: 12px 0;
}
.wk-metric + .wk-metric {
border-top: 1px solid var(--wk-border);
}
.wk-metric dt {
display: flex;
align-items: center;
gap: 8px;
color: var(--wk-muted);
}
.wk-metric-dot {
color: var(--wk-ring);
}
.wk-metric dd {
margin: 4px 0 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
.wk-metric-value {
font-family: var(--font-serif, Georgia, serif);
font-size: var(--text-display-md, 40px);
line-height: var(--text-display-md--line-height, 1.1);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.wk-metric-goal {
color: var(--wk-muted);
white-space: nowrap;
}
.wk-activity-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 16px;
color: var(--wk-muted);
border-top: 1px dashed var(--wk-border);
padding-top: 16px;
min-height: 50px;
}
.wk-activity-footer time {
white-space: nowrap;
}
.wk-activity[data-state="empty"] .wk-ring-track,
.wk-activity[data-state="error"] .wk-ring-track {
stroke-dasharray: 2 5;
opacity: 0.35;
}
.wk-activity[data-state="error"] .wk-activity-status {
color: var(--status-error-fg, #fb7185);
background: var(--status-error-soft, #f43f5e1f);
}
.wk-activity[data-state="stale"] .wk-activity-status {
color: var(--status-warning-fg, #fbbf24);
background: var(--status-warning-soft, #f59e0b1f);
}
.wk-activity[data-state="empty"] .wk-activity-status,
.wk-activity[data-state="loading"] .wk-activity-status {
color: var(--wk-muted);
background: var(--bg-surface, #18181b);
}
.wk-activity[data-state="loading"] .wk-ring-value {
animation: wk-orbit 2s linear infinite;
}
.wk-activity[data-state="loading"] .wk-metric-value {
animation: wk-pulse 1.8s ease-in-out infinite;
}
.wk-activity[data-state="stale"] .wk-ring-value {
opacity: 0.65;
}
@keyframes wk-orbit {
to {
stroke-dashoffset: -100;
}
}
@keyframes wk-pulse {
50% {
opacity: 0.3;
}
}
@container (max-width: 340px) {
.wk-activity-body {
grid-template-columns: 1fr;
gap: 16px;
padding: 24px 0;
}
.wk-rings {
max-width: 180px;
}
.wk-metric {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.wk-metric dd {
justify-content: flex-end;
}
.wk-metric-value {
font-size: var(--text-heading-lg, 24px);
}
}
@media (prefers-reduced-motion: reduce) {
.wk-activity *,
.wk-activity *::before,
.wk-activity *::after {
animation: none !important;
transition: none !important;
}
}
import { getTodayActivity } from "@/lib/wristkit/queries";
export type Goals = {
kcal: number;
exerciseMinutes: number;
steps: number;
};
export const DEFAULT_GOALS: Goals = {
kcal: 600,
exerciseMinutes: 30,
steps: 8000,
};
export type TodayData = {
kcal: number;
kcalGoal: number;
exerciseMinutes: number;
exerciseGoal: number;
steps: number;
stepsGoal: number;
/** Raw timestamp of the freshest sample (in UTC). */
lastSyncIso: string;
/** Pre-formatted "HH:mm" label in the requested tz. Safe for SSR/CSR. */
lastSyncLabel: string;
/** Whole hours since the freshest sample, snapped to >= 1 for the stale UI. */
hoursSinceSync: number;
};
export type TodayState =
| { kind: "loading" }
| { kind: "empty" }
| { kind: "error"; message?: string }
| { kind: "stale"; data: TodayData }
| { kind: "ok"; data: TodayData };
export type LoadOptions = {
/** IANA timezone for "today" boundary and the rendered timestamp. Defaults to UTC. */
tz?: string;
/** Override the default daily goals (600 kcal / 30 min / 8000 steps). */
goals?: Partial<Goals>;
};
const STALE_MS = 24 * 60 * 60 * 1000;
function formatHourMinute(d: Date, tz: string): string {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: tz,
}).format(d);
}
export async function loadTodayActivity(options: LoadOptions = {}): Promise<TodayState> {
const url = process.env.WRISTKIT_DATABASE_URL;
if (!url) return { kind: "error", message: "WRISTKIT_DATABASE_URL not set" };
const tz = options.tz ?? "UTC";
const goals: Goals = { ...DEFAULT_GOALS, ...options.goals };
try {
const r = await getTodayActivity(url, tz);
if (!r.lastSync) return { kind: "empty" };
const ageMs = Date.now() - r.lastSync.getTime();
const data: TodayData = {
kcal: r.kcal ?? 0,
kcalGoal: goals.kcal,
exerciseMinutes: r.exerciseMinutes ?? 0,
exerciseGoal: goals.exerciseMinutes,
steps: r.steps ?? 0,
stepsGoal: goals.steps,
lastSyncIso: r.lastSync.toISOString(),
lastSyncLabel: formatHourMinute(r.lastSync, tz),
hoursSinceSync: Math.max(1, Math.round(ageMs / (60 * 60 * 1000))),
};
if (ageMs > STALE_MS) return { kind: "stale", data };
return { kind: "ok", data };
} catch (err) {
return {
kind: "error",
message: err instanceof Error ? err.message : "unknown",
};
}
}
import { and, desc, eq, gte } from "drizzle-orm";
import { createDb } from "./db";
import { samples } from "./schema";
import type { Metric } from "./validation";
/**
* Midnight of "today" in the requested IANA timezone (e.g. "America/Sao_Paulo").
* Defaults to UTC. Using server time directly is wrong: on Vercel that is
* UTC, so a São Paulo user at 21:00 already crosses the next-day boundary
* and sees an empty card.
*/
function startOfToday(tz: string): Date {
const now = new Date();
// Parse the moment as a wall-clock string in the target tz, then re-read
// it in the Node tz. The diff is the tz offset in ms. Classic trick.
const local = new Date(now.toLocaleString("en-US", { timeZone: tz }));
const offsetMs = local.getTime() - now.getTime();
const midnightInNodeTz = new Date(local.getFullYear(), local.getMonth(), local.getDate());
return new Date(midnightInNodeTz.getTime() - offsetMs);
}
export async function getLatestByMetric(url: string, metric: Metric, tz: string) {
const { db } = createDb(url);
return db
.select()
.from(samples)
.where(and(eq(samples.metric, metric), gte(samples.recordedAt, startOfToday(tz))))
.orderBy(desc(samples.recordedAt))
.limit(1);
}
export async function getTodayActivity(url: string, tz: string) {
const [kcal, ex, steps] = await Promise.all([
getLatestByMetric(url, "kcal", tz),
getLatestByMetric(url, "exercise_minutes", tz),
getLatestByMetric(url, "steps", tz),
]);
// lastSync = the most recent sample we actually have, not the most recent
// ingestion time. A 03:00 backfill of yesterday's data must still register
// as stale.
const lastSync =
[kcal[0]?.recordedAt, ex[0]?.recordedAt, steps[0]?.recordedAt]
.filter((x): x is Date => Boolean(x))
.sort((a, b) => a.getTime() - b.getTime())
.pop() ?? null;
return {
kcal: kcal[0]?.value != null ? Number(kcal[0].value) : null,
exerciseMinutes: ex[0]?.value != null ? Number(ex[0].value) : null,
steps: steps[0]?.value != null ? Number(steps[0].value) : null,
lastSync,
};
}
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres, { type Sql } from "postgres";
import * as schema from "./schema";
export type RegistryDb = {
db: PostgresJsDatabase<typeof schema>;
sql: Sql;
close: () => Promise<void>;
};
/**
* One process, one client. The Shortcut posts once a day, but on Vercel
* a warm function can serve many requests in a row and we do not want to
* open + close a connection for each. Use a Supabase pooler URL in prod.
*/
let cached: { url: string; entry: RegistryDb } | null = null;
export function createDb(url: string): RegistryDb {
if (cached && cached.url === url) return cached.entry;
const sql = postgres(url, { prepare: false });
const db = drizzle(sql, { schema });
const entry: RegistryDb = {
db,
sql,
close: async () => {
// No-op: the client is shared across requests for the process lifetime.
// The runtime tears it down when the function instance is recycled.
},
};
cached = { url, entry };
return entry;
}
import {
bigserial,
index,
numeric,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
export const samples = pgTable(
"wristkit_samples",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
userId: uuid("user_id"),
metric: text("metric").notNull(),
value: numeric("value").notNull(),
unit: text("unit").notNull(),
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(),
source: text("source"),
ingestedAt: timestamp("ingested_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
metricRecordedIdx: index("idx_metric_recorded").on(t.metric, t.recordedAt),
userMetricRecordedIdx: index("idx_user_metric_recorded").on(t.userId, t.metric, t.recordedAt),
sampleDedupeIdx: uniqueIndex("uq_sample_dedupe").on(t.userId, t.metric, t.recordedAt),
}),
);
import { z } from "zod";
export const MetricSchema = z.enum(["kcal", "exercise_minutes", "steps"]);
export type Metric = z.infer<typeof MetricSchema>;
// The iOS Shortcut posts a flat dictionary with the three values it reads
// from Apple Health. The handler expands these into one row per metric so
// the storage layer stays time-series.
const MetricValue = z.number().finite().min(0).max(1_000_000);
export const IngestPayloadSchema = z
.object({
steps: MetricValue,
moveKcal: MetricValue,
exerciseMin: MetricValue,
})
.strict();
export type IngestPayload = z.infer<typeof IngestPayloadSchema>;