diff --git a/README.md b/README.md
index 808315a..0390b6d 100644
--- a/README.md
+++ b/README.md
@@ -4,47 +4,66 @@

-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
- {p.desc} -
-+ {p.desc} +
+- 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.
+ We couldn't load today's activity. Check that the sync ran and try again later. +
+ {description} +
+ )} ++)} + {/* 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{code}( <> {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"} ++ ); + } + 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+ {this.state.error.message} ++; } ``` -`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 ( ++ + ); +} + +export function TodayActivityCardEmpty({ className }: { className?: string }) { + const cx = 72, cy = 72; + return ( ++ +++ +++++ + + + + + + ); +} + +export function TodayActivityCardError({ className }: { className?: string }) { + return ( ++ +++ +++++ + + + + + + ); +} + +export function TodayActivityCardStale({ data, className }: { data: TodayData; className?: string }) { + const cx = 72, cy = 72; + return ( ++ ++Something went wrong.+We couldn't load today's activity. Try again later.++ + ); +} + +export function TodayActivityCardOk({ data, className }: { data: TodayData; className?: string }) { + const cx = 72, cy = 72; + return ( ++ +++ +++++ + + + + + + ); +} +``` + +**`components/wristkit/today-activity-card/index.tsx`** + +```tsx +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+ ++ ++ +++++ + + + + ; + case "empty": + return ; + case "error": + return ; + case "stale": + return ; + case "ok": + return ; + } +} +``` + +--- + +## 7. Use the component In any Server Component: @@ -83,11 +705,65 @@ In any Server Component: 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 ; } ``` -## 6. Set up the iOS Shortcut +`loadTodayActivity` accepts an optional `tz` (IANA timezone, defaults to `"UTC"`) and optional `goals` to override the defaults: + +```ts +loadTodayActivity({ + tz: "America/Sao_Paulo", + goals: { + kcal: 500, + exerciseMinutes: 45, + steps: 10000, + }, +}); +``` + +--- + +## 8. Set up the iOS Shortcut + +Open this link **on your iPhone**: + +``` +https://www.icloud.com/shortcuts/da2cb73b3e7d4d5e89cfdfa1e990ff01 +``` + +Tap **Add Shortcut**. + +Then open the Shortcut and edit two fields: + +**URL** — your sync endpoint: +``` +https://your-site.com/api/wristkit-sync +``` + +**API Key** — the same value you set in `WRISTKIT_API_KEY`. + +Run the Shortcut once by hand to test it. Check your Supabase `wristkit_samples` table — you should see three new rows (one per metric) within a few seconds. + +To sync every day automatically, create an iOS Automation: + +1. Open the **Shortcuts** app → **Automation** tab +2. Tap **+** → **Time of Day** +3. Set the time to **23:59** every day +4. Add the action **Run Shortcut** → pick **wristkit Sync** +5. Turn off "Ask Before Running" + +That is it. Your Apple Health data will now render on your site every day. + +--- + +## Troubleshooting + +**401 Unauthorized** — the API key sent by the Shortcut does not match `WRISTKIT_API_KEY`. Double-check both values. + +**429 Too Many Requests** — the handler limits each IP to 30 requests per 5 minutes. Wait the `Retry-After` seconds shown in the response. + +**No data in the component** — check that `wristkit_samples` actually has rows for today. `loadTodayActivity` respects the `tz` you pass; without it the default is UTC, which may not match your local midnight. -See [iOS Shortcut setup](/docs/shortcut-setup) for the full walkthrough. +**Network error from Shortcut** — your site must be reachable from the public internet. `localhost` will not work. diff --git a/apps/web/content/docs/shortcut-setup.mdx b/apps/web/content/docs/shortcut-setup.mdx index ff15701..1ed487c 100644 --- a/apps/web/content/docs/shortcut-setup.mdx +++ b/apps/web/content/docs/shortcut-setup.mdx @@ -8,7 +8,7 @@ description: Install the wristkit Shortcut on your iPhone and start syncing your Open this link **on your iPhone**: ``` -https://wristkit-web.vercel.app/shortcut +https://www.icloud.com/shortcuts/da2cb73b3e7d4d5e89cfdfa1e990ff01 ``` Tap **Add Shortcut** when the prompt shows up. @@ -17,9 +17,9 @@ Tap **Add Shortcut** when the prompt shows up. After it is added, open the Shortcut and edit two fields: -**URL**, the healthkit endpoint of your Next.js app: +**URL**, the sync endpoint of your Next.js app: ``` -https://your-site.com/api/healthkit +https://your-site.com/api/wristkit-sync ``` **API Key**, the same value you put in `WRISTKIT_API_KEY` in your `.env.local`. @@ -29,7 +29,7 @@ https://your-site.com/api/healthkit Run the Shortcut once by hand. It will: 1. Read Active Energy, Exercise Minutes and Step Count from Apple Health for today -2. POST a JSON payload to your `/api/healthkit` endpoint +2. POST a JSON payload to your `/api/wristkit-sync` endpoint 3. Show a success or failure notification on your phone If it works, you should see new rows in your Supabase `wristkit_samples` table within a few seconds. @@ -46,22 +46,24 @@ To sync every day on its own, create an iOS Automation: ## Payload format -The Shortcut sends something like this: +The Shortcut sends a flat JSON dictionary with today's three Apple Health values: ```json { - "samples": [ - { "metric": "kcal", "value": 544, "unit": "kcal", "recorded_at": "2026-04-25T23:59:00-03:00", "source": "apple_watch" }, - { "metric": "exercise_minutes", "value": 80, "unit": "min", "recorded_at": "2026-04-25T23:59:00-03:00", "source": "apple_watch" }, - { "metric": "steps", "value": 12340, "unit": "count", "recorded_at": "2026-04-25T23:59:00-03:00", "source": "apple_watch" } - ] + "steps": 12340, + "moveKcal": 544, + "exerciseMin": 80 } ``` +The route handler stamps `recorded_at` with the ingest time and expands these into one row per metric in `wristkit_samples`. + ## Troubleshooting **401 Unauthorized**: your API key does not match `WRISTKIT_API_KEY`. Double check both values. +**429 Too Many Requests**: the route handler limits each IP to 30 requests every 5 minutes. The Shortcut should only fire once a day, so if you hit this you are probably retrying too fast. Wait the `Retry-After` seconds and try again. + **Network error**: make sure your site is reachable from the public internet, not only on localhost. -**No data in components**: check that the Supabase table actually has rows. `TodayActivityCard` looks for today's data, from midnight to now in your server's timezone. +**No data in components**: check that the Supabase table actually has rows. `TodayActivityCard` looks for today's data in the timezone you pass to `loadTodayActivity({ tz })`. Without a tz it defaults to UTC, so on Vercel that may not match your local "today". diff --git a/apps/web/lib/docs.ts b/apps/web/lib/docs.ts new file mode 100644 index 0000000..a9e5446 --- /dev/null +++ b/apps/web/lib/docs.ts @@ -0,0 +1,10 @@ +import docsData from "../.velite/docs.json"; + +export type Doc = { + title: string; + description?: string; + slug: string; + body: string; +}; + +export const docs = docsData as Doc[]; diff --git a/apps/web/lib/registry-files.ts b/apps/web/lib/registry-files.ts new file mode 100644 index 0000000..963eb54 --- /dev/null +++ b/apps/web/lib/registry-files.ts @@ -0,0 +1,85 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const ROOT = path.resolve(process.cwd(), "..", ".."); + +export type RegistryFileSpec = { + /** Path on disk, relative to the monorepo root. */ + source: string; + /** Path the user should create in their project. */ + dest: string; + /** Language tag for the code block (tsx, ts, sql, json, etc). */ + language: string; + /** Optional post-read transform — use to rewrite monorepo-internal imports to user-facing paths. */ + transform?: (content: string) => string; +}; + +export type RegistryFile = { + source: string; + dest: string; + language: string; + content: string; +}; + +export async function loadRegistryFiles(specs: RegistryFileSpec[]): Promise { + return Promise.all( + specs.map(async (spec) => { + const abs = path.resolve(ROOT, spec.source); + if (!abs.startsWith(ROOT + path.sep)) { + throw new Error(`registry file outside monorepo: ${spec.source}`); + } + const raw = await readFile(abs, "utf8"); + const content = spec.transform ? spec.transform(raw) : raw; + return { source: spec.source, dest: spec.dest, language: spec.language, content }; + }), + ); +} + +export const TODAY_ACTIVITY_CARD_FILES: RegistryFileSpec[] = [ + { + source: "packages/registry/components/today-activity-card/index.tsx", + dest: "components/wristkit/today-activity-card/index.tsx", + language: "tsx", + }, + { + source: "packages/registry/components/today-activity-card/states.tsx", + dest: "components/wristkit/today-activity-card/states.tsx", + language: "tsx", + }, + { + source: "packages/registry/components/today-activity-card/load.ts", + dest: "components/wristkit/today-activity-card/load.ts", + language: "ts", + // The monorepo source imports from the local shim "./queries"; rewrite to + // the path the user will actually have in their project. + transform: (c) => c.replace(/from "\.\/queries"/, 'from "@/lib/wristkit/queries"'), + }, + { + source: "packages/registry/lib/queries.ts", + dest: "lib/wristkit/queries.ts", + language: "ts", + }, + { + source: "packages/registry/lib/db.ts", + dest: "lib/wristkit/db.ts", + language: "ts", + }, + { + source: "packages/registry/lib/schema.ts", + dest: "lib/wristkit/schema.ts", + language: "ts", + }, + { + source: "packages/registry/lib/validation.ts", + dest: "lib/wristkit/validation.ts", + language: "ts", + }, +]; + +export const HANDLER_FILES: RegistryFileSpec[] = [ + { + source: "packages/registry/handlers/wristkit-sync-handler/route.ts", + dest: "app/api/wristkit-sync/route.ts", + language: "ts", + }, +]; diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index fb6c28f..d8347e3 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,8 +1,38 @@ import { build } from "velite"; +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + // Next.js needs unsafe-inline + unsafe-eval for hydration and dev tooling. + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + // Tailwind and inline styles in components. + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +]; + /** @type {import('next').NextConfig} */ const config = { experimental: {}, + async headers() { + return [{ source: "/(.*)", headers: securityHeaders }]; + }, webpack(config, { isServer }) { if (isServer) { config.plugins.push(new VeliteWebpackPlugin()); diff --git a/apps/web/package.json b/apps/web/package.json index a467978..635a430 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,13 @@ "private": true, "scripts": { "pretypecheck": "velite build --clean", + "predev": "mkdir -p public && cp ../../packages/registry/shortcuts/wristkit-sync.shortcut public/wristkit-sync.shortcut", "dev": "next dev", - "prebuild": "velite build --clean && pnpm -C ../../packages/registry build:registry && mkdir -p public && cp ../../packages/registry/shortcuts/wristkit-sync.shortcut public/wristkit-sync.shortcut", + "prebuild": "velite build --clean && mkdir -p public && cp ../../packages/registry/shortcuts/wristkit-sync.shortcut public/wristkit-sync.shortcut", "build": "next build", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "pretest": "pnpm -C ../../packages/registry build:registry", "test": "sh -c 'if [ \"${CI:-}\" = \"true\" ]; then pnpm exec playwright install --with-deps chromium; fi; playwright test'", "test:e2e": "playwright test" }, diff --git a/apps/web/tests/e2e/site.spec.ts b/apps/web/tests/e2e/site.spec.ts index 4c32700..a5ecd8e 100644 --- a/apps/web/tests/e2e/site.spec.ts +++ b/apps/web/tests/e2e/site.spec.ts @@ -29,22 +29,6 @@ test.describe("docs", () => { }); }); -test.describe("registry API", () => { - test("/r/today-activity-card returns valid JSON", async ({ request }) => { - const res = await request.get("/r/today-activity-card"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.name).toBe("today-activity-card"); - expect(Array.isArray(body.files)).toBe(true); - expect(body.files.length).toBeGreaterThan(0); - }); - - test("/r/unknown returns 404", async ({ request }) => { - const res = await request.get("/r/does-not-exist"); - expect(res.status()).toBe(404); - }); -}); - test.describe("shortcut endpoint", () => { test("/shortcut returns application/x-apple-shortcut content-type", async ({ request }) => { const res = await request.get("/shortcut"); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 607d93f..b682531 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -5,9 +5,9 @@ "jsx": "preserve", "lib": ["ES2022", "DOM", "DOM.Iterable"], "paths": { - "@/*": ["./*"], - "velite-data": ["./.velite/index.js"] + "@/*": ["./*"] }, + "resolveJsonModule": true, "plugins": [ { "name": "next" @@ -16,13 +16,6 @@ "allowJs": true, "incremental": true }, - "include": [ - "next-env.d.ts", - "velite-data.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".velite/**/*.ts" - ], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } diff --git a/apps/web/velite-data.d.ts b/apps/web/velite-data.d.ts deleted file mode 100644 index d7599b5..0000000 --- a/apps/web/velite-data.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module "velite-data" { - export type Doc = { - title: string; - description?: string; - slug: string; - permalink: string; - weight: number; - body: string; - }; - - export const docs: Doc[]; -} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index dd61334..0cd7dcc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -112,7 +112,7 @@ export async function initCommand() { "", "Next steps:", "- Run the SQL migration in Supabase (see packages/registry/schemas/0001_initial.sql)", - "- Download the iOS Shortcut: https://wristkit-web.vercel.app/shortcut", + "- Download the iOS Shortcut: https://www.icloud.com/shortcuts/da2cb73b3e7d4d5e89cfdfa1e990ff01", "- Add the component: npx wristkit add today-activity-card", ].join("\n"), ); diff --git a/packages/cli/src/commands/shortcut.ts b/packages/cli/src/commands/shortcut.ts index 59712e5..d5404d0 100644 --- a/packages/cli/src/commands/shortcut.ts +++ b/packages/cli/src/commands/shortcut.ts @@ -1,7 +1,7 @@ import kleur from "kleur"; export async function shortcutCommand() { - const url = "https://wristkit-web.vercel.app/shortcut"; + const url = "https://www.icloud.com/shortcuts/da2cb73b3e7d4d5e89cfdfa1e990ff01"; console.log( [ "1. Open this URL on your iPhone:", diff --git a/packages/registry/build.ts b/packages/registry/build.ts deleted file mode 100644 index c0a0b7f..0000000 --- a/packages/registry/build.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; - -type RegistryItemType = "component" | "handler" | "schema" | "shortcut" | "lib"; - -type RegistryItemSource = { - name: string; - type: RegistryItemType; - version: string; - description: string; - dependencies?: { - npm?: string[]; - registry?: string[]; - }; - metrics?: string[]; - files: Array<{ - path: string; - source: string; - overwrite?: boolean; - }>; - postInstall?: { - message?: string; - sql?: string[]; - }; -}; - -type RegistryItemBuilt = Omit & { - files: Array<{ - path: string; - content: string; - overwrite?: boolean; - }>; -}; - -async function exists(p: string): Promise { - try { - await stat(p); - return true; - } catch { - return false; - } -} - -async function discoverMetaFiles(registryRoot: string): Promise { - const candidates: string[] = []; - const top = ["components", "handlers", "schemas", "shortcuts", "lib"]; - - for (const dir of top) { - const absTop = path.join(registryRoot, dir); - if (!(await exists(absTop))) continue; - - // Some sections (schemas/shortcuts) may keep meta.json at the top-level. - const topMeta = path.join(absTop, "meta.json"); - if (await exists(topMeta)) candidates.push(topMeta); - - const entries = await readdir(absTop, { withFileTypes: true }).catch(() => []); - for (const ent of entries) { - if (!ent.isDirectory()) continue; - const meta = path.join(absTop, ent.name, "meta.json"); - if (await exists(meta)) candidates.push(meta); - } - } - - return candidates; -} - -async function readJson (p: string): Promise { - const txt = await readFile(p, "utf8"); - return JSON.parse(txt) as T; -} - -async function buildItem(metaPath: string): Promise { - const item = await readJson (metaPath); - const baseDir = path.dirname(metaPath); - - const files = await Promise.all( - item.files.map(async (f) => { - const absSource = path.resolve(baseDir, f.source); - const content = await readFile(absSource, "utf8"); - return { path: f.path, content, overwrite: f.overwrite }; - }), - ); - - return { - name: item.name, - type: item.type, - version: item.version, - description: item.description, - dependencies: item.dependencies, - metrics: item.metrics, - postInstall: item.postInstall, - files, - }; -} - -async function main() { - const repoRoot = path.resolve(__dirname, "..", ".."); - const registryRoot = path.join(repoRoot, "packages", "registry"); - const outDir = path.join(repoRoot, "apps", "web", "public", "r"); - - await mkdir(outDir, { recursive: true }); - - const metaFiles = await discoverMetaFiles(registryRoot); - const built = await Promise.all( - metaFiles.map(async (meta) => ({ meta, item: await buildItem(meta) })), - ); - - for (const { item } of built) { - const outPath = path.join(outDir, `${item.name}.json`); - await writeFile(outPath, `${JSON.stringify(item, null, 2)}\n`, "utf8"); - } - - console.log(`Built ${built.length} registry items into ${path.relative(repoRoot, outDir)}`); -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -main().catch((err) => { - console.error(err); - process.exitCode = 1; -}); diff --git a/packages/registry/components/today-activity-card/index.tsx b/packages/registry/components/today-activity-card/index.tsx index fa60ce1..a578d80 100644 --- a/packages/registry/components/today-activity-card/index.tsx +++ b/packages/registry/components/today-activity-card/index.tsx @@ -6,7 +6,6 @@ import { TodayActivityCardError, TodayActivityCardLoading, TodayActivityCardOk, - TodayActivityCardPartial, TodayActivityCardStale, } from "./states"; @@ -26,15 +25,9 @@ export function TodayActivityCard({ case "empty": return ; case "error": - return ; + return ; case "stale": - return ( - - ); - case "partial": - return ( - - ); + return ; case "ok": return ; } diff --git a/packages/registry/components/today-activity-card/load.ts b/packages/registry/components/today-activity-card/load.ts index 2e6b9d8..4088ff3 100644 --- a/packages/registry/components/today-activity-card/load.ts +++ b/packages/registry/components/today-activity-card/load.ts @@ -1,5 +1,16 @@ import { getTodayActivity } from "./queries"; -import type { Metric } from "./validation"; + +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; @@ -8,46 +19,64 @@ export type TodayData = { exerciseGoal: number; steps: number; stepsGoal: number; - lastSync: Date; + /** 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"; lastSync: Date; data: TodayData } - | { kind: "partial"; data: TodayData; missing: Metric[] } + | { 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 ; +}; + const STALE_MS = 24 * 60 * 60 * 1000; -export async function loadTodayActivity(): Promise { +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); + const r = await getTodayActivity(url, tz); if (!r.lastSync) return { kind: "empty" }; - const missing = [ - r.kcal === null ? ("kcal" as const) : null, - r.exerciseMinutes === null ? ("exercise_minutes" as const) : null, - r.steps === null ? ("steps" as const) : null, - ].filter((x): x is Metric => x !== null); - + const ageMs = Date.now() - r.lastSync.getTime(); const data: TodayData = { kcal: r.kcal ?? 0, - kcalGoal: 600, + kcalGoal: goals.kcal, exerciseMinutes: r.exerciseMinutes ?? 0, - exerciseGoal: 30, + exerciseGoal: goals.exerciseMinutes, steps: r.steps ?? 0, - stepsGoal: 8000, - lastSync: r.lastSync, + stepsGoal: goals.steps, + lastSyncIso: r.lastSync.toISOString(), + lastSyncLabel: formatHourMinute(r.lastSync, tz), + hoursSinceSync: Math.max(1, Math.round(ageMs / (60 * 60 * 1000))), }; - const age = Date.now() - r.lastSync.getTime(); - if (age > STALE_MS) return { kind: "stale", lastSync: r.lastSync, data }; - if (missing.length) return { kind: "partial", data, missing }; + if (ageMs > STALE_MS) return { kind: "stale", data }; return { kind: "ok", data }; } catch (err) { return { diff --git a/packages/registry/components/today-activity-card/states.tsx b/packages/registry/components/today-activity-card/states.tsx index 61e827b..6b7b3b1 100644 --- a/packages/registry/components/today-activity-card/states.tsx +++ b/packages/registry/components/today-activity-card/states.tsx @@ -1,15 +1,8 @@ import type * as React from "react"; -import type { Metric } from "../../lib/validation"; import type { TodayData } from "./load"; -function pad2(n: number): string { - return String(n).padStart(2, "0"); -} - -function formatLastSync(d: Date): string { - return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; -} - +// Entrepta palette. Kept as literal hex so the component is portable +// (no CSS variables required in the host project). const colors = { bg: "#0b0b0f", panel: "rgba(255,255,255,0.03)", @@ -17,11 +10,11 @@ const colors = { text: "rgba(255,255,255,0.88)", muted: "rgba(255,255,255,0.55)", subtle: "rgba(255,255,255,0.70)", - move: "#ff2d55", - exercise: "#32d74b", - steps: "#5ac8fa", - warn: "#ff9f0a", - danger: "#ff453a", + move: "#7c6bff", // violet + exercise: "#10b981", // emerald + steps: "#f59e0b", // amber + warn: "#f59e0b", + danger: "#f43f5e", }; function clamp01(x: number): number { @@ -286,26 +279,16 @@ export function TodayActivityCardEmpty({ className }: { className?: string }) { ); } -export function TodayActivityCardError({ - message, - className, -}: { - message?: string; - className?: string; -}) { +export function TodayActivityCardError({ className }: { className?: string }) { return ( @@ -314,16 +297,14 @@ export function TodayActivityCardError({ export function TodayActivityCardStale({ data, - lastSync, className, }: { data: TodayData; - lastSync: Date; className?: string; }) { const cx = 72; const cy = 72; - const hoursAgo = Math.max(1, Math.round((Date.now() - lastSync.getTime()) / (60 * 60 * 1000))); + const hoursAgo = data.hoursSinceSync; return ( -Couldn’t load today’s activity.-- {message ?? "unknown error"} -+Something went wrong.+We couldn't load today's activity. Try again later.); } diff --git a/packages/registry/handlers/healthkit-handler/route.ts b/packages/registry/handlers/healthkit-handler/route.ts deleted file mode 100644 index 778e646..0000000 --- a/packages/registry/handlers/healthkit-handler/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from "next/server"; -import { createDb } from "../../lib/db"; -import { samples } from "../../lib/schema"; -import { IngestPayloadSchema } from "../../lib/validation"; - -export async function POST(req: Request) { - const apiKey = req.headers.get("x-api-key"); - if (!apiKey || apiKey !== 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 { db, close } = createDb(url); - try { - await db.insert(samples).values( - parsed.data.samples.map((s) => ({ - metric: s.metric, - value: s.value.toString(), - unit: s.unit, - recordedAt: new Date(s.recorded_at), - source: s.source ?? null, - })), - ); - } finally { - await close(); - } - - return NextResponse.json({ ok: true, inserted: parsed.data.samples.length }); -} diff --git a/packages/registry/handlers/healthkit-handler/meta.json b/packages/registry/handlers/wristkit-sync-handler/meta.json similarity index 90% rename from packages/registry/handlers/healthkit-handler/meta.json rename to packages/registry/handlers/wristkit-sync-handler/meta.json index 1865900..eb2bc4e 100644 --- a/packages/registry/handlers/healthkit-handler/meta.json +++ b/packages/registry/handlers/wristkit-sync-handler/meta.json @@ -1,5 +1,5 @@ { - "name": "healthkit-handler", + "name": "wristkit-sync-handler", "type": "handler", "version": "0.0.0", "description": "Next.js route handler that ingests Apple Health samples into Postgres.", @@ -8,7 +8,7 @@ }, "files": [ { - "path": "app/api/healthkit/route.ts", + "path": "app/api/wristkit-sync/route.ts", "source": "./route.ts", "overwrite": false }, diff --git a/packages/registry/handlers/wristkit-sync-handler/route.ts b/packages/registry/handlers/wristkit-sync-handler/route.ts new file mode 100644 index 0000000..6a00b6a --- /dev/null +++ b/packages/registry/handlers/wristkit-sync-handler/route.ts @@ -0,0 +1,114 @@ +import { timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { createDb } from "../../lib/db"; +import { samples } from "../../lib/schema"; +import { IngestPayloadSchema } from "../../lib/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 @@ -392,97 +373,6 @@ export function TodayActivityCardStale({ ); } -function metricLabel(m: Metric): string { - switch (m) { - case "kcal": - return "Move"; - case "exercise_minutes": - return "Exercise"; - case "steps": - return "Steps"; - } -} - -export function TodayActivityCardPartial({ - data, - missing, - className, -}: { - data: TodayData; - missing: Metric[]; - className?: string; -}) { - const cx = 72; - const cy = 72; - const missingText = missing.map(metricLabel).join(", "); - return ( - - - ); -} - export function TodayActivityCardOk({ data, className }: { data: TodayData; className?: string }) { const cx = 72; const cy = 72; @@ -546,7 +436,7 @@ export function TodayActivityCardOk({ data, className }: { data: TodayData; clas- -- -- ----- - - - - - + (); + +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"; +} + +// ─── 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 }); +} diff --git a/packages/registry/lib/db.ts b/packages/registry/lib/db.ts index d3c27e2..a570cc1 100644 --- a/packages/registry/lib/db.ts +++ b/packages/registry/lib/db.ts @@ -9,14 +9,26 @@ export type RegistryDb = { close: () => Promise ; }; +/** + * 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 }); - return { + const entry: RegistryDb = { db, sql, close: async () => { - await sql.end({ timeout: 5 }); + // 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; } diff --git a/packages/registry/lib/queries.ts b/packages/registry/lib/queries.ts index 9d580d2..e268c65 100644 --- a/packages/registry/lib/queries.ts +++ b/packages/registry/lib/queries.ts @@ -3,43 +3,52 @@ import { createDb } from "./db"; import { samples } from "./schema"; import type { Metric } from "./validation"; -function startOfToday(): Date { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; +/** + * 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) { - const { db, close } = createDb(url); - try { - return await db - .select() - .from(samples) - .where(and(eq(samples.metric, metric), gte(samples.recordedAt, startOfToday()))) - .orderBy(desc(samples.recordedAt)) - .limit(1); - } finally { - await close(); - } +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) { +export async function getTodayActivity(url: string, tz: string) { const [kcal, ex, steps] = await Promise.all([ - getLatestByMetric(url, "kcal"), - getLatestByMetric(url, "exercise_minutes"), - getLatestByMetric(url, "steps"), + 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]?.ingestedAt, ex[0]?.ingestedAt, steps[0]?.ingestedAt] + [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 ? Number(kcal[0].value) : null, - exerciseMinutes: ex[0]?.value ? Number(ex[0].value) : null, - steps: steps[0]?.value ? Number(steps[0].value) : null, + 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, }; } diff --git a/packages/registry/lib/schema.ts b/packages/registry/lib/schema.ts index 286d50f..dd55c86 100644 --- a/packages/registry/lib/schema.ts +++ b/packages/registry/lib/schema.ts @@ -1,4 +1,13 @@ -import { bigserial, index, numeric, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { + bigserial, + index, + numeric, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; export const samples = pgTable( "wristkit_samples", @@ -15,5 +24,6 @@ export const samples = pgTable( (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), }), ); diff --git a/packages/registry/lib/validation.ts b/packages/registry/lib/validation.ts index 0503d45..9c14072 100644 --- a/packages/registry/lib/validation.ts +++ b/packages/registry/lib/validation.ts @@ -3,16 +3,16 @@ import { z } from "zod"; export const MetricSchema = z.enum(["kcal", "exercise_minutes", "steps"]); export type Metric = z.infer ; -export const IngestSampleSchema = z.object({ - metric: MetricSchema, - value: z.number(), - unit: z.string().min(1), - recorded_at: z.string().datetime({ offset: true }), - source: z.string().min(1).optional(), -}); -export type IngestSample = z.infer ; +// 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({ - samples: z.array(IngestSampleSchema).min(1).max(1000), -}); +export const IngestPayloadSchema = z + .object({ + steps: MetricValue, + moveKcal: MetricValue, + exerciseMin: MetricValue, + }) + .strict(); export type IngestPayload = z.infer ; diff --git a/packages/registry/package.json b/packages/registry/package.json index 6d09c59..f71fdaf 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -4,7 +4,6 @@ "private": true, "scripts": { "build": "echo placeholder", - "build:registry": "tsx build.ts", "dev": "echo placeholder", "lint": "echo placeholder", "typecheck": "tsc --noEmit", @@ -19,7 +18,6 @@ "@vitejs/plugin-react": "^5.2.0", "drizzle-kit": "^0.31.10", "jsdom": "^26", - "tsx": "^4.21.0", "typescript": "5.9.2", "vitest": "^4.1.5" }, diff --git a/packages/registry/schemas/0002_dedupe.sql b/packages/registry/schemas/0002_dedupe.sql new file mode 100644 index 0000000..36eb072 --- /dev/null +++ b/packages/registry/schemas/0002_dedupe.sql @@ -0,0 +1,21 @@ +-- 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; diff --git a/packages/registry/schemas/meta.json b/packages/registry/schemas/meta.json index 72eaec5..b35acf4 100644 --- a/packages/registry/schemas/meta.json +++ b/packages/registry/schemas/meta.json @@ -1,17 +1,22 @@ { - "name": "healthkit-schema", + "name": "wristkit-schema", "type": "schema", - "version": "0.0.0", + "version": "0.1.0", "description": "Postgres schema for wristkit Apple Health samples.", "files": [ { - "path": "lib/wristkit/schema.sql", + "path": "lib/wristkit/schemas/0001_initial.sql", "source": "./0001_initial.sql", "overwrite": false + }, + { + "path": "lib/wristkit/schemas/0002_dedupe.sql", + "source": "./0002_dedupe.sql", + "overwrite": false } ], "postInstall": { - "message": "Run the SQL migration in Supabase to create wristkit_samples.", - "sql": ["0001_initial.sql"] + "message": "Run 0001_initial.sql and then 0002_dedupe.sql in Supabase to create wristkit_samples and protect against duplicate Shortcut posts.", + "sql": ["0001_initial.sql", "0002_dedupe.sql"] } } diff --git a/packages/registry/tests/components/today-activity-card.test.tsx b/packages/registry/tests/components/today-activity-card.test.tsx index eedd6c3..263c5b0 100644 --- a/packages/registry/tests/components/today-activity-card.test.tsx +++ b/packages/registry/tests/components/today-activity-card.test.tsx @@ -9,7 +9,9 @@ const okData = { exerciseGoal: 30, steps: 9_200, stepsGoal: 8_000, - lastSync: new Date("2026-04-26T20:00:00Z"), + lastSyncIso: "2026-04-26T20:00:00.000Z", + lastSyncLabel: "20:00", + hoursSinceSync: 1, }; describe("TodayActivityCard", () => { @@ -25,34 +27,24 @@ describe("TodayActivityCard", () => { expect(screen.getByText(/install shortcut/i)).toBeInTheDocument(); }); - it("error state: shows 'error' status and message", () => { + it("error state: shows generic message and never leaks details", () => { render( , ); expect(screen.getByText(/error/i)).toBeInTheDocument(); - expect(screen.getByText(/WRISTKIT_DATABASE_URL not set/)).toBeInTheDocument(); - }); - - it("error state: shows default message when none provided", () => { - render( ); - expect(screen.getByText(/unknown error/i)).toBeInTheDocument(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + expect(screen.queryByText(/WRISTKIT_DATABASE_URL/)).not.toBeInTheDocument(); }); it("stale state: shows 'stale' status and data values", () => { - const lastSync = new Date(Date.now() - 30 * 60 * 60 * 1000); // 30h ago - render( ); + const data = { ...okData, hoursSinceSync: 30 }; + render( ); expect(screen.getByText(/stale/i)).toBeInTheDocument(); expect(screen.getByText(/480/)).toBeInTheDocument(); expect(screen.getByText(/35/)).toBeInTheDocument(); expect(screen.getByText(/run shortcut/i)).toBeInTheDocument(); }); - it("partial state: shows 'partial' status and missing metrics in footer", () => { - render( ); - expect(screen.getByText(/partial/i)).toBeInTheDocument(); - expect(screen.getByText(/missing:.*Steps/i)).toBeInTheDocument(); - }); - it("ok state: shows 'synced' status and all three metric values", () => { render( ); // "synced" appears in both the header status and the footer "synced HH:MM" diff --git a/packages/registry/tests/handler.test.ts b/packages/registry/tests/handler.test.ts index ae409ca..e2d1df4 100644 --- a/packages/registry/tests/handler.test.ts +++ b/packages/registry/tests/handler.test.ts @@ -1,49 +1,52 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { POST } from "../handlers/healthkit-handler/route"; +import { POST } from "../handlers/wristkit-sync-handler/route"; vi.mock("next/server", () => ({ NextResponse: { json: (body: unknown, init?: ResponseInit) => new Response(JSON.stringify(body), { ...init, - headers: { "content-type": "application/json" }, + headers: { ...(init?.headers ?? {}), "content-type": "application/json" }, }), }, })); const mockInsertValues = vi.fn().mockResolvedValue(undefined); const mockDb = { insert: () => ({ values: mockInsertValues }) }; -const mockClose = vi.fn().mockResolvedValue(undefined); vi.mock("../lib/db", () => ({ - createDb: vi.fn(() => ({ db: mockDb, close: mockClose })), + createDb: vi.fn(() => ({ db: mockDb, close: async () => {} })), })); -const VALID_SAMPLE = { - metric: "kcal", - value: 540, - unit: "kcal", - recorded_at: "2026-04-26T23:59:00-03:00", - source: "apple_watch", +const VALID_PAYLOAD = { + steps: 8500, + moveKcal: 540, + exerciseMin: 45, }; -function makeRequest(body: unknown, apiKey?: string): Request { - return new Request("http://localhost/api/healthkit", { +let ipCounter = 0; +function uniqueIp(): string { + ipCounter += 1; + return `10.0.0.${ipCounter}`; +} + +function makeRequest(body: unknown, apiKey?: string, ip?: string): Request { + return new Request("http://localhost/api/wristkit-sync", { method: "POST", headers: { "content-type": "application/json", + "x-forwarded-for": ip ?? uniqueIp(), ...(apiKey ? { "x-api-key": apiKey } : {}), }, body: JSON.stringify(body), }); } -describe("healthkit handler", () => { +describe("wristkit-sync handler", () => { beforeEach(() => { vi.stubEnv("WRISTKIT_API_KEY", "test-key-abc"); vi.stubEnv("WRISTKIT_DATABASE_URL", "postgres://localhost/test"); mockInsertValues.mockClear(); - mockClose.mockClear(); }); afterEach(() => { @@ -51,14 +54,14 @@ describe("healthkit handler", () => { }); it("returns 401 when x-api-key is missing", async () => { - const res = await POST(makeRequest({ samples: [VALID_SAMPLE] })); + const res = await POST(makeRequest(VALID_PAYLOAD)); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe("unauthorized"); }); it("returns 401 when x-api-key is wrong", async () => { - const res = await POST(makeRequest({ samples: [VALID_SAMPLE] }, "wrong-key")); + const res = await POST(makeRequest(VALID_PAYLOAD, "wrong-key-xx")); expect(res.status).toBe(401); const body = await res.json(); expect(body.error).toBe("unauthorized"); @@ -71,48 +74,59 @@ describe("healthkit handler", () => { expect(body.error).toBe("invalid payload"); }); - it("returns 400 when samples array is empty", async () => { - const res = await POST(makeRequest({ samples: [] }, "test-key-abc")); + it("returns 400 when a metric is missing", async () => { + const res = await POST(makeRequest({ steps: 1, moveKcal: 1 }, "test-key-abc")); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toBe("invalid payload"); }); - it("returns 400 when metric is invalid", async () => { - const res = await POST( - makeRequest({ samples: [{ ...VALID_SAMPLE, metric: "heartrate" }] }, "test-key-abc"), - ); + it("returns 400 when a metric is negative", async () => { + const res = await POST(makeRequest({ ...VALID_PAYLOAD, steps: -1 }, "test-key-abc")); + expect(res.status).toBe(400); + }); + + it("returns 400 when extra keys leak through", async () => { + const res = await POST(makeRequest({ ...VALID_PAYLOAD, extra: "nope" }, "test-key-abc")); expect(res.status).toBe(400); }); - it("returns 200 and inserted count on valid payload", async () => { - const res = await POST( - makeRequest( - { - samples: [ - VALID_SAMPLE, - { - metric: "exercise_minutes", - value: 45, - unit: "min", - recorded_at: "2026-04-26T23:59:00-03:00", - }, - { - metric: "steps", - value: 8500, - unit: "count", - recorded_at: "2026-04-26T23:59:00-03:00", - }, - ], - }, - "test-key-abc", - ), - ); + it("returns 415 when content-type is not JSON", async () => { + const req = new Request("http://localhost/api/wristkit-sync", { + method: "POST", + headers: { + "content-type": "text/plain", + "x-api-key": "test-key-abc", + "x-forwarded-for": uniqueIp(), + }, + body: "hi", + }); + const res = await POST(req); + expect(res.status).toBe(415); + }); + + it("returns 429 after the per-IP limit is exhausted", async () => { + const ip = "10.0.0.250"; + for (let i = 0; i < 30; i += 1) { + await POST(makeRequest(VALID_PAYLOAD, "test-key-abc", ip)); + } + const res = await POST(makeRequest(VALID_PAYLOAD, "test-key-abc", ip)); + expect(res.status).toBe(429); + expect(res.headers.get("retry-after")).toBeTruthy(); + }); + + it("returns 200 and inserts one row per metric on valid payload", async () => { + const res = await POST(makeRequest(VALID_PAYLOAD, "test-key-abc")); expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); expect(body.inserted).toBe(3); expect(mockInsertValues).toHaveBeenCalledOnce(); - expect(mockClose).toHaveBeenCalledOnce(); + const inserted = mockInsertValues.mock.calls[0]?.[0] as Array<{ + metric: string; + value: string; + unit: string; + }>; + expect(inserted.map((r) => r.metric).sort()).toEqual(["exercise_minutes", "kcal", "steps"]); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc79f2b..d28b63f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,9 +203,6 @@ importers: jsdom: specifier: ^26 version: 26.1.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: 5.9.2 version: 5.9.2