ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
60 files +2505 -823

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/native/ManualActivity.stxmodified+52-41
Changes to resources/native/ManualActivity.stx
@@ -1,7 +1,7 @@
11<script client lang="ts">
2import { nativeApiOrigin } from '../functions/native-api'
2import { createNativeApi, onNativeAccountChange, type NativeApi } from '../functions/native-api'
33import type { NativeCraft } from '@stacksjs/stx/native'
4import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal } from '../functions/manual-activity'
4import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal, type ManualActivityPayload } from '../functions/manual-activity'
55import { nativeUi as ui } from '../functions/native-ui'
66
77const craft = (globalThis as typeof globalThis & { craft: NativeCraft }).craft
@@ -19,6 +19,9 @@ const notes = state('')
1919const saving = state(false)
2020const error = state('')
2121let active = true
22let accountGeneration = 0
23let api = createNativeApi(craft)
24let intent: { fields: string, payload: ManualActivityPayload, api: NativeApi } | null = null
2225
2326function eventText(event: { text?: unknown }): string {
2427 return event?.text == null ? '' : String(event.text)
@@ -36,18 +39,9 @@ function goBack() {
3639 craft.navigation.back()
3740}
3841
39async function messageFrom(response: Response, fallback: string): Promise<{ body: any, message: string }> {
40 const body = await response.json().catch(() => null)
41 const fields = body?.fields ?? body?.errors
42 const message = fields && typeof fields === 'object'
43 ? Object.values(fields).map(String).join(' ')
44 : String(body?.message ?? fallback)
45 return { body, message }
46}
47
4842async function submit() {
4943 if (saving()) return
50 const built = buildManualActivity({
44 const input = {
5145 type: types[typeIndex()],
5246 distance: distance(),
5347 duration: duration(),
@@ -56,58 +50,75 @@ async function submit() {
5650 date: date(),
5751 notes: notes(),
5852 visibility: visibilities[visibilityIndex()],
59 // No id: the token below is the only identity this screen has, and the API
60 // takes the actor from the session regardless. Reading one from
61 // `route.params` refused a save on a cold start, where `initialScreen`
62 // passes no params — with a valid token sitting in secureStorage.
63 }, null)
53 }
54 const built = buildManualActivity(input, null, { uploadId: '' })
6455 if (built.ok === false) {
6556 error.set(built.error)
6657 return
6758 }
6859
60 const generation = accountGeneration
61 const current = () => active && generation === accountGeneration
6962 saving.set(true)
7063 error.set('')
7164 try {
72 const token = await craft.secureStorage.get('auth_token')
73 if (!token) throw new Error('Sign in before saving an activity')
74 const response = await fetch(`${nativeApiOrigin(craft)}/api/activities`, {
75 method: 'POST',
76 headers: {
77 Accept: 'application/json',
78 Authorization: `Bearer ${token}`,
79 'Content-Type': 'application/json',
80 },
81 body: JSON.stringify(built.payload),
82 })
83 const result = await messageFrom(response, 'Could not save the activity')
84 if (!response.ok) throw new Error(result.message)
85 const created = result.body?.activity
65 const fields = JSON.stringify(input)
66 if (!intent || intent.fields !== fields) {
67 const bound = await api.bindSession()
68 if (!current()) return
69 if (!craft.crypto) throw new Error('Secure activity identities are unavailable in this app build.')
70 const id = await craft.crypto.randomUUID()
71 if (!current()) return
72 await bound.readToken()
73 const ready = buildManualActivity(input, null, { uploadId: `manual:${id}` })
74 if (ready.ok === false) throw new Error(ready.error)
75 intent = { fields, payload: ready.payload, api: bound }
76 }
77 const pending = intent
78 const response = await pending.api.send('/api/activities', 'POST', pending.payload)
79 if (!current()) return
80 const result = response.body
81 if (!response.ok) {
82 const fields = result?.fields ?? result?.errors
83 throw new Error(fields && typeof fields === 'object'
84 ? Object.values(fields).map(String).join(' ')
85 : String(result?.message ?? result?.error ?? 'Could not save the activity'))
86 }
87 const created = result?.activity
8688 if (!created?.id) throw new Error('Wildloop did not return the saved activity')
87 if (!active) return
8889 await craft.haptics.notification('success')
89 if (!active) return
90 if (!current()) return
9091 craft.navigation.push('ActivitySaved', {
9192 id: created.id,
9293 // The server's answer, not a guess carried in from the route.
9394 userId: created.userId ?? 0,
94 activityType: built.payload.activity_type,
95 distance: built.payload.distance,
96 duration: built.payload.duration,
97 pace: built.payload.pace,
98 elevation: built.payload.elevation,
99 completedAt: built.payload.completed_at,
95 activityType: pending.payload.activity_type,
96 distance: pending.payload.distance,
97 duration: pending.payload.duration,
98 pace: pending.payload.pace,
99 elevation: pending.payload.elevation,
100 completedAt: pending.payload.completed_at,
100101 })
101102 }
102103 catch (cause) {
103 if (active) error.set(cause instanceof Error ? cause.message : 'Could not save the activity')
104 if (current()) error.set(cause instanceof Error ? cause.message : 'Could not save the activity')
104105 }
105106 finally {
106 if (active) saving.set(false)
107 if (current()) saving.set(false)
107108 }
108109}
109110
111onMount(() => onNativeAccountChange(craft, () => {
112 accountGeneration++
113 intent = null
114 api = createNativeApi(craft)
115 typeIndex.set(0);visibilityIndex.set(1)
116 distance.set('');duration.set('');elevation.set('');trailId.set('');notes.set('');date.set(todayLocal())
117 saving.set(false);error.set('')
118 void api.readToken().catch(() => {})
119}))
110120onMount(() => {
121 void api.readToken().catch(() => {})
111122 craft.navigation.setOptions({ title: 'Log activity', largeTitle: true })
112123})
113124onDestroy(() => {
tests/unit/native-manual-activity-flow.test.tsmodified+23-24
Changes to tests/unit/native-manual-activity-flow.test.ts
@@ -1,12 +1,20 @@
11import { beforeAll, describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { answer, compileNativeBundle, nativeBundle, nativeNodeProps, nodesByTestId, requests, start, waitForRequest, waitForText } from '../fixtures/nativeScreen'
4import { answer, compileNativeBundle, nativeBundle, nativeNodeProps, nodesByTestId, requests, start, waitForRequest, waitForText, type NativeSession } from '../fixtures/nativeScreen'
55
66const ROOT = join(import.meta.dir, '../..')
77
88beforeAll(compileNativeBundle, 30000)
99
10function openManual(params: Record<string, unknown> = {}) {
11 return start('ManualActivity',params,['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']]))
12}
13async function supplyUUID(manual: NativeSession) {
14 const uuid=await waitForRequest(manual,0,r=>r.payload.module==='Crypto' && r.payload.method==='randomUUID')
15 answer(manual,uuid,'e8158db5-288f-4cb3-9540-016656f716c6')
16}
17
1018describe('Wildloop native manual activity flow', () => {
1119 it('keeps a system UUID and the exact manual payload when retrying a failed save',async()=>{
1220 const manual=await start('ManualActivity',{},['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']]))
@@ -32,10 +40,7 @@ describe('Wildloop native manual activity flow', () => {
3240 await manual.event('duration-input','onChangeText',{text:'30:00'})
3341 await manual.event('notes-input','onChangeText',{text:'private route notes'})
3442 await manual.event('save-activity','onPress')
35 // The original screen has no UUID request. Answer one only when the real
36 // host protocol asks for it, so both versions reach the held HTTP response.
37 const uuid=requests(manual).find(r=>r.payload.module==='Crypto')
38 if(uuid) answer(manual,uuid,'b6506ad4-d9d2-40df-8a32-bf4194adce26')
43 await supplyUUID(manual)
3944 const post=await waitForRequest(manual,0,r=>r.payload.module==='Network')
4045 keychain.set('auth_token','other-token')
4146 await manual.scope.craft.snapshots.set('wildloop-session',{user:{id:13},version:1})
@@ -48,7 +53,7 @@ describe('Wildloop native manual activity flow', () => {
4853 it('uses app components and sends the real activity payload through the Craft protocol', async () => {
4954 expect(nativeBundle()).not.toContain('WebView')
5055 expect(readFileSync(join(ROOT, 'resources/native/ManualActivity.stx'), 'utf8')).toContain('<NativeRunMetric')
51 const manual = await start('ManualActivity', { userId: 12 })
56 const manual = await openManual({ userId: 12 })
5257 const render = manual.sent.find(message => message.type === 'RENDER').payload.document
5358 expect(nodesByTestId(render).has('distance-preview')).toBe(true)
5459
@@ -59,10 +64,8 @@ describe('Wildloop native manual activity flow', () => {
5964 await manual.event('notes-input', 'onChangeText', { text: 'ridge loop' })
6065 await manual.event('save-activity', 'onPress')
6166
62 const tokenRequest = requests(manual).at(-1)
63 expect(tokenRequest.payload).toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] })
64 const requestCount = requests(manual).length
65 answer(manual, tokenRequest, 'native-token')
67 await supplyUUID(manual)
68 const requestCount = 0
6669 const post = await waitForRequest(manual, requestCount, request => request.payload?.module === 'Network')
6770 const request = post.payload.args[0]
6871 expect(post.payload.module).toBe('Network')
@@ -100,10 +103,10 @@ describe('Wildloop native manual activity flow', () => {
100103 expect(haptic.payload).toMatchObject({ module: 'Haptics', method: 'impact', args: ['light'] })
101104 answer(manual, haptic, true)
102105 await Bun.sleep(0)
103 expect(manual.sent.at(-1)).toMatchObject({
106 expect(manual.sent).toContainEqual(expect.objectContaining({
104107 type: 'NAVIGATE',
105108 payload: { screen: 'ActivitySaved', params: expect.objectContaining({ id: 42, userId: 12, distance: 5.13 }) },
106 })
109 }))
107110 })
108111
109112 /*
@@ -117,18 +120,15 @@ describe('Wildloop native manual activity flow', () => {
117120 * the token is the only identity this screen needs.
118121 */
119122 it('saves on a cold start, with no route params and no user id in the body', async () => {
120 const manual = await start('ManualActivity')
123 const manual = await openManual()
121124
122125 await manual.event('distance-input', 'onChangeText', { text: '3.1' })
123126 await manual.event('duration-input', 'onChangeText', { text: '28:00' })
124127 await manual.event('save-activity', 'onPress')
125128
126 const tokenRequest = requests(manual).at(-1)
127 expect(tokenRequest.payload, 'the token is read before anything is refused')
128 .toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] })
129
130 const before = requests(manual).length
131 answer(manual, tokenRequest, 'native-token')
129 await supplyUUID(manual)
130 expect(requests(manual).some(r=>r.payload.module==='SecureStorage' && r.payload.method==='get')).toBe(true)
131 const before = 0
132132 const post = await waitForRequest(manual, before, request => request.payload?.module === 'Network')
133133 const body = JSON.parse(post.payload.args[0].body)
134134
@@ -152,13 +152,12 @@ describe('Wildloop native manual activity flow', () => {
152152 await saved.event('log-another', 'onPress')
153153 expect(saved.sent.at(-1)).toMatchObject({ type: 'NAVIGATE_REPLACE', payload: { screen: 'ManualActivity', params: { userId: 12 } } })
154154
155 const pending = await start('ManualActivity', { userId: 12 })
155 const pending = await openManual({ userId: 12 })
156156 await pending.event('distance-input', 'onChangeText', { text: '3' })
157157 await pending.event('duration-input', 'onChangeText', { text: '30:00' })
158158 await pending.event('save-activity', 'onPress')
159 const tokenRequest = requests(pending).at(-1)
160 const requestCount = requests(pending).length
161 answer(pending, tokenRequest, 'native-token')
159 await supplyUUID(pending)
160 const requestCount = 0
162161 const post = await waitForRequest(pending, requestCount, request => request.payload?.module === 'Network')
163162 pending.scope.__stxNativeUnmount()
164163 expect(pending.sent).toContainEqual(expect.objectContaining({
@@ -166,7 +165,7 @@ describe('Wildloop native manual activity flow', () => {
166165 payload: { version: 1, requestId: post.id, reason: 'screen-closed' },
167166 }))
168167
169 const fresh = await start('ManualActivity', { userId: 12 })
168 const fresh = await openManual({ userId: 12 })
170169 const operations = fresh.sent.filter(message => message.type === 'MUTATE').flatMap(message => message.payload.operations)
171170 expect(operations.some(operation => operation.patch?.children?.includes('3 mi'))).toBe(false)
172171 })