Skip to content

Commit b1c1ab4

Browse files
authored
Add shared wearable payload contracts and mark Phase 1 progress (#1)
* Add wearable payload contracts and Phase 1 plan updates * Tighten live wearable moon payload contract
1 parent cdd397d commit b1c1ab4

4 files changed

Lines changed: 348 additions & 12 deletions

File tree

documents/wearable-companion-implementation-plan.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Wearable Companion Implementation Plan (Concrete Checklist)
22

33
Last updated: 2026-02-21
4-
Status: In Progress (Phase 0 started)
4+
Status: In Progress (Phases 0-1 implemented; hardware verification pending)
55

66
## 1. Purpose
77

@@ -76,20 +76,29 @@ Files:
7676

7777
Checklist:
7878

79-
- [ ] Define `LiveRenderPayloadV1`.
80-
- [ ] Define `PreviewRenderPayloadV1`.
81-
- [ ] Define discriminated union `WearRenderPayloadV1`.
82-
- [ ] Add lightweight runtime guards/sanitizers for payload parse/clamp.
83-
- [ ] Export new types from `packages/shared/src/index.ts`.
84-
- [ ] Add tests for:
85-
- [ ] mode discrimination (`live` vs `preview`)
86-
- [ ] numeric clamp behavior (`[0,1]` where required)
87-
- [ ] invalid payload rejection/fallback
79+
- [x] Define `LiveRenderPayloadV1`.
80+
- [x] Define `PreviewRenderPayloadV1`.
81+
- [x] Define discriminated union `WearRenderPayloadV1`.
82+
- [x] Add lightweight runtime guards/sanitizers for payload parse/clamp.
83+
- [x] Export new types from `packages/shared/src/index.ts`.
84+
- [x] Add tests for:
85+
- [x] mode discrimination (`live` vs `preview`)
86+
- [x] numeric clamp behavior (`[0,1]` where required)
87+
- [x] invalid payload rejection/fallback
88+
89+
90+
Phase 1 implementation notes (2026-02-21):
91+
92+
- Added shared wearable payload contracts and union types in `packages/shared/src/wearable.ts`.
93+
- Added runtime sanitizers for live/preview payload parsing, including numeric clamps for normalized fields.
94+
- Exported wearable contracts from shared package entrypoint (`packages/shared/src/index.ts`).
95+
- Added payload unit tests for mode discrimination, clamp behavior, and invalid payload rejection (`packages/shared/tests/wearable.payload.test.ts`).
96+
- Gap captured: phone/watch runtime code does not consume these shared contract helpers yet (planned in Phase 2+ integration work).
8897

8998
Exit criteria:
9099

91-
- [ ] Phone and watch code compile against shared payload types.
92-
- [ ] Payload tests pass.
100+
- [ ] Phone and watch code compile against shared payload types. *(Gap: adoption is pending in app/wear modules.)*
101+
- [x] Payload tests pass.
93102

94103
### Phase 2: Phone Live Compute Pipeline
95104

@@ -258,6 +267,11 @@ Phase 0 checks run (2026-02-21):
258267
- [x] `pnpm --filter @eclipse-timer/mobile typecheck`
259268
- [x] `pnpm --filter @eclipse-timer/mobile lint`
260269
- [x] `./gradlew :wear:assembleDebug` (from `apps/mobile/android`)
270+
271+
Phase 1 checks run (2026-02-21):
272+
273+
- [x] `pnpm --filter @eclipse-timer/shared test`
274+
- [x] `pnpm --filter @eclipse-timer/shared typecheck`
261275
- [x] `./gradlew :app:compileDebugKotlin :app:processDebugManifest` (from `apps/mobile/android`)
262276
- [ ] `./gradlew :app:assembleDebug` is currently blocked by existing external CMake/prefab errors in `react-native-screens` / `expo-modules-core`.
263277

packages/shared/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export * from "./types";
2+
3+
export * from "./wearable";

packages/shared/src/wearable.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
export type WearMode = "live" | "preview";
2+
3+
export type LiveMoonGeometryV1 = {
4+
radiusNorm: number;
5+
centerXNorm: number;
6+
centerYNorm: number;
7+
};
8+
9+
export type LiveRenderPayloadV1 = {
10+
version: 1;
11+
mode: "live";
12+
generatedAtUtc: string;
13+
watchLatDeg: number;
14+
watchLonDeg: number;
15+
} & (
16+
| {
17+
showMoon: false;
18+
}
19+
| {
20+
showMoon: true;
21+
moon: LiveMoonGeometryV1;
22+
}
23+
);
24+
25+
export type PreviewVisualV1 = {
26+
sunRadiusNorm: number;
27+
moonRadiusNorm: number;
28+
moonClosestOffsetNorm: number;
29+
moonTravelHalfSpanNorm: number;
30+
};
31+
32+
export type PreviewRenderPayloadV1 = {
33+
version: 1;
34+
mode: "preview";
35+
previewSessionId: string;
36+
eclipseId: string;
37+
timelineStartUtc: string;
38+
timelineEndUtc: string;
39+
initialProgress: number;
40+
visual: PreviewVisualV1;
41+
};
42+
43+
export type WearRenderPayloadV1 = LiveRenderPayloadV1 | PreviewRenderPayloadV1;
44+
45+
function isRecord(value: unknown): value is Record<string, unknown> {
46+
return typeof value === "object" && value !== null;
47+
}
48+
49+
function clamp01(value: number): number {
50+
if (!Number.isFinite(value)) {
51+
return 0;
52+
}
53+
return Math.min(1, Math.max(0, value));
54+
}
55+
56+
function getFiniteNumber(value: unknown): number | null {
57+
return typeof value === "number" && Number.isFinite(value) ? value : null;
58+
}
59+
60+
function getString(value: unknown): string | null {
61+
return typeof value === "string" && value.length > 0 ? value : null;
62+
}
63+
64+
export function sanitizeLiveRenderPayloadV1(input: unknown): LiveRenderPayloadV1 | null {
65+
if (!isRecord(input) || input.version !== 1 || input.mode !== "live") {
66+
return null;
67+
}
68+
69+
const generatedAtUtc = getString(input.generatedAtUtc);
70+
const watchLatDeg = getFiniteNumber(input.watchLatDeg);
71+
const watchLonDeg = getFiniteNumber(input.watchLonDeg);
72+
73+
if (
74+
!generatedAtUtc ||
75+
watchLatDeg === null ||
76+
watchLonDeg === null ||
77+
watchLatDeg < -90 ||
78+
watchLatDeg > 90 ||
79+
watchLonDeg < -180 ||
80+
watchLonDeg > 180 ||
81+
typeof input.showMoon !== "boolean"
82+
) {
83+
return null;
84+
}
85+
86+
if (!input.showMoon) {
87+
return {
88+
version: 1,
89+
mode: "live",
90+
generatedAtUtc,
91+
watchLatDeg,
92+
watchLonDeg,
93+
showMoon: false,
94+
};
95+
}
96+
97+
if (!isRecord(input.moon)) {
98+
return null;
99+
}
100+
101+
const radiusNorm = getFiniteNumber(input.moon.radiusNorm);
102+
const centerXNorm = getFiniteNumber(input.moon.centerXNorm);
103+
const centerYNorm = getFiniteNumber(input.moon.centerYNorm);
104+
105+
if (radiusNorm === null || centerXNorm === null || centerYNorm === null) {
106+
return null;
107+
}
108+
109+
return {
110+
version: 1,
111+
mode: "live",
112+
generatedAtUtc,
113+
watchLatDeg,
114+
watchLonDeg,
115+
showMoon: true,
116+
moon: {
117+
radiusNorm: clamp01(radiusNorm),
118+
centerXNorm: clamp01(centerXNorm),
119+
centerYNorm: clamp01(centerYNorm),
120+
},
121+
};
122+
}
123+
124+
export function sanitizePreviewRenderPayloadV1(input: unknown): PreviewRenderPayloadV1 | null {
125+
if (
126+
!isRecord(input) ||
127+
input.version !== 1 ||
128+
input.mode !== "preview" ||
129+
!isRecord(input.visual)
130+
) {
131+
return null;
132+
}
133+
134+
const previewSessionId = getString(input.previewSessionId);
135+
const eclipseId = getString(input.eclipseId);
136+
const timelineStartUtc = getString(input.timelineStartUtc);
137+
const timelineEndUtc = getString(input.timelineEndUtc);
138+
const initialProgress = getFiniteNumber(input.initialProgress);
139+
const sunRadiusNorm = getFiniteNumber(input.visual.sunRadiusNorm);
140+
const moonRadiusNorm = getFiniteNumber(input.visual.moonRadiusNorm);
141+
const moonClosestOffsetNorm = getFiniteNumber(input.visual.moonClosestOffsetNorm);
142+
const moonTravelHalfSpanNorm = getFiniteNumber(input.visual.moonTravelHalfSpanNorm);
143+
144+
if (
145+
!previewSessionId ||
146+
!eclipseId ||
147+
!timelineStartUtc ||
148+
!timelineEndUtc ||
149+
initialProgress === null ||
150+
sunRadiusNorm === null ||
151+
moonRadiusNorm === null ||
152+
moonClosestOffsetNorm === null ||
153+
moonTravelHalfSpanNorm === null
154+
) {
155+
return null;
156+
}
157+
158+
return {
159+
version: 1,
160+
mode: "preview",
161+
previewSessionId,
162+
eclipseId,
163+
timelineStartUtc,
164+
timelineEndUtc,
165+
initialProgress: clamp01(initialProgress),
166+
visual: {
167+
sunRadiusNorm: clamp01(sunRadiusNorm),
168+
moonRadiusNorm: clamp01(moonRadiusNorm),
169+
moonClosestOffsetNorm: clamp01(moonClosestOffsetNorm),
170+
moonTravelHalfSpanNorm: clamp01(moonTravelHalfSpanNorm),
171+
},
172+
};
173+
}
174+
175+
export function sanitizeWearRenderPayloadV1(input: unknown): WearRenderPayloadV1 | null {
176+
if (!isRecord(input) || input.version !== 1) {
177+
return null;
178+
}
179+
180+
if (input.mode === "live") {
181+
return sanitizeLiveRenderPayloadV1(input);
182+
}
183+
184+
if (input.mode === "preview") {
185+
return sanitizePreviewRenderPayloadV1(input);
186+
}
187+
188+
return null;
189+
}
190+
191+
export function createSunOnlyLivePayload(params: {
192+
generatedAtUtc: string;
193+
watchLatDeg: number;
194+
watchLonDeg: number;
195+
}): LiveRenderPayloadV1 {
196+
return {
197+
version: 1,
198+
mode: "live",
199+
generatedAtUtc: params.generatedAtUtc,
200+
watchLatDeg: params.watchLatDeg,
201+
watchLonDeg: params.watchLonDeg,
202+
showMoon: false,
203+
};
204+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
sanitizeLiveRenderPayloadV1,
4+
sanitizePreviewRenderPayloadV1,
5+
sanitizeWearRenderPayloadV1,
6+
} from "../src/wearable";
7+
8+
describe("sanitizeWearRenderPayloadV1", () => {
9+
it("discriminates live and preview payload modes", () => {
10+
const live = sanitizeWearRenderPayloadV1({
11+
version: 1,
12+
mode: "live",
13+
generatedAtUtc: "2026-08-12T10:00:00Z",
14+
watchLatDeg: 40.7128,
15+
watchLonDeg: -74.006,
16+
showMoon: false,
17+
});
18+
19+
const preview = sanitizeWearRenderPayloadV1({
20+
version: 1,
21+
mode: "preview",
22+
previewSessionId: "session-1",
23+
eclipseId: "eclipse-2026",
24+
timelineStartUtc: "2026-08-12T09:00:00Z",
25+
timelineEndUtc: "2026-08-12T11:00:00Z",
26+
initialProgress: 0.25,
27+
visual: {
28+
sunRadiusNorm: 0.45,
29+
moonRadiusNorm: 0.44,
30+
moonClosestOffsetNorm: 0.05,
31+
moonTravelHalfSpanNorm: 0.5,
32+
},
33+
});
34+
35+
expect(live?.mode).toBe("live");
36+
expect(preview?.mode).toBe("preview");
37+
});
38+
39+
it("clamps normalized values into [0,1]", () => {
40+
const live = sanitizeLiveRenderPayloadV1({
41+
version: 1,
42+
mode: "live",
43+
generatedAtUtc: "2026-08-12T10:00:00Z",
44+
watchLatDeg: 0,
45+
watchLonDeg: 0,
46+
showMoon: true,
47+
moon: {
48+
radiusNorm: 3,
49+
centerXNorm: -2,
50+
centerYNorm: 0.4,
51+
},
52+
});
53+
54+
const preview = sanitizePreviewRenderPayloadV1({
55+
version: 1,
56+
mode: "preview",
57+
previewSessionId: "session-2",
58+
eclipseId: "eclipse-2027",
59+
timelineStartUtc: "2027-08-02T08:00:00Z",
60+
timelineEndUtc: "2027-08-02T12:00:00Z",
61+
initialProgress: 100,
62+
visual: {
63+
sunRadiusNorm: 2,
64+
moonRadiusNorm: -0.1,
65+
moonClosestOffsetNorm: 0.2,
66+
moonTravelHalfSpanNorm: 42,
67+
},
68+
});
69+
70+
expect(live?.moon?.radiusNorm).toBe(1);
71+
expect(live?.moon?.centerXNorm).toBe(0);
72+
expect(live?.moon?.centerYNorm).toBe(0.4);
73+
expect(preview?.initialProgress).toBe(1);
74+
expect(preview?.visual.sunRadiusNorm).toBe(1);
75+
expect(preview?.visual.moonRadiusNorm).toBe(0);
76+
expect(preview?.visual.moonTravelHalfSpanNorm).toBe(1);
77+
});
78+
79+
it("rejects invalid payloads", () => {
80+
expect(
81+
sanitizeWearRenderPayloadV1({
82+
version: 1,
83+
mode: "live",
84+
generatedAtUtc: "2026-08-12T10:00:00Z",
85+
watchLatDeg: 95,
86+
watchLonDeg: 0,
87+
showMoon: false,
88+
}),
89+
).toBeNull();
90+
91+
expect(
92+
sanitizeWearRenderPayloadV1({
93+
version: 1,
94+
mode: "preview",
95+
previewSessionId: "session-3",
96+
eclipseId: "eclipse-2028",
97+
timelineStartUtc: "2028-01-26T08:00:00Z",
98+
timelineEndUtc: "2028-01-26T10:00:00Z",
99+
initialProgress: 0.5,
100+
}),
101+
).toBeNull();
102+
103+
expect(
104+
sanitizeWearRenderPayloadV1({
105+
version: 1,
106+
mode: "live",
107+
generatedAtUtc: "2026-08-12T10:00:00Z",
108+
watchLatDeg: 20,
109+
watchLonDeg: 20,
110+
showMoon: true,
111+
}),
112+
).toBeNull();
113+
114+
expect(sanitizeWearRenderPayloadV1({ version: 99, mode: "live" })).toBeNull();
115+
});
116+
});

0 commit comments

Comments
 (0)