(plugin: ReactRNPlugin)
| 329 | } else { |
| 330 | session.currentCardFirstRep = undefined; |
| 331 | session.currentCardTotalTime = 0; |
| 332 | session.currentCardRepCount = 0; |
| 333 | session.currentCardInterval = undefined; |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | export function registerQueueSessionTracking(plugin: ReactRNPlugin) { |
| 338 | // One-shot startup migration / rollover: covers users who installed this |
| 339 | // build but haven't reviewed yet. Fire-and-forget; widget reads will pick |
| 340 | // up the new state via useSyncedStorageState reactivity. |
| 341 | maybeRollOver(plugin); |
| 342 | |
| 343 | // Startup recovery: if the plugin restarted (page refresh, crash) while a |
| 344 | // session was in progress, ACTIVE_SESSION_KEY holds the last synced snapshot |
| 345 | // but currentSession is null. Rescue it into synced history so the work is |
| 346 | // not silently lost. |
| 347 | (async () => { |
| 348 | try { |
| 349 | const orphaned = await plugin.storage.getSession<PracticedQueueSession>(ACTIVE_SESSION_KEY); |
| 350 | if (orphaned && (orphaned.flashcardsCount > 0 || orphaned.incRemsCount > 0)) { |
| 351 | if (!orphaned.endTime) orphaned.endTime = Date.now(); |
| 352 | const history = |
| 353 | ((await plugin.storage.getSynced(PRACTICED_QUEUES_HISTORY_KEY)) as PracticedQueueSession[]) || []; |
| 354 | const deduped = history.filter((s): s is PracticedQueueSession => !!s && s.id !== orphaned.id); |
| 355 | await plugin.storage.setSynced(PRACTICED_QUEUES_HISTORY_KEY, [orphaned, ...deduped]); |
| 356 | await plugin.storage.setSession(ACTIVE_SESSION_KEY, null); |
| 357 | console.log( |
| 358 | `[QueueSession] Recovered orphaned session: id=${orphaned.id} scope="${orphaned.scopeName}" fc=${orphaned.flashcardsCount} ir=${orphaned.incRemsCount}` |
| 359 | ); |
| 360 | maybeRollOver(plugin); |
| 361 | } |
| 362 | } catch (err) { |
| 363 | console.error('[QueueSession] Orphaned session recovery failed:', err); |
| 364 | } |
| 365 | })(); |
| 366 | |
| 367 | // Heartbeat monitor: auto-save Mastery Drill session if the popup is closed without QueueExit. |
| 368 | // Popup drill only: the regular-queue drill writes no heartbeat, so a timestamp left behind |
| 369 | // by an earlier popup would end each of its sessions 5 s after it starts. |
| 370 | setInterval(async () => { |
| 371 | if (currentSession && currentSession.scopeName === 'Mastery Drill' && !isNativeDrillActive()) { |
| 372 | if (Date.now() - currentSession.startTime < 5000) return; // grace period |
| 373 | const lastHeartbeat = await plugin.storage.getSession<number>('finalDrillHeartbeat'); |
| 374 | if (lastHeartbeat) { |
| 375 | if (Date.now() - lastHeartbeat > 5000) { |
| 376 | await saveCurrentSession(plugin, 'Heartbeat Stale'); |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | }, 2500); |
| 381 | |
| 382 | // Cluster sibling-transition poll: inside a cluster, QueueLoadCard does NOT fire per |
| 383 | // sibling — only card_info_bar.tsx sees each sibling via getWidgetContext(). |
| 384 | // The widget broadcasts `clusterVisibleCardId`; we poll for changes and advance the |
| 385 | // per-card panel state (currentCardId/prevCardId + lifetime stats) so the UI tracks |
| 386 | // the sibling actually on screen rather than being stuck on the cluster anchor. |
| 387 | setInterval(async () => { |
| 388 | const session = currentSession; |
no test coverage detected