From 21e5208cbafc7eb70cce666b879d924e19efb6e1 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:31:30 +0800 Subject: [PATCH 1/2] Record bounded device-only onboarding support breadcrumbs --- src/cloud/handle.ts | 3 +- src/cloud/sync-diagnostics.test.ts | 22 ++++++++-- src/cloud/sync-diagnostics.ts | 15 ++++++- src/cloud/use-cloud.native.ts | 12 +++--- src/onboarding-guest.test.ts | 57 ++++++++++++++++++++++--- src/screens/OnboardingDesignPreview.tsx | 21 ++++++--- 6 files changed, 106 insertions(+), 24 deletions(-) diff --git a/src/cloud/handle.ts b/src/cloud/handle.ts index f7518323..8d8c2682 100644 --- a/src/cloud/handle.ts +++ b/src/cloud/handle.ts @@ -1,4 +1,5 @@ import type { AccountController } from './account-controller'; +import type {RecordOnboardingDiagnostic} from './sync-diagnostics'; import type {SharingApi} from './sharing-api'; -export type CloudHandle={available:boolean;controller:AccountController|null;problem:'configuration'|'local-storage'|null;getLocalName:()=>Promise;retry?:()=>void;shareDiagnostics?:()=>Promise;sharing?:SharingApi;sharingOrigin?:string}; +export type CloudHandle={available:boolean;controller:AccountController|null;problem:'configuration'|'local-storage'|null;getLocalName:()=>Promise;retry?:()=>void;shareDiagnostics?:()=>Promise;recordOnboardingDiagnostic?:RecordOnboardingDiagnostic;sharing?:SharingApi;sharingOrigin?:string}; diff --git a/src/cloud/sync-diagnostics.test.ts b/src/cloud/sync-diagnostics.test.ts index c900c78d..aea06be9 100644 --- a/src/cloud/sync-diagnostics.test.ts +++ b/src/cloud/sync-diagnostics.test.ts @@ -22,10 +22,10 @@ test('diagnostics retain only bounded allowlisted data and sanitize persisted hi const reloaded = createSyncDiagnostics(storage); const events = await reloaded.read(); assert.equal(events.length, 100); - assert.equal(events[0].pending, 5); - assert.equal(events.at(-1)?.pending, 104); - events[0].pending = 999; - assert.equal((await reloaded.read())[0].pending, 5); + assert.equal(events[0].event==='sync'&&events[0].pending, 5); + const last=events.at(-1);assert.equal(last?.event==='sync'&&last.pending, 104); + if(events[0].event==='sync')events[0].pending = 999; + const first=(await reloaded.read())[0];assert.equal(first.event==='sync'&&first.pending, 5); }); test('logging during asynchronous load keeps both histories and recovers after storage failures', async () => { @@ -47,3 +47,17 @@ test('logging during asynchronous load keeps both histories and recovers after s const corrupt = createSyncDiagnostics({ getItem: async () => 'invalid JSON', setItem: storage.setItem }); await assert.doesNotReject(corrupt.read()); }); + + +test('device-only onboarding records use the same bounded ring and strip private fields again after restart',async()=>{ + let saved=JSON.stringify([{at:1,event:'onboarding',point:'guest-handler',step:'welcome',accountActionPending:false,email:'PRIVATE_EMAIL',input:'PRIVATE_NAME',sessionId:'PRIVATE_ID'}]); + const storage={getItem:async()=>saved,setItem:async(_key:string,value:string)=>{assert.equal(_key,'puptime.sync.diagnostics.v1');saved=value;}}; + const ring=createSyncDiagnostics(storage,()=>2); + assert.deepEqual(await ring.read(),[{at:1,event:'onboarding',point:'guest-handler',step:'welcome',accountActionPending:false}]); + ring.record({event:'onboarding',point:'profile-decision',step:'welcome',decision:'accepted',...( {url:'PRIVATE_URL',error:new Error('PRIVATE_MESSAGE'),stack:'PRIVATE_STACK'} as {})}); + for(const field of ['point','step','decision','animation','accountActionPending'])ring.record({event:'onboarding',point:'screen',step:'welcome',[field]:'PRIVATE_VALUE'} as never); + await ring.flush();assert.equal((await ring.read()).length,2);assert.ok(!saved.includes('PRIVATE')); + for(let i=0;i<105;i++)ring.record(i%2?{event:'sync',result:'success',pending:i}:{event:'onboarding',point:'screen',step:'profile',accountActionPending:false}); + await ring.flush();const reopened=createSyncDiagnostics(storage,()=>3);const entries=await reopened.read();assert.equal(entries.length,100);assert.ok(entries.some(row=>row.event==='sync'));assert.ok(entries.some(row=>row.event==='onboarding')); + assert.ok(!saved.includes('PRIVATE'));assert.deepEqual(await reopened.read(),entries); +}); diff --git a/src/cloud/sync-diagnostics.ts b/src/cloud/sync-diagnostics.ts index 3258ca86..61ba7335 100644 --- a/src/cloud/sync-diagnostics.ts +++ b/src/cloud/sync-diagnostics.ts @@ -13,9 +13,20 @@ const diagnostic = z.object({ blocked: z.number().int().min(0).max(1_000_000).optional(), batchCount: z.number().int().min(0).max(50).optional(), }); -const entry = diagnostic.extend({ at: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }); +const onboardingDiagnostic = z.object({ + event: z.literal('onboarding'), + point: z.enum(['mount', 'unmount', 'screen', 'guest-press-in', 'guest-handler', 'profile-decision', 'animation-settled']), + step: z.enum(['welcome', 'account', 'profile', 'other']), + accountActionPending: z.boolean().optional(), + decision: z.enum(['account-action-pending', 'transitioning', 'same-step', 'accepted']).optional(), + animation: z.enum(['finished', 'interrupted', 'unknown']).optional(), +}); +const timestamp = { at: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }; +const entry = z.union([diagnostic.extend(timestamp), onboardingDiagnostic.extend(timestamp)]); export type CloudDiagnosticInput = z.input; export type RecordCloudDiagnostic = (input: CloudDiagnosticInput) => void; +export type OnboardingDiagnosticInput = z.input; +export type RecordOnboardingDiagnostic = (input: OnboardingDiagnosticInput) => void; type DiagnosticEntry = z.infer; type DiagnosticStorage = { getItem(key: string): Promise; setItem(key: string, value: string): Promise }; const storageKey = 'puptime.sync.diagnostics.v1'; @@ -41,7 +52,7 @@ export function createSyncDiagnostics(storage: DiagnosticStorage, now = Date.now } }).finally(() => { saving = null; if (dirty) persist(); }); } - const record: RecordCloudDiagnostic = input => { + const record = (input: CloudDiagnosticInput | OnboardingDiagnosticInput) => { try { const parsed = entry.safeParse({ ...input, at: now() }); if (!parsed.success) return; diff --git a/src/cloud/use-cloud.native.ts b/src/cloud/use-cloud.native.ts index 8816b205..b162fdbe 100644 --- a/src/cloud/use-cloud.native.ts +++ b/src/cloud/use-cloud.native.ts @@ -16,9 +16,9 @@ export function useCloud(onRemoteChange:()=>unknown):CloudHandle { const care=useCareSession(); const callback=useRef(onRemoteChange);callback.current=onRemoteChange; const [attempt,setAttempt]=useState(0),retry=useCallback(()=>setAttempt(value=>value+1),[]); - const [handle,setHandle]=useState({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics}); + const [handle,setHandle]=useState({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic}); useEffect(()=>{ - setHandle({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics}); + setHandle({available:true,controller:null,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic}); let disposed=false,controller:AccountController|null=null,stopChanges:(()=>void)|null=null; let active=AppState.currentState==='active',online=true,networkEvent=false; const connectivity=(state:Network.NetworkState)=>state.isConnected!==false&&state.isInternetReachable!==false; @@ -26,8 +26,8 @@ export function useCloud(onRemoteChange:()=>unknown):CloudHandle { const networkSubscription=Network.addNetworkStateListener(state=>{networkEvent=true;online=connectivity(state);controller?.setOnline(online);}); void (async()=>{ let client:ReturnType; - try{client=createNativeCloudClient(care.selection??undefined);}catch{if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});return;} - if(!client){if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});return;} + try{client=createNativeCloudClient(care.selection??undefined);}catch{if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});return;} + if(!client){if(!disposed)setHandle({available:true,controller:null,problem:'configuration',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});return;} try { const [runtime,network]=await Promise.all([getLocalRuntime(),Network.getNetworkStateAsync().catch(()=>null)]); if(disposed)return;if(network&&!networkEvent)online=connectivity(network); @@ -35,10 +35,10 @@ export function useCloud(onRemoteChange:()=>unknown):CloudHandle { if(care.selection){const timer=setInterval(()=>{if(active&&online)controller?.invalidate();},15000);stopChanges=()=>clearInterval(timer);} else stopChanges=watchAccountChanges({controller,open:client.openChanges}); controller.setOnline(online);controller.setActive(active); - setHandle({available:true,controller,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,sharing:client.sharing,sharingOrigin:client.origin}); + setHandle({available:true,controller,problem:null,getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic,sharing:client.sharing,sharingOrigin:client.origin}); if(care.selection){controller.subscribe(()=>{const snapshot=controller?.read();if(snapshot?.problem==='account-deleted'||snapshot?.problem==='forbidden'||snapshot?.problem==='account-mismatch'||snapshot?.problem==='conflict'||snapshot?.problem==='auth-required'){void runtime.database.write(sql=>sql.runAsync("INSERT OR REPLACE INTO metadata(key,value) VALUES('care_blocked','1')")).then(()=>{if(care.isCurrent())return callback.current();}).catch(()=>{});}});} await controller.refresh(); - }catch{if(!disposed)setHandle({available:true,controller:null,problem:'local-storage',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics});} + }catch{if(!disposed)setHandle({available:true,controller:null,problem:'local-storage',getLocalName,retry,shareDiagnostics:shareCloudDiagnostics,recordOnboardingDiagnostic:recordCloudDiagnostic});} })(); return()=>{disposed=true;stopChanges?.();controller?.stop();appSubscription.remove();networkSubscription.remove();}; },[attempt,retry]); diff --git a/src/onboarding-guest.test.ts b/src/onboarding-guest.test.ts index d17c833d..f0dd18e2 100644 --- a/src/onboarding-guest.test.ts +++ b/src/onboarding-guest.test.ts @@ -6,16 +6,20 @@ import {darkTheme,lightTheme} from './theme.ts'; import {starterPoopWindows,puppyAges,starterMealTimes,suggestedMealCount} from './onboarding-preview.ts'; import {DEFAULT_NOTIFICATION_PREFERENCES} from './notification-config.ts'; -function guestScreen(){ +function guestScreen({effects=false,animated=false,throwingRecorder=false,omitRecorder=false}:{effects?:boolean;animated?:boolean;throwingRecorder?:boolean;omitRecorder?:boolean}={}){ // Actual component/hook boundary, following native-provider-buttons.test.ts. // This deliberately makes no native rendering or provider claim. const hooks:any[]=[],snapshot={busy:false,accountActionPending:false,account:null,online:true,problem:null as string|null};let cursor=0,reads=0,finished=0; const jsx=(type:unknown,props:any)=>({type,props}); + const diagnostics:any[]=[],pendingEffects:(()=>void)[]=[],frames:(()=>void)[]=[]; + let animation:((result:unknown)=>void)|undefined,stops=0; + const frame=(callback:()=>void)=>{frames.push(callback);return frames.length;}; + const effect=(run:()=>unknown,deps:unknown[])=>{const index=cursor++,previous=hooks[index];if(effects&&(!previous||deps.some((value,i)=>!Object.is(value,previous.deps[i]))))pendingEffects.push(()=>{previous?.cleanup?.();hooks[index]={deps,cleanup:run()};});else hooks[index]??={deps};}; const modules:Record={ '@expo/vector-icons':{MaterialCommunityIcons:'Icon'},'expo-font':{useFonts:()=>[true,null]},'expo-status-bar':{StatusBar:'StatusBar'}, - react:{useEffect:()=>{},useState:(initial:any)=>{const index=cursor++;hooks[index]??={value:typeof initial==='function'?initial():initial};return [hooks[index].value,(next:any)=>{hooks[index].value=typeof next==='function'?next(hooks[index].value):next;}];},useRef:(initial:any)=>{const index=cursor++;return hooks[index]??=( {current:initial} );}}, + react:{useEffect:effect,useState:(initial:any)=>{const index=cursor++;hooks[index]??={value:typeof initial==='function'?initial():initial};return [hooks[index].value,(next:any)=>{hooks[index].value=typeof next==='function'?next(hooks[index].value):next;}];},useRef:(initial:any)=>{const index=cursor++;return hooks[index]??=( {current:initial} );}}, 'react/jsx-runtime':{jsx,jsxs:jsx,Fragment:'Fragment'}, - 'react-native':{Platform:{OS:'ios'},StyleSheet:{create:(value:unknown)=>value},Keyboard:{isVisible:()=>false,dismiss:()=>{}},Animated:{Value:class{interpolate(){return 0;}}},useWindowDimensions:()=>({width:402,height:874,fontScale:1}),...Object.fromEntries(['AccessibilityInfo','ActivityIndicator','BackHandler','Easing','Image','KeyboardAvoidingView','Linking','Pressable','ScrollView','Text','TextInput','View'].map(name=>[name,name]))}, + 'react-native':{Platform:{OS:'ios'},StyleSheet:{create:(value:unknown)=>value},Keyboard:{isVisible:()=>false,dismiss:()=>{},addListener:()=>({remove(){}})},Animated:{Value:class{interpolate(){return 0;}setValue(){}stopAnimation(){stops++;animation?.({finished:false});animation=undefined;}},timing:()=>({start:(callback:(result:unknown)=>void)=>{animation=callback;}})},useWindowDimensions:()=>({width:402,height:874,fontScale:1}),...Object.fromEntries(['ActivityIndicator','Image','KeyboardAvoidingView','Linking','Pressable','ScrollView','Text','TextInput','View'].map(name=>[name,name])),AccessibilityInfo:{isReduceMotionEnabled:async()=>!animated,addEventListener:()=>({remove(){}}),sendAccessibilityEvent(){}},BackHandler:{addEventListener:()=>({remove(){}})},Easing:{out:()=>0,cubic:0}}, 'react-native-safe-area-context':{SafeAreaView:'SafeAreaView'},'../domain':{formatMinutes:()=>'',formatScheduleWindow:()=>''},'../entity-id':{entityId:()=>{throw new Error('Guest navigation must not create a record.');}}, '../components/DateTimeSelector':{DateTimeSelector:'DateTimeSelector'},'../components/NotificationTimingControls':{NotificationTimingControls:'NotificationTimingControls'}, '../notification-config':{DEFAULT_NOTIFICATION_PREFERENCES},'../onboarding-preview':{starterPoopWindows,puppyAges,starterMealTimes,suggestedMealCount},'../theme':{darkTheme,lightTheme}, @@ -24,10 +28,10 @@ function guestScreen(){ const source=readFileSync(new URL('./screens/OnboardingDesignPreview.tsx',import.meta.url),'utf8'); const compiled=ts.transpileModule(source,{compilerOptions:{jsx:ts.JsxEmit.ReactJSX,module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText; const module={exports:{} as {OnboardingDesignPreview:(props:unknown)=>any}}; - Function('require','module','exports',compiled)((name:string)=>{if(/\.(png|ttf)$/.test(name))return 'asset';assert.ok(name in modules,name);return modules[name];},module,module.exports); + Function('require','module','exports','requestAnimationFrame','cancelAnimationFrame',compiled)((name:string)=>{if(/\.(png|ttf)$/.test(name))return 'asset';assert.ok(name in modules,name);return modules[name];},module,module.exports,frame,()=>{}); const nodes=(node:any):any[]=>!node?[]:Array.isArray(node)?node.flatMap(nodes):[node,...nodes(node.props?.children)]; - const render=()=>{cursor=0;const inner=module.exports.OnboardingDesignPreview({cloud:{controller:{read:()=>{reads++;return snapshot;}}},onFinish:async()=>{finished++;},onAllowNotifications:async()=>false,onDisableNotifications:async()=>{}});return nodes(inner.type(inner.props));}; - return {snapshot,render,get reads(){return reads;},get finished(){return finished;}}; + const render=()=>{cursor=0;const inner=module.exports.OnboardingDesignPreview({cloud:{controller:{read:()=>{reads++;return snapshot;}},...(omitRecorder?{}:{recordOnboardingDiagnostic:(input:unknown)=>{if(throwingRecorder)throw new Error('PRIVATE_RECORDER_ERROR');diagnostics.push(input);}})},onFinish:async()=>{finished++;},onAllowNotifications:async()=>false,onDisableNotifications:async()=>{}});const result=nodes(inner.type(inner.props));while(pendingEffects.length)pendingEffects.shift()!();return result;}; + return {snapshot,render,diagnostics,flushFrames:()=>{while(frames.length)frames.shift()!();},settleAnimation:(result:unknown)=>{const callback=animation;animation=undefined;assert.ok(callback);callback(result);},unmount:()=>{for(const hook of hooks)hook?.cleanup?.();},get stops(){return stops;},get reads(){return reads;},get finished(){return finished;}}; } for(const screen of ['welcome','account'])test(`actual ${screen} guest navigation stays available during passive query/retry/offline state`,()=>{ @@ -46,3 +50,44 @@ for(const screen of ['welcome','account'])test(`actual ${screen} guest handler r oldButton.props.onPress();assert.ok(!h.render().some(node=>node.props?.testID==='preview.profile'));assert.equal(h.finished,0); h.snapshot.accountActionPending=false;oldButton.props.onPress();assert.ok(h.render().some(node=>node.props?.testID==='preview.profile'));assert.equal(h.reads,2);assert.equal(h.finished,0); }); + + +test('actual onboarding records touch, live guard and committed screen separately without extra account reads',()=>{ + const h=guestScreen({effects:true});let button=h.render().find(node=>node.props?.testID==='preview.guest'); + assert.deepEqual([...h.diagnostics],[{event:'onboarding',point:'mount',step:'welcome'},{event:'onboarding',point:'screen',step:'welcome',accountActionPending:false}]); + h.snapshot.busy=true;h.render();assert.equal(h.diagnostics.length,2,'Passive refresh does not create a lock or render breadcrumb.'); + h.snapshot.accountActionPending=true;h.render();assert.deepEqual(h.diagnostics.at(-1),{event:'onboarding',point:'screen',step:'welcome',accountActionPending:true}); + button.props.onPressIn();assert.equal(h.reads,0);assert.equal(h.diagnostics.at(-1).point,'guest-press-in');assert.ok(!h.render().some(node=>node.props?.testID==='preview.profile')); + button.props.onPress();assert.equal(h.reads,1);assert.deepEqual(h.diagnostics.slice(-2),[{event:'onboarding',point:'guest-handler',step:'welcome',accountActionPending:true},{event:'onboarding',point:'profile-decision',step:'welcome',decision:'account-action-pending'}]); + assert.ok(!h.render().some(node=>node.props?.testID==='preview.profile')); + h.snapshot.accountActionPending=false;button=h.render().find(node=>node.props?.testID==='preview.guest');button.props.onPress(); + assert.equal(h.reads,2);assert.equal(h.diagnostics.at(-1).decision,'accepted');assert.ok(!h.diagnostics.some(row=>row.point==='screen'&&row.step==='profile'),'Accepted setState is not reported as committed screen.'); + assert.ok(h.render().some(node=>node.props?.testID==='preview.profile'));assert.deepEqual(h.diagnostics.at(-1),{event:'onboarding',point:'screen',step:'profile',accountActionPending:false}); + assert.equal(h.finished,0);h.unmount();assert.deepEqual(h.diagnostics.at(-1),{event:'onboarding',point:'unmount',step:'profile'});assert.equal(h.stops,1); +}); + +for(const [result,settlement] of [[{finished:true},'finished'],[{finished:false},'interrupted'],[undefined,'unknown']] as const)test(`actual animated profile transition reports ${settlement} without assuming completion`,async()=>{ + const h=guestScreen({effects:true,animated:true});h.render();await Promise.resolve();const button=h.render().find(node=>node.props?.testID==='preview.guest'); + button.props.onPress();button.props.onPress();assert.equal(h.reads,2);assert.equal(h.diagnostics.at(-1).decision,'transitioning'); + assert.equal(h.diagnostics.filter(row=>row.decision==='accepted').length,1);assert.ok(h.render().some(node=>node.props?.testID==='preview.profile')); + h.flushFrames();h.settleAnimation(result);assert.deepEqual(h.diagnostics.at(-1),{event:'onboarding',point:'animation-settled',step:'profile',animation:settlement}); + assert.ok(h.render().some(node=>node.props?.testID==='preview.profile'));assert.ok(!h.render().some(node=>node.props?.testID==='preview.welcome'),'Animation callback still removes the outgoing page.');assert.equal(h.finished,0);h.unmount();assert.equal(h.stops,1); +}); + +test('throwing support recorder cannot prevent guest navigation, animation settlement or existing cleanup',async()=>{ + const h=guestScreen({effects:true,animated:true,throwingRecorder:true});h.render();await Promise.resolve();const button=h.render().find(node=>node.props?.testID==='preview.guest'); + assert.doesNotThrow(()=>{button.props.onPressIn();button.props.onPress();});assert.equal(h.reads,1);assert.ok(h.render().some(node=>node.props?.testID==='preview.profile')); + h.flushFrames();assert.doesNotThrow(()=>h.settleAnimation({finished:false}));assert.ok(h.render().some(node=>node.props?.testID==='preview.profile'));assert.ok(!h.render().some(node=>node.props?.testID==='preview.welcome')); + assert.doesNotThrow(()=>h.unmount());assert.equal(h.stops,1);assert.equal(h.finished,0); +}); + +test('actual lifecycle breadcrumbs do not remount for a rerender, and a fresh flow starts at welcome',()=>{ + const h=guestScreen({effects:true});h.render();h.render();assert.equal(h.diagnostics.filter(row=>row.point==='mount').length,1);h.unmount(); + const next=guestScreen({effects:true});next.render();assert.deepEqual([...h.diagnostics.slice(-1),next.diagnostics[0]],[{event:'onboarding',point:'unmount',step:'welcome'},{event:'onboarding',point:'mount',step:'welcome'}]); +}); + + +test('shared onboarding works without the optional native diagnostic handle',()=>{ + const h=guestScreen({effects:true,omitRecorder:true}),button=h.render().find(node=>node.props?.testID==='preview.guest'); + assert.doesNotThrow(()=>{button.props.onPressIn();button.props.onPress();});assert.ok(h.render().some(node=>node.props?.testID==='preview.profile'));assert.equal(h.reads,1);assert.deepEqual(h.diagnostics,[]);assert.doesNotThrow(()=>h.unmount());assert.equal(h.stops,1); +}); diff --git a/src/screens/OnboardingDesignPreview.tsx b/src/screens/OnboardingDesignPreview.tsx index 4ac03059..ce85a805 100644 --- a/src/screens/OnboardingDesignPreview.tsx +++ b/src/screens/OnboardingDesignPreview.tsx @@ -19,6 +19,7 @@ import {OnboardingPlanTimeline} from '../components/OnboardingPlanTimeline'; import {AccountSignIn} from '../components/AccountSignIn'; import {useAccountSnapshot} from '../cloud/use-account-snapshot'; import type {CloudHandle} from '../cloud/handle'; +import type {OnboardingDiagnosticInput} from '../cloud/sync-diagnostics'; import {ExactReminderAccessError} from '../reminders'; import {translate} from '../localization'; export type OnboardingDraft={puppyName:string;schedule:ScheduleEntry[];notifications:NotificationPreferences;showGuide:boolean;sharingKind?:'household'|'temporary'|null}; @@ -76,6 +77,13 @@ function PreviewFlow({cloud,onFinish,onAllowNotifications,onDisableNotifications const [direction,setDirection]=useState(1); const transition=useRef(new Animated.Value(1)).current; const page=useRef(null),transitioning=useRef(false); + const diagnostic=useRef({record:cloud.recordOnboardingDiagnostic,step}); + diagnostic.current.record=cloud.recordOnboardingDiagnostic; + function record(point:OnboardingDiagnosticInput['point'],details:Pick={},observedStep:Step=step){ + try{diagnostic.current.record?.({event:'onboarding',point,step:observedStep==='welcome'||observedStep==='account'||observedStep==='profile'?observedStep:'other',...details});}catch{/* Support logging cannot affect onboarding. */} + } + useEffect(()=>{record('mount');return()=>record('unmount',{},diagnostic.current.step);},[]); + useEffect(()=>{diagnostic.current.step=step;if(step==='welcome'||step==='account'||step==='profile')record('screen',{accountActionPending:guestLocked});},[step,guestLocked]); useEffect(()=>{ let current=true; void AccessibilityInfo.isReduceMotionEnabled().then(value=>{if(current)setReduceMotion(value);}); @@ -105,15 +113,18 @@ function PreviewFlow({cloud,onFinish,onAllowNotifications,onDisableNotifications return()=>subscription.remove(); },[step,signingIn]); function go(next:Step,backward=false){ - if(transitioning.current||next===step)return; + if(transitioning.current||next===step){if(next==='profile')record('profile-decision',{decision:transitioning.current?'transitioning':'same-step'});return;} + if(next==='profile')record('profile-decision',{decision:'accepted'}); Keyboard.dismiss();setPickingTime(false); if(reduceMotion){setStep(next);return;} transitioning.current=true;setOutgoing(page.current);setDirection(backward?-1:1); transition.setValue(0);setStep(next); - requestAnimationFrame(()=>Animated.timing(transition,{toValue:1,duration:300,easing:Easing.out(Easing.cubic),useNativeDriver:true}).start(()=>{setOutgoing(null);transitioning.current=false;})); + requestAnimationFrame(()=>Animated.timing(transition,{toValue:1,duration:300,easing:Easing.out(Easing.cubic),useNativeDriver:true}).start(result=>{if(next==='profile')record('animation-settled',{animation:result?.finished===true?'finished':result?.finished===false?'interrupted':'unknown'},diagnostic.current.step);setOutgoing(null);transitioning.current=false;})); } function startWithoutAccount(){ - if(cloud.controller?.read().accountActionPending)return; + const accountActionPending=cloud.controller?.read().accountActionPending??false; + record('guest-handler',{accountActionPending}); + if(accountActionPending){record('profile-decision',{decision:'account-action-pending'});return;} go('profile'); } function preparePoop(times:number[]){ @@ -187,7 +198,7 @@ function PreviewFlow({cloud,onFinish,onAllowNotifications,onDisableNotifications How would you like to begin? - Let’s start without an account + record('guest-press-in')} onPress={startWithoutAccount} style={[styles.secondary,{backgroundColor:theme.primary}]}>Let’s start without an account go('account')}/> {if(onOpenShared)onOpenShared();else void Linking.openURL(`${cloud.sharingOrigin??'https://puptime.pages.dev'}/care`).catch(()=>setReminderError('Couldn’t open shared puppies. Open your invitation link in your browser.'));}} style={styles.secondary}>Already invited? Open shared puppies {reminderError?{reminderError}:null} @@ -196,7 +207,7 @@ function PreviewFlow({cloud,onFinish,onAllowNotifications,onDisableNotifications else if(step==='account')body= {prompt('Sign in')} - Let’s start without an account + record('guest-press-in')} onPress={startWithoutAccount} style={styles.secondary}>Let’s start without an account ; else if(step==='profile')body=<> From 57b8cf02b45da294f6495c55b81d6bf2b6257209 Mon Sep 17 00:00:00 2001 From: Patrick Chin <1392122+patrickchin@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:46:12 +0800 Subject: [PATCH 2/2] Disclose device-only onboarding support diagnostics --- PRIVACY.md | 6 ++++-- docs/product-operating-plan.md | 2 +- docs/sync-diagnostics.md | 8 ++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 97ea916b..bf5ad0c7 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -36,9 +36,11 @@ Events use a random anonymous installation identifier that is separate from your Your choice and anonymous identifier stay on this device and are not synced or included in activity exports. Turn the switch off to stop capture, discard queued events, abort pending analytics requests and remove the stored anonymous identifier. Turning it on again creates a new identifier. Events already received by PostHog are not erased by withdrawal. The event queue is bounded and kept in memory, so offline use works normally and unsent events may be lost when the app is closed. Analytics are optional and do not affect local logging or cloud access. App consent and website consent are separate. Earlier releases without this setting do not collect these app events. -## Local sync diagnostics +## Local sync and onboarding diagnostics -The app keeps up to 100 recent sync diagnostic entries on this device. They contain attempt times, API route categories, request methods, response status codes, durations, error categories and queue counts. They exclude puppy records, names, notes, photos, account or record identifiers, full URLs, credentials and raw error messages. New entries replace older entries. These diagnostics are separate from optional usage analytics and are not automatically sent to Puptime or PostHog. +The app keeps up to 100 recent diagnostic entries on this device. Sync entries contain attempt times, API route categories, request methods, response status codes, durations, error categories and queue counts. Builds with onboarding diagnostics also record fixed setup-screen and guest-button states, navigation decisions and whether an animation finished, was interrupted or had an unknown result. Those onboarding entries use predefined categories and booleans, plus their time. + +Entries exclude puppy records, names, emails, entered text, notes, photos, account/device/record identifiers, full URLs, credentials and raw error messages or stacks. New entries replace older entries. These diagnostics are separate from optional usage analytics and are not automatically sent to Puptime or PostHog. **My account → Export sync diagnostics** creates a JSON report with those entries and the app version, build and platform, then opens the system share sheet. One report remains in the app's temporary cache so the chosen destination can read it, and the next export replaces it. The operating system may clear this cache. The app or destination you choose handles saved copies under its own privacy practices. diff --git a/docs/product-operating-plan.md b/docs/product-operating-plan.md index 35a6cc75..97aaf0ac 100644 --- a/docs/product-operating-plan.md +++ b/docs/product-operating-plan.md @@ -51,7 +51,7 @@ Install over the existing app, open the journal and check **My account** for **U Batch routine updates to at most one owner install per week. An urgent qualified recovery or security fix can justify an earlier install. Give an explicit **Install**, **Optional** or **Hold** verdict. Install/Optional includes version/build, immutable artifact link, source, checksum, reason, tested upgrade path and the smallest owner action. Hold names the actual blocking gate and next step; it needs no speculative download link. -The published diagnostic export is a bounded **transport and sync-queue report**, not a backup, general crash logger or complete local-storage report. Source change #197 adds optional finite handled-read/write/import failure codes to product telemetry for a future binary; it does not expand that export or diagnose the underlying data class. Neither missing telemetry nor a read-error code proves inactivity, corruption or a healthy cloud copy. Exclude raw values, records, SQL, identifiers and secrets. +The published diagnostic export covers transport and sync queues. Candidate source extends the same device-only, 100-entry ring with finite onboarding lifecycle, guest-button, navigation and animation observations; this requires a qualified new binary and does not add records to earlier APKs. The report is shared only through explicit export and is not automatically uploaded. It is not a backup, general crash logger or complete local-storage report. Source change #197 adds separate opt-in handled-read/write/import failure codes to product telemetry; those do not diagnose the underlying data class. Missing breadcrumbs or telemetry do not prove inactivity, corruption or a healthy cloud copy. Exclude raw values, records, SQL, identifiers and secrets; see the [diagnostics scope](sync-diagnostics.md). ## Candidate verification diff --git a/docs/sync-diagnostics.md b/docs/sync-diagnostics.md index e3a13224..02dfc905 100644 --- a/docs/sync-diagnostics.md +++ b/docs/sync-diagnostics.md @@ -1,4 +1,4 @@ -# Sync diagnostics +# Local sync and onboarding diagnostics Native profile-photo uploads send validated JPEG bytes. Passing an Expo `File` directly to the authentication SDK sends its JSON metadata instead of image bytes, which can reject the photo and stop the queued activity upload that follows it. Reading the existing file's bytes preserves its photo ID and the pending activity operations. @@ -6,7 +6,11 @@ Native builds keep the latest 100 structured diagnostic entries on the device, s The report includes its schema version, export time, native app version/build and platform. Entries contain allowlisted request categories (`config`, `account`, `enroll`, `push`, `pull`, `history`, `photo-upload`, `photo-download`), method, actual HTTP status where observed, duration, result/error category, batch size and sync state/queue counts. A missing status means no response status was observed; it is not a synthetic success. A successful HTTP response can still have an invalid-response category if its body fails validation. -Entries exclude account/puppy/activity/device identifiers, names, notes, photo bytes, request/response bodies, full URLs/query strings, credentials and raw errors. Unknown properties are removed both when recording and when loading persisted history. The bounded AsyncStorage write queue is best effort and cannot prevent logging or sync. Duplicate exports share one active operation. A single cache report remains available to the chosen share target and is replaced on the next export; the operating system may clear it. User-saved copies remain where the user chose to save them. +Builds with onboarding diagnostics add fixed observations of flow mount/unmount, committed welcome/account/profile screens and account-action locks, guest touch-start/handler entry, the profile-navigation decision and animation settlement (`finished`, `interrupted` or `unknown`). These entries use only predefined categories, booleans and the existing timestamp. They share the same 100-entry ring; there is no separate recorder, storage key or automatic upload. + +A touch-start without handler entry can be gesture cancellation. An accepted navigation decision is not a committed screen, and animation settlement is not proof of visible pixels. Missing entries or a mount/unmount sequence do not establish the underlying cause. This is a bounded support report, not a crash logger or complete local-storage log. + +Entries exclude account/puppy/activity/device identifiers, names, emails, entered text, notes, photo bytes, request/response bodies, full URLs/query strings, credentials and raw error messages or stacks. Unknown properties are removed both when recording and when loading persisted history. The bounded AsyncStorage write queue is best effort and cannot prevent logging or sync. Duplicate exports share one active operation. A single cache report remains available to the chosen share target and is replaced on the next export; the operating system may clear it. User-saved copies remain where the user chose to save them. For a paused sync investigation, preserve a current external activity backup and the original installation first. Collect the diagnostic report after one retry. Read the last request category/status together with sync pending/blocked counts. A generic `invalid-request` or `conflict` during sync is not evidence of failed sign-in. Avoid signing out, clearing storage, relinking or importing over the live account to investigate an unknown failure.