From 1239312f9d4cb4bf8f7bd15070f6af1135f83d09 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:16:54 +0300 Subject: [PATCH 1/5] test(offline): add unit tests for the queued drafts badge (#795) Pending offline transactions sit in the queue with no count the user can trust. These tests pin the badge: the 'N drafts queued offline' label (singular for one), nothing rendered for an empty queue, and live updates when the queue changes in this tab or in another tab. They fail on main, where the label helper does not exist yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RcnHnXiw2CqnpbuFR5q6VA --- .../__tests__/OfflineTransactionSync.test.tsx | 70 +++++++++++++++++-- .../offline-transactions-badge.test.ts | 22 ++++++ 2 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/offline-transactions-badge.test.ts diff --git a/components/__tests__/OfflineTransactionSync.test.tsx b/components/__tests__/OfflineTransactionSync.test.tsx index e740813..8b29fc2 100644 --- a/components/__tests__/OfflineTransactionSync.test.tsx +++ b/components/__tests__/OfflineTransactionSync.test.tsx @@ -9,11 +9,19 @@ vi.mock('@/contexts/WalletContext', () => ({ useWallet: () => ({ publicKey: currentPublicKey, signTx: mockSignTx }), })); -vi.mock('@/lib/offline-transactions', () => ({ - startOnlineSync: vi.fn(), - stopOnlineSync: vi.fn(), - flushQueuedTransactions: vi.fn().mockResolvedValue(undefined), -})); +let queued: { id: string }[] = []; + +vi.mock('@/lib/offline-transactions', async () => { + const actual = await vi.importActual('@/lib/offline-transactions'); + return { + startOnlineSync: vi.fn(), + stopOnlineSync: vi.fn(), + flushQueuedTransactions: vi.fn().mockResolvedValue(undefined), + queuedTransactions: vi.fn(() => queued), + queuedDraftsLabel: actual.queuedDraftsLabel, + OFFLINE_QUEUE_STORAGE_KEY: actual.OFFLINE_QUEUE_STORAGE_KEY, + }; +}); import { OfflineTransactionSync } from '../OfflineTransactionSync'; import { @@ -43,6 +51,7 @@ describe('OfflineTransactionSync (#809)', () => { beforeEach(() => { vi.clearAllMocks(); currentPublicKey = 'GTESTPUBLICKEY'; + queued = []; }); it('binds the online listener and flushes once when a wallet is connected', async () => { @@ -71,3 +80,54 @@ describe('OfflineTransactionSync (#809)', () => { cleanup(root, container); }); }); + +describe('OfflineTransactionSync queue badge (#795)', () => { + beforeEach(() => { + vi.clearAllMocks(); + currentPublicKey = 'GTESTPUBLICKEY'; + queued = []; + }); + + it('renders nothing while the queue is empty', () => { + const { container, root } = render(); + expect(container.textContent).toBe(''); + cleanup(root, container); + }); + + it('shows how many drafts are queued offline', () => { + queued = [{ id: 'a' }, { id: 'b' }]; + const { container, root } = render(); + expect(container.textContent).toContain('2 drafts queued offline'); + expect(container.querySelector('[role="status"]')?.getAttribute('aria-label')).toBe('2 drafts queued offline'); + cleanup(root, container); + }); + + it('uses the singular for one draft', () => { + queued = [{ id: 'a' }]; + const { container, root } = render(); + expect(container.textContent).toContain('1 draft queued offline'); + cleanup(root, container); + }); + + it('updates the count when the queue changes', () => { + const { container, root } = render(); + expect(container.textContent).toBe(''); + queued = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + act(() => { window.dispatchEvent(new Event('offline-transactions-changed')); }); + expect(container.textContent).toContain('3 drafts queued offline'); + queued = []; + act(() => { window.dispatchEvent(new Event('offline-transactions-changed')); }); + expect(container.textContent).toBe(''); + cleanup(root, container); + }); + + it('updates the count when another tab changes the queue', () => { + const { container, root } = render(); + queued = [{ id: 'a' }]; + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'conduit-offline-transactions' })); + }); + expect(container.textContent).toContain('1 draft queued offline'); + cleanup(root, container); + }); +}); diff --git a/lib/__tests__/offline-transactions-badge.test.ts b/lib/__tests__/offline-transactions-badge.test.ts new file mode 100644 index 0000000..0001985 --- /dev/null +++ b/lib/__tests__/offline-transactions-badge.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { queuedDraftsLabel, OFFLINE_QUEUE_STORAGE_KEY } from '../offline-transactions'; + +describe('queuedDraftsLabel (#795)', () => { + it('uses the singular for one draft', () => { + expect(queuedDraftsLabel(1)).toBe('1 draft queued offline'); + }); + + it('uses the plural for several drafts', () => { + expect(queuedDraftsLabel(2)).toBe('2 drafts queued offline'); + expect(queuedDraftsLabel(12)).toBe('12 drafts queued offline'); + }); + + it('treats invalid counts as zero', () => { + expect(queuedDraftsLabel(-3)).toBe('0 drafts queued offline'); + expect(queuedDraftsLabel(Number.NaN)).toBe('0 drafts queued offline'); + }); + + it('exposes the storage key the queue lives under', () => { + expect(OFFLINE_QUEUE_STORAGE_KEY).toBe('conduit-offline-transactions'); + }); +}); From 72fe42643b82e6938b68c29c261a0a4fc37c8ec3 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:18:02 +0300 Subject: [PATCH 2/5] feat(offline): count label and cross-tab sync for the offline queue Users need to see that their offline drafts are kept and waiting for the connection to come back. queuedDraftsLabel() gives the badge its wording ('1 draft queued offline', '3 drafts queued offline') in one tested place, and the component now also refreshes the count on storage events for the queue key, because a draft queued or flushed in another tab never fires this tab's offline-transactions-changed event. Closes #795 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RcnHnXiw2CqnpbuFR5q6VA --- components/OfflineTransactionSync.tsx | 11 +++++++++++ lib/offline-transactions.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/components/OfflineTransactionSync.tsx b/components/OfflineTransactionSync.tsx index ebb1259..93f7ff1 100644 --- a/components/OfflineTransactionSync.tsx +++ b/components/OfflineTransactionSync.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useWallet } from '@/contexts/WalletContext'; import { + OFFLINE_QUEUE_STORAGE_KEY, flushQueuedTransactions, queuedTransactions, startOnlineSync, @@ -11,6 +12,7 @@ import { /** * #739, #809 — Queue failed transactions, retry on reconnect, and notify user. + * #795 — Show how many drafts are queued offline, kept in sync across tabs. */ export function OfflineTransactionSync() { const { publicKey, signTx } = useWallet(); @@ -39,14 +41,23 @@ export function OfflineTransactionSync() { setTimeout(() => setShowRetryPrompt(false), 3000); }; + // Another tab queuing or flushing a draft only reaches this tab as a + // `storage` event on the queue key (#795); a null key means storage was + // cleared. + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === OFFLINE_QUEUE_STORAGE_KEY) updateCount(); + }; + window.addEventListener('online', flush); window.addEventListener('offline-transactions-changed', updateCount); + window.addEventListener('storage', onStorage); updateCount(); return () => { stopOnlineSync(); window.removeEventListener('online', flush); window.removeEventListener('offline-transactions-changed', updateCount); + window.removeEventListener('storage', onStorage); }; }, [publicKey, signTx]); diff --git a/lib/offline-transactions.ts b/lib/offline-transactions.ts index 29a55fa..4c37e9d 100644 --- a/lib/offline-transactions.ts +++ b/lib/offline-transactions.ts @@ -13,6 +13,22 @@ type NewOfflineTransaction = const KEY = 'conduit-offline-transactions'; const DEAD_LETTER_KEY = `${KEY}:dead-letter`; + +/** + * localStorage key the offline queue lives under. Other tabs that queue or + * flush transactions announce it through `storage` events on this key, so + * the queue badge can stay in sync across tabs (#795). + */ +export const OFFLINE_QUEUE_STORAGE_KEY = KEY; + +/** + * Text for the offline queue badge (#795): "1 draft queued offline", + * "3 drafts queued offline". Negative or non-finite counts read as zero. + */ +export function queuedDraftsLabel(count: number): string { + const n = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0; + return `${n} draft${n === 1 ? '' : 's'} queued offline`; +} export const MAX_SYNC_ATTEMPTS = 3; function read(): OfflineTransaction[] { From ce1f91ac72a2bfb3640a46773d3b035ee70acda2 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:19:04 +0300 Subject: [PATCH 3/5] feat(offline): render the queued drafts badge The floating notice said 'N transactions queued for retry', which reads as an error rather than as reassurance that offline drafts are safe. It now shows the count on its own followed by 'drafts queued offline', using the label from queuedDraftsLabel(); the reconnect retry message is unchanged. Layout only: the black-and-white styling and accessible name follow in the next commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RcnHnXiw2CqnpbuFR5q6VA --- components/OfflineTransactionSync.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/OfflineTransactionSync.tsx b/components/OfflineTransactionSync.tsx index 93f7ff1..f0669c4 100644 --- a/components/OfflineTransactionSync.tsx +++ b/components/OfflineTransactionSync.tsx @@ -5,6 +5,7 @@ import { useWallet } from '@/contexts/WalletContext'; import { OFFLINE_QUEUE_STORAGE_KEY, flushQueuedTransactions, + queuedDraftsLabel, queuedTransactions, startOnlineSync, stopOnlineSync, @@ -63,6 +64,9 @@ export function OfflineTransactionSync() { if (queuedCount === 0 && !showRetryPrompt) return null; + const label = queuedDraftsLabel(queuedCount); + const words = label.slice(label.indexOf(' ') + 1); + return (
Connection restored. Retrying {queuedCount} queued transaction{queuedCount === 1 ? '' : 's'}… ) : ( - {queuedCount} transaction{queuedCount === 1 ? '' : 's'} queued for retry + <> + {queuedCount} {words} + )}
); From 2374099fbb15ebb36dd1210b031d2d389e947bc2 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:19:48 +0300 Subject: [PATCH 4/5] style(offline): black-and-white count badge with an accessible name The notice used blue utility classes, which the design system rules out (the app is black and white only). The badge is now a black pill with the count inside a bordered white box, inverted in dark mode, using tabular numbers so the width stays steady as the count changes. Screen readers get the whole sentence once through aria-label on the status region instead of the count and the words as separate fragments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RcnHnXiw2CqnpbuFR5q6VA --- components/OfflineTransactionSync.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/components/OfflineTransactionSync.tsx b/components/OfflineTransactionSync.tsx index f0669c4..b2be35a 100644 --- a/components/OfflineTransactionSync.tsx +++ b/components/OfflineTransactionSync.tsx @@ -69,15 +69,22 @@ export function OfflineTransactionSync() { return (
{showRetryPrompt ? ( Connection restored. Retrying {queuedCount} queued transaction{queuedCount === 1 ? '' : 's'}… ) : ( <> - {queuedCount} {words} + {' '} + )}
From 551e6754faea60ad92d54e43a20ddc41fbeec7a4 Mon Sep 17 00:00:00 2001 From: Arminas Attas <106035711+aratass@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:20:31 +0300 Subject: [PATCH 5/5] chore(offline): changelog entry; typecheck and lint clean for the badge Records the queued drafts badge under Unreleased so the next release notes mention it. next lint reports no warnings or errors for the changed files, and tsc reports no errors in them (the only tsc errors on this branch are the existing ones in app/dashboard/page.tsx, which main has too). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RcnHnXiw2CqnpbuFR5q6VA --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f7e94..4697e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ## [Unreleased] ### Added +- `OfflineTransactionSync` — floating badge shows `N drafts queued offline` (count pill, black and white, + full sentence as the status region's accessible name) and refreshes when another tab queues or + flushes a draft, via `storage` events on the queue key; label from the new + `queuedDraftsLabel()` in `lib/offline-transactions.ts` (#795) - `lib/i18n/` — i18next/react-i18next bootstrap and a `dashboard` message namespace; `/dashboard` is fully externalized as the proof-of-concept page for the string-externalization migration (#558)