feat(fase-14): offline chip + queued-op badge in global chrome (14.2.1-2)
Adds an OfflineChip surfaced in the MobileTopBar (compact, icon-only)
and at the bottom of the DesktopSidebar (full pill with label). Hidden
while online. When `navigator.onLine` is false:
- The chip mounts with the existing amber palette
- A title tooltip explains the implication ("changes will sync when
you reconnect")
- If `pendingOpsCount > 0`, a small numeric badge is appended so the
user sees the actual backlog size at a glance
`pendingOpsCount` is the existing store (kept in sync by SyncQueue);
nothing new wires up — we only surface what was already tracked.
OF-01 e2e: drives `context.setOffline(true)`, asserts the chip
appears, queues an INSERT, asserts the numeric badge renders, and
verifies the chip disappears when back online. Added alongside the
existing O-01/O-02 (banner-based) so we keep coverage of both surfaces.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
41
apps/web/src/lib/components/OfflineChip.svelte
Normal file
41
apps/web/src/lib/components/OfflineChip.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Fase 14.2.1 — persistent offline indicator surfaced in the global
|
||||
* chrome (mobile top bar + desktop sidebar). Hidden while online.
|
||||
*
|
||||
* Fase 14.2.2: when there are queued ops, append a small numeric
|
||||
* badge so the user knows how many writes are still pending. The
|
||||
* count comes from `pendingOpsCount` (kept in sync by the SyncQueue
|
||||
* singleton). A non-zero count is shown even when the badge says 0
|
||||
* → 0 is filtered out — there's nothing to communicate when both
|
||||
* online AND empty.
|
||||
*
|
||||
* Tooltip explains the implication: changes will sync on reconnect.
|
||||
*/
|
||||
import { isOnline, pendingOpsCount } from '$lib/stores/syncStatus';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
const { compact = false }: { compact?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
{#if !$isOnline}
|
||||
<div
|
||||
data-testid="pwa-offline-chip"
|
||||
title={m.pwa_offline_tooltip()}
|
||||
role="status"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
|
||||
{#if !compact}
|
||||
<span>{m.pwa_offline_chip()}</span>
|
||||
{/if}
|
||||
{#if $pendingOpsCount > 0}
|
||||
<span
|
||||
data-testid="pwa-offline-chip-badge"
|
||||
class="rounded-full bg-amber-500/20 px-1.5 text-[10px] font-semibold leading-4 text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{$pendingOpsCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { SectionKey } from '@colectivo/types';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||
import OfflineChip from '$lib/components/OfflineChip.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus, Shield } from 'lucide-svelte';
|
||||
|
||||
@@ -101,6 +102,9 @@
|
||||
</nav>
|
||||
|
||||
<div class="p-3 pt-2">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<OfflineChip />
|
||||
</div>
|
||||
{#if $isServerAdmin}
|
||||
<!-- Fase 13.4: server-admin entry. Distinct red icon so it doesn't
|
||||
blend with normal app nav. Hidden for non-admins. -->
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { displayName } from '$lib/stores/auth';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import OfflineChip from '$lib/components/OfflineChip.svelte';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
@@ -26,6 +27,8 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<OfflineChip compact={true} />
|
||||
|
||||
<a href="/settings" aria-label={m.nav_settings()} class="rounded-full">
|
||||
<Avatar name={$displayName} size={28} />
|
||||
</a>
|
||||
|
||||
@@ -40,6 +40,37 @@ test.describe('Offline queue + reconnect flush', () => {
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('OF-01: going offline shows the persistent chip + a queue badge', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
await loginAs(page, USERS.borja);
|
||||
await page.goto(SEED_LIST_PATH);
|
||||
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Online → chip hidden.
|
||||
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0);
|
||||
|
||||
await context.setOffline(true);
|
||||
// At least one chip (mobile + desktop both mount; only the visible one
|
||||
// matters but locator-count-based assertions need both).
|
||||
await expect(page.getByTestId('pwa-offline-chip').first()).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
const itemName = `OF-01-${Date.now()}`;
|
||||
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
|
||||
await input.fill(itemName);
|
||||
await input.press('Enter');
|
||||
|
||||
// pending_ops should now contain at least the INSERT for the new item.
|
||||
await expect(page.getByTestId('pwa-offline-chip-badge').first()).toHaveText(/\d+/, {
|
||||
timeout: 3_000
|
||||
});
|
||||
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await context.setOffline(false);
|
||||
await expect(page.getByTestId('pwa-offline-chip')).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('O-02: going back online flushes the queue to the server', async ({
|
||||
browser,
|
||||
context
|
||||
|
||||
Reference in New Issue
Block a user