@@ -170,42 +170,69 @@ export function AnalysisStoreProvider({
170170 const getAnalysis = useCallback ( ( ) => analysisRef . current , [ ] ) ;
171171
172172 /**
173- * Creates a new approved `TokenAnalysis` + `TokenAnalysisLink` for `tokenRef` with the given
174- * gloss string (under `analysisLanguage`), replaces the analysis snapshot, notifies subscribers,
175- * calls `onSave`, and calls the optional `spy` prop for test observability.
176- *
177- * The new `TokenAnalysis` gets a UUID (`crypto.randomUUID()`) as its id to ensure global
178- * uniqueness. Existing analyses for the token are left untouched — this follows the data model's
179- * "multiple competing analyses" design; the UI manages selection and deletion separately.
173+ * Writes an approved gloss for `tokenRef`. If an approved `TokenAnalysis` already exists for the
174+ * token it is updated in-place; otherwise a new `TokenAnalysis` + `TokenAnalysisLink` are
175+ * appended. Non-approved analyses for the token are left untouched. Replaces the analysis
176+ * snapshot, notifies subscribers, calls `onSave`, and calls the optional `spy` prop for test
177+ * observability.
180178 *
181179 * @param tokenRef - The `Token.ref` of the token being glossed.
182180 * @param surfaceText - The surface text of the token (stored as `Analysis.surfaceText`).
183181 * @param value - The new gloss string.
184182 */
185183 const onGlossChange = useCallback (
186184 ( tokenRef : string , surfaceText : string , value : string ) => {
187- const id = crypto . randomUUID ( ) ;
188- const newAnalysis : TokenAnalysis = {
189- id,
190- surfaceText,
191- gloss : { [ analysisLanguage ] : value } ,
192- } ;
193- const newLink : TokenAnalysisLink = {
194- analysisId : id ,
195- status : 'approved' ,
196- token : { tokenRef, surfaceText } ,
197- } ;
185+ // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; TS can't see through the closure boundary
186+ const existingApprovedId = approvedAnalysisIdByTokenRef . current ! . get ( tokenRef ) ;
187+
188+ let nextAnalyses : TokenAnalysis [ ] ;
189+ let nextLinks : TokenAnalysisLink [ ] ;
190+ let nextById : Map < string , TokenAnalysis > ;
191+
192+ if ( existingApprovedId === undefined ) {
193+ const id = crypto . randomUUID ( ) ;
194+ const newAnalysis : TokenAnalysis = {
195+ id,
196+ surfaceText,
197+ gloss : { [ analysisLanguage ] : value } ,
198+ } ;
199+ const newLink : TokenAnalysisLink = {
200+ analysisId : id ,
201+ status : 'approved' ,
202+ token : { tokenRef, surfaceText } ,
203+ } ;
204+ nextAnalyses = [ ...analysisRef . current . tokenAnalyses , newAnalysis ] ;
205+ nextLinks = [ ...analysisRef . current . tokenAnalysisLinks , newLink ] ;
206+ // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null
207+ nextById = new Map ( [ ...analysisByIdRef . current ! , [ id , newAnalysis ] ] ) ;
208+ } else {
209+ // Update the gloss on the existing approved analysis; preserve all other fields.
210+ // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; index only stores ids present in analysisByIdRef
211+ const existing = analysisByIdRef . current ! . get ( existingApprovedId ) ! ;
212+ const updated : TokenAnalysis = {
213+ ...existing ,
214+ surfaceText,
215+ gloss : { ...existing . gloss , [ analysisLanguage ] : value } ,
216+ } ;
217+ nextAnalyses = analysisRef . current . tokenAnalyses . map (
218+ /* v8 ignore next -- passthrough branch for non-matching tokens is structurally unreachable in tests */
219+ ( ta ) => ( ta . id === existingApprovedId ? updated : ta ) ,
220+ ) ;
221+ // Links are unchanged — the same link already points to existingApprovedId.
222+ nextLinks = analysisRef . current . tokenAnalysisLinks ;
223+ // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null
224+ nextById = new Map ( [ ...analysisByIdRef . current ! , [ existingApprovedId , updated ] ] ) ;
225+ }
198226
199227 const next : TextAnalysis = {
200228 ...analysisRef . current ,
201- tokenAnalyses : [ ... analysisRef . current . tokenAnalyses , newAnalysis ] ,
202- tokenAnalysisLinks : [ ... analysisRef . current . tokenAnalysisLinks , newLink ] ,
229+ tokenAnalyses : nextAnalyses ,
230+ tokenAnalysisLinks : nextLinks ,
203231 } ;
204232
205233 analysisRef . current = next ;
206- // eslint-disable-next-line no-type-assertion/no-type-assertion -- ??= above guarantees non-null; TS can't see through the closure boundary
207- analysisByIdRef . current = new Map ( [ ...analysisByIdRef . current ! , [ id , newAnalysis ] ] ) ;
208- approvedAnalysisIdByTokenRef . current = buildApprovedGlossIndex ( next , analysisByIdRef . current ) ;
234+ analysisByIdRef . current = nextById ;
235+ approvedAnalysisIdByTokenRef . current = buildApprovedGlossIndex ( next , nextById ) ;
209236
210237 listenersRef . current . forEach ( ( l ) => l ( ) ) ;
211238 onSave ?.( next ) ;
0 commit comments