Wristkit / Documentation
Installation
Connect your Apple Health activity to your Next.js site with your own database and an iOS Shortcut.
Before you start
You need Next.js 15 or newer with App Router, Node.js 20 or newer, a Supabase project and an iPhone with your Apple Health data.
1. Install dependencies
pnpm add drizzle-orm postgres zod2. Configure the server
Add these variables to .env.local and to your hosting provider's server environment:
WRISTKIT_DATABASE_URL=your-supabase-transaction-pooler-url
WRISTKIT_API_KEY=your-random-secretCopy the Transaction pooler connection string from the Supabase connection dialog. Keep the database URL and API key on the server, without a NEXT_PUBLIC_ prefix.
Generate a secret locally:
node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))'3. Create the database tables
The SQL migrations bundle below reads the actual migration files from the registry. Run 0001_initial.sql and then 0002_dedupe.sql in the Supabase SQL editor. If an existing table contains duplicates, review the optional cleanup instructions in the second file before running it.
4. Copy the files
The bundles below include the destination of each file:
- Sync route goes into
app/api/wristkit-sync/route.ts. - Activity Card and data helpers includes the component, states, stylesheet, loader and database helpers.
- Copy
styles.cssalongsidestates.tsx; the component imports it automatically.
These code blocks are generated from the registry source at build time, so the demo and installation files stay in sync.
5. Render the card
Use the loader in a Server Component:
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} />;
}Set tz to your timezone. The card supports loading, empty, error, stale and synced states. Goals are configurable values in this version; they are not imported from Apple Health.
6. Connect your iPhone
Follow Shortcut setup, enter your deployed sync URL and API key, and run the Shortcut once. Check that the card receives your data before configuring an automation.
Source files
Select each tab and copy its contents to the displayed destination.
SQL migrations
Run these files in order in the Supabase SQL editor.
create table if not exists wristkit_samples (
id bigserial primary key,
user_id uuid,
metric text not null,
value numeric not null,
unit text not null,
recorded_at timestamptz not null,
source text,
ingested_at timestamptz not null default now()
);
create index if not exists idx_metric_recorded
on wristkit_samples (metric, recorded_at desc);
create index if not exists idx_user_metric_recorded
on wristkit_samples (user_id, metric, recorded_at desc);
-- 0002_dedupe.sql
-- Prevent the Shortcut from inserting duplicate samples when it retries.
-- Safe to run on a table that already has duplicates: the index creation
-- will fail and the user must dedupe first. Run the cleanup CTE below if
-- needed before re-running this migration.
-- Optional one-shot cleanup (uncomment if the unique index fails):
-- with ranked as (
-- select id,
-- row_number() over (
-- partition by user_id, metric, recorded_at
-- order by ingested_at desc
-- ) as rn
-- from wristkit_samples
-- )
-- delete from wristkit_samples
-- where id in (select id from ranked where rn > 1);
create unique index if not exists uq_sample_dedupe
on wristkit_samples (user_id, metric, recorded_at)
nulls not distinct;
Sync route
Add this server route to your application.
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { createDb } from "@/lib/wristkit/db";
import { samples } from "@/lib/wristkit/schema";
import { IngestPayloadSchema } from "@/lib/wristkit/validation";
// ─── config ──────────────────────────────────────────────────
const MAX_BODY_BYTES = 256 * 1024; // 256 KB is plenty for 1000 samples.
const RATE_LIMIT_MAX = 30; // requests per IP per window
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
// ─── rate limit ──────────────────────────────────────────────
// In-memory bucket. Fine for a single Shortcut + the occasional retry.
// For production traffic, swap this for @vercel/firewall, @upstash/ratelimit,
// or a similar provider-backed bucket that survives cold starts.
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
function rateLimit(ip: string): { ok: boolean; retryAfterSec: number } {
const now = Date.now();
// Bound memory on long-lived servers (`next start`, self-host): once the map
// grows large, drop buckets whose window already elapsed, so spoofed
// x-forwarded-for IPs can't accumulate forever. No-op on serverless.
if (buckets.size > 1000) {
for (const [key, b] of buckets) {
if (b.resetAt <= now) buckets.delete(key);
}
}
const existing = buckets.get(ip);
if (!existing || existing.resetAt <= now) {
buckets.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { ok: true, retryAfterSec: 0 };
}
if (existing.count >= RATE_LIMIT_MAX) {
return { ok: false, retryAfterSec: Math.ceil((existing.resetAt - now) / 1000) };
}
existing.count += 1;
return { ok: true, retryAfterSec: 0 };
}
function clientIp(req: Request): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]?.trim() || "unknown";
return req.headers.get("x-real-ip") ?? "unknown";
}
// ─── auth ────────────────────────────────────────────────────
function apiKeyMatches(provided: string | null, expected: string | undefined): boolean {
if (!provided || !expected) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
// ─── handler ─────────────────────────────────────────────────
export async function POST(req: Request) {
const ip = clientIp(req);
const rl = rateLimit(ip);
if (!rl.ok) {
return NextResponse.json(
{ error: "rate_limited" },
{ status: 429, headers: { "retry-after": String(rl.retryAfterSec) } },
);
}
if (!req.headers.get("content-type")?.includes("application/json")) {
return NextResponse.json({ error: "expected application/json" }, { status: 415 });
}
const contentLength = Number(req.headers.get("content-length") ?? "0");
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return NextResponse.json({ error: "payload too large" }, { status: 413 });
}
if (!apiKeyMatches(req.headers.get("x-api-key"), process.env.WRISTKIT_API_KEY)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const url = process.env.WRISTKIT_DATABASE_URL;
if (!url) {
return NextResponse.json({ error: "db not configured" }, { status: 500 });
}
const json = await req.json().catch(() => null);
const parsed = IngestPayloadSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid payload", issues: parsed.error.issues },
{ status: 400 },
);
}
// The Shortcut sends a flat dictionary with today's three Apple Health
// values and no timestamp. Expand into one row per metric, stamping
// recorded_at with the ingest time — the Shortcut is scheduled at
// end-of-day so this is close enough for the daily aggregate.
const recordedAt = new Date();
const rows = [
{ metric: "kcal" as const, value: parsed.data.moveKcal, unit: "kcal" },
{ metric: "exercise_minutes" as const, value: parsed.data.exerciseMin, unit: "min" },
{ metric: "steps" as const, value: parsed.data.steps, unit: "count" },
];
const { db } = createDb(url);
try {
await db.insert(samples).values(
rows.map((r) => ({
metric: r.metric,
value: r.value.toString(),
unit: r.unit,
recordedAt,
source: "apple_watch",
})),
);
} catch (err) {
console.error("[wristkit-sync] ingest failed", err);
return NextResponse.json({ error: "ingest failed" }, { status: 500 });
}
return NextResponse.json({ ok: true, inserted: rows.length });
}
Activity Card and data helpers
Copy every file, including the stylesheet, to its destination.
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>;