Skip to content

feat(frontend): offline support via service worker - #516

Open
netchampfaris wants to merge 10 commits into
developfrom
feat/offline-support
Open

feat(frontend): offline support via service worker#516
netchampfaris wants to merge 10 commits into
developfrom
feat/offline-support

Conversation

@netchampfaris

@netchampfaris netchampfaris commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What

Adds offline support to the Gameplan SPA so the app shell loads and cached data is served when the network is unavailable.

How

  • Service worker (gameplan/www/gameplan-sw.js) precaches built assets listed in a build-time manifest and serves them when offline.
  • Vite plugin (offlineAssetManifest() in vite.config.ts) emits gameplan-offline-assets.json (CSS/JS/font URLs) at build time.
  • offline.ts registers the service worker and exposes isBrowserOffline() / isNetworkError() helpers.
  • Data layer: staleOnError + cacheKey added to useList/useDoc calls so cached responses are served on network failure.
  • Router: hydrates community/space data from cache and skips NotFound route validation when offline or on network errors.

Notes

Frontend-only behavior change plus one new backend static file (the service worker). No schema or API changes.

Update — 2026-08-09

Since the original submission:

  • Rebased onto latest develop (conflicts resolved in unreadCount.ts, Drafts.vue, PersonProfile.vue).
  • Review findings addressed: per-user cache scoping for all useList/useCall caches (useDoc scoping needs an upstream frappe-ui change — being PR'd separately); router no longer lets offline navigation crash on missing data (offline fallbacks added).
  • New since original:
    • Offline indicator + auto-refetch on reconnect.
    • Honest offline fallbacks for uncached discussions/space lists.
    • Fix for onboarding redirect racing cache hydration.
    • People page + person profiles offline (cached people list, bento cards, posts).
    • Background prefetch of members/profiles/avatars so any member profile works offline.
    • Backend fix so GP User Profile get_list accepts GET query params.
  • Verified against 9 user stories (details) with a Playwright true-offline suite, 2x consecutive green runs + online regression smoke.
  • Completed since then: user-scoped SW app-shell cache + cache clearing on logout, SW update/refresh flow, moving the offline test suite into the repo (see paragraph below).

The offline Playwright suite now lives in the repo at frontend/tests/offline (12 stories: US1-US8, P1-P3; run with yarn test:offline from frontend/) instead of a throwaway /tmp harness, so it survives reboots and can gate regressions. This round also fixed a shell-rewarm race: after a detected user switch cleared the service worker's app-shell cache, nothing repopulated it until the next successful online navigation, so going offline first could turn even a same-page reload into a hard net::ERR_FAILED instead of the offline UI. Drafts now follow a clearer policy — preserved across a plain logout/re-login as the same user, cleared only when a genuine user switch is detected. useDoc cache namespacing (noted as pending above) is now up as frappe/frappe-ui#1006.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 4/5

This is close, but the session-switch cache cleanup should be fixed before merging.

  • The logout path waits for cache cleanup before redirecting.
  • The user-switch path starts cleanup in the background and can navigate away before the worker caches are deleted.
  • The boot-time guard can run before the service worker registration exists, so old shell caches may be skipped.

Files Needing Attention: frontend/src/offline.ts and frontend/src/data/session.ts

Security Review

A user-switch cache clear can finish too late or miss an unregistered service worker, leaving prior-user shell/runtime caches available to the next user on the same browser.

Fix All in Claude Code Fix All in Codex

Reviews (4): Last reviewed commit: "test(frontend): add offline Playwright s..." | Re-trigger Greptile

Comment thread gameplan/www/gameplan-sw.js
Comment thread frontend/src/data/users.ts Outdated
Comment on lines +49 to +50
cacheKey: 'Users',
staleOnError: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security User Cache Crosses Sessions

This stale-on-error cache is keyed only as Users, so a same-tab account switch can reuse the previous account's user list when the next fetch fails. The UI can then show another account's people, roles, or profile metadata instead of failing closed for the current session.

Comment thread frontend/src/router.ts
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

UI Test Results

✅ All passed — 42/42 tests passed in 2m 50s.

Spec Tests
command-palette.cy.ts 5 5 0 0 11s
comment.cy.js 1 1 0 0 40s
community-composer.cy.ts 3 3 0 0 6s
community-discussions-actions.cy.ts 1 1 0 0 7s
community-merge-url-healing.cy.ts 1 1 0 0 4s
community-mobile-home.cy.ts 1 1 0 0 3s
community-naming.cy.ts 3 3 0 0 5s
community-scoped-links.cy.ts 3 3 0 0 3s
community-shell.cy.ts 3 3 0 0 3s
community-smoke.cy.ts 3 3 0 0 3s
community-spaces-guardrails.cy.ts 2 2 0 0 3s
discussion.cy.js 1 1 0 0 27s
drafts-comment.cy.ts 2 2 0 0 8s
member-management.cy.ts 2 2 0 0 5s
mobile-more-pages.cy.ts 4 4 0 0 6s
new-discussion.cy.ts 2 2 0 0 10s
onboarding.cy.js 1 1 0 0 2s
page.cy.js 1 1 0 0 2s
project.cy.js 1 1 0 0 11s
search-privacy.cy.ts 1 1 0 0 3s
task.cy.js 1 1 0 0 7s
Total 42 42 0 0 2m 50s

Results for commit 01e55fc.

Precache the app shell and serve cached data when the network is
unavailable:

- gameplan-sw.js: service worker that precaches built assets listed in a
  build-time manifest and serves them offline.
- vite.config.ts: offlineAssetManifest() plugin emits
  gameplan-offline-assets.json (CSS/JS/font URLs) at build time.
- offline.ts: registers the service worker and exposes isBrowserOffline()
  / isNetworkError() helpers.
- data layer: add staleOnError + cacheKey to useList/useDoc so cached
  responses are served on network failure.
- router.ts: hydrate community/space data from cache and skip route
  validation (NotFound) when offline or on network errors.
Show an unobtrusive pill while the browser is offline, and refetch feeds,
unread counts, and open discussion timelines once connectivity returns, so
content posted by others while offline shows up without a manual reload.
…en network is unreliable

navigator.onLine can briefly lag the real network state right after a reload,
so the home-route decision was racing communities/spaces IndexedDB hydration
and occasionally sending an offline reload to onboarding instead of the
cached feed. Treat a resource that already failed with a network error the
same as isBrowserOffline() when deciding whether to wait for the cache.
…lists; scope offline caches per user

Add a friendly "can't load this while offline" state (with retry) for a
never-visited discussion or space discussion list, instead of a blank or
silently-empty screen.

Also scope every offline-cached list/doc/call key to the session user
(cacheKey: [..., session.user], matching the existing drafts.ts pattern),
so a second account signing into the same browser can't read the previous
account's cached data before its own permission-checked fetch resolves —
review finding from PR #516. users.ts reads the session user straight from
the cookie rather than importing session.ts: session.ts itself imports
users.ts before assigning its `session` export, so importing it back from
users.ts at module scope threw on boot.
frappe-ui's useList sends fields/filters/start/limit as GET query-string
values, but get_list type-hinted fields/filters as dict and start/limit
implicitly as int, so Frappe's own request coercion rejected the strings
before the function body ran. Parse fields/filters with frappe.parse_json
and coerce start/limit with cint, matching how the builtin
/api/v2/document/<doctype> list route and gp_discussion.api.get_discussions
already handle this.
…son profiles

Moves the People list from the legacy Options-API resource (no offline
persistence, not scoped per user) to a data/people.ts useList singleton with
cacheKey ['People', session.user]. Reworks ProfileBento's card fetch onto
useCall with cacheKey ['ProfileBento', personId, session.user] so it can
resolve on failure instead of hanging forever, and fixes a broken relative
API URL that made bento cards fail for everyone (online included) - useCall
takes its url verbatim, unlike call()'s automatic /api/method/ prefix.

People.vue, PersonProfile.vue, PersonProfileProfile.vue,
PersonProfilePosts/Replies.vue all get an explicit offline/network failure
state (OfflineContentFallback, with retry) distinct from a genuinely empty
list or a real 404 - a failed fetch used to render as "0 members", an
infinite skeleton, or a misleading NotFound.
…for offline

Idle-delayed after login (and again on reconnect), warms the People list,
every enabled member's GP User Profile doc, their bento cards, and their
avatar bytes (loaded through an <img> element so the service worker's image
cache picks them up) through a small worker pool - so the People page and
any member's profile render offline even for a member never directly
visited this session. Posts/replies are intentionally left out; those pages
fall back to the honest offline-fallback state instead.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Test report

Backend ✅ 1175 passed · 7m 13s

46 modules
Module Tests
test_api_endpoints.py 27 27 0 0 14s
test_archived_spaces.py 27 27 0 0 16s
test_attachments.py 29 29 0 0 1s
test_bookmarks.py 22 22 0 0 12s
test_communities.py 22 22 0 0 8s
test_content_access.py 7 7 0 0 4s
test_delete_cascade.py 3 3 0 0 2s
test_demo_fixture.py 7 7 0 0 0s
test_dev_user_switcher.py 8 8 0 0 4s
test_discussions.py 83 83 0 0 43s
test_drafts.py 17 17 0 0 10s
test_email_digest.py 26 26 0 0 17s
test_get_request_transactions.py 9 9 0 0 5s
test_guest_access.py 5 5 0 0 3s
test_guest_participation.py 18 18 0 0 10s
test_html_utils.py 6 6 0 0 0s
test_install.py 2 2 0 0 0s
test_invitations.py 18 18 0 0 9s
test_list_scoping.py 8 8 0 0 4s
test_members.py 9 9 0 0 1s
test_migrations.py 13 13 0 0 2s
test_mutation_ci.py 182 182 0 0 4s
test_mutation_classification.py 34 34 0 0 0s
test_mutation_cli.py 29 29 0 0 0s
test_mutation_mutators.py 27 27 0 0 2s
test_mutation_report.py 30 30 0 0 3s
test_mutation_safety.py 27 27 0 0 0s
test_notifications.py 37 37 0 0 22s
test_pages.py 10 10 0 0 5s
test_per_user_state.py 46 46 0 0 24s
test_permission_defaults.py 9 9 0 0 5s
test_permission_matrix.py 1 1 0 0 1s
test_polls.py 65 65 0 0 37s
test_profile_image_ownership.py 4 4 0 0 0s
test_profiles.py 65 65 0 0 41s
test_reactions.py 43 43 0 0 26s
test_realtime_activity.py 17 17 0 0 9s
test_realtime_events.py 14 14 0 0 7s
test_roles.py 9 9 0 0 0s
test_search.py 20 20 0 0 7s
test_search_isolation.py 8 8 0 0 2s
test_spaces.py 58 58 0 0 31s
test_unread.py 31 31 0 0 20s
test_unsplash.py 19 19 0 0 9s
test_v16_api_compat.py 3 3 0 0 1s
test_visibility.py 21 21 0 0 11s
Total 1175 1175 0 0 7m 13s

Coverage 88.7% (3,403 / 3,835 statements)

Backend coverage by area
Area Covered Statements Coverage
DocTypes 1,731 1,933 89.5%
Mixins 335 379 88.4%
Permissions 359 372 96.5%
Other 232 279 83.2%
Email digest 252 270 93.3%
HTTP API 185 233 79.4%
Search 184 206 89.3%
Utilities 125 163 76.7%
Total 3,403 3,835 88.7%

Least covered

File Coverage
gameplan/command_palette.py 13.6% (3/22)
gameplan/mixins/manage_members.py 25.0% (5/20)
gameplan/www/g.py 35.4% (23/65)
gameplan/utils/sanitizer.py 52.6% (20/38)
gameplan/mixins/tags.py 73.2% (52/71)
gameplan/gameplan/doctype/gp_task/gp_task.py 75.8% (47/62)
gameplan/utils/utils.py 83.7% (103/123)
gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py 84.3% (323/383)
gameplan/gameplan/doctype/gp_discussion/gp_discussion.py 85.5% (189/221)
gameplan/unsplash.py 87.0% (94/108)

Measured over product code only. Excluded: test suite (gameplan/tests/), Cypress seed API (gameplan/ui_test_helpers.py), demo data generator (gameplan/demo/), one-off Discourse importer (gameplan/migrate_from_discourse/), migration patches (gameplan/patches/), desk config stubs (gameplan/config/). Coverage is informational — no minimum threshold is enforced.

Cypress ❌ 3 of 126 failed · 8m 40s

  • profile-settings.cy.tsProfile settings edits the profile, adds a bento card, and sets quick reactions

    Timed out retrying after 4000ms: Expected to find content: 'How I work' but never did.
  • discussion-actions.cy.tsDiscussion actions renames a discussion and records the rename in the activity feed

    Timed out retrying after 4000ms: Expected to find content: 'changed the title from' but never did.
  • profile-customize.cy.tsProfile customize editor moves a card past a row as soon as it clears the row

    Timed out retrying after 4000ms: expected [ Array(5) ] to deeply equal [ Array(5) ]

Full log

37 specs
Spec Tests
accept-invitation.cy.ts 2 2 0 0 5s
archived-content.cy.ts 2 2 0 0 9s
bookmarks.cy.ts 1 1 0 0 10s
command-palette.cy.ts 5 5 0 0 13s
comment-actions.cy.ts 2 2 0 0 24s
comment-drafts.cy.ts 3 3 0 0 22s
community-home.cy.ts 3 3 0 0 8s
community-routing.cy.ts 3 3 0 0 8s
community-shell.cy.ts 3 3 0 0 7s
community-switching.cy.ts 3 3 0 0 10s
composer.cy.ts 3 3 0 0 12s
create-discussion.cy.ts 1 1 0 0 9s
create-page.cy.ts 1 1 0 0 8s
discussion-actions.cy.ts 7 6 1 0 34s
feeds.cy.ts 3 3 0 0 12s
guest-access.cy.ts 2 2 0 0 10s
member-management.cy.ts 3 3 0 0 11s
membership.cy.ts 4 4 0 0 16s
merge-url-healing.cy.ts 1 1 0 0 8s
more-pages.cy.ts 4 4 0 0 13s
move-and-archive.cy.ts 2 2 0 0 13s
new-discussion.cy.ts 4 4 0 0 28s
notifications.cy.ts 1 1 0 0 9s
onboarding.cy.ts 1 1 0 0 5s
poll-lifecycle.cy.ts 3 3 0 0 23s
profile-bento-cards.cy.ts 6 6 0 0 17s
profile-card-editing.cy.ts 7 7 0 0 16s
profile-customize.cy.ts 28 27 1 0 1m 15s
profile-settings.cy.ts 1 0 1 0 12s
profile-unsplash-cover.cy.ts 7 7 0 0 21s
reactions.cy.ts 1 1 0 0 10s
realtime-activity.cy.ts 1 1 0 0 6s
scoped-links.cy.ts 3 3 0 0 8s
search-page.cy.ts 1 1 0 0 10s
search-privacy.cy.ts 1 1 0 0 5s
space-creation-guardrails.cy.ts 2 2 0 0 6s
task-actions.cy.ts 1 1 0 0 9s
Total 126 123 3 0 8m 40s

Coverage 67.3% (5,695 / 8,457 statements)

Frontend coverage by area
Area Covered Statements Coverage
Components 3,321 4,906 67.7%
Pages 1,336 2,027 65.9%
Data layer 606 782 77.5%
Other 279 452 61.7%
Utilities 128 193 66.3%
Composables 14 81 17.3%
Directives 11 16 68.8%
Total 5,695 8,457 67.3%

Least covered

File Coverage
src/pages/Configure/CommunitiesList.vue 0.0% (0/40)
src/pages/Configure/CommunityOptions.vue 0.0% (0/20)
src/components/RichQuoteExtension/quoteTextSearch.ts 1.1% (1/89)
src/composables/usePointerSortableSections.ts 5.9% (4/68)
src/components/Settings/NotificationsSettings.vue 6.0% (6/100)
src/components/Settings/QuickReactionsEditor.vue 6.0% (5/83)
src/components/AvatarCropper.vue 6.6% (6/91)
src/components/ProfileImageEditor.vue 7.3% (3/41)
src/components/Settings/PreferencesSettings.vue 8.1% (5/62)
src/pages/Configure/CommunityImageUploader.vue 10.2% (5/49)

Collected by Cypress against an istanbul-instrumented build, so it marks lines that ran, not lines a spec asserted on — read it to find untouched areas, not as a quality score against the backend number. Excluded: generated doctype types (src/types/). Coverage is informational — no minimum threshold is enforced.

Updated for commit 6b5c91c.

… update flow

Shared-computer safety (PR #516's review finding): wipe every offline cache
(SW shell/runtime caches + idb-keyval) on logout, and on detecting a
different user's session cookie at boot (guardAgainstUserSwitch). Plain
logout deliberately leaves gameplan-drafts alone so the same person can
recover an in-progress draft after logging back in; a detected switch to a
different user clears drafts too.

Also adds an update flow: the service worker no longer force-activates a
new version under an open tab (no more unconditional skipWaiting on
install); instead the app shows a "new version available" toast with a
Refresh action once an update finishes installing, and reloads once the
new worker takes control.
Comment thread frontend/src/router.ts
guardAgainstUserSwitch clears the service worker's SHELL_CACHE on a
detected user switch, but nothing repopulated it until the next
successful online navigation to /g. If the browser went offline before
that happened, even a reload of the page already open failed with
net::ERR_FAILED instead of falling back to the offline UI.

Add a WARM_SHELL_CACHE message the page sends once the switch-triggered
clear resolves (known to be online at that point, since a user just
logged in), scoped separately from CLEAR_USER_CACHES so a plain logout
still leaves the shell cache empty as intended. Bump CACHE_VERSION
v6->v7 since the SW's message handling changed.
Migrates the offline-mode Playwright suite (12 stories: US1-US8, P1-P3)
from a throwaway /tmp harness into frontend/tests/offline so it survives
reboots and can gate regressions. Seeded-content coupling and creds are
now env-overridable via config.js instead of hardcoded. Adds playwright
as a devDependency and a yarn test:offline script.
Comment thread frontend/src/offline.ts
Comment on lines +147 to +149
Promise.all([clearOfflineCaches(), clearDraftStore()])
.then(() => rewarmShellCache())
.catch((error) => console.error('Failed to clear offline caches', error))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Cache clear races guardAgainstUserSwitch() starts clearOfflineCaches() but returns before the service worker confirms SHELL_CACHE and RUNTIME_CACHE were deleted. The login switch path immediately hard-navigates after this return, and the boot path can run before the /g worker is registered, so the marker can be updated for the new user while the previous user's cached shell still remains. On a shared browser, a later offline /g navigation can still boot from the previous user's cached shell; this path should wait for a confirmed clear before updating the marker or redirecting, and handle the not-yet-registered worker case.

Rule Used: What: Ignore the pull request description and eval... (source)

Fix in Claude Code Fix in Codex

netchampfaris added a commit to frappe/frappe-ui that referenced this pull request Aug 9, 2026
useDoc caches every fetched doc unconditionally, keyed only doctype/name, in
memory and in IndexedDB. On a shared browser, user B signing in can be served
user A's cached doc offline (or before B's own fetch resolves) -- a
cross-account data leak. useList/useCall avoid this with a per-call cacheKey,
but that only works because their persistence is opt-in; useDoc has no such
call site to namespace from, and forgetting a per-call key would keep leaking
silently by default.

Add a standalone setCacheNamespace(namespace) export, backed by a method on
the internal docStore singleton (mirroring its existing setCacheTimeout
setter): one call, as soon as the session user is known, prefixes every doc
cache key for every useDoc in the app. docStore itself is not exported --
setCacheNamespace is the only entry point apps get into it, everything else
about the store stays an implementation detail of useDoc/useNewDoc/useDoctype/
useList.

Switching between two non-null namespaces in a running session (dev user
switcher, logout/login without a reload) purges the outgoing namespace's docs
from memory and IDB rather than just fencing them off. The first call in a
session never purges, since it has no prior namespace to distrust and wiping
IDB there would throw away a returning user's own offline cache -- the thing
namespacing exists to protect.

Default behavior (no namespace set) is unchanged.

Ref frappe/gameplan#516, which worked around this for lists and calls.
netchampfaris added a commit to frappe/frappe-ui that referenced this pull request Aug 9, 2026
…omatically

useDoc caches every fetched doc unconditionally, keyed only doctype/name, in
memory and in IndexedDB. On a shared browser, user B signing in can be served
user A's cached doc offline (or before B's own fetch resolves) -- a
cross-account data leak. useList/useCall avoid this with a per-call cacheKey,
but that only works because their persistence is opt-in; useDoc has no such
call site to namespace from.

