diff --git a/README.md b/README.md index 808315a..0390b6d 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,66 @@ ![wristkit hero](./assets/hero.png) -A CLI that drops production-ready React components for visualizing Apple Health data into any Next.js project. You bring your own Supabase. Zero telemetry. MIT. +A set of copy-paste React components for showing Apple Health data in any Next.js project. You bring your own Supabase. Zero telemetry. MIT. + +## What it does + +- Copy the component files into your project +- Run a SQL migration in Supabase +- Import the iOS Shortcut on your iPhone, set your URL and API key, done +- Your Apple Watch rings show up on your site, live, from your own database — we never see your data + +## Quick start + +**1. Install the peer dependencies** ```bash -npx wristkit init +pnpm add drizzle-orm postgres zod ``` -## What it does +**2. Add your environment variables** + +``` +WRISTKIT_DATABASE_URL=postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-REGION.pooler.supabase.com:6543/postgres +WRISTKIT_API_KEY=replace-with-32-random-bytes +``` -- `npx wristkit init` — detects your Next.js app, writes `components.json` and `.env.local.example`, prints the SQL migration to run in Supabase -- `npx wristkit add today-activity-card` — copies the component files into your project -- Import the iOS Shortcut on your iPhone, edit two fields (URL + API key), done -- Your Apple Watch rings render on your site, live, from your own Supabase — we never see your data +Use the **Transaction pooler** URL from Supabase (Project Settings → Database → Connect). The direct connection does not work on Vercel. -## Quick start +Generate an API key: ```bash -# 1. Initialize -npx wristkit init +node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))' +``` + +**3. Run the SQL migration in Supabase** + +Go to the [Installation docs](https://wristkit-web.vercel.app/docs/installation) and copy the two SQL blocks into the Supabase SQL Editor. -# 2. Run the SQL migration in your Supabase SQL editor (printed by init) +**4. Copy the component files** -# 3. Add the component -npx wristkit add today-activity-card +Go to the [Installation docs](https://wristkit-web.vercel.app/docs/installation) and copy the route handler and component files into your project. -# 4. Use it in a Server Component +**5. Use it in a Server Component** + +```tsx import { TodayActivityCard, loadTodayActivity } from "@/components/wristkit/today-activity-card" export default async function Dashboard() { const state = await loadTodayActivity() return } - -# 5. Set up the iOS Shortcut -npx wristkit shortcut ``` +**6. Set up the iOS Shortcut** + +Download it from [wristkit.dev/shortcut](https://wristkit-web.vercel.app/shortcut), open it in the Shortcuts app, and fill in your site URL and API key. + ## Requirements - Next.js 15+ (App Router) - A [Supabase](https://supabase.com) project (free tier works) -- Node.js ≥ 20 +- Node.js 20+ ## Documentation @@ -57,7 +76,7 @@ Full docs at **[wristkit-web.vercel.app/docs](https://wristkit-web.vercel.app/do ## Privacy -wristkit is a CLI + component library. Your data flows directly from your iPhone to your Supabase — we never see it, store it, or have access to it. No analytics SDK. No server-side logging. No third-party cloud. +Your data goes straight from your iPhone to your Supabase. We never see it, store it, or have any access to it. No analytics. No server-side logging. No third-party cloud. ## Development diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 0a5b0ba..6714deb 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -1,6 +1,5 @@ import { TodayActivityCardDemo } from "@/components/cards/today-activity-card-demo"; import { Badge } from "@/components/entrepta/badge"; -import { Button } from "@/components/entrepta/button"; import { buttonVariants } from "@/components/entrepta/button-variants"; import { Card, @@ -14,20 +13,27 @@ import { import { TopNav, TopNavLink, TopNavMenu } from "@/components/entrepta/top-nav"; import { HeroIdePreview } from "@/components/home/hero-ide-preview"; import { WristKitMark } from "@/components/mark"; +import { SkipLink } from "@/components/skip-link"; import Link from "next/link"; export default function HomePage() { return ( <> + -
+
-
+ ); } @@ -35,7 +41,7 @@ export default function HomePage() { // ─── TopNav ─────────────────────────────────────────────────── function HomeTopNav() { return ( -
- + ); } @@ -176,9 +182,9 @@ function HeroSection() { style={{ marginTop: 28, maxWidth: 540, lineHeight: 1.65 }} > wristkit is a - small CLI that drops ready to use React components into your Next.js project, so you can - show your Apple Health data on the web. You bring your own Supabase. The iOS Shortcut - posts straight to your endpoint, with no third party cloud and no SDK in the middle. + small set of React components for your Next.js project, so you can show your Apple Health + data on the web. Copy the files from the docs, point them at your own Supabase, and the + iOS Shortcut posts straight to your endpoint. No third party cloud, no SDK in the middle.

- + + browse the components → + - read the docs → + read the docs
", + href: "/docs/components/today-activity-card", }, { - label: "app/api/healthkit", + label: "app/api/wristkit-sync", num: "02", title: "Route handler", desc: "A POST endpoint that checks the x-api-key, parses the Shortcut payload with Zod and writes to your Supabase through Drizzle.", tag: "export async function POST(req)", + href: "/docs/installation", }, { label: "shortcuts/wristkit", @@ -308,6 +319,7 @@ const REGISTRY_ITEMS = [ title: "iOS Shortcut", desc: "Reads Active Energy, Exercise Minutes and Steps from HealthKit. You can schedule it to run every day at 23:59 with iOS Automation.", tag: "wristkit-sync.shortcut", + href: "/docs/shortcut-setup", }, ]; @@ -347,28 +359,34 @@ function PackagesSection() { }} > {REGISTRY_ITEMS.map((p) => ( - - - {p.label} - {p.num} - - {p.title} -

- {p.desc} -

- - {p.tag} - - -
+ + + + {p.label} + {p.num} + + {p.title} +

+ {p.desc} +

+ + {p.tag} + + +
+ ))} @@ -379,17 +397,22 @@ function PackagesSection() { const INSTALL_STEPS = [ { n: "01", - cmd: "npx wristkit init", - out: "components.json written · .env.local.example written", + cmd: "copy → components/wristkit/today-activity-card", + out: "card, states, load and queries", }, { n: "02", - cmd: "npx wristkit add today-activity-card", - out: "3 files copied · 4 deps installed", + cmd: "copy → app/api/wristkit-sync/route.ts", + out: "ingest handler · zod + drizzle", }, { n: "03", - cmd: "open wristkit.vercel.app/shortcut", + cmd: "psql -f schemas/0001_initial.sql", + out: "supabase sql editor", + }, + { + n: "04", + cmd: "open wristkit-web.vercel.app/shortcut", out: null, }, ]; @@ -414,13 +437,13 @@ function InstallSection() { · getting started

- Three commands. + Four files.
You own the pipeline.

- Install the CLI, wire up the API route and set the Apple Shortcut to run once a day. - Snapshots land in your Supabase and your React renders them. + Copy the component into your project, paste the route handler, run the SQL on Supabase and + install the Apple Shortcut. Snapshots land in your database and your React renders them.

- + + browse the components → + read the docs @@ -750,35 +776,40 @@ function SiteFooter() { gap: 8, }} > - {group.items.map((item) => ( -
  • - {item.external ? ( - - {item.label} - - ) : ( - - {item.label} - - )} -
  • - ))} + {group.items.map((item) => { + const cleanLabel = item.label.replace(/\s*↗\s*$/, ""); + return ( +
  • + {item.external ? ( + + {cleanLabel} + + (opens in new tab) + + ) : ( + + {cleanLabel} + + )} +
  • + ); + })}
    ))} diff --git a/apps/web/app/docs/[[...slug]]/page.tsx b/apps/web/app/docs/[[...slug]]/page.tsx index 4732780..752e086 100644 --- a/apps/web/app/docs/[[...slug]]/page.tsx +++ b/apps/web/app/docs/[[...slug]]/page.tsx @@ -1,17 +1,22 @@ +import { RegistryFileBundle } from "@/components/docs/registry-file-bundle"; import { MdxContent } from "@/components/mdx-content"; +import { type Doc, docs } from "@/lib/docs"; +import { + type RegistryFile, + TODAY_ACTIVITY_CARD_FILES, + loadRegistryFiles, +} from "@/lib/registry-files"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import type { Doc } from "velite-data"; -import { docs } from "velite-data"; interface Props { params: Promise<{ slug?: string[] }>; } -function getDoc(slug: string[] | undefined) { +function getDoc(slug: string[] | undefined): Doc | undefined { const path = slug && slug.length > 0 ? slug.join("/") : ""; const target = path ? `docs/${path}` : "docs"; - return (docs as Doc[]).find((doc) => doc.slug === target); + return docs.find((doc) => doc.slug === target); } export async function generateMetadata({ params }: Props): Promise { @@ -25,17 +30,34 @@ export async function generateMetadata({ params }: Props): Promise { } export function generateStaticParams() { - return (docs as Doc[]).map((doc) => { + return docs.map((doc) => { const path = doc.slug.replace(/^docs\/?/, ""); return { slug: path ? path.split("/") : [] }; }); } +type Bundle = { title: string; description: string; files: RegistryFile[] }; + +async function loadBundlesForSlug(slug: string): Promise { + if (slug === "docs/components/today-activity-card") { + return [ + { + title: "Files to copy", + description: "Drop each file in its destination path. Copy with the button on the right.", + files: await loadRegistryFiles(TODAY_ACTIVITY_CARD_FILES), + }, + ]; + } + return []; +} + export default async function DocPage({ params }: Props) { const { slug } = await params; const doc = getDoc(slug); if (!doc) notFound(); + const bundles = await loadBundlesForSlug(doc.slug); + return (
    @@ -71,6 +93,14 @@ export default async function DocPage({ params }: Props) { />
    + {bundles.map((b) => ( + + ))}
    ); } diff --git a/apps/web/app/docs/layout.tsx b/apps/web/app/docs/layout.tsx index 24865fd..b20a0a4 100644 --- a/apps/web/app/docs/layout.tsx +++ b/apps/web/app/docs/layout.tsx @@ -1,4 +1,5 @@ import { SidebarNav } from "@/components/docs/sidebar-nav"; +import { SkipLink } from "@/components/skip-link"; export default function DocsLayout({ children }: { children: React.ReactNode }) { return ( @@ -10,6 +11,7 @@ export default function DocsLayout({ children }: { children: React.ReactNode }) background: "var(--bg-canvas)", }} > + -
    {children}
    +
    + {children} +
    ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 7c59ec0..7948d2e 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -284,6 +284,30 @@ color: var(--fg-brand); } +/* ============================================================ + SKIP LINK — hidden until keyboard-focused + ============================================================ */ + +.skip-link { + position: fixed; + top: -100px; + left: 8px; + z-index: 100; + padding: 8px 14px; + background: var(--fg-brand); + color: var(--bg-canvas); + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + border-radius: var(--radius-sm); + transition: top 120ms ease-out; +} + +.skip-link:focus, +.skip-link:focus-visible { + top: 8px; +} + /* ============================================================ FOCUS VISIBLE (global ring) ============================================================ */ @@ -293,31 +317,23 @@ outline-offset: 2px; } -/* form fields, buttons, and menu items manage their own focus state */ -input:focus, -input:focus-visible, -textarea:focus, -textarea:focus-visible, -select:focus, -select:focus-visible, -button:focus, -button:focus-visible, -[role="button"]:focus, -[role="button"]:focus-visible, -[role="menuitem"]:focus, -[role="menuitem"]:focus-visible, -[role="menuitemcheckbox"]:focus, -[role="menuitemcheckbox"]:focus-visible, -[role="menuitemradio"]:focus, -[role="menuitemradio"]:focus-visible, -[role="option"]:focus, -[role="option"]:focus-visible, -[role="tab"]:focus, -[role="tab"]:focus-visible, -[cmdk-item]:focus, -[cmdk-item]:focus-visible { - outline: none !important; - outline-offset: 0 !important; +/* Suppress the outline only for mouse focus. Keyboard focus (:focus-visible) + still gets the global ring above, so components that do not ship their own + focus styling stay reachable. Components that DO style focus-visible + (Button, Tabs, code-block copy, etc.) use focus-visible:outline-none to + override that ring with their own indicator. */ +input:focus:not(:focus-visible), +textarea:focus:not(:focus-visible), +select:focus:not(:focus-visible), +button:focus:not(:focus-visible), +[role="button"]:focus:not(:focus-visible), +[role="menuitem"]:focus:not(:focus-visible), +[role="menuitemcheckbox"]:focus:not(:focus-visible), +[role="menuitemradio"]:focus:not(:focus-visible), +[role="option"]:focus:not(:focus-visible), +[role="tab"]:focus:not(:focus-visible), +[cmdk-item]:focus:not(:focus-visible) { + outline: none; } /* ============================================================ diff --git a/apps/web/app/r/[name]/route.ts b/apps/web/app/r/[name]/route.ts deleted file mode 100644 index 9d8ae91..0000000 --- a/apps/web/app/r/[name]/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { NextResponse } from "next/server"; - -export async function GET(_: Request, { params }: { params: Promise<{ name: string }> }) { - const { name } = await params; - try { - const file = path.join(process.cwd(), "public", "r", `${name}.json`); - const content = await readFile(file, "utf8"); - return new NextResponse(content, { - headers: { - "content-type": "application/json", - "cache-control": "public, max-age=300, stale-while-revalidate=86400", - }, - }); - } catch { - return NextResponse.json({ error: "not found" }, { status: 404 }); - } -} diff --git a/apps/web/app/shortcut/route.ts b/apps/web/app/shortcut/route.ts index 41d1161..be4aa2a 100644 --- a/apps/web/app/shortcut/route.ts +++ b/apps/web/app/shortcut/route.ts @@ -2,22 +2,21 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { NextResponse } from "next/server"; +/** + * Serves the iOS Shortcut file with the correct MIME so Safari hands it + * straight to the Shortcuts app on iPhone. The file is copied into + * `apps/web/public/` by the prebuild script, so on Vercel we read it + * from the deployment bundle. + */ export async function GET() { try { - const file = path.join( - process.cwd(), - "..", - "..", - "packages", - "registry", - "shortcuts", - "wristkit-sync.shortcut", - ); + const file = path.join(process.cwd(), "public", "wristkit-sync.shortcut"); const content = await readFile(file); return new NextResponse(content, { headers: { "content-type": "application/x-apple-shortcut", "content-disposition": 'attachment; filename="wristkit-sync.shortcut"', + "cache-control": "public, max-age=300, stale-while-revalidate=86400", }, }); } catch { diff --git a/apps/web/components/cards/today-activity-card-demo.tsx b/apps/web/components/cards/today-activity-card-demo.tsx index 41fe49c..8de9264 100644 --- a/apps/web/components/cards/today-activity-card-demo.tsx +++ b/apps/web/components/cards/today-activity-card-demo.tsx @@ -311,7 +311,6 @@ const splitGrid = { } as const; const italicBrand = { fontStyle: "italic", color: "var(--fg-brand)" } as const; -const italicError = { fontStyle: "italic", color: "var(--status-error-fg)" } as const; // ─── A · default ───────────────────────────────────────────── export function TodayActivityCardDemo({ @@ -334,13 +333,10 @@ export function TodayActivityCardDemo({ return ( - today / activity.tsx - - - synced - - Ln 4, Col 18 - + today / activity + + synced +
    - - Strength day.{" "} - Low cardio. -
    - strength day · low cardio · 4 cycles tracked updated {updatedAt} @@ -379,7 +370,7 @@ export function TodayActivityCardEmpty() { return ( - today / activity.tsx + today / activity not connected @@ -418,9 +409,10 @@ export function TodayActivityCardEmpty() { // ─── C · loading ───────────────────────────────────────────── export function TodayActivityCardLoading() { return ( - + // biome-ignore lint/a11y/useSemanticElements: Card is a div; role="status" is the right ARIA contract here. + - today / activity.tsx + today / activity syncing… @@ -437,24 +429,13 @@ export function TodayActivityCardLoading() { ]} />
    - - -
    - fetching from Supabase · usually under 80ms - Ln 1, Col 1 + syncing…
    ); @@ -479,7 +460,7 @@ export function TodayActivityCardStale({ return ( - today / activity.tsx + today / activity stale · 6h @@ -529,30 +510,20 @@ export function TodayActivityCardStale({ showing cached values · automation due 23:00 - ); } // ─── E · error ─────────────────────────────────────────────── -export function TodayActivityCardError({ - occurredAt = "21:14:08", -}: { - occurredAt?: string; -} = {}) { +export function TodayActivityCardError() { return ( - today / activity.tsx - - - 401 · auth - - {occurredAt} - + today / activity + + error +
    - - Token expired.{" "} - Sign in once on iPhone. -
    -
    - 401 - x-api-key rejected -
    -
    - at - POST /api/healthkit/sync -
    -
    - tz - America/Sao_Paulo · 21:14 -
    -
    - - Last good sync was four hours ago. - + Something went wrong.
    +

    + We couldn't load today's activity. Check that the sync ran and try again later. +

    showing nothing rather than guessing - - - - - - - ); -} - -// ─── F · partial ───────────────────────────────────────────── -export function TodayActivityCardPartial({ - moveKcal = 544, - exerciseMin = 80, - moveGoal = 600, - exerciseGoal = 30, -}: { - moveKcal?: number; - exerciseMin?: number; - moveGoal?: number; - exerciseGoal?: number; -} = {}) { - return ( - - - today / activity.tsx - - - partial - - 2 of 3 - - -
    - -
    - - Two of three.{" "} - Steps is locked. - - - - -
    -
    - - re-grant Steps in Health → Sources - +
    ); } -// ─── G · rings only (no card chrome) ───────────────────────── +// ─── F · rings only (no card chrome) ───────────────────────── export function TodayActivityCardRingsOnly() { return (
    +

    + · {title} +

    + {description && ( +

    + {description} +

    + )} + + + {files.map((f) => ( + + {basename(f.source)} + + ))} + + {files.map((f) => ( + + + + ))} + + + ); +} diff --git a/apps/web/components/entrepta/badge.tsx b/apps/web/components/entrepta/badge.tsx index aa32d75..9cc066e 100644 --- a/apps/web/components/entrepta/badge.tsx +++ b/apps/web/components/entrepta/badge.tsx @@ -54,7 +54,7 @@ const badgeVariants = cva( { variant: "solid", color: "error", - className: "bg-[var(--status-error)] text-[var(--fg-primary)]", + className: "bg-[var(--status-error)] text-[var(--bg-canvas)]", }, { variant: "solid", diff --git a/apps/web/components/entrepta/code-block.tsx b/apps/web/components/entrepta/code-block.tsx index 4583d2e..1dc2ced 100644 --- a/apps/web/components/entrepta/code-block.tsx +++ b/apps/web/components/entrepta/code-block.tsx @@ -135,11 +135,23 @@ const CodeBlock = React.forwardRef( {children}
    ) : ( -
    +            
                   {code}
                 
    )}
    + {/* Polite announcement so the copy state is read by screen readers + without stealing keyboard focus. */} + + {copied ? "Copied to clipboard" : ""} + ); }, diff --git a/apps/web/components/entrepta/top-nav.tsx b/apps/web/components/entrepta/top-nav.tsx index 67b4c7b..afa0238 100644 --- a/apps/web/components/entrepta/top-nav.tsx +++ b/apps/web/components/entrepta/top-nav.tsx @@ -152,9 +152,12 @@ const TopNavLink = React.forwardRef( <> {children} {external && ( - - ↗ - + <> + + ↗ + + (opens in new tab) + )} )} diff --git a/apps/web/components/home/hero-ide-preview.tsx b/apps/web/components/home/hero-ide-preview.tsx index b98b318..02d6ce6 100644 --- a/apps/web/components/home/hero-ide-preview.tsx +++ b/apps/web/components/home/hero-ide-preview.tsx @@ -24,53 +24,26 @@ const FILES: FileTab[] = [ id: "snapshot", name: "payload.json", lang: "JSON", - cursor: "Ln 4, Col 18", + cursor: "Ln 4, Col 16", body: ( <> - {"// POST /api/healthkit · x-api-key ✓"} + {"// POST /api/wristkit-sync · x-api-key ✓"} {"\n"} {"{"} {"\n "} - "samples" - : [ - {"\n "} - {"{ "} - "metric" + "steps" : - "kcal" - , - "value" + 6480 + , + {"\n "} + "moveKcal" : 544 - , - "recorded_at" - : - "2026-04-21T21:14:00-03:00" - {" }"} , - {"\n "} - {"{ "} - "metric" - : - "exercise_minutes" - , - "value" + {"\n "} + "exerciseMin" : 80 - {" }"} - , - {"\n "} - {"{ "} - "metric" - : - "steps" - , - "value" - : - 6480 - {" }"} - {"\n "} - ] {"\n"} {"}"} {"\n"} @@ -123,7 +96,7 @@ const FILES: FileTab[] = [ POST {" } "} from{" "} - "@/components/wristkit/healthkit-handler" + "@/components/wristkit/wristkit-sync-handler" {"\n\n"} export {"{ "} POST @@ -207,7 +180,7 @@ export function HeroIdePreview() { · {current.cursor} - wristkit · POST /api/healthkit + wristkit · POST /api/wristkit-sync ); diff --git a/apps/web/components/mdx-content.tsx b/apps/web/components/mdx-content.tsx index 7417696..9405d3e 100644 --- a/apps/web/components/mdx-content.tsx +++ b/apps/web/components/mdx-content.tsx @@ -5,11 +5,11 @@ import { TodayActivityCardEmpty, TodayActivityCardError, TodayActivityCardLoading, - TodayActivityCardPartial, TodayActivityCardRingsOnly, TodayActivityCardStale, } from "@/components/cards/today-activity-card-demo"; import { CodeBlock } from "@/components/entrepta/code-block"; +import { MdxErrorBoundary } from "@/components/mdx-error-boundary"; import { useMemo } from "react"; import type React from "react"; import * as runtime from "react/jsx-runtime"; @@ -44,12 +44,14 @@ function MdxPre({ children, ...props }: React.HTMLAttributes) { } const customComponents = { + // Remap MDX `h1` to `h2`. The page header already renders an `

    ` from the + // doc title, so any in-body `#` heading would otherwise produce two H1s. h1: (props: React.HTMLAttributes) => ( -

    @@ -255,12 +259,15 @@ const customComponents = { TodayActivityCardLoading, TodayActivityCardStale, TodayActivityCardError, - TodayActivityCardPartial, TodayActivityCardRingsOnly, }; export function MdxContent({ code }: { code: string }) { const Component = useMemo(() => { + // Velite compiles each MDX file into a self-contained module body. We + // run it once per page render via new Function so the React runtime + // can resolve jsx/jsxs. The source is build-time (our own MDX), not + // user input — never let untrusted strings reach this call. const fn = new Function(code); return fn({ ...runtime }).default as React.ComponentType<{ components?: Record>; @@ -268,8 +275,10 @@ export function MdxContent({ code }: { code: string }) { }, [code]); return ( - >} - /> + + >} + /> + ); } diff --git a/apps/web/components/mdx-error-boundary.tsx b/apps/web/components/mdx-error-boundary.tsx new file mode 100644 index 0000000..4f2a513 --- /dev/null +++ b/apps/web/components/mdx-error-boundary.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Component, type ReactNode } from "react"; + +type Props = { children: ReactNode }; +type State = { error: Error | null }; + +/** + * Catches render errors from the velite-compiled MDX bundle so one broken + * doc page does not blank out the whole docs section. + */ +export class MdxErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: { componentStack?: string | null }) { + console.error("[mdx] render failed", error, info.componentStack); + } + + render() { + if (this.state.error) { + return ( +
    + {"// MDX render error"} +
    + {this.state.error.message} +
    +
    + ); + } + return this.props.children; + } +} diff --git a/apps/web/components/skip-link.tsx b/apps/web/components/skip-link.tsx new file mode 100644 index 0000000..f7ed828 --- /dev/null +++ b/apps/web/components/skip-link.tsx @@ -0,0 +1,7 @@ +export function SkipLink({ href = "#main-content" }: { href?: string }) { + return ( + + skip to content + + ); +} diff --git a/apps/web/content/docs/components/today-activity-card.mdx b/apps/web/content/docs/components/today-activity-card.mdx index 0581b94..0179ad1 100644 --- a/apps/web/content/docs/components/today-activity-card.mdx +++ b/apps/web/content/docs/components/today-activity-card.mdx @@ -7,19 +7,15 @@ description: Shows today's Move, Exercise and Steps rings, with every state cove `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. -## Installation +## How to install it -```bash -npx wristkit add today-activity-card -``` +There is no CLI. You copy the files below into your project: + +- The seven files in the bundle further down this page go into `components/wristkit/today-activity-card/` and `lib/wristkit/` (paths are shown on each tab) +- The route handler at [Installation](/docs/installation) goes into `app/api/wristkit-sync/route.ts` +- The SQL migrations from the same page go into Supabase -This installs: -- `components/wristkit/today-activity-card/index.tsx` -- `components/wristkit/today-activity-card/states.tsx` -- `lib/wristkit/db.ts` -- `lib/wristkit/schema.ts` -- `lib/wristkit/queries.ts` -- `app/api/healthkit/route.ts` +Make sure you have these peer dependencies in your project: `drizzle-orm`, `postgres`, `zod`. ## Usage @@ -27,12 +23,25 @@ This installs: import { TodayActivityCard, loadTodayActivity } from "@/components/wristkit/today-activity-card"; export default async function Page() { - const state = await loadTodayActivity(); + const state = await loadTodayActivity({ tz: "America/Sao_Paulo" }); return ; } ``` -`loadTodayActivity()` is a server side helper. It reads from your Supabase database and returns the right state. You can pass it straight to the component, so you never have to think about the state machine yourself. +`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 + +```ts +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 @@ -65,21 +74,15 @@ Nothing has been synced yet. The ring tracks are dotted. Values show as `—`. T -### `partial` - -Some metrics are missing, for example because a Health permission was revoked. The metrics that came through render normally. The missing one shows a dotted ring, plus a small hint that says which metric and why. - - - ### `stale` -Data exists but it is older than 24 hours. Numbers are a bit washed out, the status pill shows `stale · Nh` and the footer offers a retry link. +Data exists but it is older than 24 hours. Numbers are a bit washed out and the status pill shows `stale · Nh`. ### `error` -Something went wrong during sync. The ring area turns into a small diagnostic block with the error code, the endpoint and the timestamp. A short italic sentence translates the technical detail into plain language. +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. @@ -91,14 +94,4 @@ A compact variant with no card chrome. Handy for a hero block or a small status ## Customization -The component reads theme tokens from CSS variables. You can override them in your `globals.css`: - -```css -:root { - --ring-move: #9d80ff; - --ring-exercise: #67e8c0; - --ring-steps: #f5a623; -} -``` - -The goals (600 kcal, 30 min and 8000 steps) are hard coded in v1. Custom goals are coming in v2. +The component file uses literal hex colors (entrepta violet, emerald, amber) so nothing breaks if you do not have wristkit's CSS variables in your project. To match a different palette, edit `colors` at the top of `states.tsx`. diff --git a/apps/web/content/docs/concepts/component-states.mdx b/apps/web/content/docs/concepts/component-states.mdx index 970b3f7..1e5163e 100644 --- a/apps/web/content/docs/concepts/component-states.mdx +++ b/apps/web/content/docs/concepts/component-states.mdx @@ -12,18 +12,17 @@ type ComponentState = | { kind: "loading" } | { kind: "empty" } | { kind: "error"; message?: string } - | { kind: "stale"; lastSync: Date } - | { kind: "partial"; missing: string[] } - | { kind: "ok"; data: T }; + | { kind: "stale"; data: TodayData } + | { kind: "ok"; data: TodayData }; ``` -This is not optional. The five state contract is what makes wristkit feel polished instead of janky. Real data is messy. A user revokes a Health permission, the Shortcut automation pauses, the Supabase connection drops. Components that only handle the `ok` state break in silence. +This is not optional. The state contract is what makes wristkit feel polished instead of janky. Real data is messy. The Shortcut automation pauses, the Supabase connection drops, an env var is missing. Components that only handle the `ok` state break in silence. ## Design principles **No new vocabulary.** Every state uses the same Panel grammar: dotted rules, mono labels, `//` footer notes, status pill. The card does not break character when data is missing. -**No generic error UI.** "Oops, something went wrong" is not enough. The error state shows a server log style block with the actual code, endpoint and timestamp. A short italic serif sentence translates the technical detail into plain language. +**Generic error copy.** The error card shows a short, generic message. It never echoes the underlying error or status code — that would leak details about your auth or database to whoever is looking at the page. **Numbers or nothing.** The stale state washes out the existing numbers to say "old", instead of hiding them. The empty and error states use `—` rather than `0` or a blank, because a zero would suggest the user burned zero calories, which is not true. @@ -34,23 +33,35 @@ This is not optional. The five state contract is what makes wristkit feel polish `loadTodayActivity()` (and every `load` function we ship) figures out the state for you: ```ts -export async function loadTodayActivity(): Promise { +export async function loadTodayActivity(options: { tz?: string } = {}): Promise { const url = process.env.WRISTKIT_DATABASE_URL; if (!url) return { kind: "error", message: "WRISTKIT_DATABASE_URL not set" }; + const tz = options.tz ?? "UTC"; + try { - const r = await getTodayActivity(url); + const r = await getTodayActivity(url, tz); if (!r.lastSync) return { kind: "empty" }; - const missing = [ - r.kcal === null && "kcal", - r.exerciseMinutes === null && "exercise_minutes", - r.steps === null && "steps", - ].filter(Boolean) as string[]; + const data = { + kcal: r.kcal ?? 0, + kcalGoal: 600, + exerciseMinutes: r.exerciseMinutes ?? 0, + exerciseGoal: 30, + steps: r.steps ?? 0, + stepsGoal: 8000, + lastSyncIso: r.lastSync.toISOString(), + lastSyncLabel: new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: tz, + }).format(r.lastSync), + hoursSinceSync: Math.max(1, Math.round((Date.now() - r.lastSync.getTime()) / 3_600_000)), + }; const age = Date.now() - r.lastSync.getTime(); - if (age > 24 * 60 * 60 * 1000) return { kind: "stale", lastSync: r.lastSync, data }; - if (missing.length) return { kind: "partial", data, missing }; + if (age > 24 * 60 * 60 * 1000) return { kind: "stale", data }; return { kind: "ok", data }; } catch (err) { return { kind: "error", message: err instanceof Error ? err.message : "unknown" }; @@ -59,3 +70,5 @@ export async function loadTodayActivity(): Promise { ``` The component is a pure renderer. It gets `state` as a prop and renders the matching UI. It never fetches. This split makes testing easy and lets the component live on both the server and the client side. + +`lastSyncLabel` and `hoursSinceSync` are pre-computed on the server using the timezone you pass to `loadTodayActivity`. That keeps the rendered string identical between server and client (no hydration mismatch) and respects the user's local "today" instead of the server's timezone. diff --git a/apps/web/content/docs/concepts/data-model.mdx b/apps/web/content/docs/concepts/data-model.mdx index 2832210..afaec87 100644 --- a/apps/web/content/docs/concepts/data-model.mdx +++ b/apps/web/content/docs/concepts/data-model.mdx @@ -36,9 +36,15 @@ create index if not exists idx_metric_recorded create index if not exists idx_user_metric_recorded on wristkit_samples (user_id, metric, recorded_at desc); + +create unique index if not exists uq_sample_dedupe + on wristkit_samples (user_id, metric, recorded_at) + nulls not distinct; ``` -Both indexes back the most common query pattern: `WHERE metric = $1 AND recorded_at >= $2 ORDER BY recorded_at DESC LIMIT 1`. +The first two back the most common query pattern: `WHERE metric = $1 AND recorded_at >= $2 ORDER BY recorded_at DESC LIMIT 1`. + +The unique index protects against duplicate samples when the Shortcut retries. `NULLS NOT DISTINCT` treats two NULL `user_id` rows as equal, which matches v1 single user mode. ## Metrics in v1 @@ -55,7 +61,7 @@ These are the three metrics `TodayActivityCard` needs. New components in v2 will `lib/wristkit/queries.ts` exports typed helpers: ```ts -export async function getTodayActivity(url: string): Promise<{ +export async function getTodayActivity(url: string, tz: string): Promise<{ kcal: number | null; exerciseMinutes: number | null; steps: number | null; @@ -63,4 +69,4 @@ export async function getTodayActivity(url: string): Promise<{ }>; ``` -Components call `loadTodayActivity()` (which calls `getTodayActivity()` for you), and never query the database directly. +Components call `loadTodayActivity({ tz })` (which calls `getTodayActivity()` for you) and never query the database directly. `lastSync` is the `recorded_at` of the most recent sample, so a backfill of yesterday's data still reads as stale. diff --git a/apps/web/content/docs/concepts/registry.mdx b/apps/web/content/docs/concepts/registry.mdx index c559346..537926e 100644 --- a/apps/web/content/docs/concepts/registry.mdx +++ b/apps/web/content/docs/concepts/registry.mdx @@ -1,56 +1,35 @@ --- title: Registry -description: How the wristkit registry works, what it serves and how the CLI uses it. +description: How wristkit is distributed and how to keep your copy in sync. --- ## What is the registry? -The registry is a set of JSON files served from `wristkit-web.vercel.app/r/:name`. Each file describes one thing you can install: a component, a handler, a schema or a shortcut. - -When you run `npx wristkit add today-activity-card`, the CLI fetches `https://wristkit-web.vercel.app/r/today-activity-card.json`, resolves the dependencies and copies the files into your project. - -## Item shape - -```ts -type RegistryItem = { - name: string; - type: "component" | "handler" | "schema" | "shortcut" | "lib"; - version: string; - description: string; - dependencies?: { - npm?: string[]; - registry?: string[]; - }; - files: { - path: string; - content: string; - overwrite?: boolean; - }[]; - metrics?: string[]; - postInstall?: { - message?: string; - sql?: string[]; - }; -}; -``` +The registry is **this site**. Each component page lists the files you need, with a copy button on every file and the destination path in your project. There is no CLI, no SDK and no install step beyond pasting the code. + +That choice keeps wristkit simple. You always see the real code before you run it, you can diff it in your editor, and there is no extra surface between us and your project. ## Available items -| Name | Type | Description | +| Name | Path in your project | Where to find it | |---|---|---| -| `today-activity-card` | component | Move, Exercise and Steps rings | -| `healthkit-handler` | handler | POST /api/healthkit route | -| `healthkit-schema` | schema | SQL migration | -| `wristkit-sync` | shortcut | iOS Shortcut file | +| `TodayActivityCard` | `components/wristkit/today-activity-card/` | [TodayActivityCard](/docs/components/today-activity-card) | +| Route handler | `app/api/wristkit-sync/route.ts` | [Installation](/docs/installation) | +| SQL migrations | Supabase SQL editor | [Installation](/docs/installation) | +| iOS Shortcut | `wristkit-sync.shortcut` on iPhone | [Shortcut setup](/docs/shortcut-setup) | + +## How to update -## How items are built +When the files on this site change, copy them again. Diff your local copy against the new content in your editor and apply the parts you want. Most updates are additive (new states, new options) and a side by side diff makes them obvious. -`packages/registry/build.ts` walks the registry source, reads each `meta.json` and writes one JSON file per item into `apps/web/public/r/`. This happens at build time, not at request time, so the result is static, fast and free. +There is no versioning beyond the wristkit release tag on GitHub. If you pin to a specific commit, copy from that commit's docs build. -The registry endpoint at `app/r/[name]/route.ts` just reads these pre built JSON files and returns them with a 5 minute cache header. +## Why no CLI -## Versioning +Earlier drafts of wristkit shipped a `wristkit` CLI that fetched JSON from a registry endpoint and wrote files into your project. We dropped it for v0.1 because: -Registry items are versioned on their own, through the `version` field in `meta.json`. When you run `npx wristkit update`, the CLI compares your local version with the registry and shows a diff before overwriting anything. +- It added an attack surface (path traversal, registry integrity, package install confirmation) that did not earn its keep for four files. +- Most users want to read the code before pasting it. A copy button does that better than a download. +- Updates were opaque. A copy paste loop is honest. -Breaking changes bump the minor version. In v2 we will introduce versioned paths like `/r/v2/...`, once the first schema breaking change makes it necessary. +If you really want to script the copy, a `curl + jq` against the files on this site is a few lines. diff --git a/apps/web/content/docs/faq.mdx b/apps/web/content/docs/faq.mdx index 1b68c73..6d7d957 100644 --- a/apps/web/content/docs/faq.mdx +++ b/apps/web/content/docs/faq.mdx @@ -5,19 +5,19 @@ description: Common questions about wristkit. ## Do you see my health data? -No. wristkit is a CLI that copies code into your project. We never see your data, your Supabase credentials or your users. The iOS Shortcut posts straight to your own endpoint, and it never touches our servers. +No. wristkit is a catalog of files. You copy them into your project and run them on your own infrastructure. We never see your data, your Supabase credentials or your users. The iOS Shortcut posts straight to your own endpoint, and it never touches our servers. ## Will this work on the Vercel free tier? -Yes, that is exactly the case wristkit is built for. The ingest endpoint is a serverless function and usually runs in under 50ms per request. The free tier handles a daily sync without breaking a sweat. +Yes, that is the case wristkit is built for. The ingest endpoint is a serverless function and usually runs in under 50ms per request. The free tier handles a daily sync without breaking a sweat. ## Can I use a different database than Supabase? -In v1, not really. The components ship with Drizzle and postgres.js set up for Supabase. If you have a Postgres connection string from somewhere else (Neon, Railway, or a self hosted instance for example), it should work. Proper multi database support is a v2 item. +In v1, not really. The files ship with Drizzle and postgres.js set up for Supabase. If you have a Postgres connection string from somewhere else (Neon, Railway, or a self hosted instance for example), it should work. Proper multi database support is a v2 item. ## What about multi user? -Out of scope for v1. The `user_id` column is in the schema, but it is nullable and unused. Multi user (per user API keys, RLS in Supabase) is the first item on the v2 list. +Out of scope for v1. The `user_id` column is in the schema but is nullable and unused. Multi user (per user API keys, RLS in Supabase) is the first item on the v2 list. ## Can I use this with Vite or Create React App? @@ -25,20 +25,25 @@ No. wristkit v1 needs Next.js 15 with the App Router. The `loadTodayActivity()` ## How do I change the goals? -Goals are hard coded in v1 (600 kcal, 30 min, 8000 steps). Custom goals are planned for v2. For now you can edit `lib/wristkit/queries.ts` in your project directly. +`loadTodayActivity` takes an optional `goals` parameter. Pass any of `kcal`, `exerciseMinutes` and `steps`: + +```tsx +const state = await loadTodayActivity({ + tz: "America/Sao_Paulo", + goals: { kcal: 800, exerciseMinutes: 45, steps: 10000 }, +}); +``` + +Defaults are 600 / 30 / 8000. ## The Shortcut posted but I see no data -Check the `wristkit_samples` table in your Supabase dashboard. If you see rows there, the issue is in the query, so double check that `WRISTKIT_DATABASE_URL` is set and points to the right database. If there are no rows, look at your API route logs and look for a 401 or 400 response. +Check the `wristkit_samples` table in your Supabase dashboard. If you see rows there, the issue is in the query, so double check that `WRISTKIT_DATABASE_URL` is set and points to the right database, and that `loadTodayActivity({ tz })` matches the timezone your sample timestamps live in. If there are no rows, look at your API route logs and look for a 401 or 400 response. ## How do I update a component? -```bash -npx wristkit update today-activity-card -``` - -This shows a diff of what changed and asks you before overwriting anything. +There is no CLI. When the files on this site change, copy them again. Each docs page has a copy button on every file. Diff your local version against the new one in your editor. ## Is this production ready? -v0.1.0 is an early release. The core (ingest handler, TodayActivityCard and CLI) is solid. Please do not use it for anything health critical. The data is meant for personal tracking and visualization, not for medical decisions. +v0.1.0 is an early release. The core (ingest handler, TodayActivityCard and migrations) is solid. Please do not use it for anything health critical. The data is meant for personal tracking and visualization, not for medical decisions. diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index fef5273..c5d71b7 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -5,9 +5,9 @@ description: What wristkit is, who it is for, and how it works. ## What is wristkit? -wristkit is a small open source CLI and component library. It drops ready to use React components into your Next.js project so you can show your Apple Health data on the web. No backend to write, no third party SaaS, and no data leaving the infrastructure you already own. +wristkit is a small open source library of React components. You copy the files from this site into your Next.js project so you can show your Apple Health data on the web. No backend to write, no third party SaaS, and no data leaving the infrastructure you already own. -Run `npx wristkit init`, point it at your own Supabase, import the iOS Shortcut on your iPhone, and a few minutes later your Apple Watch data is showing up in your Next.js app. +Copy the component, point it at your own Supabase, import the iOS Shortcut on your iPhone, and a few minutes later your Apple Watch data is showing up in your Next.js app. **Zero telemetry. MIT license. Single user in v1.** @@ -20,23 +20,21 @@ Run `npx wristkit init`, point it at your own Supabase, import the iOS Shortcut ## How it works ``` -iOS Shortcut → POST /api/healthkit → Supabase → React components +iOS Shortcut → POST /api/wristkit-sync → Supabase → React components ``` 1. The iOS Shortcut reads your Apple Health data and posts it to your Next.js app 2. A route handler checks the payload and writes it to your Supabase Postgres database 3. React components read from your database and render the data -wristkit never touches your data. We ship code. You run it on your own stack. +wristkit never touches your data. The site is a catalog of files. You copy them and run them on your own stack. ## What ships in v1 -- `TodayActivityCard`, with Move, Exercise and Steps rings and five states -- `wristkit init`, sets up your project -- `wristkit add`, installs components from the registry -- `wristkit update`, shows a diff before changing anything -- `wristkit shortcut`, prints the iOS Shortcut setup steps -- A hosted registry at `wristkit-web.vercel.app` +- `TodayActivityCard` with Move, Exercise and Steps rings and every state +- A route handler at `app/api/wristkit-sync/route.ts` +- Two SQL migrations for Supabase +- An iOS Shortcut file you install on your iPhone ## Stack requirements diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index cc81fab..d5668de 100644 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -1,6 +1,6 @@ --- title: Installation -description: Get wristkit running in your Next.js project in less than 10 minutes. +description: Everything you need to get Apple Health data rendering in your Next.js app — all on one page. --- ## Before you start @@ -9,44 +9,46 @@ description: Get wristkit running in your Next.js project in less than 10 minute - A [Supabase](https://supabase.com) project (free tier works fine) - Node.js 20 or newer -## 1. Run init +--- -Inside your Next.js project, run: +## 1. Install the peer dependencies ```bash -npx wristkit init +pnpm add drizzle-orm postgres zod ``` -This will: +Or `npm install` / `yarn add` — wristkit does not lock you in to a package manager. -- Check that you are on Next.js with the App Router -- Ask you which theme you want (`default`, `neutral` or `violet`) -- Write `components.json` to your project root -- Write `.env.local.example` with the variables you need -- Print the SQL you need to run on Supabase -- Install the peer dependencies: `drizzle-orm`, `postgres`, `zod` and `framer-motion` +--- ## 2. Set your environment variables -Copy `.env.local.example` to `.env.local` and fill in your values: +Create a `.env.local` at your project root: -```bash -cp .env.local.example .env.local ``` +WRISTKIT_DATABASE_URL=postgresql://postgres.PROJECT_REF:SENHA@aws-0-REGION.pooler.supabase.com:6543/postgres +WRISTKIT_API_KEY=replace-with-32-random-bytes +``` + +`WRISTKIT_DATABASE_URL` is your Supabase connection string. No painel do Supabase vá em **Project Settings → Database**, clique em **Connect** e selecione **Transaction pooler** (não "Direct connection"). O Vercel é serverless — a conexão direta não consegue resolver o hostname e vai falhar. A URI do pooler tem o formato: ``` +postgresql://postgres.PROJECT_REF:SENHA@aws-0-REGION.pooler.supabase.com:6543/postgres +``` + +`WRISTKIT_API_KEY` is a secret you pick yourself. The iOS Shortcut sends it in the `x-api-key` header on every POST. Generate one with: -WRISTKIT_DATABASE_URL=postgresql://postgres:password@db.xxx.supabase.co:5432/postgres -WRISTKIT_API_KEY=your-secret-key-here +```bash +node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))' ``` -`WRISTKIT_DATABASE_URL` is your Supabase connection string. You can find it in Project Settings, Database, Connection string, URI. +--- -`WRISTKIT_API_KEY` is a secret you pick yourself. The iOS Shortcut will send it in the `x-api-key` header on every POST. +## 3. Run the SQL on Supabase -## 3. Run the SQL migration +Open the **SQL Editor** in your Supabase dashboard and run these two blocks in order. -In your Supabase dashboard, open **SQL Editor** and run: +**First — create the table and indexes:** ```sql create table if not exists wristkit_samples ( @@ -67,15 +69,635 @@ create index if not exists idx_user_metric_recorded on wristkit_samples (user_id, metric, recorded_at desc); ``` -## 4. Add a component +**Second — add the deduplication index:** -```bash -npx wristkit add today-activity-card +```sql +create unique index if not exists uq_sample_dedupe + on wristkit_samples (user_id, metric, recorded_at) + nulls not distinct; +``` + +If the second block fails because the table already has duplicates, run this first, then retry: + +```sql +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); +``` + +--- + +## 4. Copy the lib files + +Create a `lib/wristkit/` folder in your project and add these four files. + +**`lib/wristkit/schema.ts`** + +```ts +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), + }), +); +``` + +**`lib/wristkit/validation.ts`** + +```ts +import { z } from "zod"; + +export const MetricSchema = z.enum(["kcal", "exercise_minutes", "steps"]); +export type Metric = z.infer; + +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; +``` + +**`lib/wristkit/db.ts`** + +```ts +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; + sql: Sql; + close: () => Promise; +}; + +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 () => {}, + }; + cached = { url, entry }; + return entry; +} +``` + +**`lib/wristkit/queries.ts`** + +```ts +import { and, desc, eq, gte } from "drizzle-orm"; +import { createDb } from "./db"; +import { samples } from "./schema"; +import type { Metric } from "./validation"; + +function startOfToday(tz: string): Date { + const now = new Date(); + 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), + ]); + + 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, + }; +} +``` + +--- + +## 5. Copy the route handler + +Create `app/api/wristkit-sync/route.ts` in your project: + +```ts +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"; + +const MAX_BODY_BYTES = 256 * 1024; +const RATE_LIMIT_MAX = 30; +const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; + +type Bucket = { count: number; resetAt: number }; +const buckets = new Map(); + +function rateLimit(ip: string): { ok: boolean; retryAfterSec: number } { + const now = Date.now(); + 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"; +} + +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); +} + +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 }, + ); + } + + 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 }); +} +``` + +--- + +## 6. Copy the component + +Create a `components/wristkit/today-activity-card/` folder and add these three files. + +**`components/wristkit/today-activity-card/load.ts`** + +```ts +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; + lastSyncIso: string; + lastSyncLabel: string; + hoursSinceSync: number; +}; + +export type TodayState = + | { kind: "loading" } + | { kind: "empty" } + | { kind: "error"; message?: string } + | { kind: "stale"; data: TodayData } + | { kind: "ok"; data: TodayData }; + +export type LoadOptions = { + tz?: string; + goals?: Partial; +}; + +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 { + 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", + }; + } +} ``` -This pulls the component from the registry, installs its npm dependencies and copies the files into your project. +**`components/wristkit/today-activity-card/states.tsx`** + +```tsx +import type * as React from "react"; +import type { TodayData } from "./load"; + +const colors = { + bg: "#0b0b0f", + panel: "rgba(255,255,255,0.03)", + border: "rgba(255,255,255,0.10)", + text: "rgba(255,255,255,0.88)", + muted: "rgba(255,255,255,0.55)", + subtle: "rgba(255,255,255,0.70)", + move: "#7c6bff", + exercise: "#10b981", + steps: "#f59e0b", + warn: "#f59e0b", + danger: "#f43f5e", +}; -## 5. Use the component +function clamp01(x: number): number { + if (Number.isNaN(x) || !Number.isFinite(x)) return 0; + return Math.min(1, Math.max(0, x)); +} + +function Ring({ + r, value, max, color, cx, cy, +}: { + r: number; value: number; max: number; color: string; cx: number; cy: number; +}) { + const circ = 2 * Math.PI * r; + const p = clamp01(max > 0 ? value / max : 0); + return ( + <> + + + + ); +} + +function Panel({ className, children }: { className?: string; children: React.ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function Header({ status, statusColor }: { status: string; statusColor: string }) { + return ( +
    + + Today / Activity + + + {status} + +
    + ); +} + +function MetricRow({ dot, label, value, suffix }: { dot: string; label: string; value: React.ReactNode; suffix?: string }) { + return ( +
    + + + {label} + + + {value} + {suffix ? {suffix} : null} + +
    + ); +} + +function Footer({ left, right }: { left: React.ReactNode; right: React.ReactNode }) { + return ( +
    + {left} + {right} +
    + ); +} + +export function TodayActivityCardLoading({ className }: { className?: string }) { + const cx = 72, cy = 72; + return ( + +
    +
    +
    + + Activity rings + + + + +
    +
    + +
    + +
    + +
    +
    +