Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions documents/wearable-companion-implementation-plan.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./types";

export * from "./wearable";
204 changes: 204 additions & 0 deletions packages/shared/src/wearable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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: false;
}
| {
showMoon: true;
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<string, unknown> {
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,
};
}
116 changes: 116 additions & 0 deletions packages/shared/tests/wearable.payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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: 1,
mode: "live",
generatedAtUtc: "2026-08-12T10:00:00Z",
watchLatDeg: 20,
watchLonDeg: 20,
showMoon: true,
}),
).toBeNull();

expect(sanitizeWearRenderPayloadV1({ version: 99, mode: "live" })).toBeNull();
});
});