An earlier version of this change added a setCacheNamespace(namespace) entry
point for apps to call once at session start. Dropped that in favor of doing
it automatically instead: any opt-in API, even a single global call, is a
call site an app can forget, and a missed one leaks silently by default --
the exact failure mode this is supposed to close. There is nothing to forget
if there is nothing to call.

docStore now reads the standard Frappe session cookie (`user_id`, set by
every logged-in Frappe app already) once at construction and prefixes every
doc cache key with it. No public API changes at all -- docStore stays fully
internal, reached only by useDoc/useNewDoc/useDoctype/useList, and this repo's
public barrel (src/data-fetching/index.ts) is untouched.

Because the cookie is only read at construction, a namespace change is only
ever detected on the next page load (a Frappe login/logout flow reloads the
document, so this holds in practice; an app that swapped the session user
without a reload would not see the new namespace take effect until the next
load). The store records the last-seen namespace in IDB across loads; when a
load's namespace differs from the previous one and both are real accounts (not
Guest/no-cookie), the outgoing account's docs are purged from IDB rather than
left to sit there indefinitely. A load with no prior recorded namespace never
purges, so a returning user's own offline cache survives the first time this
runs for them.

Ref frappe/gameplan#516, which worked around this for lists and calls.
ebrahimgamdiwala added a commit to ebrahimgamdiwala/gameplan that referenced this pull request Sep 9, 2026
guardAgainstUserSwitch used to kick off clearOfflineCaches() in a
fire-and-forget Promise.all().then() and return synchronously. Two
consequences, both flagged by review (PR frappe#516, round 4):

- session.ts's login handler hard-navigates the instant it sees `true`
  back from the guard, which could tear the page down mid-clear.
- The marker (localStorage's last-seen-user) was written unconditionally,
  so a switch that got cut off still looked "handled" on the next boot -
  a shared browser could keep serving the previous user's SHELL_CACHE.

guardAgainstUserSwitch is now async: the clear (and the marker write,
which now happens only after the clear settles) are awaited, and
session.ts's login handler awaits the guard before its hard-navigate.

Also hardens clearServiceWorkerCaches' worker lookup: guardAgainstUserSwitch
runs before this module's own service worker registration, so a plain
getRegistration() could legitimately find nothing yet even though an
earlier browser session's worker (and its stale SHELL_CACHE) is still
around. It now falls back to a bounded wait on
navigator.serviceWorker.ready when a worker is expected to exist,
instead of treating "not registered on this page load yet" as "nothing
to clear".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ebrahimgamdiwala added a commit to ebrahimgamdiwala/gameplan that referenced this pull request Sep 9, 2026
…ull space

router.ts's offline/network-error fallback let navigation continue when
a space or community couldn't be resolved (isRouteValidationUnavailable),
so a deep link to a genuine-but-uncached space or community rendered
downstream page components with space/community === null instead of
either a wrongful NotFound or a working page.

Flagged by review (PR frappe#516, round 3, escalated P2 -> P1: "Missing Space
Proceeds"). Both branches now redirect to a new OfflineUnavailable page
that says honestly that the content isn't cached yet, with a retry
action - matching the pattern OfflineContentFallback.vue already uses
for a failed discussion/space-list fetch.

Also adds backend regression tests for GP User Profile's get_list -
the only backend change in this PR (parsing fields/filters/start/limit
as GET query-string values) had no test coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant