diff --git a/.gitignore b/.gitignore index b37c702ab..4ffb5ed02 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ /out/ next-env.d.ts +# service worker (generated by @serwist/next) +/public/sw.js +/public/sw.js.map + # production /build diff --git a/README.md b/README.md index 888b3e7f9..cd33ddb1a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,71 @@ add new features to Network Canvas, but rather provides a new way to conduct int ![Alt](https://repobeats.axiom.co/api/embed/3902b97960b7e32971202cbd5b0d38f39d51df51.svg 'Repobeats analytics image') +## Offline Support + +Fresco supports offline interviews through a service worker and IndexedDB. When enabled, users can: + +- Download protocols for offline use +- Start and conduct interviews without network connectivity +- Automatically sync interview data when back online + +### Testing the Service Worker in Development + +The service worker is disabled by default in development mode. To enable it: + +```bash +ENABLE_SW=true pnpm dev +``` + +### Testing Service Worker Changes + +When making changes to `lib/pwa/sw.ts`, follow this workflow: + +1. **Make your changes** to `lib/pwa/sw.ts` + +2. **Restart the dev server** (required for the service worker to rebuild): + + ```bash + # Stop the server (Ctrl+C), then: + ENABLE_SW=true pnpm dev + ``` + +3. **In Chrome DevTools** (Application tab → Service Workers): + - Check **"Update on reload"** to force-update the service worker on each page refresh + - Or click **"Update"** manually, then **"Skipwaiting"** if the new worker is waiting + +4. **Hard refresh** the page (`Cmd+Shift+R` on Mac, `Ctrl+Shift+R` on Windows/Linux) + +5. **Clear cache if needed** (Application tab → Storage → Clear site data) + +### Testing Offline Behavior + +1. Open DevTools → Network tab +2. Check **"Offline"** checkbox to simulate offline mode +3. Navigate around the dashboard - pages should load from cache +4. Try starting an offline interview with a cached protocol + +### Useful DevTools Locations + +| Location | Purpose | +| ----------------------------------------- | ----------------------------------------------------- | +| Application → Service Workers | View registered workers, update/unregister them | +| Application → Cache Storage | See cached content (dashboard-pages, api-cache, etc.) | +| Application → IndexedDB → FrescoOfflineDB | View offline interviews, cached protocols, assets | +| Network tab (filter: "ServiceWorker") | See which requests are served from cache | + +### Common Issues + +| Issue | Solution | +| ------------------------------------- | ----------------------------------------------------------------- | +| `bad-precaching-response` error (404) | Clear site data in Application → Storage, then restart dev server | +| Service worker not updating | Enable "Update on reload" in DevTools, or clear site data | +| Old cached pages | Clear site data in Application tab | +| Changes not taking effect | Restart dev server with `ENABLE_SW=true` | +| Service worker not registering | Check browser console for errors; ensure HTTPS or localhost | + +**Note:** The `bad-precaching-response` error typically occurs when the service worker's precache manifest references files from an old build. Always clear site data after rebuilding. + ## Thanks Chromatic diff --git a/actions/protocols.ts b/actions/protocols.ts index b7996fbfc..1e1227ead 100644 --- a/actions/protocols.ts +++ b/actions/protocols.ts @@ -216,3 +216,43 @@ export async function insertProtocol( throw e; } } + +export async function getProtocolById(protocolId: string) { + await requireApiAuth(); + + return prisma.protocol.findUnique({ + where: { id: protocolId }, + include: { assets: true }, + }); +} + +export async function setProtocolOfflineStatus( + protocolId: string, + availableOffline: boolean, +) { + await requireApiAuth(); + + try { + const protocol = await prisma.protocol.update({ + where: { id: protocolId }, + data: { availableOffline }, + select: { name: true }, + }); + + void addEvent( + availableOffline + ? 'Protocol Available Offline' + : 'Protocol Offline Disabled', + `Protocol "${protocol.name}" ${availableOffline ? 'enabled' : 'disabled'} for offline use`, + ); + + safeRevalidateTag('getProtocols'); + + return { error: null, success: true }; + } catch (error) { + return { + error: 'Failed to update protocol offline status', + success: false, + }; + } +} diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index 0aff90e90..54a7ba623 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -2,6 +2,8 @@ import { Provider } from 'react-redux'; import SuperJSON from 'superjson'; +import { OfflineErrorBoundary } from '~/components/offline/OfflineErrorBoundary'; +import { OfflineIndicator } from '~/components/offline/OfflineIndicator'; import { DndStoreProvider } from '~/lib/dnd/DndStoreProvider'; import ProtocolScreen from '~/lib/interviewer/components/ProtocolScreen'; import { store } from '~/lib/interviewer/store'; @@ -20,9 +22,12 @@ const InterviewShell = (props: { return ( - - - + + + + + + ); }; diff --git a/app/api/interviews/[id]/force-sync/route.ts b/app/api/interviews/[id]/force-sync/route.ts new file mode 100644 index 000000000..12adced54 --- /dev/null +++ b/app/api/interviews/[id]/force-sync/route.ts @@ -0,0 +1,91 @@ +import { NcNetworkSchema } from '@codaco/shared-consts'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { addEvent } from '~/actions/activityFeed'; +import { prisma } from '~/lib/db'; +import { StageMetadataSchema } from '~/lib/interviewer/ducks/modules/session'; +import { requireApiAuth } from '~/utils/auth'; +import { ensureError } from '~/utils/ensureError'; + +const RequestSchema = z.object({ + network: NcNetworkSchema, + currentStep: z.number(), + stageMetadata: StageMetadataSchema.optional(), + lastUpdated: z.string(), +}); + +type RouteParams = { + params: Promise<{ id: string }>; +}; + +const routeHandler = async (request: NextRequest, { params }: RouteParams) => { + try { + await requireApiAuth(); + + const { id } = await params; + + const rawPayload: unknown = await request.json(); + const validatedRequest = RequestSchema.safeParse(rawPayload); + + if (!validatedRequest.success) { + return NextResponse.json( + { error: validatedRequest.error }, + { status: 400 }, + ); + } + + const { network, currentStep, stageMetadata, lastUpdated } = + validatedRequest.data; + + const interview = await prisma.interview.findUnique({ + where: { id }, + select: { + id: true, + version: true, + participant: { + select: { + identifier: true, + label: true, + }, + }, + }, + }); + + if (!interview) { + return NextResponse.json( + { error: 'Interview not found' }, + { status: 404 }, + ); + } + + const updatedInterview = await prisma.interview.update({ + where: { id }, + data: { + network, + currentStep, + stageMetadata: stageMetadata ?? undefined, + lastUpdated: new Date(lastUpdated), + version: { + increment: 1, + }, + }, + select: { + version: true, + }, + }); + + void addEvent( + 'Conflict Resolved', + `Conflict resolved for participant "${ + interview.participant.label ?? interview.participant.identifier + }" by keeping local changes`, + ); + + return NextResponse.json({ version: updatedInterview.version }); + } catch (e) { + const error = ensureError(e); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +}; + +export { routeHandler as POST }; diff --git a/app/api/interviews/[id]/state/route.ts b/app/api/interviews/[id]/state/route.ts new file mode 100644 index 000000000..7c37f425f --- /dev/null +++ b/app/api/interviews/[id]/state/route.ts @@ -0,0 +1,48 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { prisma } from '~/lib/db'; +import { requireApiAuth } from '~/utils/auth'; + +type RouteParams = { + params: Promise<{ id: string }>; +}; + +const routeHandler = async (_request: NextRequest, { params }: RouteParams) => { + try { + await requireApiAuth(); + + const { id } = await params; + + const interview = await prisma.interview.findUnique({ + where: { id }, + select: { + network: true, + currentStep: true, + stageMetadata: true, + version: true, + lastUpdated: true, + }, + }); + + if (!interview) { + return NextResponse.json( + { error: 'Interview not found' }, + { status: 404 }, + ); + } + + return NextResponse.json({ + network: interview.network, + currentStep: interview.currentStep, + stageMetadata: interview.stageMetadata, + version: interview.version, + lastUpdated: interview.lastUpdated.toISOString(), + }); + } catch { + return NextResponse.json( + { error: 'Failed to fetch interview state' }, + { status: 500 }, + ); + } +}; + +export { routeHandler as GET }; diff --git a/app/api/interviews/create-offline/route.ts b/app/api/interviews/create-offline/route.ts new file mode 100644 index 000000000..8e29f9715 --- /dev/null +++ b/app/api/interviews/create-offline/route.ts @@ -0,0 +1,90 @@ +import { NcNetworkSchema } from '@codaco/shared-consts'; +import { createId } from '@paralleldrive/cuid2'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { addEvent } from '~/actions/activityFeed'; +import { prisma } from '~/lib/db'; +import { StageMetadataSchema } from '~/lib/interviewer/ducks/modules/session'; +import { requireApiAuth } from '~/utils/auth'; +import { ensureError } from '~/utils/ensureError'; + +const RequestSchema = z.object({ + protocolId: z.string(), + data: z.object({ + network: NcNetworkSchema, + currentStep: z.number(), + stageMetadata: StageMetadataSchema.optional(), + lastUpdated: z.string(), + }), + participantIdentifier: z.string().optional(), +}); + +const routeHandler = async (request: NextRequest) => { + try { + await requireApiAuth(); + + const rawPayload: unknown = await request.json(); + const validatedRequest = RequestSchema.safeParse(rawPayload); + + if (!validatedRequest.success) { + return NextResponse.json( + { error: validatedRequest.error }, + { status: 400 }, + ); + } + + const { protocolId, data, participantIdentifier } = validatedRequest.data; + + const participantStatement = participantIdentifier + ? { + connectOrCreate: { + create: { + identifier: participantIdentifier, + }, + where: { + identifier: participantIdentifier, + }, + }, + } + : { + create: { + identifier: `p-${createId()}`, + label: 'Anonymous Participant', + }, + }; + + const createdInterview = await prisma.interview.create({ + select: { + participant: true, + id: true, + }, + data: { + network: data.network, + currentStep: data.currentStep, + stageMetadata: data.stageMetadata ?? undefined, + lastUpdated: new Date(data.lastUpdated), + participant: participantStatement, + protocol: { + connect: { + id: protocolId, + }, + }, + }, + }); + + void addEvent( + 'Interview Started', + `Participant "${ + createdInterview.participant.label ?? + createdInterview.participant.identifier + }" started an offline interview`, + ); + + return NextResponse.json({ serverId: createdInterview.id }); + } catch (e) { + const error = ensureError(e); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +}; + +export { routeHandler as POST }; diff --git a/app/api/interviews/duplicate/route.ts b/app/api/interviews/duplicate/route.ts new file mode 100644 index 000000000..7bb2a1473 --- /dev/null +++ b/app/api/interviews/duplicate/route.ts @@ -0,0 +1,94 @@ +import { NcNetworkSchema } from '@codaco/shared-consts'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { addEvent } from '~/actions/activityFeed'; +import { prisma } from '~/lib/db'; +import { StageMetadataSchema } from '~/lib/interviewer/ducks/modules/session'; +import { requireApiAuth } from '~/utils/auth'; +import { ensureError } from '~/utils/ensureError'; + +const RequestSchema = z.object({ + interviewId: z.string(), + data: z.object({ + network: NcNetworkSchema, + currentStep: z.number(), + stageMetadata: StageMetadataSchema.optional(), + lastUpdated: z.string(), + }), +}); + +const routeHandler = async (request: NextRequest) => { + try { + await requireApiAuth(); + + const rawPayload: unknown = await request.json(); + const validatedRequest = RequestSchema.safeParse(rawPayload); + + if (!validatedRequest.success) { + return NextResponse.json( + { error: validatedRequest.error }, + { status: 400 }, + ); + } + + const { interviewId, data } = validatedRequest.data; + + const originalInterview = await prisma.interview.findUnique({ + where: { id: interviewId }, + select: { + protocolId: true, + participantId: true, + participant: { + select: { + identifier: true, + label: true, + }, + }, + }, + }); + + if (!originalInterview) { + return NextResponse.json( + { error: 'Original interview not found' }, + { status: 404 }, + ); + } + + const duplicateInterview = await prisma.interview.create({ + data: { + network: data.network, + currentStep: data.currentStep, + stageMetadata: data.stageMetadata ?? undefined, + lastUpdated: new Date(data.lastUpdated), + protocol: { + connect: { + id: originalInterview.protocolId, + }, + }, + participant: { + connect: { + id: originalInterview.participantId, + }, + }, + }, + select: { + id: true, + }, + }); + + void addEvent( + 'Conflict Resolved', + `Conflict resolved for participant "${ + originalInterview.participant.label ?? + originalInterview.participant.identifier + }" by keeping both versions`, + ); + + return NextResponse.json({ interviewId: duplicateInterview.id }); + } catch (e) { + const error = ensureError(e); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +}; + +export { routeHandler as POST }; diff --git a/app/dashboard/_components/ActivityFeed/utils.ts b/app/dashboard/_components/ActivityFeed/utils.ts index 3a1fbab5d..5c45e4223 100644 --- a/app/dashboard/_components/ActivityFeed/utils.ts +++ b/app/dashboard/_components/ActivityFeed/utils.ts @@ -6,6 +6,10 @@ export const getBadgeColorsForActivityType = (type: ActivityType) => { return 'bg-slate-blue hover:bg-slate-blue-dark'; case 'Protocol Uninstalled': return 'bg-neon-carrot hover:bg-neon-carrot-dark'; + case 'Protocol Available Offline': + return 'bg-sea-serpent hover:bg-sea-serpent-dark'; + case 'Protocol Offline Disabled': + return 'bg-neon-carrot hover:bg-neon-carrot-dark'; case 'Participant(s) Added': return 'bg-sea-green hover:bg-sea-green'; case 'Participant(s) Removed': @@ -34,5 +38,7 @@ export const getBadgeColorsForActivityType = (type: ActivityType) => { return 'bg-tomato hover:bg-tomato-dark'; case 'User Deleted': return 'bg-charcoal hover:bg-charcoal-dark'; + case 'Conflict Resolved': + return 'bg-kiwi hover:bg-kiwi-dark'; } }; diff --git a/app/dashboard/_components/NavigationBar.tsx b/app/dashboard/_components/NavigationBar.tsx index 9bc740ce7..cc31cf709 100644 --- a/app/dashboard/_components/NavigationBar.tsx +++ b/app/dashboard/_components/NavigationBar.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import type { UrlObject } from 'url'; import { MotionSurface } from '~/components/layout/Surface'; +import { SyncStatusIndicator } from '~/components/offline/SyncStatusIndicator'; import Heading from '~/components/typography/Heading'; import { Spinner } from '~/lib/legacy-ui/components'; import { cx } from '~/utils/cva'; @@ -133,6 +134,10 @@ export function NavigationBar() { isActive={pathname === '/dashboard/settings'} /> + + + + diff --git a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx index c1104e4db..359ca6dc0 100644 --- a/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ParticipantsTable/ActionsDropdown.tsx @@ -1,17 +1,27 @@ +'use client'; + import type { Row } from '@tanstack/react-table'; -import { MoreHorizontal } from 'lucide-react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { MoreHorizontal, Play } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; +import { SelectProtocolDialog } from '~/components/offline/SelectProtocolDialog'; import { IconButton } from '~/components/ui/Button'; +import { useToast } from '~/components/ui/Toast'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from '~/components/ui/dropdown-menu'; +import useNetworkStatus from '~/hooks/useNetworkStatus'; import type { Participant } from '~/lib/db/generated/client'; +import { offlineDb } from '~/lib/offline/db'; +import { createOfflineInterview } from '~/lib/offline/offlineInterviewManager'; import type { ParticipantWithInterviews } from './ParticipantsTableClient'; export const ActionsDropdown = ({ @@ -23,15 +33,93 @@ export const ActionsDropdown = ({ data: ParticipantWithInterviews[]; deleteHandler: (participant: ParticipantWithInterviews) => void; }) => { + const router = useRouter(); + const { isOnline } = useNetworkStatus(); + const { add: addToast } = useToast(); const [selectedParticipant, setSelectedParticipant] = useState(null); const [showParticipantModal, setShowParticipantModal] = useState(false); + const [showSelectProtocolDialog, setShowSelectProtocolDialog] = + useState(false); + const [isStartingInterview, setIsStartingInterview] = useState(false); + + const cachedProtocols = useLiveQuery( + async () => { + return offlineDb.protocols.toArray(); + }, + [], + [], + ); const editParticipant = (data: Participant) => { setSelectedParticipant(data); setShowParticipantModal(true); }; + const handleStartInterview = async () => { + const protocols = cachedProtocols ?? []; + + if (!isOnline) { + if (protocols.length === 0) { + addToast({ + title: 'No Offline Protocols', + description: + 'No protocols are available offline. Please download a protocol first.', + type: 'info', + }); + return; + } + + if (protocols.length === 1) { + // Only one protocol - start directly + setIsStartingInterview(true); + try { + const result = await createOfflineInterview( + protocols[0]!.id, + row.original.identifier, + ); + if (result.error) { + addToast({ + title: 'Error', + description: result.error, + type: 'destructive', + }); + return; + } + router.push(`/interview/${result.interviewId}`); + } catch (error) { + addToast({ + title: 'Error', + description: + error instanceof Error + ? error.message + : 'Failed to start interview', + type: 'destructive', + }); + } finally { + setIsStartingInterview(false); + } + return; + } + + // Multiple protocols - show selection dialog + setShowSelectProtocolDialog(true); + return; + } + + // Online - show protocol selection or redirect to onboard + if (protocols.length > 0) { + setShowSelectProtocolDialog(true); + } else { + addToast({ + title: 'No Protocols', + description: + 'Please download a protocol for offline use from the Protocols page.', + type: 'info', + }); + } + }; + return ( <> + Actions + + + {isStartingInterview ? 'Starting...' : 'Start Interview'} + + editParticipant(row.original)}> Edit diff --git a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx index e19f06afc..c709f0fb3 100644 --- a/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx @@ -1,34 +1,194 @@ 'use client'; import type { Row } from '@tanstack/react-table'; -import { MoreHorizontal } from 'lucide-react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { MoreHorizontal, Download, Play, Check } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; +import { StartOfflineInterviewDialog } from '~/components/offline/StartOfflineInterviewDialog'; import { IconButton } from '~/components/ui/Button'; +import { useToast } from '~/components/ui/Toast'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from '~/components/ui/dropdown-menu'; +import Dialog from '~/lib/dialogs/Dialog'; +import { ProtocolDownloadProgress } from '~/components/offline/ProtocolDownloadProgress'; +import useNetworkStatus from '~/hooks/useNetworkStatus'; import type { ProtocolWithInterviews } from './ProtocolsTableClient'; +import { getProtocolById, setProtocolOfflineStatus } from '~/actions/protocols'; +import { + assetDownloadManager, + type DownloadProgress, + type ProtocolWithAssets, +} from '~/lib/offline/assetDownloadManager'; +import { offlineDb } from '~/lib/offline/db'; +import { createOfflineInterview } from '~/lib/offline/offlineInterviewManager'; export const ActionsDropdown = ({ row, }: { row: Row; }) => { + const router = useRouter(); + const { isOnline } = useNetworkStatus(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [protocolToDelete, setProtocolToDelete] = useState(); + const [showDownloadProgress, setShowDownloadProgress] = useState(false); + const [downloadProgress, setDownloadProgress] = + useState(null); + const [showStartInterviewDialog, setShowStartInterviewDialog] = + useState(false); + const [isStartingInterview, setIsStartingInterview] = useState(false); + const { add: addToast } = useToast(); + + const isCached = useLiveQuery( + async () => { + const cached = await offlineDb.protocols.get(row.original.id); + return !!cached; + }, + [row.original.id], + false, + ); const handleDelete = (data: ProtocolWithInterviews) => { setProtocolToDelete([data]); setShowDeleteModal(true); }; + const handleEnableOffline = async () => { + try { + const quota = await assetDownloadManager.checkStorageQuota(); + + if (quota.percentUsed > 95) { + addToast({ + title: 'Storage Full', + description: + 'Not enough storage space available. Please free up space and try again.', + type: 'destructive', + }); + return; + } + + if (quota.percentUsed > 80) { + addToast({ + title: 'Storage Warning', + description: `Storage is ${Math.round(quota.percentUsed)}% full. Download may fail if storage runs out.`, + type: 'info', + }); + } + + const protocol = await getProtocolById(row.original.id); + + if (!protocol) { + addToast({ + title: 'Error', + description: 'Protocol not found', + type: 'destructive', + }); + return; + } + + setShowDownloadProgress(true); + + await offlineDb.protocols.put({ + id: protocol.id, + name: protocol.name, + cachedAt: Date.now(), + data: JSON.stringify({ + ...protocol, + assets: undefined, + }), + }); + + const result = await assetDownloadManager.downloadProtocolAssets( + protocol as ProtocolWithAssets, + (progress) => { + setDownloadProgress(progress); + }, + ); + + if (result.success) { + await setProtocolOfflineStatus(protocol.id, true); + + addToast({ + title: 'Success', + description: `Protocol "${protocol.name}" is now available offline`, + type: 'success', + }); + + setShowDownloadProgress(false); + setDownloadProgress(null); + } else { + await offlineDb.protocols.delete(protocol.id); + + addToast({ + title: 'Download Failed', + description: result.error ?? 'Failed to download protocol assets', + type: 'destructive', + }); + } + } catch (error) { + addToast({ + title: 'Error', + description: + error instanceof Error ? error.message : 'An unknown error occurred', + type: 'destructive', + }); + } + }; + + const handleCancelDownload = () => { + assetDownloadManager.pauseDownload(); + setShowDownloadProgress(false); + setDownloadProgress(null); + }; + + const handleStartInterview = async () => { + if (!isOnline && !isCached) { + setShowStartInterviewDialog(true); + return; + } + + if (!isOnline && isCached) { + setIsStartingInterview(true); + try { + const result = await createOfflineInterview(row.original.id); + if (result.error) { + addToast({ + title: 'Error', + description: result.error, + type: 'destructive', + }); + return; + } + router.push(`/interview/${result.interviewId}`); + } catch (error) { + addToast({ + title: 'Error', + description: + error instanceof Error + ? error.message + : 'Failed to start interview', + type: 'destructive', + }); + } finally { + setIsStartingInterview(false); + } + return; + } + + // Online - use the standard interview creation flow via onboard + router.push(`/onboard/${row.original.id}`); + }; + return ( <> + setShowDownloadProgress(false)} + title="Downloading Protocol Assets" + description="Please wait while we download all protocol assets for offline use." + > + {downloadProgress && ( + + )} + + Actions + + + {isStartingInterview ? 'Starting...' : 'Start Interview'} + + + {isCached ? ( + + + Available Offline + + ) : ( + + + Enable Offline + + )} + handleDelete(row.original)}> Delete diff --git a/app/dashboard/_components/ProtocolsTable/Columns.tsx b/app/dashboard/_components/ProtocolsTable/Columns.tsx index 9b0d601ae..5b02d5c91 100644 --- a/app/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/dashboard/_components/ProtocolsTable/Columns.tsx @@ -3,6 +3,7 @@ import { type ColumnDef } from '@tanstack/react-table'; import Image from 'next/image'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; +import { OfflineStatusBadge } from '~/components/offline/OfflineStatusBadge'; import TimeAgo from '~/components/ui/TimeAgo'; import Checkbox from '~/lib/form/components/fields/Checkbox'; import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; @@ -66,6 +67,18 @@ export const getProtocolColumns = ( }, cell: ({ row }) => , }, + { + accessorKey: 'availableOffline', + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + const status = row.original.availableOffline + ? 'available-offline' + : 'online-only'; + return ; + }, + }, ]; if (allowAnonRecruitment) { diff --git a/app/dashboard/participants/_components/ImportCSVModal.tsx b/app/dashboard/participants/_components/ImportCSVModal.tsx index b8168ad22..9d00ba68b 100644 --- a/app/dashboard/participants/_components/ImportCSVModal.tsx +++ b/app/dashboard/participants/_components/ImportCSVModal.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { ZodError } from 'zod'; import { importParticipants } from '~/actions/participants'; import Paragraph from '~/components/typography/Paragraph'; -import UnorderedList from '~/components/typography/UnorderedList'; +import { UnorderedList } from '~/components/typography/UnorderedList'; import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; import { Button } from '~/components/ui/Button'; import { useToast } from '~/components/ui/Toast'; diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 99ff81554..b34caecca 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -5,6 +5,8 @@ import DisableAnalyticsSwitch from '~/components/DisableAnalyticsSwitch'; import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch'; import PreviewModeAuthSwitch from '~/components/PreviewModeAuthSwitch'; import PreviewModeSwitch from '~/components/PreviewModeSwitch'; +import { OfflineModeSwitch } from '~/components/offline/OfflineModeSwitch'; +import { StorageUsage } from '~/components/offline/StorageUsage'; import SettingsCard from '~/components/settings/SettingsCard'; import SettingsField from '~/components/settings/SettingsField'; import SettingsNavigation, { @@ -43,6 +45,7 @@ function getSettingsSections(): SettingsSection[] { { id: 'user-management', title: 'User Management' }, { id: 'configuration', title: 'Configuration' }, { id: 'interview-settings', title: 'Interview Settings' }, + { id: 'offline-mode', title: 'Offline Mode' }, { id: 'privacy', title: 'Privacy' }, { id: 'preview-mode', title: 'Preview Mode' }, ]; @@ -194,6 +197,21 @@ export default async function Settings() { + + } + /> + + + + + +
{children} {env.SANDBOX_MODE && ( diff --git a/app/not-found.tsx b/app/not-found.tsx index b93eca5f0..10c5dc1d4 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -5,7 +5,7 @@ import Paragraph from '~/components/typography/Paragraph'; export default function NotFound() { return (
- + 404 Page not found.
diff --git a/components/DataTable/types.ts b/components/DataTable/types.ts index a441db102..6cb4a36a5 100644 --- a/components/DataTable/types.ts +++ b/components/DataTable/types.ts @@ -21,6 +21,8 @@ export type DataTableFilterableColumn = { export const activityTypes = [ 'Protocol Installed', 'Protocol Uninstalled', + 'Protocol Available Offline', + 'Protocol Offline Disabled', 'Participant(s) Added', 'Participant(s) Removed', 'Interview Started', @@ -35,6 +37,7 @@ export const activityTypes = [ 'User Created', 'User Deleted', 'Password Changed', + 'Conflict Resolved', ] as const; export type ActivityType = (typeof activityTypes)[number]; diff --git a/components/InfoTooltip.stories.tsx b/components/InfoTooltip.stories.tsx index af9f46dbb..98c5bc8ea 100644 --- a/components/InfoTooltip.stories.tsx +++ b/components/InfoTooltip.stories.tsx @@ -55,7 +55,7 @@ export const CustomTrigger: Story = { {...args} trigger={ } diff --git a/components/offline/ConflictResolutionDialog.tsx b/components/offline/ConflictResolutionDialog.tsx new file mode 100644 index 000000000..8c2d4718e --- /dev/null +++ b/components/offline/ConflictResolutionDialog.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { useState } from 'react'; +import Dialog, { DialogFooter } from '~/lib/dialogs/Dialog'; +import Button from '~/components/ui/Button'; +import Checkbox from '~/lib/form/components/fields/Checkbox'; +import { Label } from '~/components/ui/Label'; +import { + conflictResolver, + type ConflictDiff, +} from '~/lib/offline/conflictResolver'; + +type ConflictItem = { + interviewId: string; + localData: string; + serverData: string; +}; + +type ConflictResolutionDialogProps = { + conflicts: ConflictItem[]; + open: boolean; + onClose: () => void; + onResolved: () => void; +}; + +export default function ConflictResolutionDialog({ + conflicts, + open, + onClose, + onResolved, +}: ConflictResolutionDialogProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [applyToAll, setApplyToAll] = useState(false); + const [isResolving, setIsResolving] = useState(false); + + const currentConflict = conflicts[currentIndex]; + + if (!currentConflict) { + return null; + } + + const localData = JSON.parse(currentConflict.localData); + const serverData = JSON.parse(currentConflict.serverData); + const diff = conflictResolver.computeDiff(localData, serverData); + + const showApplyToAll = conflicts.length >= 6; + + const handleResolve = async ( + action: 'keepLocal' | 'keepServer' | 'keepBoth', + ) => { + setIsResolving(true); + + try { + const conflictsToResolve = applyToAll ? conflicts : [currentConflict]; + + for (const conflict of conflictsToResolve) { + if (action === 'keepLocal') { + await conflictResolver.resolveKeepLocal(conflict.interviewId); + } else if (action === 'keepServer') { + await conflictResolver.resolveKeepServer(conflict.interviewId); + } else { + await conflictResolver.resolveKeepBoth(conflict.interviewId); + } + } + + if (applyToAll || currentIndex === conflicts.length - 1) { + onResolved(); + onClose(); + } else { + setCurrentIndex(currentIndex + 1); + } + } finally { + setIsResolving(false); + } + }; + + return ( + 1 + ? `Conflict ${currentIndex + 1} of ${conflicts.length}` + : undefined + } + accent="destructive" + footer={ + + + + + + } + > +
+

+ Changes were made to this interview both locally and on the server. + Choose which version to keep. +

+ +
+
+

Local Version

+ +
+ +
+

Server Version

+ +
+
+ +
+

Changes Summary

+
    + {diff.nodesAdded > 0 && ( +
  • + {diff.nodesAdded} node{diff.nodesAdded > 1 ? 's' : ''} added + locally +
  • + )} + {diff.nodesRemoved > 0 && ( +
  • + {diff.nodesRemoved} node{diff.nodesRemoved > 1 ? 's' : ''}{' '} + removed locally +
  • + )} + {diff.nodesModified > 0 && ( +
  • + {diff.nodesModified} node{diff.nodesModified > 1 ? 's' : ''}{' '} + modified locally +
  • + )} + {diff.edgesAdded > 0 && ( +
  • + {diff.edgesAdded} edge{diff.edgesAdded > 1 ? 's' : ''} added + locally +
  • + )} + {diff.edgesRemoved > 0 && ( +
  • + {diff.edgesRemoved} edge{diff.edgesRemoved > 1 ? 's' : ''}{' '} + removed locally +
  • + )} + {diff.edgesModified > 0 && ( +
  • + {diff.edgesModified} edge{diff.edgesModified > 1 ? 's' : ''}{' '} + modified locally +
  • + )} + {diff.egoChanged &&
  • Ego attributes changed locally
  • } + {diff.stepChanged && ( +
  • + Interview step changed (local: step{' '} + {(localData as { currentStep: number }).currentStep}, server: + step {(serverData as { currentStep: number }).currentStep}) +
  • + )} +
+
+ + {showApplyToAll && ( +
+ setApplyToAll(checked)} + /> + +
+ )} +
+
+ ); +} + +type DiffSummaryProps = { + diff: ConflictDiff; + side: 'local' | 'server'; +}; + +function DiffSummary({ diff, side }: DiffSummaryProps) { + const isLocal = side === 'local'; + + const nodeCount = isLocal + ? diff.nodesAdded - diff.nodesRemoved + : -diff.nodesAdded + diff.nodesRemoved; + + const edgeCount = isLocal + ? diff.edgesAdded - diff.edgesRemoved + : -diff.edgesAdded + diff.edgesRemoved; + + return ( +
+
+ Nodes: {nodeCount > 0 && '+'} + {nodeCount} + {diff.nodesModified > 0 && ` (${diff.nodesModified} modified)`} +
+
+ Edges: {edgeCount > 0 && '+'} + {edgeCount} + {diff.edgesModified > 0 && ` (${diff.edgesModified} modified)`} +
+ {diff.egoChanged &&
Ego data changed
} +
+ ); +} diff --git a/components/offline/ManageStorageDialog.tsx b/components/offline/ManageStorageDialog.tsx new file mode 100644 index 000000000..64002b3dd --- /dev/null +++ b/components/offline/ManageStorageDialog.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { + Database, + FileText, + HardDrive, + Image as ImageIcon, + Trash2, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import Modal from '~/components/Modal/Modal'; +import ModalPopup from '~/components/Modal/ModalPopup'; +import Surface from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { Button } from '~/components/ui/Button'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { + deleteProtocolCache, + getStorageBreakdown, + offlineDb, + type StorageBreakdown, +} from '~/lib/offline/db'; +import { ensureError } from '~/utils/ensureError'; +import { cx } from '~/utils/cva'; + +type CachedProtocolInfo = { + id: string; + name: string; + cachedAt: number; + assetCount: number; +}; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const sizeIndex = sizes[i]; + if (!sizeIndex) return '0 B'; + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizeIndex}`; +} + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +export type ManageStorageDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function ManageStorageDialog({ + open, + onOpenChange, +}: ManageStorageDialogProps) { + const [breakdown, setBreakdown] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [deletingProtocolId, setDeletingProtocolId] = useState( + null, + ); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const cachedProtocols = useLiveQuery( + async (): Promise => { + const protocols = await offlineDb.protocols.toArray(); + const protocolsWithCounts = await Promise.all( + protocols.map(async (p) => { + const assetCount = await offlineDb.assets + .where('protocolId') + .equals(p.id) + .count(); + return { + id: p.id, + name: p.name, + cachedAt: p.cachedAt, + assetCount, + }; + }), + ); + return protocolsWithCounts; + }, + [], + [], + ); + + useEffect(() => { + if (open) { + void loadBreakdown(); + } + }, [open]); + + const loadBreakdown = async () => { + setLoading(true); + setError(null); + try { + const data = await getStorageBreakdown(); + setBreakdown(data); + } catch (e) { + const err = ensureError(e); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDeleteProtocol = async (protocolId: string) => { + setDeletingProtocolId(protocolId); + setError(null); + + try { + await deleteProtocolCache(protocolId); + await loadBreakdown(); + setConfirmDeleteId(null); + } catch (e) { + const err = ensureError(e); + setError(`Failed to delete protocol: ${err.message}`); + } finally { + setDeletingProtocolId(null); + } + }; + + return ( + + + +
+ Manage Storage + +
+ + {error && ( + + Error + {error} + + )} + + {loading ? ( +
+ Loading storage information... +
+ ) : breakdown ? ( +
+
+ Storage Overview + +
+ } + label="Protocols" + count={breakdown.protocols.count} + size={breakdown.protocols.estimatedSize} + /> + } + label="Assets" + count={breakdown.assets.count} + size={breakdown.assets.estimatedSize} + /> + } + label="Interviews" + count={breakdown.interviews.count} + size={breakdown.interviews.estimatedSize} + /> +
+ } + label="Total" + size={breakdown.total} + emphasized + /> +
+
+
+ +
+ Cached Protocols + + {cachedProtocols && cachedProtocols.length > 0 ? ( +
+ {cachedProtocols.map((protocol) => ( +
+
+ + {protocol.name} + + + Cached {formatDate(protocol.cachedAt)} •{' '} + {protocol.assetCount} assets + +
+ + {confirmDeleteId === protocol.id ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+ ) : ( + + + No cached protocols. Download a protocol for offline use + to see it here. + + + )} +
+
+ ) : null} +
+
+
+ ); +} + +type StorageItemProps = { + icon: React.ReactNode; + label: string; + count?: number; + size: number; + emphasized?: boolean; +}; + +function StorageItem({ + icon, + label, + count, + size, + emphasized, +}: StorageItemProps) { + return ( +
+
+
{icon}
+ + {label} + {count !== undefined && ` (${count})`} + +
+ + {formatBytes(size)} + +
+ ); +} diff --git a/components/offline/OfflineErrorBoundary.tsx b/components/offline/OfflineErrorBoundary.tsx new file mode 100644 index 000000000..f4d3f6ebc --- /dev/null +++ b/components/offline/OfflineErrorBoundary.tsx @@ -0,0 +1,104 @@ +'use client'; + +import React, { type ReactNode } from 'react'; +import Surface from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { Button } from '~/components/ui/Button'; +import { logOfflineError, offlineDb } from '~/lib/offline/db'; +import { ensureError } from '~/utils/ensureError'; + +type OfflineErrorBoundaryProps = { + children: ReactNode; + fallback?: ReactNode; +}; + +type OfflineErrorBoundaryState = { + hasError: boolean; + error: Error | null; +}; + +export class OfflineErrorBoundary extends React.Component< + OfflineErrorBoundaryProps, + OfflineErrorBoundaryState +> { + constructor(props: OfflineErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): OfflineErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + const err = ensureError(error); + void logOfflineError('OfflineErrorBoundary', err, { + componentStack: errorInfo.componentStack, + }); + } + + handleReset = (): void => { + this.setState({ hasError: false, error: null }); + }; + + handleClearCache = async (): Promise => { + try { + await offlineDb.delete(); + window.location.reload(); + } catch (error) { + const err = ensureError(error); + // eslint-disable-next-line no-console + console.error('Failed to clear cache:', err); + } + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + +
+
+ Offline Feature Error + + An error occurred with the offline features. Your interview data + is safe, but you may need to refresh the page. + +
+ + + Error Details + + {this.state.error?.message ?? 'Unknown error occurred'} + + + +
+ + + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/components/offline/OfflineIndicator.tsx b/components/offline/OfflineIndicator.tsx new file mode 100644 index 000000000..79ca7212a --- /dev/null +++ b/components/offline/OfflineIndicator.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { WifiOff } from 'lucide-react'; +import { Badge } from '~/components/ui/badge'; +import useNetworkStatus from '~/hooks/useNetworkStatus'; +import { cx } from '~/utils/cva'; + +type OfflineIndicatorProps = { + className?: string; +} & React.HTMLAttributes; + +export const OfflineIndicator = ({ + className, + ...props +}: OfflineIndicatorProps) => { + const { isOnline } = useNetworkStatus(); + + if (isOnline) { + return null; + } + + return ( +
+ + + Offline Mode + +
+ ); +}; diff --git a/components/offline/OfflineModeSwitch.tsx b/components/offline/OfflineModeSwitch.tsx new file mode 100644 index 000000000..742aba9eb --- /dev/null +++ b/components/offline/OfflineModeSwitch.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Switch } from '~/components/ui/switch'; + +const OFFLINE_MODE_KEY = 'offlineModeEnabled'; + +export type OfflineModeSwitchProps = object & + React.HTMLAttributes; + +export function OfflineModeSwitch({ + className, + ...props +}: OfflineModeSwitchProps) { + const [enabled, setEnabled] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + const stored = localStorage.getItem(OFFLINE_MODE_KEY); + setEnabled(stored === 'true'); + setMounted(true); + }, []); + + const handleChange = (checked: boolean) => { + setEnabled(checked); + localStorage.setItem(OFFLINE_MODE_KEY, checked.toString()); + }; + + if (!mounted) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/components/offline/OfflineStatusBadge.tsx b/components/offline/OfflineStatusBadge.tsx new file mode 100644 index 000000000..34aba5303 --- /dev/null +++ b/components/offline/OfflineStatusBadge.tsx @@ -0,0 +1,52 @@ +import { Badge } from '~/components/ui/badge'; +import { cva, type VariantProps } from '~/utils/cva'; +import { cx } from '~/utils/cva'; + +const offlineStatusVariants = cva({ + base: '', + variants: { + status: { + 'online-only': 'border-current/20 text-current', + 'downloading': 'border-info bg-info/10 text-info animate-pulse', + 'available-offline': 'border-success bg-success/10 text-success', + 'sync-required': 'border-warning bg-warning/10 text-warning', + }, + }, + defaultVariants: { + status: 'online-only', + }, +}); + +export type OfflineStatus = + | 'online-only' + | 'downloading' + | 'available-offline' + | 'sync-required'; + +const statusLabels: Record = { + 'online-only': 'Online Only', + 'downloading': 'Downloading', + 'available-offline': 'Available Offline', + 'sync-required': 'Sync Required', +}; + +export type OfflineStatusBadgeProps = { + status: OfflineStatus; +} & React.HTMLAttributes & + VariantProps; + +export function OfflineStatusBadge({ + status, + className, + ...props +}: OfflineStatusBadgeProps) { + return ( + + {statusLabels[status]} + + ); +} diff --git a/components/offline/OfflineUnavailableScreen.tsx b/components/offline/OfflineUnavailableScreen.tsx new file mode 100644 index 000000000..bb0d4670a --- /dev/null +++ b/components/offline/OfflineUnavailableScreen.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { WifiOff } from 'lucide-react'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import Surface from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { Button } from '~/components/ui/Button'; +import { offlineDb, type CachedProtocol } from '~/lib/offline/db'; +import { cx } from '~/utils/cva'; + +type OfflineUnavailableScreenProps = { + protocolId?: string; + protocolName?: string; + className?: string; +} & React.HTMLAttributes; + +export const OfflineUnavailableScreen = ({ + protocolId: _protocolId, + protocolName, + className, + ...props +}: OfflineUnavailableScreenProps) => { + const [cachedProtocols, setCachedProtocols] = useState([]); + + useEffect(() => { + const loadCachedProtocols = async () => { + try { + const protocols = await offlineDb.protocols.toArray(); + setCachedProtocols(protocols); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load cached protocols:', error); + } + }; + + loadCachedProtocols().catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to load cached protocols:', error); + }); + }, []); + + return ( +
+ +
+
+
+ +
+
+ +
+ + Protocol Not Available Offline + +
+ +
+ + You are currently offline and{' '} + {protocolName ? ( + <> + the protocol {protocolName} + + ) : ( + 'this protocol' + )}{' '} + has not been downloaded for offline use. + + + To use this protocol offline, you need to mark it for offline use + when connected to the internet. + +
+ + {cachedProtocols.length > 0 && ( +
+ + Available Offline Protocols + +
    + {cachedProtocols.map((protocol) => ( +
  • {protocol.name}
  • + ))} +
+
+ )} + +
+ + +
+
+
+
+ ); +}; diff --git a/components/offline/ProtocolDownloadProgress.tsx b/components/offline/ProtocolDownloadProgress.tsx new file mode 100644 index 000000000..eb2ce3c62 --- /dev/null +++ b/components/offline/ProtocolDownloadProgress.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { IconButton } from '~/components/ui/Button'; +import { Progress } from '~/components/ui/progress'; +import type { DownloadProgress } from '~/lib/offline/assetDownloadManager'; +import { cx } from '~/utils/cva'; + +export type ProtocolDownloadProgressProps = { + progress: DownloadProgress; + onCancel?: () => void; + className?: string; +} & React.HTMLAttributes; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function ProtocolDownloadProgress({ + progress, + onCancel, + className, + ...props +}: ProtocolDownloadProgressProps) { + const [downloadSpeed, setDownloadSpeed] = useState(null); + const [lastBytes, setLastBytes] = useState(0); + const [lastTime, setLastTime] = useState(Date.now()); + + useEffect(() => { + if (progress.status !== 'downloading') { + setDownloadSpeed(null); + return; + } + + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; + const bytesDiff = progress.downloadedBytes - lastBytes; + + if (timeDiff > 0 && bytesDiff > 0) { + const speed = bytesDiff / timeDiff; + setDownloadSpeed(speed); + setLastBytes(progress.downloadedBytes); + setLastTime(now); + } + }, [progress.downloadedBytes, progress.status, lastBytes, lastTime]); + + const percentage = + progress.totalAssets > 0 + ? Math.round((progress.downloadedAssets / progress.totalAssets) * 100) + : 0; + + return ( +
+
+
+
+ {progress.status === 'downloading' && 'Downloading protocol assets'} + {progress.status === 'paused' && 'Download paused'} + {progress.status === 'completed' && 'Download completed'} + {progress.status === 'error' && 'Download failed'} +
+
+ {progress.downloadedAssets} of {progress.totalAssets} assets + {progress.totalBytes > 0 && ( + <> · {formatBytes(progress.downloadedBytes)} + )} + {downloadSpeed !== null && progress.status === 'downloading' && ( + <> · {formatBytes(downloadSpeed)}/s + )} +
+
+ {onCancel && ( + } + onClick={onCancel} + aria-label="Cancel download" + /> + )} +
+ + + + {progress.status === 'error' && progress.error && ( +
{progress.error}
+ )} + + {progress.status === 'paused' && ( +
+ Download paused. Restart to continue. +
+ )} +
+ ); +} diff --git a/components/offline/SelectProtocolDialog.tsx b/components/offline/SelectProtocolDialog.tsx new file mode 100644 index 000000000..721c9bc82 --- /dev/null +++ b/components/offline/SelectProtocolDialog.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useLiveQuery } from 'dexie-react-hooks'; +import { Play, WifiOff } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import Modal from '~/components/Modal/Modal'; +import ModalPopup from '~/components/Modal/ModalPopup'; +import Surface from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { Button } from '~/components/ui/Button'; +import { Alert, AlertDescription } from '~/components/ui/Alert'; +import useNetworkStatus from '~/hooks/useNetworkStatus'; +import { offlineDb } from '~/lib/offline/db'; +import { createOfflineInterview } from '~/lib/offline/offlineInterviewManager'; +import { ensureError } from '~/utils/ensureError'; +import { cx } from '~/utils/cva'; + +type SelectProtocolDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + participantIdentifier: string; + participantLabel?: string | null; +}; + +export function SelectProtocolDialog({ + open, + onOpenChange, + participantIdentifier, + participantLabel, +}: SelectProtocolDialogProps) { + const router = useRouter(); + const { isOnline } = useNetworkStatus(); + const [selectedProtocolId, setSelectedProtocolId] = useState( + null, + ); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const cachedProtocols = useLiveQuery( + async () => { + return offlineDb.protocols.toArray(); + }, + [], + [], + ); + + useEffect(() => { + if (open && cachedProtocols?.length === 1) { + setSelectedProtocolId(cachedProtocols[0]?.id ?? null); + } + }, [open, cachedProtocols]); + + useEffect(() => { + if (!open) { + setSelectedProtocolId(null); + setError(null); + } + }, [open]); + + const handleStartInterview = async () => { + if (!selectedProtocolId) { + setError('Please select a protocol'); + return; + } + + setIsCreating(true); + setError(null); + + try { + const result = await createOfflineInterview( + selectedProtocolId, + participantIdentifier, + ); + + if (result.error) { + setError(result.error); + return; + } + + onOpenChange(false); + router.push(`/interview/${result.interviewId}`); + } catch (e) { + const err = ensureError(e); + setError(err.message); + } finally { + setIsCreating(false); + } + }; + + const hasProtocols = cachedProtocols && cachedProtocols.length > 0; + const displayName = participantLabel ?? participantIdentifier; + + return ( + + + +
+ {!isOnline && } + + Start Interview + +
+ + + Starting interview for participant: {displayName} + + + {error && ( + + {error} + + )} + + {!hasProtocols ? ( +
+ + + No protocols are available offline. Please download a protocol + for offline use first from the Protocols page. + + +
+ +
+
+ ) : ( +
+
+ Select Protocol +
+ {cachedProtocols?.map((protocol) => ( + + ))} +
+
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/offline/SessionExpiryWarning.tsx b/components/offline/SessionExpiryWarning.tsx new file mode 100644 index 000000000..c97e0c92d --- /dev/null +++ b/components/offline/SessionExpiryWarning.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { Button } from '~/components/ui/Button'; +import { + sessionManager, + type SessionState, +} from '~/lib/offline/sessionManager'; +import { ensureError } from '~/utils/ensureError'; + +export function SessionExpiryWarning() { + const [sessionState, setSessionState] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + useEffect(() => { + sessionManager.startMonitoring(); + + const unsubscribe = sessionManager.onSessionChange((state) => { + setSessionState(state); + }); + + return () => { + unsubscribe(); + sessionManager.stopMonitoring(); + }; + }, []); + + const handleRefresh = async () => { + setRefreshing(true); + try { + const success = await sessionManager.refreshSession(); + if (success) { + const newState = await sessionManager.checkSession(); + setSessionState(newState); + } + } catch (error) { + const err = ensureError(error); + // eslint-disable-next-line no-console + console.error('Failed to refresh session:', err); + } finally { + setRefreshing(false); + } + }; + + if (!sessionState?.needsReauth) { + return null; + } + + if (sessionState.status === 'expired') { + return ( + + Session Expired + + Your session has expired. Your work is saved offline. Please sign in + again to sync your data. +
+ +
+
+
+ ); + } + + return ( + + Session Expiring Soon + + Your session will expire soon. Refresh now to continue syncing data + without interruption. +
+ +
+
+
+ ); +} diff --git a/components/offline/StartOfflineInterviewDialog.tsx b/components/offline/StartOfflineInterviewDialog.tsx new file mode 100644 index 000000000..0b2c64848 --- /dev/null +++ b/components/offline/StartOfflineInterviewDialog.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useLiveQuery } from 'dexie-react-hooks'; +import { Play, WifiOff } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import Modal from '~/components/Modal/Modal'; +import ModalPopup from '~/components/Modal/ModalPopup'; +import Surface from '~/components/layout/Surface'; +import Heading from '~/components/typography/Heading'; +import Paragraph from '~/components/typography/Paragraph'; +import { Button } from '~/components/ui/Button'; +import { Alert, AlertDescription } from '~/components/ui/Alert'; +import useNetworkStatus from '~/hooks/useNetworkStatus'; +import InputField from '~/lib/form/components/fields/InputField'; +import { offlineDb } from '~/lib/offline/db'; +import { createOfflineInterview } from '~/lib/offline/offlineInterviewManager'; +import { ensureError } from '~/utils/ensureError'; +import { cx } from '~/utils/cva'; + +type StartOfflineInterviewDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function StartOfflineInterviewDialog({ + open, + onOpenChange, +}: StartOfflineInterviewDialogProps) { + const router = useRouter(); + const { isOnline } = useNetworkStatus(); + const [selectedProtocolId, setSelectedProtocolId] = useState( + null, + ); + const [participantIdentifier, setParticipantIdentifier] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const cachedProtocols = useLiveQuery( + async () => { + return offlineDb.protocols.toArray(); + }, + [], + [], + ); + + useEffect(() => { + if (open && cachedProtocols?.length === 1) { + setSelectedProtocolId(cachedProtocols[0]?.id ?? null); + } + }, [open, cachedProtocols]); + + useEffect(() => { + if (!open) { + setSelectedProtocolId(null); + setParticipantIdentifier(''); + setError(null); + } + }, [open]); + + const handleStartInterview = async () => { + if (!selectedProtocolId) { + setError('Please select a protocol'); + return; + } + + setIsCreating(true); + setError(null); + + try { + const result = await createOfflineInterview( + selectedProtocolId, + participantIdentifier || undefined, + ); + + if (result.error) { + setError(result.error); + return; + } + + onOpenChange(false); + router.push(`/interview/${result.interviewId}`); + } catch (e) { + const err = ensureError(e); + setError(err.message); + } finally { + setIsCreating(false); + } + }; + + const hasProtocols = cachedProtocols && cachedProtocols.length > 0; + + return ( + + + +
+ {!isOnline && } + + Start Offline Interview + +
+ + {error && ( + + {error} + + )} + + {!hasProtocols ? ( +
+ + No protocols are available offline. Please download a protocol + for offline use first. + +
+ +
+
+ ) : ( +
+
+ Select Protocol +
+ {cachedProtocols?.map((protocol) => ( + + ))} +
+
+ +
+ + Participant Identifier (optional) + + setParticipantIdentifier(value ?? '')} + placeholder="Leave empty for anonymous" + /> + + If left empty, an anonymous participant will be created. + +
+ +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/offline/StorageUsage.tsx b/components/offline/StorageUsage.tsx new file mode 100644 index 000000000..d0633497c --- /dev/null +++ b/components/offline/StorageUsage.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Progress } from '~/components/ui/progress'; +import Paragraph from '~/components/typography/Paragraph'; + +type StorageEstimate = { + usage: number; + quota: number; +}; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; +} + +export type StorageUsageProps = object & React.HTMLAttributes; + +export function StorageUsage({ className, ...props }: StorageUsageProps) { + const [storage, setStorage] = useState(null); + const [isSupported, setIsSupported] = useState(true); + + useEffect(() => { + const getStorageEstimate = async () => { + if (!navigator.storage?.estimate) { + setIsSupported(false); + return; + } + + try { + const estimate = await navigator.storage.estimate(); + setStorage({ + usage: estimate.usage ?? 0, + quota: estimate.quota ?? 0, + }); + } catch (error) { + setIsSupported(false); + } + }; + + void getStorageEstimate(); + }, []); + + if (!isSupported) { + return ( +
+ Storage information unavailable +
+ ); + } + + if (!storage) { + return null; + } + + const percentUsed = + storage.quota > 0 ? (storage.usage / storage.quota) * 100 : 0; + + return ( +
+
+
+ Storage Used + + {formatBytes(storage.usage)} / {formatBytes(storage.quota)} + +
+ +
+
+ ); +} diff --git a/components/offline/SyncErrorSummary.tsx b/components/offline/SyncErrorSummary.tsx new file mode 100644 index 000000000..696c7131f --- /dev/null +++ b/components/offline/SyncErrorSummary.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import Paragraph from '~/components/typography/Paragraph'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { Button } from '~/components/ui/Button'; +import { syncManager, type BatchSyncResult } from '~/lib/offline/syncManager'; +import { ensureError } from '~/utils/ensureError'; + +type SyncErrorSummaryProps = { + result: BatchSyncResult; + onRetry?: () => void; +}; + +export function SyncErrorSummary({ result, onRetry }: SyncErrorSummaryProps) { + const [retrying, setRetrying] = useState(false); + const [retryResult, setRetryResult] = useState(null); + + const handleRetry = async () => { + setRetrying(true); + try { + const failedIds = result.failed.map((f) => f.interviewId); + const newResult = await syncManager.retryFailedSyncs(failedIds); + setRetryResult(newResult); + onRetry?.(); + } catch (error) { + const err = ensureError(error); + // eslint-disable-next-line no-console + console.error('Retry failed:', err); + } finally { + setRetrying(false); + } + }; + + const displayResult = retryResult ?? result; + + if (displayResult.failed.length === 0) { + return ( + + Sync Complete + + All {displayResult.succeeded.length} interviews synced successfully. + + + ); + } + + return ( +
+ 0 ? 'warning' : 'destructive'} + > + Sync Partially Complete + + {displayResult.succeeded.length > 0 && ( + + {displayResult.succeeded.length} of {displayResult.total}{' '} + interviews synced successfully. + + )} + + {displayResult.failed.length} interview + {displayResult.failed.length !== 1 ? 's' : ''} failed to sync: + +
    + {displayResult.failed.map((failed) => ( +
  • + {failed.interviewId}: {failed.error ?? 'Unknown error'} +
  • + ))} +
+
+
+ + {!retryResult && ( + + )} +
+ ); +} diff --git a/components/offline/SyncStatusIndicator.tsx b/components/offline/SyncStatusIndicator.tsx new file mode 100644 index 000000000..2f1b164e4 --- /dev/null +++ b/components/offline/SyncStatusIndicator.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { AlertCircle, Check, Loader2 } from 'lucide-react'; +import useSyncStatus from '~/hooks/useSyncStatus'; +import { cx } from '~/utils/cva'; + +export type SyncStatusIndicatorProps = { + className?: string; +}; + +export function SyncStatusIndicator({ className }: SyncStatusIndicatorProps) { + const { pendingSyncs, conflicts, isInitialized } = useSyncStatus(); + + if (!isInitialized) { + return null; + } + + if (conflicts > 0) { + return ( +
+ + + {conflicts} conflict{conflicts === 1 ? '' : 's'} + +
+ ); + } + + if (pendingSyncs > 0) { + return ( +
+
+ + {pendingSyncs > 1 && ( + + {pendingSyncs} + + )} +
+ Syncing... +
+ ); + } + + return ( +
+ + Synced +
+ ); +} diff --git a/components/offline/__tests__/OfflineModeSwitch.test.tsx b/components/offline/__tests__/OfflineModeSwitch.test.tsx new file mode 100644 index 000000000..f49bc357f --- /dev/null +++ b/components/offline/__tests__/OfflineModeSwitch.test.tsx @@ -0,0 +1,267 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OfflineModeSwitch } from '../OfflineModeSwitch'; + +const OFFLINE_MODE_KEY = 'offlineModeEnabled'; + +describe('OfflineModeSwitch', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('should render after mounting', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('offline-switch')).toBeInTheDocument(); + }); + }); + + it('should render with custom className', async () => { + render( + , + ); + + await waitFor(() => { + const element = screen.getByTestId('offline-switch'); + expect(element).toHaveClass('custom-class'); + }); + }); + + it('should spread additional props to root element', async () => { + render( + , + ); + + await waitFor(() => { + const element = screen.getByTestId('offline-switch'); + expect(element).toHaveAttribute('aria-label', 'Toggle offline mode'); + }); + }); + }); + + describe('localStorage initialization', () => { + it('should default to unchecked when localStorage is empty', async () => { + render(); + + await waitFor(() => { + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + }); + + it('should initialize as checked when localStorage is "true"', async () => { + localStorage.setItem(OFFLINE_MODE_KEY, 'true'); + + render(); + + await waitFor(() => { + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + }); + + it('should initialize as unchecked when localStorage is "false"', async () => { + localStorage.setItem(OFFLINE_MODE_KEY, 'false'); + + render(); + + await waitFor(() => { + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + }); + + it('should treat invalid localStorage values as unchecked', async () => { + localStorage.setItem(OFFLINE_MODE_KEY, 'invalid-value'); + + render(); + + await waitFor(() => { + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + }); + }); + + describe('user interaction', () => { + it('should toggle to checked when clicked', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + + await user.click(switchElement); + + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + + it('should toggle to unchecked when clicked twice', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + + await user.click(switchElement); + await user.click(switchElement); + + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + }); + + it('should support multiple toggles', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + + await user.click(switchElement); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + + await user.click(switchElement); + expect(switchElement).toHaveAttribute('aria-checked', 'false'); + + await user.click(switchElement); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('localStorage persistence', () => { + it('should save "true" to localStorage when toggled on', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('switch')); + + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('true'); + }); + + it('should save "false" to localStorage when toggled off', async () => { + const user = userEvent.setup(); + localStorage.setItem(OFFLINE_MODE_KEY, 'true'); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('switch')); + + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('false'); + }); + + it('should persist state across multiple toggles', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + + await user.click(switchElement); + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('true'); + + await user.click(switchElement); + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('false'); + + await user.click(switchElement); + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('true'); + }); + + it('should maintain localStorage state between component remounts', async () => { + const user = userEvent.setup(); + + const { unmount } = render( + , + ); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('switch')); + + expect(localStorage.getItem(OFFLINE_MODE_KEY)).toBe('true'); + + unmount(); + + render(); + + await waitFor(() => { + const switchElement = screen.getByRole('switch'); + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + }); + }); + + describe('keyboard interaction', () => { + it('should toggle when Space key is pressed', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + switchElement.focus(); + + await user.keyboard(' '); + + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + + it('should toggle when Enter key is pressed', async () => { + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole('switch'); + switchElement.focus(); + + await user.keyboard('{Enter}'); + + expect(switchElement).toHaveAttribute('aria-checked', 'true'); + }); + }); +}); diff --git a/components/offline/__tests__/OfflineStatusBadge.test.tsx b/components/offline/__tests__/OfflineStatusBadge.test.tsx new file mode 100644 index 000000000..fa1d6a999 --- /dev/null +++ b/components/offline/__tests__/OfflineStatusBadge.test.tsx @@ -0,0 +1,275 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { type OfflineStatus, OfflineStatusBadge } from '../OfflineStatusBadge'; + +describe('OfflineStatusBadge', () => { + describe('rendering', () => { + it('should render without crashing', () => { + render(); + + expect(screen.getByText('Online Only')).toBeInTheDocument(); + }); + + it('should render with custom className', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('custom-class'); + }); + + it('should spread additional props to root element', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveAttribute('aria-label', 'Status indicator'); + }); + }); + + describe('status variants', () => { + it('should render "Online Only" label for online-only status', () => { + render(); + + expect(screen.getByText('Online Only')).toBeInTheDocument(); + }); + + it('should render "Downloading" label for downloading status', () => { + render(); + + expect(screen.getByText('Downloading')).toBeInTheDocument(); + }); + + it('should render "Available Offline" label for available-offline status', () => { + render(); + + expect(screen.getByText('Available Offline')).toBeInTheDocument(); + }); + + it('should render "Sync Required" label for sync-required status', () => { + render(); + + expect(screen.getByText('Sync Required')).toBeInTheDocument(); + }); + }); + + describe('CSS classes', () => { + it('should apply correct classes for online-only status', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('border-current/20'); + expect(badge).toHaveClass('text-current'); + }); + + it('should apply correct classes for downloading status', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('border-info'); + expect(badge).toHaveClass('bg-info/10'); + expect(badge).toHaveClass('text-info'); + expect(badge).toHaveClass('animate-pulse'); + }); + + it('should apply correct classes for available-offline status', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('border-success'); + expect(badge).toHaveClass('bg-success/10'); + expect(badge).toHaveClass('text-success'); + }); + + it('should apply correct classes for sync-required status', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('border-warning'); + expect(badge).toHaveClass('bg-warning/10'); + expect(badge).toHaveClass('text-warning'); + }); + + it('should always include base Badge variant classes', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge.className).toContain('inline-flex'); + }); + }); + + describe('status transitions', () => { + it('should update label when status prop changes', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Online Only')).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText('Downloading')).toBeInTheDocument(); + expect(screen.queryByText('Online Only')).not.toBeInTheDocument(); + }); + + it('should update classes when status prop changes', () => { + const { rerender } = render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('border-current/20'); + expect(badge).not.toHaveClass('animate-pulse'); + + rerender( + , + ); + + expect(badge).toHaveClass('animate-pulse'); + expect(badge).not.toHaveClass('border-current/20'); + }); + + it('should handle all status transitions', () => { + const statuses: OfflineStatus[] = [ + 'online-only', + 'downloading', + 'available-offline', + 'sync-required', + ]; + + const firstStatus = statuses[0] ?? 'online-only'; + + const { rerender } = render( + , + ); + + const badge = screen.getByTestId('status-badge'); + + for (const status of statuses) { + rerender( + , + ); + + const expectedLabels: Record = { + 'online-only': 'Online Only', + 'downloading': 'Downloading', + 'available-offline': 'Available Offline', + 'sync-required': 'Sync Required', + }; + + expect(screen.getByText(expectedLabels[status])).toBeInTheDocument(); + expect(badge).toBeInTheDocument(); + } + }); + }); + + describe('combined classes', () => { + it('should merge custom className with variant classes', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('extra-class'); + expect(badge).toHaveClass('border-info'); + expect(badge).toHaveClass('animate-pulse'); + }); + + it('should allow overriding variant classes with custom className', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveClass('custom-animation'); + expect(badge.className).toContain('animate-pulse'); + }); + }); + + describe('accessibility', () => { + it('should be accessible as a generic element', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toBeInTheDocument(); + }); + + it('should support custom ARIA attributes', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge).toHaveAttribute('aria-live', 'polite'); + expect(badge).toHaveAttribute('role', 'status'); + }); + + it('should have visible text for screen readers', () => { + render(); + + const text = screen.getByText('Available Offline'); + expect(text).toBeVisible(); + }); + }); + + describe('Badge component integration', () => { + it('should render as a Badge with outline variant', () => { + render( + , + ); + + const badge = screen.getByTestId('status-badge'); + expect(badge.className).toContain('inline-flex'); + expect(badge.className).toContain('border'); + }); + }); +}); diff --git a/components/offline/__tests__/StorageUsage.test.tsx b/components/offline/__tests__/StorageUsage.test.tsx new file mode 100644 index 000000000..e05bb0f50 --- /dev/null +++ b/components/offline/__tests__/StorageUsage.test.tsx @@ -0,0 +1,406 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StorageUsage } from '../StorageUsage'; + +describe('StorageUsage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('should render storage information when API is available', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 50, + quota: 1024 * 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Storage Used')).toBeInTheDocument(); + }); + + expect(mockEstimate).toHaveBeenCalledTimes(1); + }); + + it('should show fallback message when Storage API is unavailable', async () => { + Object.defineProperty(navigator, 'storage', { + writable: true, + value: undefined, + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Storage information unavailable'), + ).toBeInTheDocument(); + }); + }); + + it('should show fallback message when estimate method is unavailable', async () => { + Object.defineProperty(navigator, 'storage', { + writable: true, + value: {}, + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Storage information unavailable'), + ).toBeInTheDocument(); + }); + }); + + it('should show fallback message when estimate throws an error', async () => { + const mockEstimate = vi.fn().mockRejectedValue(new Error('API Error')); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Storage information unavailable'), + ).toBeInTheDocument(); + }); + }); + + it('should render with custom className', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 50, + quota: 1024 * 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render( + , + ); + + await waitFor(() => { + const element = screen.getByTestId('storage-usage'); + expect(element).toHaveClass('custom-class'); + }); + }); + + it('should spread additional props to root element', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 50, + quota: 1024 * 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render( + , + ); + + await waitFor(() => { + const element = screen.getByTestId('storage-usage'); + expect(element).toHaveAttribute('aria-label', 'Storage usage display'); + }); + }); + + it('should not render anything while loading', () => { + const mockEstimate = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 10000)), + ); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('byte formatting', () => { + it('should format bytes correctly', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 512, + quota: 1024, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/512\.00 B/)).toBeInTheDocument(); + expect(screen.getByText(/1\.00 KB/)).toBeInTheDocument(); + }); + }); + + it('should format kilobytes correctly', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 50, + quota: 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/50\.00 KB/)).toBeInTheDocument(); + expect(screen.getByText(/100\.00 KB/)).toBeInTheDocument(); + }); + }); + + it('should format megabytes correctly', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 50, + quota: 1024 * 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/50\.00 MB/)).toBeInTheDocument(); + expect(screen.getByText(/100\.00 MB/)).toBeInTheDocument(); + }); + }); + + it('should format gigabytes correctly', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 1024 * 2, + quota: 1024 * 1024 * 1024 * 5, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/2\.00 GB/)).toBeInTheDocument(); + expect(screen.getByText(/5\.00 GB/)).toBeInTheDocument(); + }); + }); + + it('should format zero bytes correctly', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 0, + quota: 1024, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/0 B/)).toBeInTheDocument(); + }); + }); + }); + + describe('percentage calculation', () => { + it('should display progress bar with correct percentage', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 50, + quota: 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('should handle 0% usage', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 0, + quota: 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('should handle 100% usage', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 100, + quota: 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('should handle zero quota gracefully', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 50, + quota: 0, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toBeInTheDocument(); + }); + }); + }); + + describe('missing estimate values', () => { + it('should handle missing usage value', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + quota: 1024 * 1024 * 100, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/0 B/)).toBeInTheDocument(); + expect(screen.getByText(/100\.00 MB/)).toBeInTheDocument(); + }); + }); + + it('should handle missing quota value', async () => { + const mockEstimate = vi.fn().mockResolvedValue({ + usage: 1024 * 1024 * 50, + }); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/50\.00 MB/)).toBeInTheDocument(); + expect(screen.getByText(/0 B/)).toBeInTheDocument(); + }); + }); + + it('should handle both values missing', async () => { + const mockEstimate = vi.fn().mockResolvedValue({}); + + Object.defineProperty(navigator, 'storage', { + writable: true, + value: { + estimate: mockEstimate, + }, + }); + + render(); + + await waitFor(() => { + const text = screen.getByText(/0 B/); + expect(text).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/components/offline/__tests__/TEST_COVERAGE.md b/components/offline/__tests__/TEST_COVERAGE.md new file mode 100644 index 000000000..f7fec375b --- /dev/null +++ b/components/offline/__tests__/TEST_COVERAGE.md @@ -0,0 +1,236 @@ +# Offline Component Test Coverage + +This document outlines the comprehensive test coverage for Phase 2 offline components. + +## Test Files + +### 1. OfflineModeSwitch.test.tsx (16 tests) + +**Component:** `components/offline/OfflineModeSwitch.tsx` + +**Coverage Areas:** +- Rendering + - Renders after mounting (client-side only) + - Custom className spreading + - Additional props spreading (data-testid, aria-label, etc.) +- localStorage Initialization + - Default unchecked state when localStorage is empty + - Initialize from localStorage "true" value + - Initialize from localStorage "false" value + - Handle invalid localStorage values +- User Interaction + - Toggle to checked on click + - Toggle to unchecked on second click + - Support multiple toggle operations +- localStorage Persistence + - Save "true" on toggle on + - Save "false" on toggle off + - Persist across multiple toggles + - Maintain state between component remounts +- Keyboard Interaction + - Toggle with Space key + - Toggle with Enter key + +**Edge Cases Covered:** +- Invalid localStorage values default to unchecked +- Component does not render during SSR (client-side only with mounted check) +- State persists across component unmount/remount cycles + +--- + +### 2. StorageUsage.test.tsx (19 tests) + +**Component:** `components/offline/StorageUsage.tsx` + +**Coverage Areas:** +- Rendering + - Render storage info when API available + - Show fallback when Storage API unavailable + - Show fallback when estimate method unavailable + - Show fallback when estimate throws error + - Custom className spreading + - Additional props spreading + - No render while loading +- Byte Formatting + - Format bytes (B) + - Format kilobytes (KB) + - Format megabytes (MB) + - Format gigabytes (GB) + - Format zero bytes +- Percentage Calculation + - Display progress bar with correct percentage + - Handle 0% usage + - Handle 100% usage + - Handle zero quota gracefully +- Missing Estimate Values + - Handle missing usage value (defaults to 0) + - Handle missing quota value (defaults to 0) + - Handle both values missing + +**Edge Cases Covered:** +- Browser Storage API not available (older browsers) +- Storage estimate method missing +- Storage estimate throws errors +- Missing/undefined usage or quota values +- Zero quota (division by zero protection) +- Async loading state (returns null while loading) + +--- + +### 3. OfflineStatusBadge.test.tsx (21 tests) + +**Component:** `components/offline/OfflineStatusBadge.tsx` + +**Coverage Areas:** +- Rendering + - Render without crashing + - Custom className spreading + - Additional props spreading +- Status Variants + - "Online Only" label for online-only status + - "Downloading" label for downloading status + - "Available Offline" label for available-offline status + - "Sync Required" label for sync-required status +- CSS Classes + - Correct classes for online-only (border-current/20, text-current) + - Correct classes for downloading (border-info, bg-info/10, text-info, animate-pulse) + - Correct classes for available-offline (border-success, bg-success/10, text-success) + - Correct classes for sync-required (border-warning, bg-warning/10, text-warning) + - Base Badge variant classes always included +- Status Transitions + - Update label when status prop changes + - Update classes when status prop changes + - Handle all status transitions correctly +- Combined Classes + - Merge custom className with variant classes + - Allow overriding variant classes +- Accessibility + - Accessible as generic element + - Support custom ARIA attributes + - Visible text for screen readers +- Badge Component Integration + - Renders as Badge with outline variant + +**Edge Cases Covered:** +- Status prop changes trigger correct re-renders +- Custom classes merge properly without overriding critical styles +- All four status variants work correctly +- Accessibility attributes can be added for enhanced screen reader support + +--- + +## Test Strategy + +### Unit Testing Approach +All tests use Vitest with React Testing Library following these principles: +- **Isolation**: Each test is independent with proper setup/teardown +- **User-centric**: Tests interact with components as users would (clicks, keyboard) +- **Accessibility-first**: Use semantic queries (getByRole, getByLabelText) +- **No implementation details**: Test behavior, not implementation + +### Mocking Strategy +- **localStorage**: Mocked in test environment, cleared between tests +- **navigator.storage**: Mocked with different scenarios (available, unavailable, errors) +- **Progress component**: Uses Radix UI, tested via integration + +### Test Patterns +1. **AAA Pattern**: Arrange-Act-Assert structure throughout +2. **Descriptive names**: Clear test names explain what is being tested +3. **Wait for async**: All async operations properly awaited with waitFor +4. **User events**: Use @testing-library/user-event for realistic interactions + +--- + +## Edge Cases Identified + +### Handled Edge Cases +1. **OfflineModeSwitch** + - Invalid localStorage values + - SSR rendering (component only renders client-side) + - State persistence across remounts + +2. **StorageUsage** + - Browser compatibility (older browsers without Storage API) + - API errors during estimate call + - Missing estimate values (usage/quota) + - Zero quota (prevents division by zero) + - Async loading state + +3. **OfflineStatusBadge** + - Dynamic status changes + - Custom className merging + - All status variant transitions + +### Potential Uncovered Edge Cases + +#### OfflineModeSwitch +1. **Concurrent Tab Updates**: If multiple tabs update offline mode simultaneously, there could be race conditions. Consider implementing cross-tab communication (BroadcastChannel or storage events). +2. **localStorage Quota**: If localStorage is full, setItem will throw. Should add try-catch error handling. +3. **Private Browsing**: Some browsers disable localStorage in private mode. Consider graceful degradation. + +#### StorageUsage +1. **Rapid Storage Changes**: Component doesn't re-fetch storage info after mount. Consider adding periodic refresh or manual refresh capability. +2. **Storage Permissions**: Some browsers require user permission for storage APIs. Consider handling permission denials. +3. **IndexedDB Impact**: Storage estimate includes all storage (localStorage, IndexedDB, Cache API). Large IndexedDB usage might not be obvious to users. + +#### OfflineStatusBadge +1. **Animation Performance**: The animate-pulse class on "downloading" status might impact performance on low-end devices. Consider respecting prefers-reduced-motion. +2. **Status Change Notifications**: Rapid status changes might be missed by screen reader users. Consider using aria-live regions. +3. **Color Accessibility**: Status colors rely on semantic colors (success, warning, info). Ensure sufficient contrast ratios in all themes. + +--- + +## Recommendations + +### For Production +1. **Add error boundaries** around components to catch and handle unexpected errors gracefully +2. **Implement cross-tab sync** for OfflineModeSwitch using BroadcastChannel or storage events +3. **Add try-catch** for localStorage operations with fallback to in-memory state +4. **Respect prefers-reduced-motion** for animate-pulse in OfflineStatusBadge +5. **Add aria-live** to OfflineStatusBadge for status change announcements +6. **Consider periodic refresh** for StorageUsage to show live usage updates +7. **Add visual quota warnings** when storage usage exceeds 80-90% + +### For Testing +1. **Add integration tests** to verify components work together in SettingsCard +2. **Add E2E tests** to verify offline mode behavior in actual browser environment +3. **Add visual regression tests** in Storybook/Chromatic for status badge variants +4. **Test color contrast** for all status variants against light/dark themes + +--- + +## Test Execution + +Run all offline component tests: +```bash +pnpm test components/offline/__tests__/ +``` + +Run specific test file: +```bash +pnpm test components/offline/__tests__/OfflineModeSwitch.test.tsx +``` + +Run with coverage: +```bash +pnpm test --coverage components/offline/__tests__/ +``` + +--- + +## Summary + +**Total Tests**: 56 +- OfflineModeSwitch: 16 tests +- StorageUsage: 19 tests +- OfflineStatusBadge: 21 tests + +**Coverage**: Comprehensive coverage of all component functionality including: +- Rendering behavior +- User interactions +- State management +- Error handling +- Accessibility +- Edge cases + +**Quality**: All tests follow best practices, use semantic queries, and test user-visible behavior rather than implementation details. diff --git a/components/pwa/ServiceWorkerRegistration.tsx b/components/pwa/ServiceWorkerRegistration.tsx new file mode 100644 index 000000000..d8cbc0e81 --- /dev/null +++ b/components/pwa/ServiceWorkerRegistration.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useEffect } from 'react'; +import { registerServiceWorkerIfEnabled } from '~/lib/pwa/registerServiceWorker'; + +export function ServiceWorkerRegistration() { + useEffect(() => { + registerServiceWorkerIfEnabled(); + }, []); + + return null; +} diff --git a/components/ui/TimeAgo.tsx b/components/ui/TimeAgo.tsx index 781597de2..5676c74fd 100644 --- a/components/ui/TimeAgo.tsx +++ b/components/ui/TimeAgo.tsx @@ -8,14 +8,19 @@ type TimeAgoProps = React.TimeHTMLAttributes & { const TimeAgo: React.FC = ({ date: dateProp, ...props }) => { const date = useMemo(() => new Date(dateProp), [dateProp]); - const localisedDate = new Intl.DateTimeFormat( - navigator.language, - dateOptions, - ).format(date); + const isValidDate = !isNaN(date.getTime()); + const localisedDate = isValidDate + ? new Intl.DateTimeFormat(navigator.language, dateOptions).format(date) + : 'Unknown'; const [timeAgo, setTimeAgo] = useState(''); useEffect(() => { + if (!isValidDate) { + setTimeAgo('Unknown'); + return; + } + const calculateTimeAgo = () => { const now = new Date(); const distance = now.getTime() - date.getTime(); @@ -46,7 +51,7 @@ const TimeAgo: React.FC = ({ date: dateProp, ...props }) => { const interval = setInterval(calculateTimeAgo, 60000); return () => clearInterval(interval); - }, [date, localisedDate]); + }, [date, localisedDate, isValidDate]); return (