From 8ee9d6281eb2cba500e889b931952f1057354f4c Mon Sep 17 00:00:00 2001 From: Lalit Sharma Date: Sat, 21 Feb 2026 03:24:49 +0000 Subject: [PATCH 1/2] Add wearable payload contracts and Phase 1 plan updates --- .../wearable-companion-implementation-plan.md | 38 ++-- packages/shared/src/index.ts | 2 + packages/shared/src/wearable.ts | 198 ++++++++++++++++++ .../shared/tests/wearable.payload.test.ts | 105 ++++++++++ 4 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/wearable.ts create mode 100644 packages/shared/tests/wearable.payload.test.ts diff --git a/documents/wearable-companion-implementation-plan.md b/documents/wearable-companion-implementation-plan.md index 8e07b99..1de535e 100644 --- a/documents/wearable-companion-implementation-plan.md +++ b/documents/wearable-companion-implementation-plan.md @@ -1,7 +1,7 @@ # Wearable Companion Implementation Plan (Concrete Checklist) Last updated: 2026-02-21 -Status: In Progress (Phase 0 started) +Status: In Progress (Phases 0-1 implemented; hardware verification pending) ## 1. Purpose @@ -76,20 +76,29 @@ Files: Checklist: -- [ ] Define `LiveRenderPayloadV1`. -- [ ] Define `PreviewRenderPayloadV1`. -- [ ] Define discriminated union `WearRenderPayloadV1`. -- [ ] Add lightweight runtime guards/sanitizers for payload parse/clamp. -- [ ] Export new types from `packages/shared/src/index.ts`. -- [ ] Add tests for: - - [ ] mode discrimination (`live` vs `preview`) - - [ ] numeric clamp behavior (`[0,1]` where required) - - [ ] invalid payload rejection/fallback +- [x] Define `LiveRenderPayloadV1`. +- [x] Define `PreviewRenderPayloadV1`. +- [x] Define discriminated union `WearRenderPayloadV1`. +- [x] Add lightweight runtime guards/sanitizers for payload parse/clamp. +- [x] Export new types from `packages/shared/src/index.ts`. +- [x] Add tests for: + - [x] mode discrimination (`live` vs `preview`) + - [x] numeric clamp behavior (`[0,1]` where required) + - [x] invalid payload rejection/fallback + + +Phase 1 implementation notes (2026-02-21): + +- Added shared wearable payload contracts and union types in `packages/shared/src/wearable.ts`. +- Added runtime sanitizers for live/preview payload parsing, including numeric clamps for normalized fields. +- Exported wearable contracts from shared package entrypoint (`packages/shared/src/index.ts`). +- Added payload unit tests for mode discrimination, clamp behavior, and invalid payload rejection (`packages/shared/tests/wearable.payload.test.ts`). +- Gap captured: phone/watch runtime code does not consume these shared contract helpers yet (planned in Phase 2+ integration work). Exit criteria: -- [ ] Phone and watch code compile against shared payload types. -- [ ] Payload tests pass. +- [ ] Phone and watch code compile against shared payload types. *(Gap: adoption is pending in app/wear modules.)* +- [x] Payload tests pass. ### Phase 2: Phone Live Compute Pipeline @@ -258,6 +267,11 @@ Phase 0 checks run (2026-02-21): - [x] `pnpm --filter @eclipse-timer/mobile typecheck` - [x] `pnpm --filter @eclipse-timer/mobile lint` - [x] `./gradlew :wear:assembleDebug` (from `apps/mobile/android`) + +Phase 1 checks run (2026-02-21): + +- [x] `pnpm --filter @eclipse-timer/shared test` +- [x] `pnpm --filter @eclipse-timer/shared typecheck` - [x] `./gradlew :app:compileDebugKotlin :app:processDebugManifest` (from `apps/mobile/android`) - [ ] `./gradlew :app:assembleDebug` is currently blocked by existing external CMake/prefab errors in `react-native-screens` / `expo-modules-core`. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eea524d..457fe08 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,3 @@ export * from "./types"; + +export * from "./wearable"; diff --git a/packages/shared/src/wearable.ts b/packages/shared/src/wearable.ts new file mode 100644 index 0000000..0ab941f --- /dev/null +++ b/packages/shared/src/wearable.ts @@ -0,0 +1,198 @@ +export type WearMode = "live" | "preview"; + +export type LiveMoonGeometryV1 = { + radiusNorm: number; + centerXNorm: number; + centerYNorm: number; +}; + +export type LiveRenderPayloadV1 = { + version: 1; + mode: "live"; + generatedAtUtc: string; + watchLatDeg: number; + watchLonDeg: number; + showMoon: boolean; + moon?: LiveMoonGeometryV1; +}; + +export type PreviewVisualV1 = { + sunRadiusNorm: number; + moonRadiusNorm: number; + moonClosestOffsetNorm: number; + moonTravelHalfSpanNorm: number; +}; + +export type PreviewRenderPayloadV1 = { + version: 1; + mode: "preview"; + previewSessionId: string; + eclipseId: string; + timelineStartUtc: string; + timelineEndUtc: string; + initialProgress: number; + visual: PreviewVisualV1; +}; + +export type WearRenderPayloadV1 = LiveRenderPayloadV1 | PreviewRenderPayloadV1; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.min(1, Math.max(0, value)); +} + +function getFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +export function sanitizeLiveRenderPayloadV1(input: unknown): LiveRenderPayloadV1 | null { + if (!isRecord(input) || input.version !== 1 || input.mode !== "live") { + return null; + } + + const generatedAtUtc = getString(input.generatedAtUtc); + const watchLatDeg = getFiniteNumber(input.watchLatDeg); + const watchLonDeg = getFiniteNumber(input.watchLonDeg); + + if ( + !generatedAtUtc || + watchLatDeg === null || + watchLonDeg === null || + watchLatDeg < -90 || + watchLatDeg > 90 || + watchLonDeg < -180 || + watchLonDeg > 180 || + typeof input.showMoon !== "boolean" + ) { + return null; + } + + if (!input.showMoon) { + return { + version: 1, + mode: "live", + generatedAtUtc, + watchLatDeg, + watchLonDeg, + showMoon: false, + }; + } + + if (!isRecord(input.moon)) { + return null; + } + + const radiusNorm = getFiniteNumber(input.moon.radiusNorm); + const centerXNorm = getFiniteNumber(input.moon.centerXNorm); + const centerYNorm = getFiniteNumber(input.moon.centerYNorm); + + if (radiusNorm === null || centerXNorm === null || centerYNorm === null) { + return null; + } + + return { + version: 1, + mode: "live", + generatedAtUtc, + watchLatDeg, + watchLonDeg, + showMoon: true, + moon: { + radiusNorm: clamp01(radiusNorm), + centerXNorm: clamp01(centerXNorm), + centerYNorm: clamp01(centerYNorm), + }, + }; +} + +export function sanitizePreviewRenderPayloadV1(input: unknown): PreviewRenderPayloadV1 | null { + if ( + !isRecord(input) || + input.version !== 1 || + input.mode !== "preview" || + !isRecord(input.visual) + ) { + return null; + } + + const previewSessionId = getString(input.previewSessionId); + const eclipseId = getString(input.eclipseId); + const timelineStartUtc = getString(input.timelineStartUtc); + const timelineEndUtc = getString(input.timelineEndUtc); + const initialProgress = getFiniteNumber(input.initialProgress); + const sunRadiusNorm = getFiniteNumber(input.visual.sunRadiusNorm); + const moonRadiusNorm = getFiniteNumber(input.visual.moonRadiusNorm); + const moonClosestOffsetNorm = getFiniteNumber(input.visual.moonClosestOffsetNorm); + const moonTravelHalfSpanNorm = getFiniteNumber(input.visual.moonTravelHalfSpanNorm); + + if ( + !previewSessionId || + !eclipseId || + !timelineStartUtc || + !timelineEndUtc || + initialProgress === null || + sunRadiusNorm === null || + moonRadiusNorm === null || + moonClosestOffsetNorm === null || + moonTravelHalfSpanNorm === null + ) { + return null; + } + + return { + version: 1, + mode: "preview", + previewSessionId, + eclipseId, + timelineStartUtc, + timelineEndUtc, + initialProgress: clamp01(initialProgress), + visual: { + sunRadiusNorm: clamp01(sunRadiusNorm), + moonRadiusNorm: clamp01(moonRadiusNorm), + moonClosestOffsetNorm: clamp01(moonClosestOffsetNorm), + moonTravelHalfSpanNorm: clamp01(moonTravelHalfSpanNorm), + }, + }; +} + +export function sanitizeWearRenderPayloadV1(input: unknown): WearRenderPayloadV1 | null { + if (!isRecord(input) || input.version !== 1) { + return null; + } + + if (input.mode === "live") { + return sanitizeLiveRenderPayloadV1(input); + } + + if (input.mode === "preview") { + return sanitizePreviewRenderPayloadV1(input); + } + + return null; +} + +export function createSunOnlyLivePayload(params: { + generatedAtUtc: string; + watchLatDeg: number; + watchLonDeg: number; +}): LiveRenderPayloadV1 { + return { + version: 1, + mode: "live", + generatedAtUtc: params.generatedAtUtc, + watchLatDeg: params.watchLatDeg, + watchLonDeg: params.watchLonDeg, + showMoon: false, + }; +} diff --git a/packages/shared/tests/wearable.payload.test.ts b/packages/shared/tests/wearable.payload.test.ts new file mode 100644 index 0000000..f4b0027 --- /dev/null +++ b/packages/shared/tests/wearable.payload.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + sanitizeLiveRenderPayloadV1, + sanitizePreviewRenderPayloadV1, + sanitizeWearRenderPayloadV1, +} from "../src/wearable"; + +describe("sanitizeWearRenderPayloadV1", () => { + it("discriminates live and preview payload modes", () => { + const live = sanitizeWearRenderPayloadV1({ + version: 1, + mode: "live", + generatedAtUtc: "2026-08-12T10:00:00Z", + watchLatDeg: 40.7128, + watchLonDeg: -74.006, + showMoon: false, + }); + + const preview = sanitizeWearRenderPayloadV1({ + version: 1, + mode: "preview", + previewSessionId: "session-1", + eclipseId: "eclipse-2026", + timelineStartUtc: "2026-08-12T09:00:00Z", + timelineEndUtc: "2026-08-12T11:00:00Z", + initialProgress: 0.25, + visual: { + sunRadiusNorm: 0.45, + moonRadiusNorm: 0.44, + moonClosestOffsetNorm: 0.05, + moonTravelHalfSpanNorm: 0.5, + }, + }); + + expect(live?.mode).toBe("live"); + expect(preview?.mode).toBe("preview"); + }); + + it("clamps normalized values into [0,1]", () => { + const live = sanitizeLiveRenderPayloadV1({ + version: 1, + mode: "live", + generatedAtUtc: "2026-08-12T10:00:00Z", + watchLatDeg: 0, + watchLonDeg: 0, + showMoon: true, + moon: { + radiusNorm: 3, + centerXNorm: -2, + centerYNorm: 0.4, + }, + }); + + const preview = sanitizePreviewRenderPayloadV1({ + version: 1, + mode: "preview", + previewSessionId: "session-2", + eclipseId: "eclipse-2027", + timelineStartUtc: "2027-08-02T08:00:00Z", + timelineEndUtc: "2027-08-02T12:00:00Z", + initialProgress: 100, + visual: { + sunRadiusNorm: 2, + moonRadiusNorm: -0.1, + moonClosestOffsetNorm: 0.2, + moonTravelHalfSpanNorm: 42, + }, + }); + + expect(live?.moon?.radiusNorm).toBe(1); + expect(live?.moon?.centerXNorm).toBe(0); + expect(live?.moon?.centerYNorm).toBe(0.4); + expect(preview?.initialProgress).toBe(1); + expect(preview?.visual.sunRadiusNorm).toBe(1); + expect(preview?.visual.moonRadiusNorm).toBe(0); + expect(preview?.visual.moonTravelHalfSpanNorm).toBe(1); + }); + + it("rejects invalid payloads", () => { + expect( + sanitizeWearRenderPayloadV1({ + version: 1, + mode: "live", + generatedAtUtc: "2026-08-12T10:00:00Z", + watchLatDeg: 95, + watchLonDeg: 0, + showMoon: false, + }), + ).toBeNull(); + + expect( + sanitizeWearRenderPayloadV1({ + version: 1, + mode: "preview", + previewSessionId: "session-3", + eclipseId: "eclipse-2028", + timelineStartUtc: "2028-01-26T08:00:00Z", + timelineEndUtc: "2028-01-26T10:00:00Z", + initialProgress: 0.5, + }), + ).toBeNull(); + + expect(sanitizeWearRenderPayloadV1({ version: 99, mode: "live" })).toBeNull(); + }); +}); From 359016282008a98e74e45d43c81b4a144e4f9f4e Mon Sep 17 00:00:00 2001 From: Lalit Sharma Date: Sat, 21 Feb 2026 03:37:39 +0000 Subject: [PATCH 2/2] Tighten live wearable moon payload contract --- packages/shared/src/wearable.ts | 12 +++++++++--- packages/shared/tests/wearable.payload.test.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/wearable.ts b/packages/shared/src/wearable.ts index 0ab941f..38d828e 100644 --- a/packages/shared/src/wearable.ts +++ b/packages/shared/src/wearable.ts @@ -12,9 +12,15 @@ export type LiveRenderPayloadV1 = { generatedAtUtc: string; watchLatDeg: number; watchLonDeg: number; - showMoon: boolean; - moon?: LiveMoonGeometryV1; -}; +} & ( + | { + showMoon: false; + } + | { + showMoon: true; + moon: LiveMoonGeometryV1; + } +); export type PreviewVisualV1 = { sunRadiusNorm: number; diff --git a/packages/shared/tests/wearable.payload.test.ts b/packages/shared/tests/wearable.payload.test.ts index f4b0027..06946d7 100644 --- a/packages/shared/tests/wearable.payload.test.ts +++ b/packages/shared/tests/wearable.payload.test.ts @@ -100,6 +100,17 @@ describe("sanitizeWearRenderPayloadV1", () => { }), ).toBeNull(); + expect( + sanitizeWearRenderPayloadV1({ + version: 1, + mode: "live", + generatedAtUtc: "2026-08-12T10:00:00Z", + watchLatDeg: 20, + watchLonDeg: 20, + showMoon: true, + }), + ).toBeNull(); + expect(sanitizeWearRenderPayloadV1({ version: 99, mode: "live" })).toBeNull(); }); });