Files
ApiCore/ApiCore-Frontend/src/components/offline/offline-band.tsx
T
Nick SzaunigandClaude Opus 5.5 92323197a7
Backend Image / build-and-push (push) Successful in 6m2s
Frontend Image / build-and-push (push) Failing after 12m25s
Manifests / validate (push) Successful in 16s
fix: Sprachnotizen nach Browserprüfung (Wiedergabe, Fokus, Warteschlange)
- CSP erlaubt media-src 'self' blob:, damit die eigene Aufnahme
  abspielbar ist; Dauer kommt vom Recorder statt aus den Metadaten
- Fokus nach gesperrtem Mikrofon auf der Hilfe, nach Verwerfen in
  /voice auf der Liste
- automatisch beendete Aufnahme läuft über denselben Speicherweg wie
  Senden, kein zweites Speichern; Volk und Datum bleiben bis „Noch eine
  Aufnahme“ gesperrt
- Offline-Band öffnet die Warteschlange auch ohne Ablehnung, wenn
  Sprachnotizen warten; leere Liste offline mit Text
- Abmelde-Dialog und Zurück-Link mit 56 px, Schließen-Knopf der Dialoge
  44 px

Gates: Frontend lint, typecheck, 2991 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 12:22:47 +02:00

211 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { RefObject, useEffect, useRef, useState } from "react";
import { AlertTriangle, CloudOff, Send } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { useConnectivity } from "@/lib/offline/connectivity";
import { QueueSummary, summarizeQueue } from "@/lib/offline/db";
import {
entriesRejected,
entriesSynced,
entriesWaiting,
voiceNotesAmong,
} from "@/lib/offline/queue-texts";
import { resumeSync, syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { PendingQueueSheet } from "./pending-queue-sheet";
const COUNT_INTERVAL_MS = 15_000;
const EMPTY_QUEUE: QueueSummary = { own: 0, failed: 0, foreign: 0 };
// Both auto-sync triggers can fire together; they share one sync run
// (syncPendingEntries), even one already running - they change nothing that
// is to be sent - and one toast id keeps it to one message.
const SYNC_TOAST_ID = "offline-sync";
// Wraps on narrow phones instead of overflowing; never below 44 px.
const BAND_BUTTON_CLASS = "h-auto min-h-11 whitespace-normal text-left";
interface OfflineBandProps {
userId: string;
/** Focus target when the queue sheet closes and the band shows nothing. */
pageContentRef: RefObject<HTMLElement | null>;
/**
* False in an offline session (opened from the cached identity, no access
* token): the band then shows what waits but neither opens the sync gate
* nor sends - the server would only answer 401.
*/
syncEnabled?: boolean;
}
/**
* One line under the header that says whether input is only stored on this
* device, how much still waits to be sent and what the server rejected.
* Sends by itself as soon as the connection is back.
*/
export function OfflineBand({ userId, pageContentRef, syncEnabled = true }: OfflineBandProps) {
const queryClient = useQueryClient();
const connectivity = useConnectivity();
const statusRef = useRef<HTMLDivElement>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const { data: queue = EMPTY_QUEUE } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "summary"],
queryFn: () => summarizeQueue(userId),
refetchInterval: COUNT_INTERVAL_MS,
// Reads this device's storage, never the network.
networkMode: "always",
});
const sync = useMutation({
mutationFn: () => syncPendingEntries(userId),
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced), { id: SYNC_TOAST_ID });
// New records on the server: every list and count may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () =>
toast.error("Synchronisierung fehlgeschlagen - wird beim nächsten Versuch erneut versucht", {
id: SYNC_TOAST_ID,
}),
});
const { mutate: startSync } = sync;
// The band only exists for a signed-in user, and the auth context sets that
// user's access token before the user: the moment to reopen sync after a
// logout's cancelSync(). Not without a token (offline session): the gate
// stays closed until the session is online. Declared first, so it runs
// before the sync below.
useEffect(() => {
if (syncEnabled) {
resumeSync();
}
}, [userId, syncEnabled]);
// Mount, and every switch back to online (a request reached the server again).
useEffect(() => {
if (syncEnabled && connectivity === "online") {
startSync();
}
}, [connectivity, startSync, userId, syncEnabled]);
// The browser's own `online` event: try even while the last request failed.
useEffect(() => {
if (!syncEnabled) {
return;
}
const handleOnline = () => startSync();
window.addEventListener("online", handleOnline);
return () => window.removeEventListener("online", handleOnline);
}, [startSync, syncEnabled]);
// The sheet stays open while the user still has entries in it (waiting
// ones too, after the last rejected one was discarded), and closes once
// there is nothing left to show.
const hasRejected = queue.failed > 0;
if (sheetOpen && queue.own === 0 && !hasRejected) {
setSheetOpen(false);
}
// An offline session cannot send, whatever the network says.
const isOffline = connectivity === "offline" || !syncEnabled;
// „(davon 2 Sprachnotizen)“: voice notes are part of the counts above.
const voiceSuffix = queue.voiceWaiting ? ` (${voiceNotesAmong(queue.voiceWaiting)})` : "";
const hasContent = isOffline || queue.own > 0 || hasRejected;
// Back to the trigger while it exists; otherwise to the band's message, or
// to the page content when the band shows nothing.
function sheetFinalFocus(): true | HTMLElement | null {
if (hasRejected) {
return true;
}
return hasContent ? statusRef.current : pageContentRef.current;
}
// The live region is always there, so its first message is announced too.
return (
<div
ref={statusRef}
role="status"
aria-live="polite"
tabIndex={-1}
className={cn(
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
hasContent &&
"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border/60 px-4 py-2 text-sm sm:px-6",
hasContent && (isOffline ? "bg-warning/15" : "bg-muted/60"),
)}
>
{isOffline ? (
<p className="flex items-center gap-2 font-medium">
<CloudOff className="size-4 shrink-0" aria-hidden />
{queue.own > 0
? `Offline – ${entriesWaiting(queue.own)}${voiceSuffix}`
: "Offline – Erfassungen werden gespeichert und später gesendet. Vorschläge und Aufgaben brauchen eine Verbindung."}
</p>
) : null}
{!isOffline && queue.own > 0 ? (
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
disabled={sync.isPending}
onClick={() => startSync()}
>
<Send aria-hidden />
{sync.isPending ? "Wird gesendet…" : `${entriesWaiting(queue.own)} – Jetzt senden`}
</Button>
) : null}
{!isOffline && queue.own > 0 && queue.voiceWaiting ? (
<span>{voiceSuffix.trim()}</span>
) : null}
<PendingQueueSheet
userId={userId}
foreignCount={queue.foreign}
open={sheetOpen}
onOpenChange={setSheetOpen}
finalFocus={sheetFinalFocus}
trigger={
hasRejected ? (
<SheetTrigger
render={
<Button
type="button"
variant="destructive"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
<AlertTriangle aria-hidden />
{`${entriesRejected(queue.failed)} – ansehen`}
</SheetTrigger>
) : queue.voiceWaiting ? (
// Waiting voice notes cannot be seen anywhere else but the list: the sheet opens from here too.
<SheetTrigger
render={
<Button
type="button"
variant="outline"
size="touch"
className={BAND_BUTTON_CLASS}
/>
}
>
Warteschlange ansehen
</SheetTrigger>
) : null
}
/>
</div>
);
}