Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
/out/
next-env.d.ts

# service worker (generated by @serwist/next)
/public/sw.js
/public/sw.js.map

# production
/build

Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
Expand Down
40 changes: 40 additions & 0 deletions actions/protocols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
11 changes: 8 additions & 3 deletions app/(interview)/interview/_components/InterviewShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,9 +22,12 @@ const InterviewShell = (props: {

return (
<Provider store={store(decodedPayload, { disableSync: props.disableSync })}>
<DndStoreProvider>
<ProtocolScreen />
</DndStoreProvider>
<OfflineErrorBoundary>
<DndStoreProvider>
<ProtocolScreen />
</DndStoreProvider>
<OfflineIndicator />
</OfflineErrorBoundary>
</Provider>
);
};
Expand Down
91 changes: 91 additions & 0 deletions app/api/interviews/[id]/force-sync/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
48 changes: 48 additions & 0 deletions app/api/interviews/[id]/state/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
90 changes: 90 additions & 0 deletions app/api/interviews/create-offline/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading