1- /**
2- * Parse an instance ID into taskId and optional index.
3- * "2.1.3" → { taskId: "2.1.3" }
4- * "2.1.3[0]" → { taskId: "2.1.3", index: 0 }
5- */
61export function parseInstanceId ( instanceId : string ) : { taskId : string ; index ?: number } {
72 const match = instanceId . match ( / ^ ( .+ ) \[ ( \d + ) \] $ / )
83 if ( match ) return { taskId : match [ 1 ] , index : parseInt ( match [ 2 ] ) }
@@ -28,40 +23,71 @@ export type EditRecord = {
2823 newValue : unknown
2924}
3025
26+ function isGroupedArray ( value : unknown ) : value is Array < { _index : number ; [ key : string ] : unknown } > {
27+ return Array . isArray ( value ) && value . length > 0 && typeof value [ 0 ] ?. _index === 'number'
28+ }
29+
3130/**
32- * Compares two states and produces field-level edit records.
33- * Tracks answer changes, completed section changes, and task instance add/remove.
34- * Uses URN-based field identifiers when available.
31+ * Compare two grouped arrays element by element, matching on _index.
3532 */
36- export function diffStates (
37- oldState : unknown ,
38- newState : unknown ,
33+ function diffGroupedArrays (
34+ oldArr : Array < { _index : number ; [ key : string ] : unknown } > | undefined ,
35+ newArr : Array < { _index : number ; [ key : string ] : unknown } > | undefined ,
36+ parentKey : string ,
37+ urn : string | undefined ,
3938 editedBy : string ,
4039) : EditRecord [ ] {
4140 const edits : EditRecord [ ] = [ ]
41+ const oldByIndex = new Map < number , Record < string , unknown > > ( )
42+ const newByIndex = new Map < number , Record < string , unknown > > ( )
43+
44+ for ( const el of oldArr ?? [ ] ) oldByIndex . set ( el . _index , el )
45+ for ( const el of newArr ?? [ ] ) newByIndex . set ( el . _index , el )
46+
47+ const allIndices = new Set ( [ ...oldByIndex . keys ( ) , ...newByIndex . keys ( ) ] )
48+
49+ for ( const idx of allIndices ) {
50+ const oldEl = oldByIndex . get ( idx )
51+ const newEl = newByIndex . get ( idx )
52+
53+ const childKeys = new Set < string > ( )
54+ if ( oldEl ) for ( const k of Object . keys ( oldEl ) ) { if ( k !== '_index' ) childKeys . add ( k ) }
55+ if ( newEl ) for ( const k of Object . keys ( newEl ) ) { if ( k !== '_index' ) childKeys . add ( k ) }
56+
57+ // Entire instance added or removed — bundle child values into one edit
58+ if ( ( ! oldEl && newEl ) || ( oldEl && ! newEl ) ) {
59+ // Skip default index 0 when the parent key was newly saved —
60+ // index 0 always exists implicitly via init() and is not a user action.
61+ const skipDefault = idx === 0 && ! oldEl && ( ! oldArr || oldArr . length === 0 )
62+ if ( ! skipDefault ) {
63+ // Collect child field values from the instance that existed
64+ const source = ( oldEl ?? newEl ) !
65+ const fields : Record < string , unknown > = { }
66+ for ( const k of Object . keys ( source ) ) {
67+ if ( k !== '_index' ) fields [ k ] = source [ k ]
68+ }
69+
70+ const instanceId = `${ parentKey } [${ idx } ]`
71+ const fieldId = urn ? buildFieldUrn ( urn , instanceId ) : instanceId
72+ edits . push ( {
73+ fieldId,
74+ editType : ! oldEl ? 'instance_added' : 'instance_removed' ,
75+ editedBy,
76+ oldValue : ! oldEl ? null : ( Object . keys ( fields ) . length > 0 ? fields : null ) ,
77+ newValue : ! newEl ? null : ( Object . keys ( fields ) . length > 0 ? fields : null ) ,
78+ } )
79+ }
80+ continue
81+ }
4282
43- const newMeta = ( newState as any ) ?. metadata || { }
44- const urn : string | undefined = newMeta . urn
45-
46- const oldAnswers = ( oldState as any ) ?. answers || { }
47- const newAnswers = ( newState as any ) ?. answers || { }
48-
49- // Compare answers across namespaces
50- const allNamespaces = new Set ( [ ...Object . keys ( oldAnswers ) , ...Object . keys ( newAnswers ) ] )
51-
52- for ( const ns of allNamespaces ) {
53- const oldNs = oldAnswers [ ns ] || { }
54- const newNs = newAnswers [ ns ] || { }
55- const allKeys = new Set ( [ ...Object . keys ( oldNs ) , ...Object . keys ( newNs ) ] )
56-
57- for ( const key of allKeys ) {
58- const oldVal = oldNs [ key ]
59- const newVal = newNs [ key ]
83+ for ( const childKey of childKeys ) {
84+ const oldVal = oldEl ?. [ childKey ]
85+ const newVal = newEl ?. [ childKey ]
6086
6187 if ( JSON . stringify ( oldVal ) !== JSON . stringify ( newVal ) ) {
62- const fieldId = urn ? buildFieldUrn ( urn , key ) : `${ ns } .${ key } `
88+ const instanceId = `${ childKey } [${ idx } ]`
89+ const fieldId = urn ? buildFieldUrn ( urn , instanceId ) : instanceId
6390 edits . push ( {
64-
6591 fieldId,
6692 editType : 'answer_change' ,
6793 editedBy,
@@ -72,74 +98,68 @@ export function diffStates(
7298 }
7399 }
74100
75- // Compare task state across namespaces
76- const oldTaskState = ( oldState as any ) ?. taskState || { }
77- const newTaskState = ( newState as any ) ?. taskState || { }
78- const taskNamespaces = new Set ( [ ...Object . keys ( oldTaskState ) , ...Object . keys ( newTaskState ) ] )
101+ return edits
102+ }
79103
80- for ( const ns of taskNamespaces ) {
81- // Compare completed sections
82- const oldCompleted = new Set < string > ( oldTaskState [ ns ] ?. completedRootTaskIds || [ ] )
83- const newCompleted = new Set < string > ( newTaskState [ ns ] ?. completedRootTaskIds || [ ] )
104+ /**
105+ * Compares two states and produces field-level edit records.
106+ * States use the unwrapped format: answers at top level, completedTasks in metadata.
107+ * Uses URN-based field identifiers when available.
108+ */
109+ export function diffStates (
110+ oldState : unknown ,
111+ newState : unknown ,
112+ editedBy : string ,
113+ ) : EditRecord [ ] {
114+ const edits : EditRecord [ ] = [ ]
84115
85- for ( const id of newCompleted ) {
86- if ( ! oldCompleted . has ( id ) ) {
87- const fieldId = urn ? buildFieldUrn ( urn , `completed.${ id } ` ) : `${ ns } .completed.${ id } `
88- edits . push ( {
116+ const newMeta = ( newState as any ) ?. metadata || { }
117+ const urn : string | undefined = newMeta . urn
89118
90- fieldId,
91- editType : 'section_complete' ,
92- editedBy,
93- oldValue : false ,
94- newValue : true ,
95- } )
96- }
119+ const oldAnswers = ( oldState as any ) ?. answers || { }
120+ const newAnswers = ( newState as any ) ?. answers || { }
121+ const allKeys = new Set ( [ ...Object . keys ( oldAnswers ) , ...Object . keys ( newAnswers ) ] )
122+
123+ for ( const key of allKeys ) {
124+ const oldVal = oldAnswers [ key ]
125+ const newVal = newAnswers [ key ]
126+
127+ // Handle grouped arrays: compare child-by-child
128+ if ( isGroupedArray ( oldVal ) || isGroupedArray ( newVal ) ) {
129+ edits . push ( ...diffGroupedArrays (
130+ isGroupedArray ( oldVal ) ? oldVal : undefined ,
131+ isGroupedArray ( newVal ) ? newVal : undefined ,
132+ key , urn , editedBy ,
133+ ) )
134+ continue
97135 }
98- for ( const id of oldCompleted ) {
99- if ( ! newCompleted . has ( id ) ) {
100- const fieldId = urn ? buildFieldUrn ( urn , `completed.${ id } ` ) : `${ ns } .completed.${ id } `
101- edits . push ( {
102136
103- fieldId,
104- editType : 'section_complete' ,
105- editedBy,
106- oldValue : true ,
107- newValue : false ,
108- } )
109- }
137+ if ( JSON . stringify ( oldVal ) !== JSON . stringify ( newVal ) ) {
138+ const fieldId = urn ? buildFieldUrn ( urn , key ) : key
139+ edits . push ( {
140+ fieldId,
141+ editType : 'answer_change' ,
142+ editedBy,
143+ oldValue : oldVal ?? null ,
144+ newValue : newVal ?? null ,
145+ } )
110146 }
147+ }
111148
112- // Compare task instances (add/remove)
113- const oldInstances = Object . keys ( oldTaskState [ ns ] ?. taskInstances || { } )
114- const newInstances = Object . keys ( newTaskState [ ns ] ?. taskInstances || { } )
115- const oldInstanceSet = new Set ( oldInstances )
116- const newInstanceSet = new Set ( newInstances )
117-
118- for ( const id of newInstances ) {
119- if ( ! oldInstanceSet . has ( id ) ) {
120- const fieldId = urn ? buildFieldUrn ( urn , id ) : `${ ns } .${ id } `
121- edits . push ( {
149+ // Compare completedTasks in metadata
150+ const oldCompleted = new Set < string > ( ( oldState as any ) ?. metadata ?. completedTasks || [ ] )
151+ const newCompleted = new Set < string > ( newMeta . completedTasks || [ ] )
122152
123- fieldId,
124- editType : 'task_instance_add' ,
125- editedBy,
126- oldValue : null ,
127- newValue : newTaskState [ ns ] . taskInstances [ id ] ,
128- } )
129- }
153+ for ( const id of newCompleted ) {
154+ if ( ! oldCompleted . has ( id ) ) {
155+ const fieldId = urn ? buildFieldUrn ( urn , `completed.${ id } ` ) : `completed.${ id } `
156+ edits . push ( { fieldId, editType : 'section_complete' , editedBy, oldValue : false , newValue : true } )
130157 }
131- for ( const id of oldInstances ) {
132- if ( ! newInstanceSet . has ( id ) ) {
133- const fieldId = urn ? buildFieldUrn ( urn , id ) : `${ ns } .${ id } `
134- edits . push ( {
135-
136- fieldId,
137- editType : 'task_instance_remove' ,
138- editedBy,
139- oldValue : oldTaskState [ ns ] . taskInstances [ id ] ,
140- newValue : null ,
141- } )
142- }
158+ }
159+ for ( const id of oldCompleted ) {
160+ if ( ! newCompleted . has ( id ) ) {
161+ const fieldId = urn ? buildFieldUrn ( urn , `completed.${ id } ` ) : `completed.${ id } `
162+ edits . push ( { fieldId, editType : 'section_complete' , editedBy, oldValue : true , newValue : false } )
143163 }
144164 }
145165
0 commit comments