From 1966171586e0b736b298b35ad2b209538160639f Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 5 Jul 2026 20:43:19 +0530
Subject: [PATCH 01/68] feat(frontend): add offline support via service worker
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.
---
frontend/src/components/CommentsArea.vue | 5 +
frontend/src/components/CommentsList.vue | 3 +
frontend/src/components/LastPostReminder.vue | 1 +
frontend/src/components/TaskList.vue | 1 +
frontend/src/data/apps.ts | 1 +
frontend/src/data/communities.ts | 1 +
frontend/src/data/communitySpaces.ts | 1 +
frontend/src/data/customEmojis.ts | 1 +
frontend/src/data/discussions.ts | 2 +
frontend/src/data/drafts.ts | 1 +
frontend/src/data/notifications.ts | 1 +
frontend/src/data/spaces.ts | 2 +
frontend/src/data/tasks.ts | 1 +
frontend/src/data/unreadCount.ts | 13 +-
frontend/src/data/useDraftSync.ts | 2 +
frontend/src/data/users.ts | 1 +
frontend/src/main.js | 2 +
frontend/src/offline.ts | 85 +++++++
frontend/src/pages/Notifications.vue | 1 +
frontend/src/pages/Page.vue | 1 +
frontend/src/pages/PageGrid.vue | 1 +
frontend/src/pages/PersonProfile.vue | 1 +
frontend/src/router.ts | 64 ++++-
frontend/vite.config.ts | 28 +++
gameplan/www/gameplan-sw.js | 235 +++++++++++++++++++
25 files changed, 450 insertions(+), 5 deletions(-)
create mode 100644 frontend/src/offline.ts
create mode 100644 gameplan/www/gameplan-sw.js
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index bf3a68a1c..76a8bbd8c 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -389,6 +389,7 @@ const composerStorageKey = computed(() => {
const comments = useList({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
+ staleOnError: true,
fields: [
'name',
'content',
@@ -424,6 +425,8 @@ const comments = useList({
const activities = useList({
doctype: 'GP Activity',
+ cacheKey: ['Activities', props.doctype, props.name],
+ staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
@@ -455,6 +458,8 @@ watch(
const polls = useList({
doctype: 'GP Poll',
+ cacheKey: ['Polls', props.name],
+ staleOnError: true,
fields: [
'name',
'title',
diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue
index 833ebf977..ffe92cd5e 100644
--- a/frontend/src/components/CommentsList.vue
+++ b/frontend/src/components/CommentsList.vue
@@ -172,6 +172,7 @@ const comments = useList<
>({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
+ staleOnError: true,
fields: [
'name',
'content',
@@ -212,6 +213,8 @@ interface Activity extends Pick({
doctype: 'GP Activity',
+ cacheKey: ['Activities', props.doctype, props.name],
+ staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
diff --git a/frontend/src/components/LastPostReminder.vue b/frontend/src/components/LastPostReminder.vue
index 8cd8607f8..ff2549494 100644
--- a/frontend/src/components/LastPostReminder.vue
+++ b/frontend/src/components/LastPostReminder.vue
@@ -27,6 +27,7 @@ import { useLocalStorage } from '@vueuse/core'
let lastPostAt = useCall({
url: `/api/v2/method/GP User Profile/get_last_post`,
cacheKey: 'last_post_at',
+ staleOnError: true,
})
const daysSinceLastPost = computed(() => {
diff --git a/frontend/src/components/TaskList.vue b/frontend/src/components/TaskList.vue
index 8dc20d35e..41f1aa10f 100644
--- a/frontend/src/components/TaskList.vue
+++ b/frontend/src/components/TaskList.vue
@@ -179,6 +179,7 @@ const tasks = useList({
orderBy: props.listOptions.orderBy,
limit: props.listOptions.pageLength,
cacheKey: ['Tasks', props.listOptions],
+ staleOnError: true,
})
const tasksByStatus = computed(() => {
diff --git a/frontend/src/data/apps.ts b/frontend/src/data/apps.ts
index b4798ccc2..86e00a1cd 100644
--- a/frontend/src/data/apps.ts
+++ b/frontend/src/data/apps.ts
@@ -10,6 +10,7 @@ interface AppInfo {
export const installedApps = useCall({
url: '/api/v2/method/frappe.apps.get_apps',
cacheKey: 'apps',
+ staleOnError: true,
immediate: true,
transform(data) {
let _apps = [
diff --git a/frontend/src/data/communities.ts b/frontend/src/data/communities.ts
index 8dbcd9ad9..cc3793124 100644
--- a/frontend/src/data/communities.ts
+++ b/frontend/src/data/communities.ts
@@ -32,6 +32,7 @@ export let communities = useList({
orderBy: 'title asc',
initialData: [],
cacheKey: ['Communities', 'with-image'],
+ staleOnError: true,
limit: 999,
transform(data) {
for (let community of data) {
diff --git a/frontend/src/data/communitySpaces.ts b/frontend/src/data/communitySpaces.ts
index 5ec37d485..eb79a5958 100644
--- a/frontend/src/data/communitySpaces.ts
+++ b/frontend/src/data/communitySpaces.ts
@@ -14,6 +14,7 @@ const INACTIVE_SPACE_MONTHS = 2
const spaceActivity = useCall>({
url: '/api/v2/method/GP Project/get_activity',
cacheKey: 'spaceActivity',
+ staleOnError: true,
initialData: {},
immediate: true,
})
diff --git a/frontend/src/data/customEmojis.ts b/frontend/src/data/customEmojis.ts
index a62a24458..0add10195 100644
--- a/frontend/src/data/customEmojis.ts
+++ b/frontend/src/data/customEmojis.ts
@@ -13,6 +13,7 @@ export const customEmojis = useList({
orderBy: 'creation desc',
initialData: [],
cacheKey: 'CustomEmojis',
+ staleOnError: true,
limit: 999,
immediate: true,
})
diff --git a/frontend/src/data/discussions.ts b/frontend/src/data/discussions.ts
index 1b2bfa43d..c6f4346f1 100644
--- a/frontend/src/data/discussions.ts
+++ b/frontend/src/data/discussions.ts
@@ -43,6 +43,7 @@ export function useDiscussions(options: UseDiscussionOptions) {
url: '/api/v2/method/gameplan.gameplan.doctype.gp_discussion.api.get_discussions',
doctype: 'GP Discussion',
cacheKey: options.cacheKey ? ['Discussions', options.cacheKey] : undefined,
+ staleOnError: true,
filters: options.filters,
limit: options.limit || 50,
orderBy: options.orderBy,
@@ -97,6 +98,7 @@ export function useDiscussion(discussionId: MaybeRefOrGetter) {
discussionsCache[name] = useDoc({
doctype: 'GP Discussion',
name: discussionId,
+ staleOnError: true,
methods: {
trackVisit: 'track_visit',
markAsUnread: 'mark_as_unread',
diff --git a/frontend/src/data/drafts.ts b/frontend/src/data/drafts.ts
index 51a46f77b..d49c12808 100644
--- a/frontend/src/data/drafts.ts
+++ b/frontend/src/data/drafts.ts
@@ -34,6 +34,7 @@ export const drafts = useCall({
// get_my_drafts is owner-scoped on the server; scope the client cache to the session user
// too, so a same-tab account switch can't briefly show the previous user's draft rows.
cacheKey: ['drafts', session.user],
+ staleOnError: true,
immediate: true,
})
diff --git a/frontend/src/data/notifications.ts b/frontend/src/data/notifications.ts
index 756660fae..176206d0a 100644
--- a/frontend/src/data/notifications.ts
+++ b/frontend/src/data/notifications.ts
@@ -4,6 +4,7 @@ import { onSocketEvent } from '@/socket'
export let unreadNotifications = useCall({
cacheKey: 'Unread Notifications Count',
+ staleOnError: true,
url: '/api/v2/method/gameplan.api.unread_notifications',
initialData: 0,
})
diff --git a/frontend/src/data/spaces.ts b/frontend/src/data/spaces.ts
index 75c1831c8..87d35c1db 100644
--- a/frontend/src/data/spaces.ts
+++ b/frontend/src/data/spaces.ts
@@ -43,6 +43,7 @@ export let spaces = useList({
orderBy: 'title asc',
limit: 99999,
cacheKey: 'spaces',
+ staleOnError: true,
transform(data) {
for (let space of data) {
space.name = space.name.toString()
@@ -118,6 +119,7 @@ export function getSpace(name: string) {
export const joinedSpaces = useCall({
url: '/api/v2/method/GP Project/get_joined_spaces',
cacheKey: 'joinedSpaces',
+ staleOnError: true,
initialData: [],
})
diff --git a/frontend/src/data/tasks.ts b/frontend/src/data/tasks.ts
index bc804b9f3..96edcbd61 100644
--- a/frontend/src/data/tasks.ts
+++ b/frontend/src/data/tasks.ts
@@ -16,6 +16,7 @@ export function useTask(taskId: MaybeRefOrGetter) {
tasksCache[name] = useDoc({
doctype: 'GP Task',
name: taskId,
+ staleOnError: true,
methods: {
trackVisit: 'track_visit',
},
diff --git a/frontend/src/data/unreadCount.ts b/frontend/src/data/unreadCount.ts
index 8953ee59c..65265d1ff 100644
--- a/frontend/src/data/unreadCount.ts
+++ b/frontend/src/data/unreadCount.ts
@@ -77,7 +77,10 @@ function loadProjectUnreadCounts(projects?: string[]) {
unreadCounts[spaceId] = Number(count) || 0
}
return counts
- }),
+ })
+ // Offline / network failure: keep serving the last known counts rather than
+ // rejecting and blanking the UI.
+ .catch(() => unreadCounts),
)
}
@@ -100,10 +103,14 @@ export function fetchParticipatingUnreadCount(team: string) {
return queued(participatingCountApi, () =>
participatingCountApi.runMethod
.submit({ method: 'get_participating_unread_count', params: { team } })
- .then((count: number) => {
+ .then((count: number | null) => {
+ // Offline / network failure surfaces as a null response here — keep the last known
+ // count instead of zeroing it out.
+ if (count == null) return participatingUnreadCounts[team] ?? 0
participatingUnreadCounts[team] = Number(count) || 0
return participatingUnreadCounts[team]
- }),
+ })
+ .catch(() => participatingUnreadCounts[team] ?? 0),
)
}
diff --git a/frontend/src/data/useDraftSync.ts b/frontend/src/data/useDraftSync.ts
index e49805eed..432500c48 100644
--- a/frontend/src/data/useDraftSync.ts
+++ b/frontend/src/data/useDraftSync.ts
@@ -14,6 +14,7 @@
*/
import { ref, computed, watch, toValue, nextTick, onScopeDispose, type MaybeRefOrGetter } from 'vue'
import { call, debounce, toast, dayjsLocal } from 'frappe-ui'
+import { isNetworkError } from '@/offline'
import { session } from './session'
import { isEditorContentEmpty } from '@/utils'
import {
@@ -240,6 +241,7 @@ export function useDraftSync(options: UseDraftSyncOptions) {
})
}
} catch (error) {
+ if (isNetworkError(error)) return null
console.error('Draft lookup failed', error)
}
return null
diff --git a/frontend/src/data/users.ts b/frontend/src/data/users.ts
index 63e147397..b93d4e3ae 100644
--- a/frontend/src/data/users.ts
+++ b/frontend/src/data/users.ts
@@ -75,6 +75,7 @@ function mergeUserInfo(user: UserInfo) {
export let users = useCall({
url: '/api/v2/method/gameplan.api.get_user_info',
cacheKey: 'Users',
+ staleOnError: true,
initialData: [],
transform(data) {
for (let user of data) {
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 0d296212a..0be900dce 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -22,6 +22,7 @@ import { useUser, users } from './data/users'
import { isSessionUser, session } from './data/session'
import { initSocket } from './socket'
import resetDataMixin from './utils/resetDataMixin'
+import { setupOfflineSupport } from './offline'
let globalComponents = {
Button,
@@ -81,6 +82,7 @@ function setupApp() {
socket = initSocket()
app.config.globalProperties.$socket = socket
app.mount('#app')
+ setupOfflineSupport()
}
// Sentry error logging. Loaded lazily (dynamic import) so the ~250 KB SDK stays
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
new file mode 100644
index 000000000..43cf77b49
--- /dev/null
+++ b/frontend/src/offline.ts
@@ -0,0 +1,85 @@
+const SERVICE_WORKER_URL = '/gameplan-sw.js'
+const SERVICE_WORKER_SCOPE = '/g'
+const CACHE_URLS_MESSAGE = 'CACHE_URLS'
+
+export function setupOfflineSupport() {
+ if (
+ import.meta.env.DEV ||
+ typeof navigator === 'undefined' ||
+ !window.isSecureContext ||
+ !('serviceWorker' in navigator)
+ ) {
+ return
+ }
+
+ const register = () => {
+ navigator.serviceWorker
+ .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
+ .then(async (registration) => {
+ await unregisterLegacyServiceWorkers(registration)
+ await warmLoadedAssets(registration)
+ })
+ .catch((error) => {
+ console.error('Failed to register Gameplan service worker', error)
+ })
+ }
+
+ if (document.readyState === 'complete') {
+ register()
+ } else {
+ window.addEventListener('load', register, { once: true })
+ }
+}
+
+export function isBrowserOffline() {
+ return typeof navigator !== 'undefined' && navigator.onLine === false
+}
+
+export function isNetworkError(error: unknown) {
+ return error instanceof TypeError && error.message === 'Failed to fetch'
+}
+
+async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorkerRegistration) {
+ const registrations = await navigator.serviceWorker.getRegistrations()
+ const legacyScope = new URL('/g/', window.location.origin).href
+
+ await Promise.all(
+ registrations
+ .filter((registration) => {
+ return registration.scope === legacyScope && registration.scope !== currentRegistration.scope
+ })
+ .map((registration) => registration.unregister()),
+ )
+}
+
+async function warmLoadedAssets(registration: ServiceWorkerRegistration) {
+ await navigator.serviceWorker.ready
+ postLoadedAssetsToWorker(registration)
+ window.setTimeout(() => postLoadedAssetsToWorker(registration), 3000)
+}
+
+function postLoadedAssetsToWorker(registration: ServiceWorkerRegistration) {
+ const urls = getLoadedAssetUrls()
+ if (!urls.length) return
+
+ registration.active?.postMessage({
+ type: CACHE_URLS_MESSAGE,
+ urls,
+ })
+}
+
+function getLoadedAssetUrls() {
+ return performance
+ .getEntriesByType('resource')
+ .map((entry) => entry.name)
+ .filter(isSameOriginAssetUrl)
+}
+
+function isSameOriginAssetUrl(url: string) {
+ try {
+ const assetUrl = new URL(url)
+ return assetUrl.origin === window.location.origin && assetUrl.pathname.startsWith('/assets/')
+ } catch {
+ return false
+ }
+}
diff --git a/frontend/src/pages/Notifications.vue b/frontend/src/pages/Notifications.vue
index 5605179d3..47b6b00b0 100644
--- a/frontend/src/pages/Notifications.vue
+++ b/frontend/src/pages/Notifications.vue
@@ -396,6 +396,7 @@ function useNotificationList(read: 0 | 1, cacheKey: string) {
// which makes a "load more" button unsafe on a list that mark-as-read reloads.
limit: 100,
cacheKey,
+ staleOnError: true,
})
}
diff --git a/frontend/src/pages/Page.vue b/frontend/src/pages/Page.vue
index d7f02ed06..c2517bc7e 100644
--- a/frontend/src/pages/Page.vue
+++ b/frontend/src/pages/Page.vue
@@ -140,6 +140,7 @@ const contentField = useTemplateRef('contentField')
const page = useDoc({
doctype: 'GP Page',
name: () => props.pageId,
+ staleOnError: true,
})
// Read from the document, not from the fetch response. The body renders as soon
diff --git a/frontend/src/pages/PageGrid.vue b/frontend/src/pages/PageGrid.vue
index 19923337f..3b7c114d0 100644
--- a/frontend/src/pages/PageGrid.vue
+++ b/frontend/src/pages/PageGrid.vue
@@ -96,6 +96,7 @@ const pages = useList({
filters: props.listOptions.filters,
orderBy: props.listOptions.orderBy,
cacheKey: ['Pages', props.listOptions],
+ staleOnError: true,
})
function getSpace(page: Page) {
diff --git a/frontend/src/pages/PersonProfile.vue b/frontend/src/pages/PersonProfile.vue
index 2c18e8db0..960894b08 100644
--- a/frontend/src/pages/PersonProfile.vue
+++ b/frontend/src/pages/PersonProfile.vue
@@ -86,6 +86,7 @@ const profileResource = useDoc({
setImage: 'set_image',
setCoverImagePosition: 'set_cover_image_position',
},
+ staleOnError: true,
})
const profile = computed(() => profileResource.doc)
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index b9f7a2115..0b4b038f8 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -6,6 +6,7 @@ import {
type RouteRecordRaw,
} from 'vue-router'
import { until } from '@vueuse/core'
+import { watch } from 'vue'
import { call } from 'frappe-ui'
import { session } from './data/session'
import { users, usersReady } from './data/users'
@@ -15,11 +16,14 @@ import type { Space } from './data/spaces'
import { communityState } from './data/communityState'
import { settingsBackgroundPath } from './components/Settings'
import { getScrollContainer, scrollTo } from 'frappe-ui'
+import { isBrowserOffline, isNetworkError } from './offline'
declare const __FRONTEND_ROUTE__: string
type ResourceLike = {
isFinished?: boolean
+ data?: unknown
+ error?: unknown
}
type RouteParamValue = string | string[]
@@ -36,7 +40,8 @@ type ProjectContentDoc = {
}
const discussionFeeds = ['recent', 'unread', 'participating']
-const projectContentDocRequests = new Map>()
+const projectContentDocRequests = new Map>()
+const OFFLINE_CACHE_HYDRATION_TIMEOUT = 3000
// Redirect-style guards still need a component record so Vue Router matches them consistently.
const RouteGuard = { render: () => null }
@@ -801,6 +806,10 @@ router.beforeEach(async (to, from) => {
let space = to.params.spaceId ? getSpace(routeParam(to.params.spaceId)) : null
if (to.params.spaceId && !space) {
+ if (isRouteValidationUnavailable()) {
+ communityState.scope(communityId)
+ return
+ }
return { name: 'NotFound' }
}
@@ -814,6 +823,10 @@ router.beforeEach(async (to, from) => {
// Public communities are visible even when the user has not joined them, so route validity
// cannot be tied to the active sidebar community list.
if (!community) {
+ if (isRouteValidationUnavailable()) {
+ communityState.scope(communityId)
+ return
+ }
return { name: 'NotFound' }
}
@@ -828,6 +841,9 @@ export default router
async function ensureCommunityDataLoaded() {
await Promise.all([waitForResource(communities), waitForResource(spaces)])
+ if (isBrowserOffline()) {
+ await Promise.all([waitForOfflineCachedData(communities), waitForOfflineCachedData(spaces)])
+ }
}
async function waitForResource(resource: ResourceLike) {
@@ -838,6 +854,43 @@ async function waitForResource(resource: ResourceLike) {
await until(() => resource?.isFinished).toBe(true)
}
+function waitForOfflineCachedData(resource: ResourceLike) {
+ if (hasHydratedData(resource)) {
+ return Promise.resolve()
+ }
+
+ return new Promise((resolve) => {
+ const timeout = window.setTimeout(done, OFFLINE_CACHE_HYDRATION_TIMEOUT)
+ const stop = watch(
+ () => resource.data,
+ () => {
+ if (hasHydratedData(resource)) {
+ done()
+ }
+ },
+ )
+
+ function done() {
+ window.clearTimeout(timeout)
+ stop()
+ resolve()
+ }
+ })
+}
+
+function hasHydratedData(resource: ResourceLike) {
+ const data = resource.data
+ return Array.isArray(data) ? data.length > 0 : data != null
+}
+
+function isRouteValidationUnavailable() {
+ return isBrowserOffline() || hasNetworkError(communities) || hasNetworkError(spaces)
+}
+
+function hasNetworkError(resource: ResourceLike) {
+ return Boolean(resource?.error && isNetworkError(resource.error))
+}
+
export function getHomeRoute(): RouteLocationRaw {
if (isMobileViewport() && communityState.id) {
return { name: 'Home' }
@@ -904,8 +957,14 @@ async function getCanonicalContentRoute(
// space/slug rewrites to canonical.
const isInAppNavigation = from.matched.length > 0
if (isInAppNavigation && hasCanonicalLocalParams(to, descriptor)) return
+ if (isRouteValidationUnavailable() && hasCanonicalLocalParams(to, descriptor)) return
+ if (isRouteValidationUnavailable()) return
const doc = await getProjectContentDoc(descriptor.doctype, documentName)
+ if (doc === undefined) {
+ if (hasCanonicalLocalParams(to, descriptor)) return
+ return
+ }
if (!doc?.project) return { name: 'NotFound' }
const space = await findSpace(String(doc.project))
@@ -993,7 +1052,8 @@ async function getProjectContentDoc(doctype: ContentRouteDescriptor['doctype'],
async function fetchProjectContentDoc(doctype: ContentRouteDescriptor['doctype'], name: string) {
try {
return await call('frappe.client.get', { doctype, name })
- } catch {
+ } catch (error) {
+ if (isNetworkError(error)) return undefined
return null
}
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index b3ea4b6f5..a9c7b150a 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -60,6 +60,7 @@ export default defineConfig({
}),
vue(),
vueJsx(),
+ offlineAssetManifest(),
visualizer({ emitFile: true }) as PluginOption,
// `extension` must list .vue explicitly: the plugin's default covers .js/.ts
// only, which would silently report on the ~600 lines of utils and composables
@@ -144,3 +145,30 @@ export default defineConfig({
include: ['feather-icons'],
},
})
+
+function offlineAssetManifest(): PluginOption {
+ return {
+ name: 'gameplan-offline-asset-manifest',
+ apply: 'build',
+ generateBundle(_, bundle) {
+ const urls = Object.values(bundle)
+ .map((entry) => entry.fileName)
+ .filter(isOfflineAsset)
+ .sort()
+ .map((fileName) => `/assets/gameplan/frontend/${fileName}`)
+
+ this.emitFile({
+ type: 'asset',
+ fileName: 'gameplan-offline-assets.json',
+ source: JSON.stringify(urls),
+ })
+ },
+ }
+}
+
+function isOfflineAsset(fileName: string) {
+ if (!fileName.startsWith('assets/')) return false
+ if (fileName.endsWith('.map')) return false
+
+ return ['.css', '.js', '.woff', '.woff2'].some((extension) => fileName.endsWith(extension))
+}
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
new file mode 100644
index 000000000..7c2de13ce
--- /dev/null
+++ b/gameplan/www/gameplan-sw.js
@@ -0,0 +1,235 @@
+const CACHE_PREFIX = "gameplan-readonly-offline";
+const CACHE_VERSION = "v5";
+const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`;
+const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`;
+const APP_SHELL_URL = "/g";
+const OFFLINE_ASSET_MANIFEST_URL =
+ "/assets/gameplan/frontend/gameplan-offline-assets.json";
+const PRECACHE_URLS = [
+ APP_SHELL_URL,
+ OFFLINE_ASSET_MANIFEST_URL,
+ "/assets/gameplan/manifest/site.webmanifest",
+ "/assets/gameplan/manifest/manifest-icon-192.maskable.png",
+ "/assets/gameplan/manifest/manifest-icon-512.maskable.png",
+];
+const CACHEABLE_DESTINATIONS = new Set(["font", "image", "script", "style"]);
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(warmShellCache());
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(deleteOldCaches());
+ self.clients.claim();
+});
+
+self.addEventListener("fetch", (event) => {
+ const request = event.request;
+ if (request.method !== "GET") return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ if (isGameplanNavigation(request, url)) {
+ event.respondWith(networkFirstNavigation(request));
+ return;
+ }
+
+ if (isApiRequest(url)) return;
+
+ if (url.pathname.startsWith("/assets/")) {
+ event.respondWith(cacheFirst(request));
+ return;
+ }
+
+ if (CACHEABLE_DESTINATIONS.has(request.destination)) {
+ event.respondWith(staleWhileRevalidate(request));
+ }
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type !== "CACHE_URLS" || !Array.isArray(event.data.urls))
+ return;
+ event.waitUntil(cacheUrls(event.data.urls));
+});
+
+async function warmShellCache() {
+ const cache = await caches.open(SHELL_CACHE);
+ await Promise.all(
+ PRECACHE_URLS.map(async (url) => {
+ try {
+ const response = await fetch(
+ new Request(url, { credentials: "include", cache: "reload" }),
+ );
+ if (isCacheableResponse(response)) {
+ await cache.put(url, response.clone());
+ if (isHtmlResponse(response)) {
+ await cacheShellAssets(response);
+ }
+ if (url === OFFLINE_ASSET_MANIFEST_URL) {
+ await cacheOfflineAssetManifest(response.clone());
+ }
+ }
+ } catch {
+ // The runtime fetch handler will populate the cache once the app is online.
+ }
+ }),
+ );
+}
+
+async function deleteOldCaches() {
+ const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE]);
+ const names = await caches.keys();
+ await Promise.all(
+ names
+ .filter(
+ (name) => name.startsWith(CACHE_PREFIX) && !currentCaches.has(name),
+ )
+ .map((name) => caches.delete(name)),
+ );
+}
+
+function isGameplanNavigation(request, url) {
+ return (
+ request.mode === "navigate" &&
+ (url.pathname === "/g" || url.pathname.startsWith("/g/"))
+ );
+}
+
+function isApiRequest(url) {
+ return (
+ url.pathname.startsWith("/api/") || url.pathname.startsWith("/socket.io/")
+ );
+}
+
+async function networkFirstNavigation(request) {
+ const cache = await caches.open(SHELL_CACHE);
+ try {
+ const response = await fetch(request);
+ if (isHtmlResponse(response)) {
+ await cache.put(APP_SHELL_URL, response.clone());
+ await cacheShellAssets(response.clone());
+ }
+ return response;
+ } catch {
+ const cachedRequest = await cache.match(request);
+ const cachedShell = await cache.match(APP_SHELL_URL);
+ return cachedRequest || cachedShell || Response.error();
+ }
+}
+
+async function cacheShellAssets(response) {
+ const urls = getShellAssetUrls(await response.text());
+ await cacheUrls(urls);
+ await cacheOfflineAssetManifest();
+}
+
+function getShellAssetUrls(html) {
+ const urls = new Set();
+ const assetPattern = /\b(?:src|href)=["']([^"']+)["']/g;
+
+ for (const match of html.matchAll(assetPattern)) {
+ const url = new URL(match[1], self.location.origin);
+ if (
+ url.origin === self.location.origin &&
+ url.pathname.startsWith("/assets/")
+ ) {
+ urls.add(url.href);
+ }
+ }
+
+ return [...urls];
+}
+
+async function cacheUrls(urls) {
+ const cache = await caches.open(ASSET_CACHE);
+ const sameOriginAssetUrls = urls.filter(isSameOriginAssetUrl);
+
+ await Promise.all(
+ sameOriginAssetUrls.map(async (url) => {
+ try {
+ const request = new Request(url, { credentials: "include" });
+ const cached = await cache.match(request);
+ if (cached) return;
+
+ const response = await fetch(request);
+ if (isCacheableResponse(response)) {
+ await cache.put(request, response);
+ }
+ } catch {
+ // The next online visit to the route will retry this asset.
+ }
+ }),
+ );
+}
+
+async function cacheOfflineAssetManifest(response) {
+ try {
+ const manifestResponse =
+ response ||
+ (await fetch(OFFLINE_ASSET_MANIFEST_URL, { cache: "reload" }));
+ if (!isCacheableResponse(manifestResponse)) return;
+
+ const urls = await manifestResponse.clone().json();
+ if (Array.isArray(urls)) {
+ await cacheUrls(urls);
+ }
+ } catch {
+ // Older builds do not have the manifest; route assets will still be cached as they load.
+ }
+}
+
+function isSameOriginAssetUrl(url) {
+ try {
+ const assetUrl = new URL(url, self.location.origin);
+ return (
+ assetUrl.origin === self.location.origin &&
+ assetUrl.pathname.startsWith("/assets/")
+ );
+ } catch {
+ return false;
+ }
+}
+
+async function cacheFirst(request) {
+ const cache = await caches.open(ASSET_CACHE);
+ const cached = await cache.match(request);
+ if (cached) return cached;
+
+ try {
+ const response = await fetch(request);
+ if (isCacheableResponse(response)) {
+ await cache.put(request, response.clone());
+ }
+ return response;
+ } catch {
+ return Response.error();
+ }
+}
+
+async function staleWhileRevalidate(request) {
+ const cache = await caches.open(ASSET_CACHE);
+ const cached = await cache.match(request);
+ const fetched = fetch(request)
+ .then((response) => {
+ if (isCacheableResponse(response)) {
+ cache.put(request, response.clone());
+ }
+ return response;
+ })
+ .catch(() => null);
+
+ return cached || (await fetched) || Response.error();
+}
+
+function isCacheableResponse(response) {
+ return response && response.ok && response.type === "basic";
+}
+
+function isHtmlResponse(response) {
+ return (
+ isCacheableResponse(response) &&
+ response.headers.get("content-type")?.includes("text/html")
+ );
+}
From 8d1eb4f359b03bdb83f117f53c1534509d3626b6 Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 03:02:46 +0530
Subject: [PATCH 02/68] feat(frontend): offline indicator and auto-refetch on
reconnect
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.
---
frontend/src/App.vue | 2 +
frontend/src/components/CommentsArea.vue | 14 ++++++
frontend/src/components/CommentsList.vue | 9 ++++
frontend/src/components/OfflineIndicator.vue | 29 +++++++++++++
frontend/src/data/discussions.ts | 6 +++
frontend/src/data/online.ts | 45 ++++++++++++++++++++
frontend/src/data/unreadCount.ts | 27 +++++++-----
7 files changed, 121 insertions(+), 11 deletions(-)
create mode 100644 frontend/src/components/OfflineIndicator.vue
create mode 100644 frontend/src/data/online.ts
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 0d9a36065..c9b973028 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -13,6 +13,7 @@
flip isFinished back to false and unmount the open settings dialog. -->
+
@@ -27,6 +28,7 @@ import { useTheme } from '@/utils/useTheme'
import { useCursorStyle } from '@/utils/useCursorStyle'
import NewTaskDialog from './components/NewTaskDialog/NewTaskDialog.vue'
import SettingsDialog from './components/Settings/SettingsDialog.vue'
+import OfflineIndicator from './components/OfflineIndicator.vue'
import { settingsBackgroundPath } from './components/Settings'
import { getHomeRoute } from '@/router'
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index 76a8bbd8c..e27723304 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -275,6 +275,7 @@ import { tags } from '@/data/tags'
import { isNewCommentOpen } from '@/data/newComment'
import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes'
import { useDraftSync } from '@/data/useDraftSync'
+import { onReconnect } from '@/data/online'
import { useSessionUser } from '@/data/users'
import type { Space } from '@/data/spaces'
import { useIsMobile } from 'frappe-ui'
@@ -494,6 +495,19 @@ watchEffect(() => {
}
})
+// US5 (seamless recovery): while offline, comments/activity/polls posted by
+// other users never arrive — the socket that normally pushes them is down too.
+// Reload this discussion's timeline once the browser comes back online.
+// Unregistered on unmount: this callback closes over lists owned by this
+// component instance, and there's no reason to keep refetching an open
+// discussion the user has already navigated away from.
+const unregisterReconnect = onReconnect(() => {
+ comments.reload()
+ activities.reload()
+ polls.reload()
+})
+onUnmounted(unregisterReconnect)
+
// Computed
const timelineItems = computed(() => {
let items: Array = []
diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue
index ffe92cd5e..69d1b47fc 100644
--- a/frontend/src/components/CommentsList.vue
+++ b/frontend/src/components/CommentsList.vue
@@ -118,6 +118,7 @@ import { subscribeToDoc, useSocket, type NewActivityEvent } from '@/socket'
import { GPActivity, GPComment } from '@/types/doctypes'
import type { Space } from '@/data/spaces'
import { useDraftSync } from '@/data/useDraftSync'
+import { onReconnect } from '@/data/online'
interface Props {
doctype: string
@@ -231,6 +232,14 @@ const activities = useList({
},
})
+// US5 (seamless recovery): mirrors the same reconnect reload in CommentsArea.vue
+// (discussion comments) for this task's comment/activity timeline.
+const unregisterReconnect = onReconnect(() => {
+ comments.reload()
+ activities.reload()
+})
+onUnmounted(unregisterReconnect)
+
// Computed
type GroupedActivity = {
doctype: 'GP Activity'
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue
new file mode 100644
index 000000000..b409c8d28
--- /dev/null
+++ b/frontend/src/components/OfflineIndicator.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ You're offline — showing saved content
+
+
+
+
+
+
+
diff --git a/frontend/src/data/discussions.ts b/frontend/src/data/discussions.ts
index c6f4346f1..6fad89eec 100644
--- a/frontend/src/data/discussions.ts
+++ b/frontend/src/data/discussions.ts
@@ -3,6 +3,7 @@ import { useDoc, useList } from 'frappe-ui'
import { UseListOptions } from 'frappe-ui'
import { useDocumentVisibility } from '@vueuse/core'
import { GPDiscussion } from '@/types/doctypes'
+import { onReconnect } from '@/data/online'
// Reload the feed when the tab is re-activated after sitting in the background
// for at least this long, so new posts show up without a manual refresh.
@@ -22,6 +23,11 @@ export function reloadDiscussionLists() {
reloadSignal.value++
}
+// US5 (seamless recovery): a discussion created or updated by someone else
+// while we were offline is invisible until something refetches. Mounted feeds
+// pick this signal up via the reloadSignal watcher below.
+onReconnect(reloadDiscussionLists)
+
export interface Discussion extends GPDiscussion {
project_title: string
last_post_at: string
diff --git a/frontend/src/data/online.ts b/frontend/src/data/online.ts
new file mode 100644
index 000000000..b759a9b6b
--- /dev/null
+++ b/frontend/src/data/online.ts
@@ -0,0 +1,45 @@
+import { useDebounceFn, useOnline } from '@vueuse/core'
+import { watch } from 'vue'
+
+// Single shared `navigator.onLine` + online/offline event listener for the whole
+// app (US3's indicator and US5's reconnect refetch both read this).
+export const isOnline = useOnline()
+
+// Flaky connectivity (a train tunnel, a flapping wifi radio) can fire several
+// offline→online transitions within a second or two. Debouncing the notification
+// (not the ref itself, so the indicator stays instantly responsive) means a
+// stampede of refetches only fires once connectivity actually settles.
+const RECONNECT_DEBOUNCE_MS = 1500
+
+type ReconnectCallback = () => void
+const callbacks = new Set()
+
+/**
+ * Register a callback to run when the browser transitions from offline to
+ * online (debounced — see RECONNECT_DEBOUNCE_MS). Returns an unregister
+ * function; call it from `onUnmounted` for callbacks owned by a component so a
+ * torn-down view doesn't keep refetching after it's gone.
+ */
+export function onReconnect(callback: ReconnectCallback): () => void {
+ callbacks.add(callback)
+ return () => callbacks.delete(callback)
+}
+
+const notifyReconnect = useDebounceFn(() => {
+ // Re-check at fire time: connectivity may have dropped again during the
+ // debounce window, in which case there's nothing to reconnect yet.
+ if (!isOnline.value) return
+ for (const callback of callbacks) {
+ try {
+ callback()
+ } catch (error) {
+ console.error('onReconnect callback failed', error)
+ }
+ }
+}, RECONNECT_DEBOUNCE_MS)
+
+// watch() (not `immediate`) only fires on actual changes, so this is inert on
+// initial load and only triggers on a genuine offline→online flip.
+watch(isOnline, (online) => {
+ if (online) notifyReconnect()
+})
diff --git a/frontend/src/data/unreadCount.ts b/frontend/src/data/unreadCount.ts
index 65265d1ff..809711bc1 100644
--- a/frontend/src/data/unreadCount.ts
+++ b/frontend/src/data/unreadCount.ts
@@ -3,6 +3,7 @@ import { GPProject } from '@/types/doctypes'
import { reactive } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { onSocketEvent } from '@/socket'
+import { onReconnect } from '@/data/online'
interface ProjectUnreadCount {
[spaceId: string]: number
@@ -167,19 +168,23 @@ export function refreshUnreadCountForProjects(projects: string[]) {
return loadProjectUnreadCounts(projects)
}
+function refreshAllUnreadCounts() {
+ // Nothing awaits these; swallow failures so a dropped request doesn't surface as an
+ // unhandled rejection. The next signal (or a page load) refetches anyway.
+ Promise.allSettled([
+ loadProjectUnreadCounts(),
+ ...Object.keys(participatingUnreadCounts).map((team) => fetchParticipatingUnreadCount(team)),
+ ])
+}
+
// The backend signals this after any create/mark-read change to GP Unread Record, so other tabs
// (and spaces you're not currently viewing) pick up the change without a manual reload.
// Debounced because one action fans out several signals — posting creates records for every
// recipient and marks the thread read for the author — and each one costs a full map fetch plus
// a request per cached community.
-onSocketEvent(
- 'gameplan:unread_counts_changed',
- useDebounceFn(() => {
- // Nothing awaits these; swallow failures so a dropped request doesn't surface as an
- // unhandled rejection. The next signal (or a page load) refetches anyway.
- Promise.allSettled([
- loadProjectUnreadCounts(),
- ...Object.keys(participatingUnreadCounts).map((team) => fetchParticipatingUnreadCount(team)),
- ])
- }, 500),
-)
+onSocketEvent('gameplan:unread_counts_changed', useDebounceFn(refreshAllUnreadCounts, 500))
+
+// US5 (seamless recovery): while offline the socket is disconnected too, so any
+// unread-count-changing activity from other users never reached us. Reload once
+// reconnected rather than waiting on the next unrelated socket signal.
+onReconnect(refreshAllUnreadCounts)
From 327d7ae30015f7c79d043e300411bd375d3bae19 Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 03:02:52 +0530
Subject: [PATCH 03/68] fix(frontend): wait for cache hydration before
onboarding redirect when 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.
---
frontend/src/router.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index 0b4b038f8..3621913d4 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -841,7 +841,13 @@ export default router
async function ensureCommunityDataLoaded() {
await Promise.all([waitForResource(communities), waitForResource(spaces)])
- if (isBrowserOffline()) {
+ // Right after a reload, navigator.onLine can briefly lag the browser's actual
+ // network state, so isBrowserOffline() alone can miss a reload that's genuinely
+ // offline. A resource that already finished with a network error is unambiguous
+ // proof the fresh fetch can't be trusted, so treat either signal the same way:
+ // give the IndexedDB cache a bounded chance to hydrate before the home route is
+ // decided from (possibly still-empty) `communities`/`spaces` data.
+ if (isNetworkUnreliable()) {
await Promise.all([waitForOfflineCachedData(communities), waitForOfflineCachedData(spaces)])
}
}
@@ -884,6 +890,10 @@ function hasHydratedData(resource: ResourceLike) {
}
function isRouteValidationUnavailable() {
+ return isNetworkUnreliable()
+}
+
+function isNetworkUnreliable() {
return isBrowserOffline() || hasNetworkError(communities) || hasNetworkError(spaces)
}
From 56489a59092555a7b14653d35a3f0a5325752d34 Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 03:03:23 +0530
Subject: [PATCH 04/68] feat(frontend): offline fallbacks for uncached
discussions and space lists; scope offline caches per user
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
frontend/src/components/CommentsArea.vue | 9 +++--
frontend/src/components/CommentsList.vue | 8 +++-
frontend/src/components/DiscussionView.vue | 15 ++++++++
frontend/src/components/LastPostReminder.vue | 5 ++-
.../src/components/OfflineContentFallback.vue | 24 ++++++++++++
frontend/src/components/TaskList.vue | 6 ++-
frontend/src/data/communities.ts | 5 ++-
frontend/src/data/communitySpaces.ts | 5 ++-
frontend/src/data/discussions.ts | 6 ++-
frontend/src/data/notifications.ts | 5 ++-
frontend/src/data/spaces.ts | 7 +++-
frontend/src/data/users.ts | 15 +++++++-
.../pages/Configure/useCommunitySpaceData.ts | 8 +++-
frontend/src/pages/Notifications.vue | 4 +-
frontend/src/pages/PageGrid.vue | 17 +++++----
frontend/src/pages/SpaceDiscussions.vue | 37 +++++++++++++++++++
16 files changed, 152 insertions(+), 24 deletions(-)
create mode 100644 frontend/src/components/OfflineContentFallback.vue
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index e27723304..e00e6ad7b 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -389,7 +389,10 @@ const composerStorageKey = computed(() => {
const comments = useList({
doctype: 'GP Comment',
- cacheKey: ['Comments', props.doctype, props.name],
+ // Scoped to the session user: a discussion's comments can live in a private space,
+ // so a second account on the same browser must not see them cached offline before
+ // its own permission-checked fetch resolves (review finding from PR #516).
+ cacheKey: ['Comments', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
@@ -426,7 +429,7 @@ const comments = useList({
const activities = useList({
doctype: 'GP Activity',
- cacheKey: ['Activities', props.doctype, props.name],
+ cacheKey: ['Activities', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
@@ -459,7 +462,7 @@ watch(
const polls = useList({
doctype: 'GP Poll',
- cacheKey: ['Polls', props.name],
+ cacheKey: ['Polls', props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue
index 69d1b47fc..4b7acd447 100644
--- a/frontend/src/components/CommentsList.vue
+++ b/frontend/src/components/CommentsList.vue
@@ -119,6 +119,7 @@ import { GPActivity, GPComment } from '@/types/doctypes'
import type { Space } from '@/data/spaces'
import { useDraftSync } from '@/data/useDraftSync'
import { onReconnect } from '@/data/online'
+import { session } from '@/data/session'
interface Props {
doctype: string
@@ -172,7 +173,10 @@ const comments = useList<
>
>({
doctype: 'GP Comment',
- cacheKey: ['Comments', props.doctype, props.name],
+ // Scoped to the session user: a discussion's comments can live in a private space,
+ // so a second account on the same browser must not see them cached offline before
+ // its own permission-checked fetch resolves (review finding from PR #516).
+ cacheKey: ['Comments', props.doctype, props.name, session.user],
staleOnError: true,
fields: [
'name',
@@ -214,7 +218,7 @@ interface Activity extends Pick({
doctype: 'GP Activity',
- cacheKey: ['Activities', props.doctype, props.name],
+ cacheKey: ['Activities', props.doctype, props.name, session.user],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
diff --git a/frontend/src/components/DiscussionView.vue b/frontend/src/components/DiscussionView.vue
index e3eee11a3..d3d280d8a 100644
--- a/frontend/src/components/DiscussionView.vue
+++ b/frontend/src/components/DiscussionView.vue
@@ -275,6 +275,16 @@
again.
+
+
+
(null)
const discussionListRef = useTemplateRef('discussionListRef')
const router = useRouter()
+// DiscussionList owns the useList resource; reach into it through its exposed ref rather
+// than duplicating the fetch here, so this page can tell "loaded, genuinely empty" (handled
+// inside DiscussionList already) apart from "fetch failed, nothing cached" (not handled
+// there - see the v-show/OfflineContentFallback pairing below).
+const discussionsResource = computed(() => discussionListRef.value?.discussions)
+const listFailure = computed(() => {
+ const discussions = discussionsResource.value
+ if (!discussions) return null
+ const failed =
+ discussions.isFinished && !discussions.loading && discussions.error && discussions.data == null
+ if (!failed) return null
+
+ const offline = isBrowserOffline() || isNetworkError(discussions.error)
+ return offline
+ ? {
+ title: "Can't load this while offline",
+ message: "This space's discussions haven't been saved for offline use yet.",
+ }
+ : {
+ title: 'Could not load discussions',
+ message: 'Something went wrong while loading this list. Retry to try again.',
+ }
+})
const {
space: currentSpace,
isArchived,
From 94d311b0578ad8ad6b98cfcb48adbbb9772cf34e Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 15:51:20 +0530
Subject: [PATCH 05/68] fix(profiles): accept query params in GP User Profile
get_list
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/ list route and gp_discussion.api.get_discussions
already handle this.
---
.../gp_user_profile/gp_user_profile.py | 20 ++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py b/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py
index 2b7d337e2..9d412ba2a 100644
--- a/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py
+++ b/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py
@@ -8,6 +8,7 @@
from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.query_builder.functions import Count
+from frappe.utils import cint
from frappe.website.utils import cleanup_page_name
from gameplan.api import get_user_info, require_admin
@@ -208,7 +209,7 @@ def on_user_update(doc, method=None):
@frappe.whitelist()
def get_list(
fields=None,
- filters: dict | None = None,
+ filters=None,
order_by=None,
start=0,
limit=20,
@@ -216,15 +217,24 @@ def get_list(
parent=None,
debug=False,
):
+ # `fields`/`filters` are typed `dict | list` shapes on the query builder, and
+ # `start`/`limit` are ints, but a GET caller (frappe-ui's `useList`, which this
+ # endpoint is built for) can only send everything JSON-encoded/stringified in the
+ # query string. `fields`/`filters` left type-hinted `dict | None` had Frappe's own
+ # request-typing coercion reject the string before this function body ever ran, so
+ # they're parsed by hand instead - the same way the builtin `/api/v2/document/`
+ # list route and `gp_discussion.api.get_discussions` do it for the same reason.
+ # `start`/`limit` get the same treatment via `cint` - the query builder requires an
+ # actual int and rejects `"3"` outright.
doctype = "GP User Profile"
check_permissions(doctype, parent)
query = frappe.qb.get_query(
table=doctype,
- fields=fields,
- filters=filters,
+ fields=frappe.parse_json(fields) if fields else None,
+ filters=frappe.parse_json(filters) if filters else None,
order_by=order_by,
- offset=start,
- limit=limit,
+ offset=cint(start),
+ limit=cint(limit),
group_by=group_by,
)
data = query.run(as_dict=True, debug=debug)
From db554e12086ece6aae4017bd96e82d62e250eb57 Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 15:51:30 +0530
Subject: [PATCH 06/68] feat(frontend): offline caching and fallbacks for
people list and person 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.
---
.../ProfileBento/profileBentoSource.ts | 96 ++++++-
frontend/src/data/people.ts | 48 ++++
frontend/src/pages/People.vue | 252 +++++++++---------
frontend/src/pages/PersonProfile.vue | 125 +++++----
frontend/src/pages/PersonProfilePosts.vue | 57 +++-
frontend/src/pages/PersonProfileProfile.vue | 16 +-
frontend/src/pages/PersonProfileReplies.vue | 57 +++-
7 files changed, 451 insertions(+), 200 deletions(-)
create mode 100644 frontend/src/data/people.ts
diff --git a/frontend/src/components/ProfileBento/profileBentoSource.ts b/frontend/src/components/ProfileBento/profileBentoSource.ts
index 222f56dee..eece2fc48 100644
--- a/frontend/src/components/ProfileBento/profileBentoSource.ts
+++ b/frontend/src/components/ProfileBento/profileBentoSource.ts
@@ -1,6 +1,8 @@
-import { call } from 'frappe-ui'
+import { call, useCall } from 'frappe-ui'
+import { computed, MaybeRefOrGetter, toValue, watch } from 'vue'
import type { ProfileBentoCard } from './types'
import type { ProfileBentoCardSource } from './useProfileBentoCustomization'
+import { session } from '@/data/session'
interface ProfileBentoResponse {
profile: string
@@ -49,14 +51,96 @@ export async function resetProfileBentoCards() {
return getLoadResultFromResponse(response)
}
-export async function getProfileBentoCards(profile: string) {
- let response = await call(getProfileBentoCardsMethod, { profile })
- return getLoadResultFromResponse(response)
-}
-
function getLoadResultFromResponse(response: ProfileBentoResponse): ProfileBentoLoadResult {
return {
cards: response.cards || [],
isDefault: response.is_default,
}
}
+
+// One `get_bento_cards` fetch per profile, keyed by profile name + session user (an
+// offline cache scoped any other way could leak one account's cached cards to a second
+// account sharing the browser - review finding from PR #516). Shared by `useProfileBento`
+// (the live page) and `prefetchProfileBento` (the background cache warmer) so both read
+// and write the same IndexedDB entry instead of racing two independent requests for the
+// same profile.
+const bentoCalls: Record> = {}
+
+function createProfileBentoCall(profile: string) {
+ return useCall({
+ // `useCall` takes `url` verbatim - unlike `call()` (used by createServerProfileBentoSource
+ // below), it does not prefix a dotted method path with /api/method/ itself. A bare method
+ // name here would resolve relative to whatever page the app is currently on and hit the
+ // SPA's own catch-all route (200, HTML) instead of the API - see the same trap called out
+ // in Notifications.vue's markAllAsRead.
+ url: `/api/v2/method/${getProfileBentoCardsMethod}`,
+ params: { profile },
+ cacheKey: ['ProfileBento', profile, session.user],
+ staleOnError: true,
+ immediate: false,
+ })
+}
+
+function getProfileBentoCall(profile: string) {
+ if (!bentoCalls[profile]) {
+ bentoCalls[profile] = createProfileBentoCall(profile)
+ }
+ return bentoCalls[profile]
+}
+
+/**
+ * Reactive bento-card read for one profile (`PersonProfile.vue`'s Profile tab). Cards come
+ * straight off the shared per-profile call above instead of being copied into local refs,
+ * so revisiting a profile already viewed this session reuses its resource rather than
+ * re-racing a fresh request against whatever that earlier visit's request was doing - the
+ * out-of-order-response guard the old `loadProfileBentoCards` needed is gone because each
+ * profile now owns an isolated resource instead of sharing one mutable ref.
+ */
+export function useProfileBento(profile: MaybeRefOrGetter) {
+ const bentoCall = computed(() => {
+ let name = toValue(profile)
+ return name ? getProfileBentoCall(name) : null
+ })
+
+ const cards = computed(() => bentoCall.value?.data?.cards || [])
+ const isDefault = computed(() => bentoCall.value?.data?.is_default ?? true)
+ // Resolves on failure too (not just success) - the forever-skeleton bug this replaces
+ // came from the old plain `call()` throwing uncaught and never flipping its "loaded" ref.
+ // `data != null` lets a cache hit show immediately without waiting for the network leg
+ // that staleOnError may still be racing in the background.
+ const loaded = computed(() => {
+ let current = bentoCall.value
+ if (!current) return false
+ return current.data != null || Boolean(current.isFinished)
+ })
+ const failed = computed(() => {
+ let current = bentoCall.value
+ return Boolean(current && current.error && current.data == null)
+ })
+ const error = computed(() => bentoCall.value?.error ?? null)
+
+ watch(
+ () => toValue(profile),
+ (name) => {
+ if (!name) return
+ let current = getProfileBentoCall(name)
+ if (!current.isFinished && !current.loading) current.reload()
+ },
+ { immediate: true },
+ )
+
+ function reload() {
+ return bentoCall.value?.reload()
+ }
+
+ return { cards, isDefault, loaded, failed, error, reload }
+}
+
+/**
+ * Warms a profile's bento-card cache ahead of a visit, for the background prefetcher.
+ * Same cache key path as `useProfileBento`, so a later visit to this profile reads
+ * whatever this fetched.
+ */
+export function prefetchProfileBento(personId: string) {
+ return getProfileBentoCall(personId).reload()
+}
diff --git a/frontend/src/data/people.ts b/frontend/src/data/people.ts
new file mode 100644
index 000000000..d8cb1ba54
--- /dev/null
+++ b/frontend/src/data/people.ts
@@ -0,0 +1,48 @@
+import { ref } from 'vue'
+import { useList } from 'frappe-ui'
+import type { OrderBy } from 'frappe-ui'
+import type { GPUserProfile } from '@/types/doctypes'
+import { session } from './session'
+
+export interface Person extends Pick<
+ GPUserProfile,
+ 'name' | 'user' | 'bio' | 'modified' | 'cover_image' | 'cover_image_position'
+> {
+ discussions_count: number
+ comments_count: number
+ reactions_given: number
+ reactions_received: number
+}
+
+/**
+ * `full_name` and `modified` are real columns on `GP User Profile`, so the backend can
+ * sort by them directly. The other Select options in People.vue (posts/replies/reactions)
+ * have no backing column — they're derived counts computed per-row in `get_list` — so
+ * People.vue re-sorts for those client-side after fetching at `peopleOrderBy`'s value.
+ */
+export const peopleOrderBy = ref('modified desc')
+
+/**
+ * The People list: server-sorted, filtered to enabled accounts, cached for offline use.
+ * A module-level singleton (like `data/spaces.ts`'s `spaces`) rather than a per-call
+ * composable, so the People page and a future background prefetcher share one fetch and
+ * one cache entry instead of racing two independent requests. To warm this cache ahead of
+ * a visit, a prefetcher can `import { people } from '@/data/people'` and call
+ * `people.reload()`.
+ */
+export const people = useList({
+ // GP User Profile's default list view; the aggregate post/reply/reaction counts are
+ // computed server-side per row, so this can't be the generic `/api/v2/document/...`
+ // REST list.
+ url: '/api/v2/method/gameplan.gameplan.doctype.gp_user_profile.gp_user_profile.get_list',
+ doctype: 'GP User Profile',
+ fields: ['name', 'user', 'bio', 'modified', 'cover_image', 'cover_image_position'],
+ filters: { enabled: 1 },
+ orderBy: peopleOrderBy,
+ limit: 999,
+ // Scoped to the session user so a second account on the same browser can't read the
+ // first account's cached People list while offline (review finding from PR #516).
+ cacheKey: ['People', session.user],
+ staleOnError: true,
+ immediate: true,
+})
diff --git a/frontend/src/pages/People.vue b/frontend/src/pages/People.vue
index 411bed0e4..e04a04529 100644
--- a/frontend/src/pages/People.vue
+++ b/frontend/src/pages/People.vue
@@ -23,7 +23,7 @@
-
{{ people.length }} members
+
{{ peopleList.length }} members
-
+
@@ -75,7 +65,7 @@
-
+
- {{ $user(user.user).full_name }}
+ {{ useUser(user.user).full_name }}
-
Guest
+
Guest
- {{ $user(user.user).full_name }}
+ {{ useUser(user.user).full_name }}
-
Guest
+
Guest
-
-
- Load more
-
+
+
+ {{ search ? 'No members match your search' : 'No members yet' }}
+
+
+
+ Load more
+
+
+
-
diff --git a/frontend/src/pages/PersonProfile.vue b/frontend/src/pages/PersonProfile.vue
index 960894b08..addf61027 100644
--- a/frontend/src/pages/PersonProfile.vue
+++ b/frontend/src/pages/PersonProfile.vue
@@ -41,21 +41,31 @@
+
+
diff --git a/frontend/src/pages/PersonProfileProfile.vue b/frontend/src/pages/PersonProfileProfile.vue
index 6b12fb284..b712b60b7 100644
--- a/frontend/src/pages/PersonProfileProfile.vue
+++ b/frontend/src/pages/PersonProfileProfile.vue
@@ -7,7 +7,17 @@
-
+
+
@@ -68,6 +78,7 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button, Skeleton } from 'frappe-ui'
import EmptyStateBox from '@/components/EmptyStateBox.vue'
+import OfflineContentFallback from '@/components/OfflineContentFallback.vue'
import ProfileAboutDialog from '@/components/ProfileBento/ProfileAboutDialog.vue'
import ProfileBentoGrid from '@/components/ProfileBento/ProfileBentoGrid.vue'
import type { ProfileBentoCard, ProfileFieldEditor } from '@/components/ProfileBento/types'
@@ -91,6 +102,8 @@ const props = withDefaults(
bentoCardsLoaded?: boolean
/** False once this profile has a saved layout rather than the computed default. */
bentoIsDefault?: boolean
+ /** Set when the bento fetch failed and nothing was cached to fall back to (US6). */
+ bentoFailure?: { title: string; message: string } | null
isOwnProfile?: boolean
/** Set only when the viewer owns this profile; enables the card edit buttons. */
fieldEditor?: ProfileFieldEditor
@@ -100,6 +113,7 @@ const props = withDefaults(
defineEmits<{
restoreDefaultLayout: []
+ retryBento: []
}>()
const router = useRouter()
diff --git a/frontend/src/pages/PersonProfileReplies.vue b/frontend/src/pages/PersonProfileReplies.vue
index c83064e75..e6bb932c4 100644
--- a/frontend/src/pages/PersonProfileReplies.vue
+++ b/frontend/src/pages/PersonProfileReplies.vue
@@ -1,13 +1,58 @@
-
+
+
+
-
From 57642e9a535050642d437f879520fad3fa055a6f Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 15:51:37 +0530
Subject: [PATCH 07/68] feat(frontend): background prefetch of members,
profiles and avatars 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 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.
---
frontend/src/data/offlinePrefetch.ts | 206 +++++++++++++++++++++++++++
frontend/src/main.js | 3 +
2 files changed, 209 insertions(+)
create mode 100644 frontend/src/data/offlinePrefetch.ts
diff --git a/frontend/src/data/offlinePrefetch.ts b/frontend/src/data/offlinePrefetch.ts
new file mode 100644
index 000000000..6356357e4
--- /dev/null
+++ b/frontend/src/data/offlinePrefetch.ts
@@ -0,0 +1,206 @@
+import { watch } from 'vue'
+import { useDoc } from 'frappe-ui'
+import type { GPUserProfile } from '@/types/doctypes'
+import { people } from './people'
+import { useUser, usersReady } from './users'
+import { session } from './session'
+import { isOnline, onReconnect } from './online'
+import { prefetchProfileBento } from '@/components/ProfileBento/profileBentoSource'
+
+/**
+ * Warms every enabled member's offline data in the background so the People page and
+ * any member's profile render offline even when neither was ever visited this session:
+ * the People list itself, each member's `GP User Profile` doc (into the same `docStore`
+ * entry `useDoc` reads on `PersonProfile.vue`), each member's bento cards, and each
+ * member's avatar bytes (into the service worker's image cache).
+ *
+ * `useDoc` is the only supported way to warm `docStore` from outside frappe-ui - it isn't
+ * exported from the package, only reached through `useDoc`'s own `afterFetch` hook (see
+ * node_modules/frappe-ui/src/data-fetching/useDoc/useDoc.ts and ./docStore.ts). Calling it
+ * here with the exact `doctype`/`name` PersonProfile.vue uses means a later visit finds
+ * `docStore` already holding the doc (in memory, not just IDB) and skips straight to
+ * rendering it while its own network refresh races in the background - the same
+ * `staleOnError` fallback path an already-visited profile relies on offline.
+ */
+
+// A shared pool size (not one member at a time, not unbounded) so a workspace with a few
+// hundred members doesn't flood the network, while still finishing in reasonable time.
+const POOL_SIZE = 3
+// Long enough that this never competes with the initial page load's own requests.
+const INITIAL_DELAY_MS = 4000
+const IDLE_TIMEOUT_MS = 10000
+
+let running = false
+
+/**
+ * Idle-delayed kickoff so this never competes with the initial page load. Triggered by
+ * the `session.isLoggedIn` watch below (covers both "already logged in at boot" and
+ * "logged in mid-session without a page reload") rather than being called explicitly from
+ * main.js, the same way data/discussions.ts and data/unreadCount.ts wire up their own
+ * `onReconnect` callback at module scope - importing this module for its side effects is
+ * enough (see main.js).
+ */
+export function scheduleOfflinePrefetch() {
+ runWhenIdle(() => {
+ if (session.isLoggedIn && isOnline.value) runOfflinePrefetch()
+ })
+}
+
+watch(
+ () => session.isLoggedIn,
+ (loggedIn) => loggedIn && scheduleOfflinePrefetch(),
+ {
+ immediate: true,
+ },
+)
+
+// A session that started offline never got its scheduled run in, and one that dropped
+// mid-run may have only cached the first few members - both are exactly what a
+// reconnect should retry. `onReconnect` is already debounced against flapping
+// connectivity (data/online.ts), so this can't stampede on a flaky connection. The
+// overlapping-run guard in `runOfflinePrefetch` covers this firing close to the initial
+// idle-delayed run too.
+onReconnect(() => {
+ if (session.isLoggedIn) runOfflinePrefetch()
+})
+
+function runWhenIdle(fn: () => void) {
+ if (typeof requestIdleCallback === 'function') {
+ requestIdleCallback(() => fn(), { timeout: IDLE_TIMEOUT_MS })
+ } else {
+ setTimeout(fn, INITIAL_DELAY_MS)
+ }
+}
+
+/**
+ * Runs the full warm-up pass: reload the People list, then fan out per-member work
+ * (profile doc, bento cards, avatar) through a small worker pool. Exported mainly so
+ * tests/tools can trigger and await one pass directly instead of waiting on the idle
+ * timer.
+ */
+export async function runOfflinePrefetch() {
+ if (running || !isOnline.value) return
+ running = true
+ let counts = { members: 0, profiles: 0, bento: 0, avatars: 0 }
+ try {
+ console.debug('[offline-prefetch] start')
+ await people.reload()
+
+ // Avatar URLs come from the users store, not the People list - wait for its first
+ // fetch to settle so an early run (or a slow `get_user_info`) doesn't skip every
+ // avatar because `useUser` only has placeholders to hand back yet.
+ await waitForUsersReady()
+
+ let members = people.data || []
+ counts.members = members.length
+
+ let tasks = members.flatMap((member) => [
+ () =>
+ prefetchProfileDoc(member.name).then((ok) => {
+ if (ok) counts.profiles++
+ }),
+ () =>
+ prefetchProfileBento(member.name).then((ok) => {
+ if (ok) counts.bento++
+ }),
+ () =>
+ prefetchAvatar(useUser(member.user).user_image).then((ok) => {
+ if (ok) counts.avatars++
+ }),
+ ])
+
+ await runPool(tasks, POOL_SIZE)
+
+ console.debug(
+ `[offline-prefetch] done members=${counts.members} profiles=${counts.profiles} bento=${counts.bento} avatars=${counts.avatars}`,
+ )
+ } finally {
+ running = false
+ }
+}
+
+function waitForUsersReady(): Promise {
+ if (usersReady.value) return Promise.resolve()
+ return new Promise((resolve) => {
+ let stop = watch(usersReady, (ready) => {
+ if (!ready) return
+ stop()
+ resolve()
+ })
+ })
+}
+
+/**
+ * Pulls tasks off a shared queue with `poolSize` workers running concurrently, instead of
+ * awaiting fixed-size batches - a worker that finishes early (a fast avatar load) picks up
+ * the next task immediately rather than waiting on the slowest task in its batch. Checked
+ * before every task, not just once: connectivity can drop midway through a run of a few
+ * hundred members, and there is no point queuing more requests that will only fail once it
+ * does.
+ */
+async function runPool(tasks: Array<() => Promise>, poolSize: number) {
+ let index = 0
+ async function worker() {
+ while (index < tasks.length) {
+ if (!isOnline.value) return
+ let task = tasks[index++]
+ try {
+ await task()
+ } catch (error) {
+ console.error('[offline-prefetch] task failed', error)
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(poolSize, tasks.length) }, worker))
+}
+
+/**
+ * Fetches one member's profile doc and lets `useDoc`'s own `afterFetch` hook write it into
+ * `docStore` (both the in-memory ref and IDB) - see the module doc comment above. `execute`
+ * (aliased `fetch` on the returned object) never rejects; a network/HTTP failure resolves
+ * to `null` instead, so this only reports success/failure for the debug counters, nothing
+ * to catch.
+ */
+function prefetchProfileDoc(name: string): Promise {
+ let profileDoc = useDoc({
+ doctype: 'GP User Profile',
+ name,
+ immediate: false,
+ staleOnError: true,
+ })
+ return profileDoc.fetch().then(Boolean)
+}
+
+/**
+ * Loads an avatar through an ` ` element rather than `fetch()` - the service worker
+ * (gameplan/www/gameplan-sw.js) only caches requests whose `destination` is `image`
+ * (`staleWhileRevalidate`), and a plain `fetch()` has `destination: ""`, which the worker
+ * ignores. This has to actually load through the browser's image-fetch machinery, not
+ * just hit the same URL.
+ */
+function prefetchAvatar(url: string | undefined): Promise {
+ if (!isSafeImageUrl(url)) return Promise.resolve(false)
+ return new Promise((resolve) => {
+ let img = new Image()
+ img.onload = () => resolve(true)
+ img.onerror = () => resolve(false)
+ img.src = url
+ })
+}
+
+// Same-origin only: the service worker ignores cross-origin requests outright
+// (`url.origin !== self.location.origin` in gameplan-sw.js), and there is no reason to
+// send a user's avatar URL to a third-party origin from a background prefetcher anyway.
+function isSafeImageUrl(url: string | undefined): url is string {
+ if (!url) return false
+ if (url.startsWith('/') && !url.startsWith('//')) return true
+ try {
+ let parsed = new URL(url, window.location.origin)
+ return (
+ (parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
+ parsed.origin === window.location.origin
+ )
+ } catch {
+ return false
+ }
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 0be900dce..47b7fd249 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -23,6 +23,9 @@ import { isSessionUser, session } from './data/session'
import { initSocket } from './socket'
import resetDataMixin from './utils/resetDataMixin'
import { setupOfflineSupport } from './offline'
+// Side-effect import: registers this module's own session/reconnect watchers (see
+// data/offlinePrefetch.ts), same pattern as data/discussions.ts and data/unreadCount.ts.
+import './data/offlinePrefetch'
let globalComponents = {
Button,
From 51d400fc8e96005ab1d7bd8085c49c28d09a9aaa Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 18:51:07 +0530
Subject: [PATCH 08/68] feat(frontend): user-scoped offline cache clearing and
service worker 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.
---
frontend/src/data/draftStore.ts | 13 ++-
frontend/src/data/session.ts | 19 +++-
frontend/src/offline.ts | 161 +++++++++++++++++++++++++++++++-
gameplan/www/gameplan-sw.js | 48 ++++++++--
4 files changed, 231 insertions(+), 10 deletions(-)
diff --git a/frontend/src/data/draftStore.ts b/frontend/src/data/draftStore.ts
index 7f60b6215..1e06921bb 100644
--- a/frontend/src/data/draftStore.ts
+++ b/frontend/src/data/draftStore.ts
@@ -7,7 +7,7 @@
* can stay coherent. The reactive orchestration (debounced server sync, lazy row
* creation, reconciliation) lives in `useDraftSync`.
*/
-import { get, set, del, entries, createStore } from 'idb-keyval'
+import { get, set, del, entries, clear, createStore } from 'idb-keyval'
export type DraftType = 'Discussion' | 'Comment'
export type DraftMode = 'New' | 'Edit'
@@ -65,6 +65,17 @@ export function listDraftRecords(): Promise {
return entries(store).then((all) => all.map(([, record]) => record))
}
+/**
+ * Wipe every locally stored draft, regardless of owner. `record.user` already keeps
+ * another account's drafts from being read back into an editor on a shared browser
+ * (see useDraftSync's `load()`), so this is only called when a *different* user is
+ * detected on this device (offline.ts's guardAgainstUserSwitch) - not on a plain
+ * logout, where the same person may log back in and expect their draft still there.
+ */
+export function clearDraftStore(): Promise {
+ return clear(store)
+}
+
/** Deterministic key for singleton drafts — the same target always resolves to one
* record, so two tabs editing the same post share it instead of forking. */
export function singletonKey(identity: DraftIdentity): string {
diff --git a/frontend/src/data/session.ts b/frontend/src/data/session.ts
index 62e3c234c..43a0745d1 100644
--- a/frontend/src/data/session.ts
+++ b/frontend/src/data/session.ts
@@ -2,6 +2,7 @@ import { computed, MaybeRef, reactive, ref } from 'vue'
import { useCall } from 'frappe-ui'
import { users } from './users'
import router from '@/router'
+import { clearOfflineCaches, guardAgainstUserSwitch } from '@/offline'
interface LoginResponse {
user: string
@@ -25,15 +26,29 @@ export let session = reactive({
users.reload()
sessionUser.value = getSessionUserFromCookie()
session.login.reset()
- router.replace(data.default_route || '/')
+ // Every user-scoped cacheKey in the data layer (communities, users, drafts) was
+ // computed once at module-eval time from whichever user (or Guest) was signed in
+ // when this tab first loaded - logging in as someone else here doesn't rebuild
+ // them. A plain router.replace would keep those singletons around, so force a full
+ // reload once a switch is detected and let the app rebuild everything fresh for
+ // the new user (same reasoning as DevUserSwitcher.vue's own hard reload).
+ if (guardAgainstUserSwitch(sessionUser.value)) {
+ window.location.href = data.default_route || '/'
+ } else {
+ router.replace(data.default_route || '/')
+ }
},
}),
logout: useCall({
url: '/api/v2/method/logout',
method: 'POST',
immediate: false,
- onSuccess() {
+ async onSuccess() {
sessionUser.value = getSessionUserFromCookie()
+ // Shared-computer safety: don't leave this session's cached content behind for
+ // whoever logs in next (PR #516). Awaited so the redirect (which tears down this
+ // page) doesn't cut the clear short.
+ await clearOfflineCaches()
window.location.href = '/login'
},
}),
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index 43cf77b49..d1160a154 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -1,8 +1,21 @@
+import { clear as clearIdbKeyval } from 'idb-keyval'
+import { toast } from 'frappe-ui'
+import { clearDraftStore } from '@/data/draftStore'
+import { onReconnect } from '@/data/online'
+
const SERVICE_WORKER_URL = '/gameplan-sw.js'
const SERVICE_WORKER_SCOPE = '/g'
const CACHE_URLS_MESSAGE = 'CACHE_URLS'
+const CLEAR_USER_CACHES_MESSAGE = 'CLEAR_USER_CACHES'
+const SKIP_WAITING_MESSAGE = 'SKIP_WAITING'
+const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user'
export function setupOfflineSupport() {
+ // Runs even in dev / non-secure contexts (unlike SW registration below): this is what
+ // makes the dev user switcher (DevUserSwitcher.vue) safe to test with, and it's cheap
+ // enough to always run at boot.
+ guardAgainstUserSwitch(getSessionUserFromCookie())
+
if (
import.meta.env.DEV ||
typeof navigator === 'undefined' ||
@@ -18,6 +31,8 @@ export function setupOfflineSupport() {
.then(async (registration) => {
await unregisterLegacyServiceWorkers(registration)
await warmLoadedAssets(registration)
+ watchForUpdates(registration)
+ onReconnect(() => registration.update().catch(() => {}))
})
.catch((error) => {
console.error('Failed to register Gameplan service worker', error)
@@ -29,6 +44,8 @@ export function setupOfflineSupport() {
} else {
window.addEventListener('load', register, { once: true })
}
+
+ watchForControllerChange()
}
export function isBrowserOffline() {
@@ -39,6 +56,146 @@ export function isNetworkError(error: unknown) {
return error instanceof TypeError && error.message === 'Failed to fetch'
}
+/**
+ * Shared-computer safety (PR #516's main review finding): wipe every trace of offline
+ * content this browser holds, so the next person logged in on this machine can't read
+ * a previous user's cached data. Call this on logout.
+ *
+ * Covers two physically separate stores:
+ * - The service worker's SHELL_CACHE and RUNTIME_CACHE (app shell HTML + cached
+ * avatars/files) - the hashed /assets build cache is content-addressed and the same
+ * for every user, so the worker deliberately leaves it alone.
+ * - The idb-keyval default store (IndexedDB db `keyval-store`, object store `keyval`) -
+ * frappe-ui's shared backing store for useList/useCall's persisted cache, useDoc's
+ * docStore, and the legacy Options-API listResource's saveLocal/getLocal. None of
+ * these call idb-keyval's `createStore` with a custom store, so clearing this one
+ * store wipes all of them at once (verified by reading frappe-ui beta.28's
+ * idbStore.ts, docStore.ts, resources/local.ts and resources/listResource.js).
+ *
+ * Deliberately does NOT touch `gameplan-drafts` (draftStore.ts's own custom idb-keyval
+ * store): a plain logout+re-login as the *same* user on the same device should still be
+ * able to recover an in-progress draft, and useDraftSync already guards reads by
+ * `record.user` so leaving another account's draft rows on disk isn't a leak. Drafts are
+ * only wiped when a genuine user switch is detected - see guardAgainstUserSwitch below,
+ * which calls clearDraftStore itself alongside this function.
+ */
+export async function clearOfflineCaches(): Promise {
+ await Promise.all([
+ clearServiceWorkerCaches(),
+ clearIdbKeyval().catch((error) => console.error('Failed to clear IndexedDB cache', error)),
+ ])
+}
+
+async function clearServiceWorkerCaches(): Promise {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return
+
+ const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE)
+ const activeWorker = registration?.active
+ if (!activeWorker) return
+
+ await new Promise((resolve) => {
+ const channel = new MessageChannel()
+ // Don't let logout hang forever if a stuck/buggy worker never responds.
+ const timeoutId = window.setTimeout(resolve, 2000)
+ channel.port1.onmessage = () => {
+ window.clearTimeout(timeoutId)
+ resolve()
+ }
+ activeWorker.postMessage({ type: CLEAR_USER_CACHES_MESSAGE }, [channel.port2])
+ })
+}
+
+/**
+ * Compares `user` against the last user this browser saw (persisted in localStorage so
+ * it survives full reloads) and clears every offline cache when they differ. Returns
+ * whether a switch was detected.
+ *
+ * Every cacheKey in the data layer (data/communities.ts, data/users.ts, data/drafts.ts)
+ * is computed once from the session cookie at module-eval time - correct for a fresh
+ * page load (the cookie is already the new user's by the time this module runs), but
+ * stale for a user switch that happens *without* a reload. Callers that change the
+ * session user in place (e.g. session.ts's login) must force a reload after a detected
+ * switch instead of relying on those singletons to pick up the new identity.
+ */
+export function guardAgainstUserSwitch(user: string | null): boolean {
+ if (typeof localStorage === 'undefined') return false
+
+ const lastSeenUser = localStorage.getItem(LAST_SEEN_USER_STORAGE_KEY)
+ const switched = Boolean(lastSeenUser && user && lastSeenUser !== user)
+ if (switched) {
+ // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a
+ // *different* user also wipes gameplan-drafts - the same-user recovery case that
+ // policy exists for doesn't apply here.
+ Promise.all([clearOfflineCaches(), clearDraftStore()]).catch((error) =>
+ console.error('Failed to clear offline caches', error),
+ )
+ }
+
+ if (user) {
+ localStorage.setItem(LAST_SEEN_USER_STORAGE_KEY, user)
+ } else {
+ localStorage.removeItem(LAST_SEEN_USER_STORAGE_KEY)
+ }
+
+ return switched
+}
+
+// A local copy, not an import from data/session.ts: session.ts imports this module (to
+// call clearOfflineCaches/guardAgainstUserSwitch on login/logout), so importing `session`
+// back here would cycle. Same cookie-read pattern data/communities.ts, data/users.ts and
+// data/session.ts itself use, each for their own version of this trap.
+function getSessionUserFromCookie(): string | null {
+ const cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
+ const user = cookies.get('user_id')
+ return user && user !== 'Guest' ? user : null
+}
+
+/**
+ * Surfaces a "new version available" toast once an updated worker has finished
+ * installing behind the currently active one (it never auto-activates - see
+ * gameplan-sw.js's install handler). Also checks for a worker that was already sitting
+ * in `waiting` before this listener attached (e.g. this tab was open across the deploy).
+ */
+function watchForUpdates(registration: ServiceWorkerRegistration) {
+ if (registration.waiting && navigator.serviceWorker.controller) {
+ notifyUpdateAvailable(registration.waiting)
+ }
+
+ registration.addEventListener('updatefound', () => {
+ const installingWorker = registration.installing
+ if (!installingWorker) return
+
+ installingWorker.addEventListener('statechange', () => {
+ if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ notifyUpdateAvailable(installingWorker)
+ }
+ })
+ })
+}
+
+function notifyUpdateAvailable(worker: ServiceWorker) {
+ toast('A new version of Gameplan is available', {
+ duration: Infinity,
+ action: {
+ label: 'Refresh',
+ onClick: () => worker.postMessage({ type: SKIP_WAITING_MESSAGE }),
+ },
+ })
+}
+
+/** Reloads once the newly-activated worker takes control, so the refresh action above
+ * actually picks up the new code. Guarded by a once-flag: `controllerchange` can also
+ * fire for reasons unrelated to our SKIP_WAITING message, and reloading more than once
+ * would loop. */
+function watchForControllerChange() {
+ let reloaded = false
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ if (reloaded) return
+ reloaded = true
+ window.location.reload()
+ })
+}
+
async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorkerRegistration) {
const registrations = await navigator.serviceWorker.getRegistrations()
const legacyScope = new URL('/g/', window.location.origin).href
@@ -46,7 +203,9 @@ async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorker
await Promise.all(
registrations
.filter((registration) => {
- return registration.scope === legacyScope && registration.scope !== currentRegistration.scope
+ return (
+ registration.scope === legacyScope && registration.scope !== currentRegistration.scope
+ )
})
.map((registration) => registration.unregister()),
)
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
index 7c2de13ce..8ad237dcc 100644
--- a/gameplan/www/gameplan-sw.js
+++ b/gameplan/www/gameplan-sw.js
@@ -1,7 +1,13 @@
const CACHE_PREFIX = "gameplan-readonly-offline";
-const CACHE_VERSION = "v5";
+const CACHE_VERSION = "v6";
const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`;
const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`;
+// Avatars and other runtime images are user-visible content fetched by URL, with no
+// user scoping (unlike the app's IndexedDB caches - see offline.ts's clearOfflineCaches).
+// Keeping them in a separate bucket from ASSET_CACHE (hashed, content-addressed /assets
+// build output, which is identical for every user) lets CLEAR_USER_CACHES below wipe the
+// former on logout/user-switch without also evicting the latter.
+const RUNTIME_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:runtime`;
const APP_SHELL_URL = "/g";
const OFFLINE_ASSET_MANIFEST_URL =
"/assets/gameplan/frontend/gameplan-offline-assets.json";
@@ -16,7 +22,11 @@ const CACHEABLE_DESTINATIONS = new Set(["font", "image", "script", "style"]);
self.addEventListener("install", (event) => {
event.waitUntil(warmShellCache());
- self.skipWaiting();
+ // No self.skipWaiting() here: when this install is replacing an already-active
+ // worker (a deploy landing under an open tab), the new worker should sit in
+ // `waiting` until the page confirms via SKIP_WAITING (offline.ts's update toast).
+ // Skipping unconditionally would swap the controller under a running tab with no
+ // warning. A first-ever install (no prior controller) activates regardless of this.
});
self.addEventListener("activate", (event) => {
@@ -49,11 +59,37 @@ self.addEventListener("fetch", (event) => {
});
self.addEventListener("message", (event) => {
- if (event.data?.type !== "CACHE_URLS" || !Array.isArray(event.data.urls))
+ const type = event.data?.type;
+
+ if (type === "CACHE_URLS" && Array.isArray(event.data.urls)) {
+ event.waitUntil(cacheUrls(event.data.urls));
+ return;
+ }
+
+ if (type === "SKIP_WAITING") {
+ self.skipWaiting();
return;
- event.waitUntil(cacheUrls(event.data.urls));
+ }
+
+ if (type === "CLEAR_USER_CACHES") {
+ const port = event.ports[0];
+ event.waitUntil(
+ clearUserCaches()
+ .then(() => port?.postMessage({ ok: true }))
+ .catch(() => port?.postMessage({ ok: false })),
+ );
+ }
});
+// Shared-computer safety (see clearOfflineCaches in offline.ts, called on logout and on
+// detecting a different session user at boot): wipe everything that can hold the
+// previous user's content. The app shell HTML and runtime images/files are the only
+// user-visible things this worker caches - ASSET_CACHE (hashed /assets build output) is
+// content-addressed and identical for every user, so it's left alone.
+async function clearUserCaches() {
+ await Promise.all([caches.delete(SHELL_CACHE), caches.delete(RUNTIME_CACHE)]);
+}
+
async function warmShellCache() {
const cache = await caches.open(SHELL_CACHE);
await Promise.all(
@@ -79,7 +115,7 @@ async function warmShellCache() {
}
async function deleteOldCaches() {
- const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE]);
+ const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE, RUNTIME_CACHE]);
const names = await caches.keys();
await Promise.all(
names
@@ -209,7 +245,7 @@ async function cacheFirst(request) {
}
async function staleWhileRevalidate(request) {
- const cache = await caches.open(ASSET_CACHE);
+ const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
const fetched = fetch(request)
.then((response) => {
From bda9da4e8c8bc3bde137de8e622af392e52535be Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 19:20:10 +0530
Subject: [PATCH 09/68] fix(frontend): rewarm app shell cache after user-switch
cache clear
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.
---
frontend/src/offline.ts | 24 +++++++++++++++++++++---
gameplan/www/gameplan-sw.js | 17 ++++++++++++++++-
2 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index d1160a154..1e659ddc2 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -7,6 +7,7 @@ const SERVICE_WORKER_URL = '/gameplan-sw.js'
const SERVICE_WORKER_SCOPE = '/g'
const CACHE_URLS_MESSAGE = 'CACHE_URLS'
const CLEAR_USER_CACHES_MESSAGE = 'CLEAR_USER_CACHES'
+const WARM_SHELL_CACHE_MESSAGE = 'WARM_SHELL_CACHE'
const SKIP_WAITING_MESSAGE = 'SKIP_WAITING'
const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user'
@@ -105,6 +106,23 @@ async function clearServiceWorkerCaches(): Promise {
})
}
+/**
+ * Round-4 finding: without this, the SW's SHELL_CACHE stays empty from the moment a
+ * user-switch clear runs (clearOfflineCaches, above) until the *next* successful online
+ * navigation to /g - if the browser goes offline before that happens, even a reload of
+ * the page already open fails with net::ERR_FAILED instead of falling back to the
+ * offline UI. Fired only after guardAgainstUserSwitch's clear resolves, a moment known
+ * to be online (a user just logged in). Deliberately not run after a plain logout
+ * (clearOfflineCaches's other call site, data/session.ts) - an empty shell cache is the
+ * intended post-logout state there, same as every other offline cache.
+ */
+async function rewarmShellCache(): Promise {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return
+
+ const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE)
+ registration?.active?.postMessage({ type: WARM_SHELL_CACHE_MESSAGE })
+}
+
/**
* Compares `user` against the last user this browser saw (persisted in localStorage so
* it survives full reloads) and clears every offline cache when they differ. Returns
@@ -126,9 +144,9 @@ export function guardAgainstUserSwitch(user: string | null): boolean {
// Unlike a plain logout (clearOfflineCaches alone), a detected switch to a
// *different* user also wipes gameplan-drafts - the same-user recovery case that
// policy exists for doesn't apply here.
- Promise.all([clearOfflineCaches(), clearDraftStore()]).catch((error) =>
- console.error('Failed to clear offline caches', error),
- )
+ Promise.all([clearOfflineCaches(), clearDraftStore()])
+ .then(() => rewarmShellCache())
+ .catch((error) => console.error('Failed to clear offline caches', error))
}
if (user) {
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
index 8ad237dcc..16b9e42fb 100644
--- a/gameplan/www/gameplan-sw.js
+++ b/gameplan/www/gameplan-sw.js
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "gameplan-readonly-offline";
-const CACHE_VERSION = "v6";
+const CACHE_VERSION = "v7";
const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`;
const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`;
// Avatars and other runtime images are user-visible content fetched by URL, with no
@@ -78,6 +78,21 @@ self.addEventListener("message", (event) => {
.then(() => port?.postMessage({ ok: true }))
.catch(() => port?.postMessage({ ok: false })),
);
+ return;
+ }
+
+ if (type === "WARM_SHELL_CACHE") {
+ // Round-4 finding: guardAgainstUserSwitch (offline.ts) clears SHELL_CACHE after a
+ // detected user switch, and nothing repopulates it until the *next* successful
+ // online navigation to /g. If the browser goes offline before that happens, even a
+ // reload of the page already open fails with net::ERR_FAILED instead of the offline
+ // UI. offline.ts posts this message right after that clear resolves, at a moment
+ // it's known to be online (a user just logged in) - warmShellCache() is already
+ // best-effort per-URL, so this is harmless if connectivity drops mid-fetch.
+ // Deliberately a separate message from CLEAR_USER_CACHES (not folded into
+ // clearUserCaches itself): a plain logout also clears via CLEAR_USER_CACHES, and an
+ // empty shell cache is the intended post-logout state there.
+ event.waitUntil(warmShellCache());
}
});
From 6b5c91c79d56b4c965be3b58fda30ca3d13d3874 Mon Sep 17 00:00:00 2001
From: Faris Ansari
Date: Sun, 9 Aug 2026 19:20:17 +0530
Subject: [PATCH 10/68] test(frontend): add offline Playwright suite
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.
---
frontend/.gitignore | 1 +
frontend/package.json | 3 +
frontend/tests/offline/README.md | 88 +++++
frontend/tests/offline/config.js | 72 ++++
frontend/tests/offline/helpers.js | 351 ++++++++++++++++++
frontend/tests/offline/p1.js | 222 +++++++++++
frontend/tests/offline/p2.js | 154 ++++++++
frontend/tests/offline/p3.js | 224 +++++++++++
frontend/tests/offline/package.json | 4 +
frontend/tests/offline/runner.js | 47 +++
frontend/tests/offline/smoke-online-people.js | 137 +++++++
.../offline/smoke-online-shared-computer.js | 66 ++++
frontend/tests/offline/smoke-online.js | 173 +++++++++
frontend/tests/offline/us1.js | 81 ++++
frontend/tests/offline/us2.js | 196 ++++++++++
frontend/tests/offline/us3.js | 132 +++++++
frontend/tests/offline/us4.js | 214 +++++++++++
frontend/tests/offline/us5.js | 143 +++++++
frontend/tests/offline/us6.js | 111 ++++++
frontend/tests/offline/us7a.js | 203 ++++++++++
frontend/tests/offline/us7b.js | 214 +++++++++++
frontend/tests/offline/us8.js | 206 ++++++++++
frontend/yarn.lock | 19 +
23 files changed, 3061 insertions(+)
create mode 100644 frontend/tests/offline/README.md
create mode 100644 frontend/tests/offline/config.js
create mode 100644 frontend/tests/offline/helpers.js
create mode 100644 frontend/tests/offline/p1.js
create mode 100644 frontend/tests/offline/p2.js
create mode 100644 frontend/tests/offline/p3.js
create mode 100644 frontend/tests/offline/package.json
create mode 100644 frontend/tests/offline/runner.js
create mode 100644 frontend/tests/offline/smoke-online-people.js
create mode 100644 frontend/tests/offline/smoke-online-shared-computer.js
create mode 100644 frontend/tests/offline/smoke-online.js
create mode 100644 frontend/tests/offline/us1.js
create mode 100644 frontend/tests/offline/us2.js
create mode 100644 frontend/tests/offline/us3.js
create mode 100644 frontend/tests/offline/us4.js
create mode 100644 frontend/tests/offline/us5.js
create mode 100644 frontend/tests/offline/us6.js
create mode 100644 frontend/tests/offline/us7a.js
create mode 100644 frontend/tests/offline/us7b.js
create mode 100644 frontend/tests/offline/us8.js
diff --git a/frontend/.gitignore b/frontend/.gitignore
index f9b92b8c7..9855df6f9 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -7,6 +7,7 @@ cypress/results
cypress/screenshots
cypress/videos
cypress/downloads
+tests/offline/results
coverage
.nyc_output
.nyc_merged
diff --git a/frontend/package.json b/frontend/package.json
index c054f2197..23004e81e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,8 @@
"serve": "vite preview",
"test-local": "cypress open --e2e --browser chrome",
"test": "cypress run",
+ "test:offline": "node tests/offline/runner.js",
+ "test:offline:smoke": "node tests/offline/smoke-online.js && node tests/offline/smoke-online-people.js && node tests/offline/smoke-online-shared-computer.js",
"coverage:merge": "rm -rf .nyc_merged && mkdir -p .nyc_merged && cp coverage/baseline.json .nyc_merged/baseline.json && cp .nyc_output/out.json .nyc_merged/out.json && nyc report --nycrc-path \"$PWD/.nycrc.json\" --temp-dir \"$PWD/.nyc_merged\" --cwd \"${COVERAGE_ROOT:-$PWD}\" --report-dir \"$PWD/coverage\""
},
"dependencies": {
@@ -42,6 +44,7 @@
"cypress": "14.5.1",
"mocha-junit-reporter": "^2.2.1",
"nyc": "^18.0.0",
+ "playwright": "^1.62.1",
"postcss": "^8.4.5",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md
new file mode 100644
index 000000000..bdc2d1ef0
--- /dev/null
+++ b/frontend/tests/offline/README.md
@@ -0,0 +1,88 @@
+# Offline mode Playwright suite
+
+End-to-end coverage for Gameplan's offline support (service worker + shell/data caching,
+`gameplan/www/gameplan-sw.js` and `frontend/src/offline.ts`). Plain CommonJS Playwright
+scripts, not `@playwright/test` — each story is a `run()` function that returns a
+`{ pass, checks: [...] }` result and can also be executed directly with `node`.
+
+Originally built as a throwaway harness at `/tmp/offline-mvp/pw`; migrated here so it
+survives reboots and can gate regressions in CI/local dev.
+
+## What's covered (12 stories)
+
+| Story | Covers |
+| ----- | ------------------------------------------------------------------------------------------------------------ |
+| US1 | App shell loads offline (reload + deep link) instead of a browser error page |
+| US2 | Previously-viewed feed / space / discussion render from cache while offline |
+| US3 | Offline indicator appears when connectivity drops, clears on reconnect |
+| US4 | A comment typed while offline fails gracefully and isn't lost |
+| US5 | Fresh data appears automatically on reconnect, no manual reload |
+| US6 | Never-cached content shows an honest "can't load this offline" fallback |
+| P1 | Background prefetch (`data/offlinePrefetch.ts`) makes an unvisited member's profile offline-ready |
+| P2 | A profile visited fully online (incl. Posts tab) is available offline |
+| P3 | A profile opened right as the browser goes offline (before prefetch runs) degrades honestly |
+| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft |
+| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data |
+| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version |
+
+## Prerequisites
+
+1. **A production build.** From the repo root: `yarn build`. The suite talks to a real
+ service worker, which Vite's dev server doesn't register the same way — always test
+ against the built bundle.
+2. **A Frappe server serving that build**, e.g.:
+ ```
+ bench --site serve --port 8003
+ ```
+ (any port works; point `GAMEPLAN_OFFLINE_BASE_URL` at it — see Configuration below).
+3. **A seeded test user and content**, on that site:
+ - A user with a password (`GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD`),
+ member of at least one `GP Team` (Community) with a non-private `GP Project`
+ (Space) that has a `GP Discussion` with a few comments.
+ - A **second** user (`GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2`),
+ member of the same team — used by US7b to simulate a second person on a shared
+ computer. Follow the pattern in `gameplan/debug.py` (per `AGENTS.md`'s debugging
+ convention: add an `execute()` function there and run it with
+ `bench --site execute gameplan.debug.execute`) to create the user,
+ set its password, and add it to the team.
+ - A few enabled `GP User Profile` members for the People/profile stories (P1-P3) —
+ any real members on the site work; override their IDs via env vars if needed (see
+ below).
+
+## Running
+
+```
+cd frontend
+yarn install
+yarn test:offline
+```
+
+Runs all 12 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default
+`http://gameplan.localhost:8003`), writes a summary to `tests/offline/results/summary.json`
+and per-story JSON/screenshots under `tests/offline/results/` (gitignored). Exits non-zero
+if any story fails.
+
+Run a single story directly: `node tests/offline/us3.js`.
+
+Online regression smokes (confirm normal online usage isn't broken — not part of the 12
+offline stories): `yarn test:offline:smoke`.
+
+## Configuration
+
+All seeded-content coupling lives in `config.js`, overridable via env vars:
+
+| Env var | Default | Meaning |
+| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------- |
+| `GAMEPLAN_OFFLINE_BASE_URL` | `http://gameplan.localhost:8003` | Origin serving the production build |
+| `GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD` | `offline-tester@example.com` / `offline-test-1234` | Primary test account |
+| `GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2` | `offline-tester-2@example.com` / `offline-test-1234` | Second account (US7b) |
+| `GAMEPLAN_OFFLINE_COMMUNITY` | `common-room` | `GP Team` name both accounts belong to |
+| `GAMEPLAN_OFFLINE_SPACE_ID` | `3` | `GP Project` name for the visited/cached space |
+| `GAMEPLAN_OFFLINE_DISCUSSION_ID` | `55` | `GP Discussion` name for the visited/cached discussion |
+| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `5` / `54` | Content never visited by any story before going offline (US6) |
+| `GAMEPLAN_OFFLINE_PERSON_PREFETCH` / `GAMEPLAN_OFFLINE_PERSON_VISITED` / `GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH` | `priya-sharma` / `maya-iyer` / `hana-suzuki` | `GP User Profile` IDs for P1/P2/P3 |
+| `GAMEPLAN_OFFLINE_RESULTS_DIR` | `tests/offline/results` | Where JSON results + screenshots are written |
+
+`us8.js` additionally rebuilds the app in place (bumps `gameplan-sw.js`'s
+`CACHE_VERSION`, runs `yarn build`, then reverts and rebuilds again in a `finally`) — it
+resolves the gameplan app root from its own file location, not an env var.
diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js
new file mode 100644
index 000000000..088165c53
--- /dev/null
+++ b/frontend/tests/offline/config.js
@@ -0,0 +1,72 @@
+// Central, env-overridable configuration for the offline Playwright suite. Defaults match
+// the seeded `gameplan.localhost` dev site this suite was developed against (see
+// README.md for how to reproduce that seed). Every story/helper reads its URLs, creds,
+// and seeded-content IDs from here instead of hardcoding them, so the suite can run
+// against a differently-seeded site by only setting env vars.
+const path = require('path')
+
+const BASE = process.env.GAMEPLAN_OFFLINE_BASE_URL || 'http://gameplan.localhost:8003'
+const EMAIL = process.env.GAMEPLAN_OFFLINE_USER || 'offline-tester@example.com'
+const PWD = process.env.GAMEPLAN_OFFLINE_PASSWORD || 'offline-test-1234'
+// Second account, used by US7b to simulate a second person logging in on the same shared
+// computer right after the first — needs to be a distinct identity, member of the same
+// community so it can see the same seeded Space.
+const EMAIL2 = process.env.GAMEPLAN_OFFLINE_USER2 || 'offline-tester-2@example.com'
+const PWD2 = process.env.GAMEPLAN_OFFLINE_PASSWORD2 || 'offline-test-1234'
+const FULL_NAME = process.env.GAMEPLAN_OFFLINE_FULL_NAME || 'Offline Tester'
+const FULL_NAME2 = process.env.GAMEPLAN_OFFLINE_FULL_NAME2 || 'Offline Tester Two'
+
+// Seeded content coordinates (GP Team/GP Project/GP Discussion names) — see README.md.
+const COMMUNITY = process.env.GAMEPLAN_OFFLINE_COMMUNITY || 'common-room'
+const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '3'
+const DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_DISCUSSION_ID || '55'
+// Never visited by any story before going offline -> used for US6 "uncached content".
+const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4'
+const UNCACHED_DISCUSSION_SPACE_ID =
+ process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5'
+const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54'
+
+const URLS = {
+ feed: `${BASE}/g`,
+ spaceDiscussions: `${BASE}/g/community/${COMMUNITY}/space/${SPACE_ID}/discussions`,
+ discussion: `${BASE}/g/community/${COMMUNITY}/space/${SPACE_ID}/discussion/${DISCUSSION_ID}`,
+ uncachedDiscussion: `${BASE}/g/community/${COMMUNITY}/space/${UNCACHED_DISCUSSION_SPACE_ID}/discussion/${UNCACHED_DISCUSSION_ID}`,
+ uncachedSpace: `${BASE}/g/community/${COMMUNITY}/space/${UNCACHED_SPACE_ID}/discussions`,
+ people: `${BASE}/g/people`,
+ person: (id) => `${BASE}/g/people/${id}`,
+ personPosts: (id) => `${BASE}/g/people/${id}/posts`,
+}
+
+// Real, enabled `GP User Profile` members on the seeded site, used across the
+// People/profile (P1-P3) stories. Kept distinct per role so no story's "never visited"
+// member is accidentally warmed by another story's "visited" step within the same run.
+const PEOPLE = {
+ // P1: never opened in that story's context; relies entirely on the background
+ // prefetcher (data/offlinePrefetch.ts) to be offline-ready.
+ neverVisitedForPrefetch: process.env.GAMEPLAN_OFFLINE_PERSON_PREFETCH || 'priya-sharma',
+ // P2: visited fully online (profile + Posts tab) before going offline.
+ visitedFully: process.env.GAMEPLAN_OFFLINE_PERSON_VISITED || 'maya-iyer',
+ // P3: opened only after immediately forcing offline, before prefetch can run.
+ neverVisitedNoPrefetch: process.env.GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH || 'hana-suzuki',
+}
+
+// frontend/tests/offline -> the gameplan app root (used by us8.js to rebuild after
+// bumping gameplan-sw.js's CACHE_VERSION).
+const APP_DIR = path.join(__dirname, '..', '..', '..')
+const RESULTS_DIR = process.env.GAMEPLAN_OFFLINE_RESULTS_DIR || path.join(__dirname, 'results')
+const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots')
+
+module.exports = {
+ BASE,
+ EMAIL,
+ PWD,
+ EMAIL2,
+ PWD2,
+ FULL_NAME,
+ FULL_NAME2,
+ URLS,
+ PEOPLE,
+ APP_DIR,
+ RESULTS_DIR,
+ SHOTS_DIR,
+}
diff --git a/frontend/tests/offline/helpers.js b/frontend/tests/offline/helpers.js
new file mode 100644
index 000000000..11def707f
--- /dev/null
+++ b/frontend/tests/offline/helpers.js
@@ -0,0 +1,351 @@
+// Shared helpers for the offline MVP Playwright suite (plain scripts, no @playwright/test).
+// Originally a throwaway harness at /tmp/offline-mvp/pw; migrated here so it survives
+// reboots and can gate regressions (see README.md).
+const path = require('path')
+const fs = require('fs')
+const { chromium, request: pwRequest } = require('playwright')
+const {
+ BASE,
+ EMAIL,
+ PWD,
+ EMAIL2,
+ PWD2,
+ FULL_NAME,
+ FULL_NAME2,
+ URLS,
+ PEOPLE,
+ SHOTS_DIR,
+ RESULTS_DIR,
+} = require('./config')
+
+fs.mkdirSync(SHOTS_DIR, { recursive: true })
+fs.mkdirSync(RESULTS_DIR, { recursive: true })
+
+async function newLoggedInContextAs(browser, usr, pwd, contextOptions = {}) {
+ const context = await browser.newContext(contextOptions)
+ const loginResp = await context.request.post(`${BASE}/api/method/login`, {
+ form: { usr, pwd },
+ })
+ if (!loginResp.ok()) {
+ throw new Error(`login failed: ${loginResp.status()} ${await loginResp.text()}`)
+ }
+ const page = await context.newPage()
+ const consoleErrors = []
+ const pageErrors = []
+ const prefetchLog = []
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') consoleErrors.push(msg.text())
+ if (msg.text().includes('[offline-prefetch]')) prefetchLog.push(msg.text())
+ })
+ page.on('pageerror', (err) => {
+ pageErrors.push(String(err))
+ })
+ return { context, page, consoleErrors, pageErrors, prefetchLog }
+}
+
+async function newLoggedInContext(browser) {
+ return newLoggedInContextAs(browser, EMAIL, PWD)
+}
+
+/**
+ * Logs in as a second, already-authenticated identity within the SAME browser context
+ * (same IndexedDB/localStorage/Cache Storage origin) — used by US7b to simulate a second
+ * person using the same shared computer right after the first. Reuses the context's
+ * cookie jar (context.request.post sets the cookie on the context), so the existing
+ * `page` picks up the new session on its next navigation.
+ */
+async function loginAsInSameContext(context, page, usr, pwd) {
+ const loginResp = await context.request.post(`${BASE}/api/method/login`, {
+ form: { usr, pwd },
+ })
+ if (!loginResp.ok()) {
+ throw new Error(`login failed: ${loginResp.status()} ${await loginResp.text()}`)
+ }
+ // The app's own guardAgainstUserSwitch (frontend/src/offline.ts) only runs at module
+ // boot, so a fresh navigation is required for it to see the new user_id cookie.
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // `/g` client-side redirects to the community's discussions route once the app has
+ // hydrated enough to know where to send you (post-327d7ae3, that redirect itself
+ // waits on cache hydration under unreliable network). Wait for it to actually land
+ // before the caller issues its own page.goto() — otherwise the in-flight SPA redirect
+ // can still be settling when the next real navigation starts, and Playwright reports
+ // that next goto() as "interrupted by another navigation" to the redirect's target.
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+}
+
+/** Opens the AppRail user-avatar dropdown (bottom-left) and clicks "Log out" — the same
+ * UI path a real user takes (UserDropdown.vue -> session.logout.submit(), see
+ * data/session.ts), so this also exercises the offline-cache-clear side effect under
+ * test rather than bypassing it with a raw API call. Selector is the avatar trigger
+ * button's own template classes (AppRail.vue) since the avatar itself may render either
+ * an or an initials div depending on whether the user has a profile photo. */
+async function logoutViaUI(page) {
+ const trigger = page.locator('button.rounded-full.size-7').last()
+ await trigger.click({ timeout: 8000 })
+ await page.getByRole('menuitem', { name: 'Log out' }).click({ timeout: 8000 })
+}
+
+/** Reads idb-keyval's default store (IndexedDB db `keyval-store`, object store `keyval`)
+ * — frappe-ui's shared backing store for useList/useCall/useDoc caches (see offline.ts's
+ * clearOfflineCaches doc comment). Opening a DB that doesn't exist yet is harmless (it
+ * just creates an empty one with no object stores, same as p3.js already relies on). */
+async function idbKeyvalKeys(page) {
+ return page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const req = indexedDB.open('keyval-store')
+ req.onsuccess = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains('keyval')) {
+ db.close()
+ resolve([])
+ return
+ }
+ const tx = db.transaction('keyval', 'readonly')
+ const keysReq = tx.objectStore('keyval').getAllKeys()
+ keysReq.onsuccess = () => {
+ db.close()
+ resolve(keysReq.result)
+ }
+ keysReq.onerror = () => {
+ db.close()
+ resolve([])
+ }
+ }
+ req.onerror = () => resolve([])
+ }),
+ )
+}
+
+/** Reads draftStore.ts's custom idb-keyval store (`gameplan-drafts` db, `records` store). */
+async function draftStoreKeys(page) {
+ return page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ const req = indexedDB.open('gameplan-drafts')
+ req.onsuccess = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains('records')) {
+ db.close()
+ resolve([])
+ return
+ }
+ const tx = db.transaction('records', 'readonly')
+ const keysReq = tx.objectStore('records').getAllKeys()
+ keysReq.onsuccess = () => {
+ db.close()
+ resolve(keysReq.result)
+ }
+ keysReq.onerror = () => {
+ db.close()
+ resolve([])
+ }
+ }
+ req.onerror = () => resolve([])
+ }),
+ )
+}
+
+/** Cache Storage bucket names the SW created — see gameplan-sw.js's SHELL_CACHE/
+ * ASSET_CACHE/RUNTIME_CACHE naming (`gameplan-readonly-offline::`). */
+async function cacheStorageNames(page) {
+ return page.evaluate(() => (typeof caches !== 'undefined' ? caches.keys() : []))
+}
+
+async function lastSeenUserFromStorage(page) {
+ return page.evaluate(() => {
+ try {
+ return localStorage.getItem('gameplan:last-seen-user')
+ } catch {
+ return null
+ }
+ })
+}
+
+/**
+ * Waits for data/offlinePrefetch.ts's `[offline-prefetch] done members=... profiles=...
+ * bento=... avatars=...` console.debug line, by polling the `prefetchLog` array
+ * `newLoggedInContext` populates. The prefetcher is idle-delayed (up to
+ * IDLE_TIMEOUT_MS=10s) then fans out over every member with a small worker pool, so this
+ * needs a generous timeout — callers should navigate to a lightweight page (the feed) and
+ * just let it sit rather than doing other work while waiting.
+ */
+async function waitForPrefetchDone(prefetchLog, { timeoutMs = 45000, pollMs = 500 } = {}) {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ const doneLine = prefetchLog.find((l) => l.includes('[offline-prefetch] done'))
+ if (doneLine) {
+ const m = doneLine.match(/members=(\d+)\s+profiles=(\d+)\s+bento=(\d+)\s+avatars=(\d+)/)
+ return {
+ sawDone: true,
+ sawStartFirst: prefetchLog[0]?.includes('[offline-prefetch] start') ?? false,
+ doneLine,
+ counts: m
+ ? {
+ members: Number(m[1]),
+ profiles: Number(m[2]),
+ bento: Number(m[3]),
+ avatars: Number(m[4]),
+ }
+ : null,
+ }
+ }
+ await new Promise((r) => setTimeout(r, pollMs))
+ }
+ return {
+ sawDone: false,
+ sawStartFirst: prefetchLog[0]?.includes('[offline-prefetch] start') ?? false,
+ doneLine: null,
+ counts: null,
+ log: [...prefetchLog],
+ }
+}
+
+async function avatarInfo(page, scope = 'img') {
+ return page.evaluate((sel) => {
+ const imgs = Array.from(document.querySelectorAll(sel))
+ return imgs.slice(0, 12).map((img) => ({
+ src: img.src,
+ complete: img.complete,
+ naturalWidth: img.naturalWidth,
+ broken: img.complete && img.naturalWidth === 0,
+ }))
+ }, scope)
+}
+
+/** Separate, independent auth context for API calls that must succeed while the
+ * browser `page`/`context` is simulating offline (context.setOffline only affects
+ * the page's network stack, not a wholly separate APIRequestContext, but per the
+ * task brief we keep this fully separate for clarity and to avoid any doubt). */
+async function newApiRequestContext() {
+ const api = await pwRequest.newContext({ baseURL: BASE })
+ const loginResp = await api.post('/api/method/login', {
+ form: { usr: EMAIL, pwd: PWD },
+ })
+ if (!loginResp.ok()) {
+ throw new Error(`API login failed: ${loginResp.status()} ${await loginResp.text()}`)
+ }
+ return api
+}
+
+async function warmup(page) {
+ // Visit the three pages online, waiting for real content, then wait for the
+ // service worker to finish installing so offline tests have a warm cache.
+ const swBefore = await page.evaluate(() => ({
+ hasSW: 'serviceWorker' in navigator,
+ isSecureContext: window.isSecureContext,
+ }))
+
+ for (const url of [URLS.feed, URLS.spaceDiscussions, URLS.discussion]) {
+ await page.goto(url, { waitUntil: 'load', timeout: 15000 })
+ // `/g` itself client-side redirects to the community's discussions route once the
+ // app has hydrated enough to know where to send you (post-327d7ae3, that redirect
+ // waits on cache hydration under unreliable network, so it isn't always instant).
+ // Let it land before starting the next goto() in this loop — otherwise, under load,
+ // the in-flight SPA redirect can still be settling when the next real navigation
+ // starts, and Playwright reports that next goto() as "interrupted by another
+ // navigation" to the redirect's target (same race loginAsInSameContext guards
+ // against for the US7b user-switch flow).
+ if (url === URLS.feed) {
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ }
+ await page.waitForTimeout(1000)
+ }
+
+ let swController = null
+ let swReady = false
+ let swReadyError = null
+ try {
+ swController = await page.evaluate(() => Boolean(navigator.serviceWorker.controller))
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true), { timeout: 10000 })
+ swReady = true
+ } catch (e) {
+ swReadyError = String(e)
+ }
+
+ // Give the SW's warmLoadedAssets() postMessage (fired on load + a 3s follow-up
+ // timer, see frontend/src/offline.ts) time to finish caching assets/shell.
+ await page.waitForTimeout(4000)
+
+ const swRegistrations = await page.evaluate(async () => {
+ const regs = await navigator.serviceWorker.getRegistrations()
+ return regs.map((r) => ({
+ scope: r.scope,
+ active: Boolean(r.active),
+ activeState: r.active?.state,
+ }))
+ })
+
+ return { ...swBefore, swController, swReady, swReadyError, swRegistrations }
+}
+
+async function shot(page, name) {
+ const p = path.join(SHOTS_DIR, `${name}.png`)
+ try {
+ await page.screenshot({ path: p, fullPage: false, timeout: 8000 })
+ } catch (e) {
+ return null
+ }
+ return p
+}
+
+async function innerTextSafe(page, selector = '#app, body') {
+ try {
+ return await page.locator(selector).first().innerText({ timeout: 5000 })
+ } catch (e) {
+ try {
+ return await page.evaluate(() => document.body?.innerText?.slice(0, 2000) || '')
+ } catch {
+ return ''
+ }
+ }
+}
+
+async function appRootInfo(page) {
+ return page.evaluate(() => {
+ const app = document.querySelector('#app')
+ return {
+ appExists: Boolean(app),
+ appChildCount: app ? app.childElementCount : 0,
+ bodyText: document.body.innerText.slice(0, 500),
+ title: document.title,
+ }
+ })
+}
+
+function writeResult(story, obj) {
+ const p = path.join(RESULTS_DIR, `${story}.json`)
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2))
+ return p
+}
+
+module.exports = {
+ BASE,
+ EMAIL,
+ PWD,
+ EMAIL2,
+ PWD2,
+ FULL_NAME,
+ FULL_NAME2,
+ URLS,
+ PEOPLE,
+ SHOTS_DIR,
+ RESULTS_DIR,
+ newLoggedInContext,
+ newLoggedInContextAs,
+ loginAsInSameContext,
+ logoutViaUI,
+ idbKeyvalKeys,
+ draftStoreKeys,
+ cacheStorageNames,
+ lastSeenUserFromStorage,
+ newApiRequestContext,
+ warmup,
+ waitForPrefetchDone,
+ avatarInfo,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+ chromium,
+}
diff --git a/frontend/tests/offline/p1.js b/frontend/tests/offline/p1.js
new file mode 100644
index 000000000..5b4c4214d
--- /dev/null
+++ b/frontend/tests/offline/p1.js
@@ -0,0 +1,222 @@
+// P1 — Prefetch makes members offline-ready: the background prefetcher
+// (frontend/src/data/offlinePrefetch.ts) should warm the People list, every member's
+// GP User Profile doc, their bento cards, and their avatar bytes, all without the user
+// ever opening the People page or a profile themselves. This story visits ONLY the feed
+// online, waits for the prefetcher's '[offline-prefetch] done' log, then goes offline and
+// checks: (a) the People page (client-nav + reload) renders member names AND avatars,
+// (b) a member's profile NEVER visited in this context renders real content, and
+// (c) that member's Posts tab is an honest offline fallback, not a silently-empty list
+// (posts are intentionally not prefetched).
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ newLoggedInContext,
+ waitForPrefetchDone,
+ avatarInfo,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+const MEMBER = PEOPLE.neverVisitedForPrefetch // 'priya-sharma' — never opened in this context
+
+async function warmupFeedOnly(page) {
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort
+ }
+}
+
+async function fuiSkeletonCount(page) {
+ return page.evaluate(() => document.querySelectorAll('.fui-skeleton').length)
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors, prefetchLog } =
+ await newLoggedInContext(browser)
+ const result = { story: 'P1', checks: [] }
+
+ try {
+ await warmupFeedOnly(page)
+
+ // Wait for the idle-delayed background prefetcher to finish its pass. Only the feed
+ // was ever visited — People/profiles/bento/avatars must come purely from the
+ // prefetcher, not from the user's own browsing.
+ const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 })
+ result.prefetch = prefetch
+ result.checks.push({
+ name: 'prefetch done log observed before going offline',
+ pass: prefetch.sawDone && Boolean(prefetch.counts) && prefetch.counts.members > 0,
+ symptom: prefetch.sawDone
+ ? `saw done line: ${prefetch.doneLine}`
+ : `never saw '[offline-prefetch] done' within timeout; log so far: ${JSON.stringify(prefetchLog)}`,
+ })
+
+ await context.setOffline(true)
+
+ // (a) People page: client-nav from the feed, then a hard reload, both offline.
+ let checkA = { name: 'People page offline: client-nav shows members + avatars' }
+ try {
+ const peopleRailBtn = page.getByRole('button', { name: 'People', exact: true })
+ if (await peopleRailBtn.count()) {
+ await peopleRailBtn.click()
+ } else {
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 })
+ }
+ await page.waitForTimeout(2000)
+
+ const text = await innerTextSafe(page)
+ const avatars = await avatarInfo(page, 'img')
+ const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0)
+ const memberishText = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim())
+
+ checkA.textSnippet = text.slice(0, 300)
+ checkA.avatarCount = avatars.length
+ checkA.loadedAvatarCount = loadedAvatars.length
+ checkA.screenshot = await shot(page, 'p1-a-clientnav-people')
+ checkA.pass = memberishText && loadedAvatars.length > 0
+ checkA.symptom = checkA.pass
+ ? `member list + ${loadedAvatars.length} loaded avatar(s) rendered from cache`
+ : !memberishText
+ ? 'no non-zero member count text found (looks like silent-empty or offline fallback)'
+ : 'member text present but no avatar image had naturalWidth > 0 (avatars not cached)'
+ } catch (e) {
+ checkA.pass = false
+ checkA.symptom = `threw: ${e.message}`
+ }
+ result.checks.push(checkA)
+
+ let checkAReload = { name: 'People page offline: hard reload shows members + avatars' }
+ try {
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkAReload.gotoError = String(e)
+ })
+ await page.waitForTimeout(2000)
+
+ const text = await innerTextSafe(page)
+ const avatars = await avatarInfo(page, 'img')
+ const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0)
+ const memberishText = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim())
+
+ checkAReload.textSnippet = text.slice(0, 300)
+ checkAReload.avatarCount = avatars.length
+ checkAReload.loadedAvatarCount = loadedAvatars.length
+ checkAReload.screenshot = await shot(page, 'p1-a-reload-people')
+ checkAReload.pass = memberishText && loadedAvatars.length > 0
+ checkAReload.symptom = checkAReload.pass
+ ? `member list + ${loadedAvatars.length} loaded avatar(s) rendered from cache after hard reload`
+ : !memberishText
+ ? 'no non-zero member count text found after reload'
+ : 'member text present but no avatar image had naturalWidth > 0 after reload'
+ } catch (e) {
+ checkAReload.pass = false
+ checkAReload.symptom = `threw: ${e.message}`
+ }
+ result.checks.push(checkAReload)
+
+ // (b) A member's profile never visited in this context — should render real content
+ // from the profile doc + bento cache the prefetcher warmed, not a skeleton forever and
+ // not a false NotFound.
+ let checkB = { name: `never-visited profile (${MEMBER}) renders offline` }
+ try {
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkB.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+
+ const skeletonBefore = await fuiSkeletonCount(page)
+ await page.waitForTimeout(3000)
+ const skeletonAfter = await fuiSkeletonCount(page)
+ const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0
+
+ const text = await innerTextSafe(page)
+ const info = await appRootInfo(page)
+ const isNotFound = /page not found/i.test(text)
+ const hasCardContent = await page
+ .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]')
+ .count()
+
+ checkB.textSnippet = text.slice(0, 400)
+ checkB.stuckOnSkeleton = stuckOnSkeleton
+ checkB.isNotFound = isNotFound
+ checkB.hasCardContent = hasCardContent > 0
+ checkB.info = info
+ checkB.screenshot = await shot(page, 'p1-b-unvisited-profile')
+ checkB.pass = !stuckOnSkeleton && !isNotFound && hasCardContent > 0
+ checkB.symptom = checkB.pass
+ ? 'profile header + bento content rendered from prefetched cache'
+ : stuckOnSkeleton
+ ? 'stuck on skeleton — bento cards never resolved'
+ : isNotFound
+ ? 'showed NotFound instead of prefetched profile content'
+ : 'neither bento cards nor empty-state box rendered (blank content area)'
+ } catch (e) {
+ checkB.pass = false
+ checkB.symptom = `threw: ${e.message}`
+ checkB.screenshot = await shot(page, 'p1-b-unvisited-profile-error')
+ }
+ result.checks.push(checkB)
+
+ // (c) That member's Posts tab — posts are NOT prefetched, so this must be the honest
+ // offline fallback (OfflineContentFallback), never a silently-empty "no posts" list.
+ let checkC = { name: `never-visited profile (${MEMBER}) Posts tab: honest offline fallback` }
+ try {
+ const postsTabBtn = page.locator('button', { hasText: 'Posts' }).first()
+ if (await postsTabBtn.count()) {
+ await postsTabBtn.click()
+ await page.waitForTimeout(2000)
+ } else {
+ await page.goto(URLS.personPosts(MEMBER), { waitUntil: 'load', timeout: 10000 })
+ await page.waitForTimeout(2000)
+ }
+
+ const text = await innerTextSafe(page)
+ const fallbackDetected =
+ /can.?t load this while offline|haven.?t been saved for offline use/i.test(text)
+ const retryVisible = await page.locator('button:has-text("Retry")').count()
+
+ checkC.textSnippet = text.slice(0, 400)
+ checkC.fallbackDetected = fallbackDetected
+ checkC.retryVisible = retryVisible > 0
+ checkC.screenshot = await shot(page, 'p1-c-unvisited-posts-tab')
+ checkC.pass = fallbackDetected && retryVisible > 0
+ checkC.symptom = checkC.pass
+ ? 'honest offline fallback with retry shown for un-prefetched posts'
+ : 'no offline-fallback messaging/retry found — risk of silently-empty posts list'
+ } catch (e) {
+ checkC.pass = false
+ checkC.symptom = `threw: ${e.message}`
+ checkC.screenshot = await shot(page, 'p1-c-unvisited-posts-tab-error')
+ }
+ result.checks.push(checkC)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ result.prefetchLog = prefetchLog
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('p1', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/p2.js b/frontend/tests/offline/p2.js
new file mode 100644
index 000000000..7575147b2
--- /dev/null
+++ b/frontend/tests/offline/p2.js
@@ -0,0 +1,154 @@
+// P2 — A profile visited fully online (header + bento + Posts) should work fully offline
+// on reload: header, bento cards, and posts all rendering from cache — no reliance on the
+// background prefetcher (which never warms posts) because this session visited everything
+// itself.
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ newLoggedInContext,
+ avatarInfo,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+const MEMBER = PEOPLE.visitedFully // 'maya-iyer' — has real posts (see env notes)
+
+async function fuiSkeletonCount(page) {
+ return page.evaluate(() => document.querySelectorAll('.fui-skeleton').length)
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'P2', checks: [] }
+
+ try {
+ // Online warmup: visit the profile (header + bento) and its Posts tab, and let the
+ // service worker finish caching (mirrors helpers.warmup's SW-ready wait).
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(2000)
+ const postsTabBtn = page.locator('button', { hasText: 'Posts' }).first()
+ if (await postsTabBtn.count()) {
+ await postsTabBtn.click()
+ await page.waitForTimeout(2000)
+ }
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort
+ }
+ await page.waitForTimeout(4000) // warmLoadedAssets() follow-up timer
+
+ result.onlineWarmupUrl = page.url()
+
+ await context.setOffline(true)
+
+ // Hard reload straight onto the Posts tab URL — the harder case, since it must
+ // hydrate the profile header (parent route) and the posts list (child route) from
+ // cache in one navigation.
+ let checkPosts = { name: 'reload on Posts tab: header + posts render offline' }
+ try {
+ await page
+ .goto(URLS.personPosts(MEMBER), { waitUntil: 'load', timeout: 10000 })
+ .catch((e) => {
+ checkPosts.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+
+ const text = await innerTextSafe(page)
+ const info = await appRootInfo(page)
+ const postLinks = await page.locator('a[href*="/discussion/"]').count()
+ const isNotFound = /page not found/i.test(text)
+ const fallbackShown =
+ /can.?t load this while offline|haven.?t been saved for offline use/i.test(text)
+
+ checkPosts.textSnippet = text.slice(0, 400)
+ checkPosts.info = info
+ checkPosts.postLinks = postLinks
+ checkPosts.isNotFound = isNotFound
+ checkPosts.fallbackShown = fallbackShown
+ checkPosts.screenshot = await shot(page, 'p2-reload-posts-tab')
+ checkPosts.pass = !isNotFound && !fallbackShown && postLinks > 0
+ checkPosts.symptom = checkPosts.pass
+ ? `${postLinks} cached post row(s) rendered offline`
+ : isNotFound
+ ? 'NotFound shown instead of cached profile'
+ : fallbackShown
+ ? 'offline fallback shown even though Posts tab was visited online first (cache miss)'
+ : 'no post rows found — posts list likely empty/blank'
+ } catch (e) {
+ checkPosts.pass = false
+ checkPosts.symptom = `threw: ${e.message}`
+ checkPosts.screenshot = await shot(page, 'p2-reload-posts-tab-error')
+ }
+ result.checks.push(checkPosts)
+
+ // Now reload onto the Profile tab specifically, to check header + bento independent
+ // of whatever the Posts-tab reload left mounted.
+ let checkProfile = { name: 'reload on Profile tab: header + bento render offline' }
+ try {
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkProfile.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+
+ const skeletonBefore = await fuiSkeletonCount(page)
+ await page.waitForTimeout(3000)
+ const skeletonAfter = await fuiSkeletonCount(page)
+ const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0
+
+ const text = await innerTextSafe(page)
+ const isNotFound = /page not found/i.test(text)
+ const hasCardContent = await page
+ .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]')
+ .count()
+ const avatars = await avatarInfo(page, 'img')
+ const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0)
+
+ checkProfile.textSnippet = text.slice(0, 400)
+ checkProfile.stuckOnSkeleton = stuckOnSkeleton
+ checkProfile.isNotFound = isNotFound
+ checkProfile.hasCardContent = hasCardContent > 0
+ checkProfile.loadedAvatarCount = loadedAvatars.length
+ checkProfile.screenshot = await shot(page, 'p2-reload-profile-tab')
+ checkProfile.pass = !stuckOnSkeleton && !isNotFound && hasCardContent > 0
+ checkProfile.symptom = checkProfile.pass
+ ? 'header + bento content rendered from cache on reload'
+ : stuckOnSkeleton
+ ? 'stuck on skeleton — bento cards never resolved offline'
+ : isNotFound
+ ? 'showed NotFound instead of cached profile'
+ : 'neither bento cards nor empty-state box rendered'
+ } catch (e) {
+ checkProfile.pass = false
+ checkProfile.symptom = `threw: ${e.message}`
+ checkProfile.screenshot = await shot(page, 'p2-reload-profile-tab-error')
+ }
+ result.checks.push(checkProfile)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('p2', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/p3.js b/frontend/tests/offline/p3.js
new file mode 100644
index 000000000..154944afc
--- /dev/null
+++ b/frontend/tests/offline/p3.js
@@ -0,0 +1,224 @@
+// P3 — Honest failure without cache: a session that goes offline before the background
+// prefetcher (data/offlinePrefetch.ts) or any manual browsing populated the People/profile
+// caches must show an honest "can't load this offline" fallback with retry — never a
+// silent "0 members" empty list, an infinite skeleton, or a misleading NotFound.
+//
+// Race note: we go offline as fast as possible after the very first (minimal) online page
+// load, before the prefetcher's idle-delayed pass can finish. If the prefetcher still wins
+// the race in a given run (slow/fast CI hardware makes the exact timing non-deterministic),
+// we fall back to explicitly deleting the specific IndexedDB cache entries the prefetcher
+// would have written (People list, this profile's doc, this profile's bento) — the
+// documented escape hatch in the task brief — so the "no cache" condition this story is
+// actually about is guaranteed either way, and which path was taken is recorded in the
+// result for transparency.
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ EMAIL,
+ newLoggedInContext,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+const MEMBER = PEOPLE.neverVisitedNoPrefetch // 'hana-suzuki'
+
+// Mirrors frappe-ui's idb-keyval default store (idbStore.ts -> createStore('keyval-store',
+// 'keyval')) and the exact key shapes data/people.ts, ProfileBento/profileBentoSource.ts,
+// and docStore.ts write to, so this only ever touches the People/this-profile entries — not
+// session/communities/spaces data the app needs to know it's still logged in offline.
+async function clearPeopleAndProfileCache(page, personId, sessionUser) {
+ const keysToDelete = [
+ JSON.stringify(['useList', 'People', sessionUser]),
+ JSON.stringify(['useCall', 'ProfileBento', personId, sessionUser]),
+ `doc:GP User Profile/${personId}`,
+ ]
+ return page.evaluate((keys) => {
+ return new Promise((resolve) => {
+ const req = indexedDB.open('keyval-store')
+ req.onsuccess = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains('keyval')) {
+ db.close()
+ resolve({ deleted: [], reason: 'no keyval store' })
+ return
+ }
+ const tx = db.transaction('keyval', 'readwrite')
+ const store = tx.objectStore('keyval')
+ keys.forEach((k) => store.delete(k))
+ tx.oncomplete = () => {
+ db.close()
+ resolve({ deleted: keys })
+ }
+ tx.onerror = () => {
+ db.close()
+ resolve({ deleted: [], reason: String(tx.error) })
+ }
+ }
+ req.onerror = () => resolve({ deleted: [], reason: String(req.error) })
+ })
+ }, keysToDelete)
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors, prefetchLog } =
+ await newLoggedInContext(browser)
+ const result = { story: 'P3', checks: [] }
+
+ try {
+ // Minimal online exposure: enough for the SW to register and become active (it only
+ // starts registering on the window `load` event - see offline.ts's setupOfflineSupport
+ // - and a subsequent offline navigation needs it active to serve the shell at all,
+ // which is a US1 concern, not what this story is testing) but deliberately not waiting
+ // for anything past that, so we go offline as close as possible to page load, ahead of
+ // the prefetcher's idle-delayed kickoff.
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // `/g` itself client-side redirects to the community's discussions route once the
+ // app has hydrated enough to know where to send you (post-327d7ae3, cache-hydration
+ // gated, so not always instant). This story doesn't actually depend on racing ahead
+ // of that specific redirect — only ahead of the background prefetcher's much slower
+ // idle-delayed pass, and it force-clears the People/profile/bento cache entries
+ // below regardless of how any race went (see the comment above) — so it's safe to
+ // let the redirect land first, rather than risk it firing mid-`page.evaluate()`
+ // below and destroying the execution context out from under this test.
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort - if this never resolves, the offline navigations below will
+ // surface it as a shell-level failure rather than the data-layer fallback.
+ }
+ await context.setOffline(true)
+
+ const sawDoneBeforeOffline = prefetchLog.some((l) => l.includes('[offline-prefetch] done'))
+ result.racedSuccessfully = !sawDoneBeforeOffline
+ result.prefetchLogAtOfflineTime = [...prefetchLog]
+
+ // Always clear, not just when the prefetcher's "done" log won the race: `data/people.ts`
+ // exports its `people` list as a module-level `useList({ immediate: true })` singleton,
+ // so the People list starts fetching the moment the app boots on ANY page (imported
+ // transitively by main.js -> offlinePrefetch.ts -> people.ts) - well before the
+ // prefetcher's own idle-delayed pass, and far faster than this script can react after
+ // the page `load` event. Racing `setOffline` against that fetch is not reliably
+ // winnable, so - per the task brief's documented escape hatch - this story always forces
+ // the "nothing cached yet" condition it's actually about, rather than depending on
+ // timing luck for a signal (`people.ts`'s own immediate fetch) this story never races on.
+ result.cacheClear = await clearPeopleAndProfileCache(page, MEMBER, EMAIL)
+
+ result.checks.push({
+ name: 'no People/profile/bento cache present before navigating offline',
+ pass: Boolean(result.cacheClear && result.cacheClear.deleted?.length),
+ symptom: sawDoneBeforeOffline
+ ? `prefetch "done" log had already fired; cleared its cache entries: ${JSON.stringify(result.cacheClear)}`
+ : `raced offline ahead of prefetch "done"; cleared cache entries anyway (people.ts's own immediate fetch races independently): ${JSON.stringify(result.cacheClear)}`,
+ })
+
+ // People page: must show an offline fallback with retry, not "0 members".
+ let checkPeople = { name: 'People page offline, no cache: honest fallback with retry' }
+ try {
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkPeople.gotoError = String(e)
+ })
+ await page.waitForTimeout(2000)
+
+ const text = await innerTextSafe(page)
+ const info = await appRootInfo(page)
+ const fallbackDetected = /can.?t load|not available while offline|offline/i.test(text)
+ const retryVisible = await page.locator('button:has-text("Retry")').count()
+ // "0 members" in the header count is fine as long as it's paired with the honest
+ // fallback message below it — that's a live count reading zero because `people.data`
+ // is null, not a silent claim that the org has no members. Only flag it when there's
+ // no fallback messaging to explain the zero (the actual US6-class bug this guards).
+ const silentEmpty = !fallbackDetected && /\b0 members\b/i.test(text)
+
+ checkPeople.textSnippet = text.slice(0, 400)
+ checkPeople.info = info
+ checkPeople.silentEmpty = silentEmpty
+ checkPeople.fallbackDetected = fallbackDetected
+ checkPeople.retryVisible = retryVisible > 0
+ checkPeople.screenshot = await shot(page, 'p3-people-no-cache')
+ checkPeople.pass = !silentEmpty && fallbackDetected && retryVisible > 0
+ checkPeople.symptom = checkPeople.pass
+ ? 'honest offline fallback with retry shown (not silent "0 members")'
+ : silentEmpty
+ ? 'silent "0 members" with no fallback messaging — indistinguishable from an empty org (US6-class bug)'
+ : !fallbackDetected
+ ? 'no offline messaging found'
+ : 'no retry affordance found'
+ } catch (e) {
+ checkPeople.pass = false
+ checkPeople.symptom = `threw: ${e.message}`
+ checkPeople.screenshot = await shot(page, 'p3-people-no-cache-error')
+ }
+ result.checks.push(checkPeople)
+
+ // A profile with nothing cached: honest fallback, not infinite skeleton, not a
+ // misleading "page not found".
+ let checkProfile = { name: `profile (${MEMBER}) offline, no cache: honest fallback` }
+ try {
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkProfile.gotoError = String(e)
+ })
+ await page.waitForTimeout(2000)
+ const skeletonBefore = await page.evaluate(
+ () => document.querySelectorAll('.fui-skeleton').length,
+ )
+ await page.waitForTimeout(3000)
+ const skeletonAfter = await page.evaluate(
+ () => document.querySelectorAll('.fui-skeleton').length,
+ )
+ const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0
+
+ const text = await innerTextSafe(page)
+ const isNotFound = /page not found/i.test(text)
+ const fallbackDetected =
+ /can.?t load this (profile )?while offline|isn.?t available offline/i.test(text)
+ const retryVisible = await page.locator('button:has-text("Retry")').count()
+
+ checkProfile.textSnippet = text.slice(0, 400)
+ checkProfile.stuckOnSkeleton = stuckOnSkeleton
+ checkProfile.isNotFound = isNotFound
+ checkProfile.fallbackDetected = fallbackDetected
+ checkProfile.retryVisible = retryVisible > 0
+ checkProfile.screenshot = await shot(page, 'p3-profile-no-cache')
+ checkProfile.pass = !stuckOnSkeleton && !isNotFound && fallbackDetected
+ checkProfile.symptom = checkProfile.pass
+ ? 'honest "can\'t load this profile while offline" fallback shown'
+ : stuckOnSkeleton
+ ? 'stuck on skeleton forever'
+ : isNotFound
+ ? 'misleading NotFound page shown instead of an offline fallback'
+ : 'no offline fallback messaging detected'
+ } catch (e) {
+ checkProfile.pass = false
+ checkProfile.symptom = `threw: ${e.message}`
+ checkProfile.screenshot = await shot(page, 'p3-profile-no-cache-error')
+ }
+ result.checks.push(checkProfile)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('p3', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/package.json b/frontend/tests/offline/package.json
new file mode 100644
index 000000000..0fef86e33
--- /dev/null
+++ b/frontend/tests/offline/package.json
@@ -0,0 +1,4 @@
+{
+ "private": true,
+ "type": "commonjs"
+}
diff --git a/frontend/tests/offline/runner.js b/frontend/tests/offline/runner.js
new file mode 100644
index 000000000..010227250
--- /dev/null
+++ b/frontend/tests/offline/runner.js
@@ -0,0 +1,47 @@
+// Runs the full offline suite: US1-US6 (baseline offline UX) + P1-P3 (People/profile
+// offline caching + background prefetch) + US7a/US7b (shared-computer cache scoping) +
+// US8 (service worker update flow). Each story launches its own fresh browser/context
+// for isolation. Run with: node tests/offline/runner.js (or `yarn test:offline` from
+// frontend/).
+const fs = require('fs')
+const path = require('path')
+const { RESULTS_DIR } = require('./config')
+
+const stories = ['us1', 'us2', 'us3', 'us4', 'us5', 'us6', 'p1', 'p2', 'p3', 'us7a', 'us7b', 'us8']
+
+async function main() {
+ const summary = []
+ for (const story of stories) {
+ console.log(`\n=== Running ${story.toUpperCase()} ===`)
+ const mod = require(`./${story}`)
+ try {
+ const r = await mod.run()
+ summary.push({
+ story: r.story,
+ pass: r.pass,
+ checks: (r.checks || []).map((c) => ({ name: c.name, pass: c.pass, symptom: c.symptom })),
+ fatalError: r.fatalError,
+ cleanup: r.cleanup,
+ })
+ console.log(`${story.toUpperCase()}: ${r.pass ? 'PASS' : 'FAIL'}`)
+ } catch (e) {
+ console.error(`${story.toUpperCase()} crashed:`, e)
+ summary.push({ story: story.toUpperCase(), pass: false, fatalError: String(e) })
+ }
+ }
+
+ fs.mkdirSync(RESULTS_DIR, { recursive: true })
+ const outPath = path.join(RESULTS_DIR, 'summary.json')
+ fs.writeFileSync(outPath, JSON.stringify(summary, null, 2))
+ console.log('\n=== Summary ===')
+ for (const s of summary) {
+ console.log(`${s.story}: ${s.pass ? 'PASS' : 'FAIL'}`)
+ }
+ console.log(`\nFull summary written to ${outPath}`)
+
+ if (summary.some((s) => !s.pass)) {
+ process.exitCode = 1
+ }
+}
+
+main()
diff --git a/frontend/tests/offline/smoke-online-people.js b/frontend/tests/offline/smoke-online-people.js
new file mode 100644
index 000000000..cc23e37a9
--- /dev/null
+++ b/frontend/tests/offline/smoke-online-people.js
@@ -0,0 +1,137 @@
+// Online regression smoke test (round 3 addition) — fresh context, always online. Confirms
+// the People page and a member profile load normally with the new caching/prefetch code in
+// place, no new console errors beyond the known :9000 socket.io refusal (see env.md), and
+// the background prefetcher's '[offline-prefetch] done' log appears with plausible counts.
+// Complements smoke-online.js (feed/space/discussion + comment post/delete), which this
+// does not repeat.
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ newLoggedInContext,
+ waitForPrefetchDone,
+ avatarInfo,
+ shot,
+ innerTextSafe,
+ writeResult,
+} = require('./helpers')
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors, prefetchLog } =
+ await newLoggedInContext(browser)
+ const result = { story: 'SMOKE-ONLINE-PEOPLE', checks: [] }
+
+ // `msg.text()` for a "Failed to load resource" console error doesn't include the URL, so
+ // matching it against a socket.io/:9000 regex (as smoke-online.js's own comment describes
+ // doing "via a requestfailed listener") needs the request-level event instead - track how
+ // many of those failures are the known :9000 socket.io refusal so the console-error check
+ // below can subtract exactly that many, not just pattern-match on generic text.
+ let socketIoFailures = 0
+ page.on('requestfailed', (req) => {
+ if (/:9000\/socket\.io/i.test(req.url())) socketIoFailures++
+ })
+
+ try {
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // See smoke-online.js's identical comment: let /g's client-side redirect land first.
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ await page.waitForTimeout(1000)
+
+ // People page
+ let checkPeople = { name: 'People page loads online' }
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ const peopleText = await innerTextSafe(page)
+ const avatars = await avatarInfo(page, 'img')
+ const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0)
+ checkPeople.textSnippet = peopleText.slice(0, 300)
+ checkPeople.loadedAvatarCount = loadedAvatars.length
+ checkPeople.pass =
+ /\d+\s+members?/i.test(peopleText) && !/^0\s+members/i.test(peopleText.trim())
+ checkPeople.symptom = checkPeople.pass
+ ? 'member list rendered online'
+ : 'People page did not render a real member count online'
+ checkPeople.screenshot = await shot(page, 'smoke-people-online')
+ result.checks.push(checkPeople)
+
+ // A profile
+ let checkProfile = { name: 'A member profile loads online' }
+ await page.goto(URLS.person(PEOPLE.visitedFully), { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(2000)
+ const profileText = await innerTextSafe(page)
+ const hasCardContent = await page
+ .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]')
+ .count()
+ checkProfile.textSnippet = profileText.slice(0, 300)
+ checkProfile.hasCardContent = hasCardContent > 0
+ checkProfile.pass = !/page not found/i.test(profileText) && hasCardContent > 0
+ checkProfile.symptom = checkProfile.pass
+ ? 'profile header + bento content rendered online'
+ : 'profile did not render bento content online (see hasCardContent)'
+ checkProfile.screenshot = await shot(page, 'smoke-profile-online')
+ result.checks.push(checkProfile)
+
+ // Background prefetch completes with plausible counts
+ let checkPrefetch = { name: 'background prefetch "done" log with plausible counts' }
+ const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 })
+ checkPrefetch.prefetch = prefetch
+ checkPrefetch.pass = Boolean(
+ prefetch.sawDone &&
+ prefetch.counts &&
+ prefetch.counts.members > 0 &&
+ prefetch.counts.profiles > 0 &&
+ prefetch.counts.bento > 0,
+ )
+ checkPrefetch.symptom = checkPrefetch.pass
+ ? `plausible counts: ${JSON.stringify(prefetch.counts)}`
+ : `no plausible "done" log seen: ${JSON.stringify(prefetch)}`
+ result.checks.push(checkPrefetch)
+
+ // No new console errors beyond the known socket.io :9000 refusal. Every "Failed to load
+ // resource" console error should be accounted for by an equal number of :9000 socket.io
+ // requestfailed events (see the listener above) — anything left over is unexpected.
+ const failedResourceErrors = consoleErrors.filter((e) => e.includes('Failed to load resource'))
+ const otherConsoleErrors = consoleErrors.filter((e) => !e.includes('Failed to load resource'))
+ const unexplainedFailedResourceCount = Math.max(
+ 0,
+ failedResourceErrors.length - socketIoFailures,
+ )
+ result.socketIoFailures = socketIoFailures
+ result.checks.push({
+ name: 'no unexpected console errors',
+ pass:
+ unexplainedFailedResourceCount === 0 &&
+ otherConsoleErrors.length === 0 &&
+ pageErrors.length === 0,
+ symptom:
+ unexplainedFailedResourceCount === 0 &&
+ otherConsoleErrors.length === 0 &&
+ pageErrors.length === 0
+ ? `only the known socket.io :9000 refusal (${socketIoFailures} request(s)), zero uncaught exceptions`
+ : `unexplained failed-resource errors=${unexplainedFailedResourceCount}, other console errors=${JSON.stringify(otherConsoleErrors)}, pageErrors=${JSON.stringify(pageErrors)}`,
+ })
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ result.prefetchLog = prefetchLog
+ await browser.close()
+ }
+
+ writeResult('smoke-online-people', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/smoke-online-shared-computer.js b/frontend/tests/offline/smoke-online-shared-computer.js
new file mode 100644
index 000000000..6aeb0b37d
--- /dev/null
+++ b/frontend/tests/offline/smoke-online-shared-computer.js
@@ -0,0 +1,66 @@
+// Online regression smoke test (round 4 addition) — confirms this round's shared-computer
+// safety + update-flow changes didn't break ordinary online usage: a plain login/logout
+// cycle still works and lands on /login, and a completely fresh session sees NO update
+// toast on first visit (no stale 'waiting' worker, no spurious controllerchange reload).
+// Complements smoke-online.js (feed/space/discussion + comment) and
+// smoke-online-people.js (People/profile + prefetch), which this does not repeat.
+const { chromium, URLS, newLoggedInContext, logoutViaUI, shot, writeResult } = require('./helpers')
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'SMOKE-ONLINE-SHARED-COMPUTER', checks: [] }
+
+ try {
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+
+ // No update toast on a fresh first visit (no prior SW version to diff against).
+ const toastVisible = await page
+ .locator('text=/A new version of Gameplan is available/i')
+ .isVisible()
+ .catch(() => false)
+ result.checks.push({
+ name: 'no update toast on first visit',
+ pass: !toastVisible,
+ symptom: toastVisible
+ ? 'update toast unexpectedly shown on a fresh session'
+ : 'no toast shown',
+ })
+
+ // Normal logout via the real UI path still works and lands on /login.
+ let checkLogout = { name: 'logout via UI works, lands on /login' }
+ try {
+ await logoutViaUI(page)
+ await page.waitForURL('**/login**', { timeout: 10000 })
+ checkLogout.pass = true
+ checkLogout.symptom = `redirected to ${page.url()}`
+ } catch (e) {
+ checkLogout.pass = false
+ checkLogout.symptom = `threw: ${e.message}`
+ checkLogout.screenshot = await shot(page, 'smoke-shared-computer-logout-error')
+ }
+ result.checks.push(checkLogout)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await browser.close()
+ }
+
+ writeResult('smoke-online-shared-computer', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/smoke-online.js b/frontend/tests/offline/smoke-online.js
new file mode 100644
index 000000000..2132b8b76
--- /dev/null
+++ b/frontend/tests/offline/smoke-online.js
@@ -0,0 +1,173 @@
+// Online regression smoke test — fresh context, online only. Confirms feed/space/
+// discussion load, post+delete a comment works, no new console errors, and the
+// offline indicator is NOT visible while online. Not part of the US1-US6 suite.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ newApiRequestContext,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+const MARKER = `smoke-${Date.now()}`
+const DISTINCTIVE_TEXT = `Online smoke test comment ${MARKER}`
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'SMOKE-ONLINE', checks: [] }
+ let createdCommentName = null
+
+ try {
+ // Feed
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // `/g` client-side redirects to the community's discussions route once the app has
+ // hydrated — let it land before the next goto(), or that next navigation can get
+ // reported as "interrupted by another navigation" to the redirect's target.
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ // Poll rather than a single fixed-delay check: under system load, the redirect and
+ // first render can land a bit after `waitForURL` resolves — same content, just slower.
+ let feedInfo = { title: '', bodyText: '' }
+ const feedDeadline = Date.now() + 8000
+ while (Date.now() < feedDeadline) {
+ feedInfo = await page.evaluate(() => ({
+ title: document.title,
+ bodyText: document.body.innerText.slice(0, 300),
+ }))
+ if (feedInfo.bodyText.length > 0) break
+ await page.waitForTimeout(500)
+ }
+ result.checks.push({ name: 'feed loads', pass: feedInfo.bodyText.length > 0, info: feedInfo })
+
+ // Space discussion list
+ await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ const spaceInfo = await page.evaluate(() => document.body.innerText.slice(0, 300))
+ result.checks.push({
+ name: 'space discussion list loads',
+ pass: spaceInfo.includes('Art'),
+ info: spaceInfo,
+ })
+
+ // Discussion
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ const discInfo = await page.evaluate(() => document.body.innerText.slice(0, 300))
+ result.checks.push({
+ name: 'discussion loads',
+ pass: discInfo.includes('Capsule art'),
+ info: discInfo,
+ })
+
+ // Offline indicator should NOT be visible online
+ const offlineIndicatorVisible = await page.evaluate(() => {
+ const els = Array.from(document.querySelectorAll('body *'))
+ const re = /offline|you.?re offline|showing saved|reconnect/i
+ return els.some((el) => {
+ if (el.children.length > 0) return false
+ const text = el.textContent?.trim()
+ if (!text || text.length > 100) return false
+ if (!re.test(text)) return false
+ const rect = el.getBoundingClientRect()
+ const style = window.getComputedStyle(el)
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ style.visibility !== 'hidden' &&
+ style.display !== 'none'
+ )
+ })
+ })
+ result.checks.push({
+ name: 'offline indicator NOT visible while online',
+ pass: !offlineIndicatorVisible,
+ info: { offlineIndicatorVisible },
+ })
+
+ // Post a comment
+ const addCommentBtn = page.locator('button:has-text("Add a comment")').first()
+ if (await addCommentBtn.isVisible().catch(() => false)) {
+ await addCommentBtn.click()
+ await page.waitForTimeout(300)
+ }
+ const editor = page.locator('[contenteditable="true"]').last()
+ await editor.click({ timeout: 8000 })
+ await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 })
+ await page.waitForTimeout(300)
+ const submitBtn = page.locator('button:has-text("Submit"):visible').last()
+ await submitBtn.click({ timeout: 8000 })
+ await page.waitForTimeout(2000)
+ const afterPostText = await page.evaluate(() => document.body.innerText)
+ const posted = afterPostText.includes(DISTINCTIVE_TEXT)
+ result.checks.push({ name: 'post comment online', pass: posted })
+ result.screenshotAfterPost = await shot(page, 'smoke-online-comment-posted')
+
+ if (posted) {
+ // Delete via UI: open the comment's "..." (Comment Options) dropdown, click
+ // Delete, confirm the danger dialog.
+ let deletedViaUI = false
+ try {
+ const commentRow = page.locator(':has-text("' + DISTINCTIVE_TEXT + '")').last()
+ const optionsBtn = page.locator('button[aria-label="Comment Options"]').last()
+ await optionsBtn.click({ timeout: 5000 })
+ await page.waitForTimeout(200)
+ await page.locator('text=Delete').last().click({ timeout: 5000 })
+ await page.waitForTimeout(300)
+ await page.locator('button:has-text("Delete")').last().click({ timeout: 5000 })
+ await page.waitForTimeout(1500)
+ const afterDeleteText = await page.evaluate(() => document.body.innerText)
+ deletedViaUI = !afterDeleteText.includes(DISTINCTIVE_TEXT)
+ } catch (e) {
+ result.deleteViaUIError = String(e)
+ }
+ result.checks.push({ name: 'delete comment via UI', pass: deletedViaUI })
+ result.screenshotAfterDelete = await shot(page, 'smoke-online-comment-deleted')
+
+ // Verify + fallback cleanup via API regardless (belt and suspenders — don't
+ // leave a stray comment on the site if the UI delete selector was off).
+ const api = await newApiRequestContext()
+ const resp = await api.get(
+ `/api/method/frappe.client.get_list?doctype=GP%20Comment&filters=${encodeURIComponent(
+ JSON.stringify([
+ ['reference_name', '=', '55'],
+ ['content', 'like', `%${MARKER}%`],
+ ]),
+ )}&fields=${encodeURIComponent(JSON.stringify(['name']))}`,
+ )
+ const body = await resp.json().catch(() => null)
+ createdCommentName = body?.message?.[0]?.name ?? null
+ if (createdCommentName) {
+ await api.post('/api/method/frappe.client.delete', {
+ form: { doctype: 'GP Comment', name: String(createdCommentName) },
+ })
+ result.cleanup = `deletedViaUI=${deletedViaUI}; also deleted GP Comment ${createdCommentName} via API (fallback/verify)`
+ } else {
+ result.cleanup = `deletedViaUI=${deletedViaUI}; no leftover GP Comment found via API`
+ }
+ await api.dispose()
+ }
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ result.screenshotOnError = await shot(page, 'smoke-online-error')
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await browser.close()
+ }
+
+ writeResult('smoke-online', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us1.js b/frontend/tests/offline/us1.js
new file mode 100644
index 000000000..25559b598
--- /dev/null
+++ b/frontend/tests/offline/us1.js
@@ -0,0 +1,81 @@
+// US1 — Launch offline: reload /g and a deep link while offline; app shell should
+// render instead of the browser's net::ERR page.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ warmup,
+ shot,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US1', checks: [] }
+
+ try {
+ result.warmup = await warmup(page)
+
+ await context.setOffline(true)
+
+ // Check 1: reload /g while offline.
+ let check1 = { name: 'reload /g offline' }
+ try {
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 8000 })
+ await page.waitForTimeout(1500)
+ check1.info = await appRootInfo(page)
+ check1.screenshot = await shot(page, 'us1-reload-feed')
+ check1.pass = check1.info.appExists && check1.info.appChildCount > 0
+ check1.symptom = check1.pass
+ ? 'app shell rendered'
+ : `blank/unmounted app root (appExists=${check1.info.appExists}, children=${check1.info.appChildCount})`
+ } catch (e) {
+ check1.pass = false
+ check1.symptom = `navigation threw: ${e.message}`
+ check1.screenshot = await shot(page, 'us1-reload-feed-error')
+ }
+ result.checks.push(check1)
+
+ // Check 2: reload the discussion deep link while offline.
+ let check2 = { name: 'reload discussion deep link offline' }
+ try {
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 8000 })
+ await page.waitForTimeout(1500)
+ check2.info = await appRootInfo(page)
+ check2.screenshot = await shot(page, 'us1-reload-discussion')
+ check2.pass = check2.info.appExists && check2.info.appChildCount > 0
+ check2.symptom = check2.pass
+ ? 'app shell rendered'
+ : `blank/unmounted app root (appExists=${check2.info.appExists}, children=${check2.info.appChildCount})`
+ } catch (e) {
+ check2.pass = false
+ check2.symptom = `navigation threw: ${e.message}`
+ check2.screenshot = await shot(page, 'us1-reload-discussion-error')
+ }
+ result.checks.push(check2)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us1', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us2.js b/frontend/tests/offline/us2.js
new file mode 100644
index 000000000..9d9bdbd4b
--- /dev/null
+++ b/frontend/tests/offline/us2.js
@@ -0,0 +1,196 @@
+// US2 — Read what I've seen: after warming feed / space discussion list / discussion,
+// go offline and revisit each via both reload and client-side nav; cached content
+// (title/body/comments/list items) should be visible, no infinite spinner/error screen.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ warmup,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+const EXPECTED = {
+ discussionTitle: 'Capsule art, near-final, need eyes before it goes on the page',
+ spaceTitle: 'Art',
+}
+
+function textCheck(text, needle) {
+ return text.toLowerCase().includes(needle.toLowerCase())
+}
+
+async function evaluateContent(page, name, requiredSnippets) {
+ await page.waitForTimeout(2000)
+ const info = await appRootInfo(page)
+ const text = await innerTextSafe(page)
+ const screenshot = await shot(page, name)
+ const foundSnippets = requiredSnippets.filter((s) => textCheck(text, s))
+ const missingSnippets = requiredSnippets.filter((s) => !textCheck(text, s))
+ const looksLikeSpinnerOnly = /^\s*$/.test(text) || text.trim().length < 5
+ const landedOnOnboarding = page.url().includes('/onboarding')
+ return {
+ url: page.url(),
+ info,
+ screenshot,
+ textSnippet: text.slice(0, 400),
+ foundSnippets,
+ missingSnippets,
+ looksLikeSpinnerOnly,
+ landedOnOnboarding,
+ pass:
+ info.appExists &&
+ info.appChildCount > 0 &&
+ missingSnippets.length === 0 &&
+ !landedOnOnboarding,
+ }
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US2', checks: [] }
+
+ try {
+ result.warmup = await warmup(page)
+ await context.setOffline(true)
+
+ // --- Reload-based checks (fresh navigation while offline) ---
+ for (const [label, url, snippets] of [
+ ['reload feed', URLS.feed, ['Common Room']],
+ ['reload space discussions', URLS.spaceDiscussions, [EXPECTED.spaceTitle]],
+ ['reload discussion', URLS.discussion, [EXPECTED.discussionTitle]],
+ ]) {
+ let check = { name: label, mode: 'reload' }
+ try {
+ await page.goto(url, { waitUntil: 'load', timeout: 8000 })
+ Object.assign(
+ check,
+ await evaluateContent(page, `us2-${label.replace(/\s+/g, '-')}`, snippets),
+ )
+ check.symptom = check.pass
+ ? 'cached content visible'
+ : check.landedOnOnboarding
+ ? 'redirected to /g/onboarding instead of showing cached feed content'
+ : check.looksLikeSpinnerOnly
+ ? 'blank / spinner-only, no content'
+ : `missing expected text: ${check.missingSnippets.join(', ') || '(app root did not mount)'}`
+ } catch (e) {
+ check.pass = false
+ check.symptom = `navigation threw: ${e.message}`
+ check.screenshot = await shot(page, `us2-${label.replace(/\s+/g, '-')}-error`)
+ }
+ result.checks.push(check)
+ }
+
+ // --- Client-side navigation checks (SPA nav while offline, starting from the
+ // discussion page which we know loads from the reload check above) ---
+ let clientNavOk = true
+ try {
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 8000 })
+ await page.waitForTimeout(1500)
+ } catch (e) {
+ clientNavOk = false
+ result.checks.push({
+ name: 'client-nav setup (load discussion)',
+ mode: 'client-nav',
+ pass: false,
+ symptom: `could not establish SPA starting point offline: ${e.message}`,
+ })
+ }
+
+ if (clientNavOk) {
+ for (const [label, url, snippets] of [
+ ['client-nav to space discussions', URLS.spaceDiscussions, [EXPECTED.spaceTitle]],
+ ['client-nav to feed', URLS.feed, ['Common Room']],
+ ['client-nav back to discussion', URLS.discussion, [EXPECTED.discussionTitle]],
+ ]) {
+ let check = { name: label, mode: 'client-nav' }
+ try {
+ // Use page.evaluate + history/router link click semantics via direct goto with
+ // waitUntil 'commit' would still be a browser navigation; to truly exercise SPA
+ // client-side routing we click an in-app link where possible. Falling back to
+ // page.goto with 'commit' (not 'load') most closely emulates a client nav wait
+ // profile without forcing a full reload wait, but Playwright's goto is always a
+ // real navigation. We accept this as "navigation" coverage; a dedicated in-app
+ // link click is used for at least one hop below for genuine SPA-nav evidence.
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 })
+ Object.assign(
+ check,
+ await evaluateContent(page, `us2-${label.replace(/\s+/g, '-')}`, snippets),
+ )
+ check.symptom = check.pass
+ ? 'cached content visible'
+ : check.landedOnOnboarding
+ ? 'redirected to /g/onboarding instead of showing cached feed content'
+ : check.looksLikeSpinnerOnly
+ ? 'blank / spinner-only, no content'
+ : `missing expected text: ${check.missingSnippets.join(', ') || '(app root did not mount)'}`
+ } catch (e) {
+ check.pass = false
+ check.symptom = `navigation threw: ${e.message}`
+ check.screenshot = await shot(page, `us2-${label.replace(/\s+/g, '-')}-error`)
+ }
+ result.checks.push(check)
+ }
+
+ // Genuine in-app SPA link click: from space discussions list, click the seeded
+ // discussion row and confirm it opens client-side (no full navigation) while offline.
+ let clickCheck = {
+ name: 'in-app link click: space list -> discussion',
+ mode: 'client-nav-click',
+ }
+ try {
+ await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 8000 })
+ await page.waitForTimeout(1500)
+ const link = page.locator(`a[href*="/discussion/55"]`).first()
+ const linkVisible = await link.isVisible().catch(() => false)
+ clickCheck.linkVisible = linkVisible
+ if (linkVisible) {
+ await link.click()
+ await page.waitForTimeout(1500)
+ Object.assign(
+ clickCheck,
+ await evaluateContent(page, 'us2-click-into-discussion', [EXPECTED.discussionTitle]),
+ )
+ clickCheck.symptom = clickCheck.pass
+ ? 'cached content visible after client-side link click'
+ : clickCheck.landedOnOnboarding
+ ? 'redirected to /g/onboarding instead of the discussion'
+ : `missing expected text: ${clickCheck.missingSnippets?.join(', ') || '(app root did not mount)'}`
+ } else {
+ clickCheck.pass = false
+ clickCheck.symptom = 'discussion row link not found/visible in cached space list'
+ clickCheck.screenshot = await shot(page, 'us2-click-into-discussion-no-link')
+ }
+ } catch (e) {
+ clickCheck.pass = false
+ clickCheck.symptom = `click nav threw: ${e.message}`
+ }
+ result.checks.push(clickCheck)
+ }
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us2', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us3.js b/frontend/tests/offline/us3.js
new file mode 100644
index 000000000..00d1a2196
--- /dev/null
+++ b/frontend/tests/offline/us3.js
@@ -0,0 +1,132 @@
+// US3 — Know I'm offline: an indicator should appear when offline and clear on
+// reconnect. We search broadly (role=status/alert, common banner/toast/pill classes,
+// and any element whose text matches /offline/i) since we don't know the exact
+// implementation up front.
+const {
+ chromium,
+ URLS,
+ EMAIL,
+ newLoggedInContext,
+ warmup,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+// The seeded test account's own username ("offline-tester") literally contains
+// "offline" and renders on the page regardless of connectivity (header/sidebar/hover
+// cards showing the signed-in user's name) — exclude an exact match on it so it can't
+// masquerade as the real connectivity indicator below.
+const USERNAME = EMAIL.split('@')[0]
+
+async function findOfflineIndicator(page, excludeExact) {
+ return page.evaluate((exclude) => {
+ const re = /offline|you.?re offline|showing saved|no connection|reconnect/i
+ const candidates = []
+ const all = document.querySelectorAll('body *')
+ for (const el of all) {
+ // Only leaf-ish elements with direct text, to avoid matching giant containers.
+ const text = el.textContent?.trim() || ''
+ if (!text || text.length > 200) continue
+ if (re.test(text)) {
+ const ownText = Array.from(el.childNodes)
+ .filter((n) => n.nodeType === Node.TEXT_NODE)
+ .map((n) => n.textContent)
+ .join('')
+ .trim()
+ if (ownText && re.test(ownText) && ownText.toLowerCase() !== exclude.toLowerCase()) {
+ const rect = el.getBoundingClientRect()
+ candidates.push({
+ tag: el.tagName,
+ class: el.className?.toString?.() || '',
+ text: ownText.slice(0, 200),
+ visible: rect.width > 0 && rect.height > 0,
+ role: el.getAttribute('role'),
+ })
+ }
+ }
+ }
+ return candidates
+ }, excludeExact)
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US3', checks: [] }
+
+ try {
+ result.warmup = await warmup(page)
+
+ // Baseline (online): no offline indicator should be present.
+ const onlineCandidates = await findOfflineIndicator(page, USERNAME)
+ result.onlineBaseline = { candidates: onlineCandidates }
+
+ await context.setOffline(true)
+ // Give any 'offline'/'online' event listener a moment to react (no reload —
+ // this exercises the live indicator, not a reload-triggered one).
+ await page.waitForTimeout(3000)
+ // Nudge in case the app only reacts to navigation/focus rather than the
+ // browser 'offline' event.
+ await page.evaluate(() => window.dispatchEvent(new Event('offline')))
+ await page.waitForTimeout(1000)
+
+ const offlineCandidates = await findOfflineIndicator(page, USERNAME)
+ const offlineShot = await shot(page, 'us3-offline-indicator-search')
+ const visibleOfflineCandidates = offlineCandidates.filter((c) => c.visible)
+
+ let check1 = {
+ name: 'indicator appears when offline',
+ candidates: offlineCandidates,
+ screenshot: offlineShot,
+ pass: visibleOfflineCandidates.length > 0,
+ }
+ check1.symptom = check1.pass
+ ? `found ${visibleOfflineCandidates.length} visible offline-related element(s)`
+ : 'no offline indicator UI exists anywhere in the DOM (searched all elements for /offline|reconnect|no connection/i text)'
+ result.checks.push(check1)
+
+ // Go back online and check the indicator clears (only meaningful if one appeared).
+ await context.setOffline(false)
+ await page.evaluate(() => window.dispatchEvent(new Event('online')))
+ await page.waitForTimeout(3000)
+ const afterOnlineCandidates = await findOfflineIndicator(page, USERNAME)
+ const stillVisible = afterOnlineCandidates.filter((c) => c.visible)
+ const afterOnlineShot = await shot(page, 'us3-after-reconnect')
+
+ let check2 = {
+ name: 'indicator clears on reconnect',
+ candidates: afterOnlineCandidates,
+ screenshot: afterOnlineShot,
+ // Only a meaningful pass if an indicator existed in the first place.
+ pass: check1.pass ? stillVisible.length === 0 : null,
+ }
+ check2.symptom = !check1.pass
+ ? 'n/a — no indicator existed to clear'
+ : check2.pass
+ ? 'indicator cleared after reconnect'
+ : 'indicator still visible after going back online'
+ result.checks.push(check2)
+
+ result.pass = check1.pass && (check2.pass === null || check2.pass === true)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us3', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us4.js b/frontend/tests/offline/us4.js
new file mode 100644
index 000000000..44db5060c
--- /dev/null
+++ b/frontend/tests/offline/us4.js
@@ -0,0 +1,214 @@
+// US4 — Don't lose my words: offline comment submit should fail gracefully (error
+// surfaced, text preserved in the editor), then succeed once back online.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ newApiRequestContext,
+ warmup,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+const MARKER = `offline-us4-${Date.now()}`
+const DISTINCTIVE_TEXT = `Testing offline comment preservation ${MARKER}`
+
+async function openComposer(page) {
+ const addCommentBtn = page.locator('button:has-text("Add a comment")').first()
+ if (await addCommentBtn.isVisible().catch(() => false)) {
+ await addCommentBtn.click()
+ await page.waitForTimeout(300)
+ }
+}
+
+async function getEditor(page) {
+ // The comment composer's ProseMirror/TipTap root; last contenteditable on the
+ // page corresponds to the new-comment box (existing comments render read-only).
+ return page.locator('[contenteditable="true"]').last()
+}
+
+async function getVisibleSubmitButton(page) {
+ // The composer renders both a Comment submit and a (v-show hidden) Poll submit
+ // button with the same text; `:visible` filters out the inactive tab's button.
+ return page.locator('button:has-text("Submit"):visible').last()
+}
+
+async function bodyTextSnapshot(page) {
+ try {
+ return await page.evaluate(() => document.body.innerText)
+ } catch {
+ return ''
+ }
+}
+
+function findNewErrorText(before, after) {
+ const re = /(fail|error|offline|network|could not|try again|unable|no internet|not connected)/i
+ const beforeLines = new Set(
+ before
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean),
+ )
+ const afterLines = after
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean)
+ return afterLines.filter((l) => re.test(l) && !beforeLines.has(l))
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US4', checks: [] }
+ let createdCommentName = null
+
+ try {
+ result.warmup = await warmup(page)
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ await openComposer(page)
+
+ await context.setOffline(true)
+
+ const editor = await getEditor(page)
+ let check1 = { name: 'offline submit fails gracefully, text preserved' }
+ try {
+ await editor.click({ timeout: 8000 })
+ await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 })
+ await page.waitForTimeout(300)
+
+ const beforeSubmitText = await bodyTextSnapshot(page)
+ const submitBtn = await getVisibleSubmitButton(page)
+ await submitBtn.click({ timeout: 8000 }).catch((e) => {
+ check1.clickError = String(e)
+ })
+ await page.waitForTimeout(2000)
+ const afterSubmitText = await bodyTextSnapshot(page)
+
+ const newErrorLines = findNewErrorText(beforeSubmitText, afterSubmitText)
+ const editorTextAfter = await editor.innerText().catch(() => '')
+ const textPreserved = editorTextAfter.includes(DISTINCTIVE_TEXT)
+
+ check1.newErrorLines = newErrorLines
+ check1.textPreserved = textPreserved
+ check1.editorTextAfter = editorTextAfter.slice(0, 300)
+ check1.screenshot = await shot(page, 'us4-offline-submit-attempt')
+ check1.pass = newErrorLines.length > 0 && textPreserved
+ check1.symptom = check1.pass
+ ? `error shown (${newErrorLines[0]}) and text preserved`
+ : !textPreserved
+ ? 'typed text was lost from the editor after failed submit (silent data loss)'
+ : 'no visible error/toast surfaced after offline submit attempt'
+ } catch (e) {
+ check1.pass = false
+ check1.symptom = `threw: ${e.message}`
+ check1.screenshot = await shot(page, 'us4-offline-submit-error')
+ }
+ result.checks.push(check1)
+
+ // Now go online and resubmit — should succeed.
+ let check2 = { name: 'submit succeeds after reconnect' }
+ try {
+ await context.setOffline(false)
+ await page.waitForTimeout(1500)
+
+ // The offline-failure toast from check1 is a real sonner Toast whose
+ // auto-dismiss timer is paused while `document.visibilityState` isn't
+ // "visible" (vue-sonner's `isDocumentHidden` gate) — headless Chromium
+ // pages can sit in that state indefinitely, so the toast lingers over
+ // the Submit button far longer than the 4s TOAST_LIFETIME a real,
+ // focused tab would give it. Dismiss it explicitly (same action a real
+ // user would take) rather than waiting on a timer that may never fire
+ // in this environment.
+ const closeButtons = page.locator('[data-close-button="true"]')
+ const closeCount = await closeButtons.count().catch(() => 0)
+ for (let i = 0; i < closeCount; i++) {
+ await closeButtons
+ .first()
+ .click({ timeout: 2000 })
+ .catch(() => {})
+ }
+ await page.waitForTimeout(300)
+
+ const submitBtn = await getVisibleSubmitButton(page)
+ const editorNow = await getEditor(page)
+ const editorTextNow = await editorNow.innerText().catch(() => '')
+ check2.editorTextBeforeRetry = editorTextNow.slice(0, 300)
+
+ if (!editorTextNow.includes(DISTINCTIVE_TEXT)) {
+ check2.pass = false
+ check2.symptom =
+ 'skipped: distinctive text was not present in editor to resubmit (see check1)'
+ } else {
+ await submitBtn.click({ timeout: 8000 })
+ await page.waitForTimeout(2500)
+ const pageText = await bodyTextSnapshot(page)
+ check2.commentAppeared = pageText.includes(DISTINCTIVE_TEXT)
+ check2.screenshot = await shot(page, 'us4-online-resubmit')
+ check2.pass = check2.commentAppeared
+ check2.symptom = check2.pass
+ ? 'comment posted successfully after reconnect'
+ : 'comment did not appear after resubmitting online'
+
+ if (check2.commentAppeared) {
+ // Find the created comment's name via API for cleanup.
+ const api = await newApiRequestContext()
+ const resp = await api.get(
+ `/api/method/frappe.client.get_list?doctype=GP%20Comment&filters=${encodeURIComponent(
+ JSON.stringify([
+ ['reference_name', '=', '55'],
+ ['content', 'like', `%${MARKER}%`],
+ ]),
+ )}&fields=${encodeURIComponent(JSON.stringify(['name']))}`,
+ )
+ const body = await resp.json().catch(() => null)
+ createdCommentName = body?.message?.[0]?.name ?? null
+ await api.dispose()
+ }
+ }
+ } catch (e) {
+ check2.pass = false
+ check2.symptom = `threw: ${e.message}`
+ check2.screenshot = await shot(page, 'us4-online-resubmit-error')
+ }
+ result.checks.push(check2)
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+
+ // Cleanup: delete the comment we created during this test, if any.
+ if (createdCommentName) {
+ try {
+ const api = await newApiRequestContext()
+ await api.post('/api/method/frappe.client.delete', {
+ form: { doctype: 'GP Comment', name: String(createdCommentName) },
+ })
+ await api.dispose()
+ result.cleanup = `deleted GP Comment ${createdCommentName}`
+ } catch (e) {
+ result.cleanup = `FAILED to delete GP Comment ${createdCommentName}: ${e.message}`
+ }
+ } else {
+ result.cleanup = 'no comment created (or name lookup failed) — nothing to delete'
+ }
+ }
+
+ writeResult('us4', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us5.js b/frontend/tests/offline/us5.js
new file mode 100644
index 000000000..0a2a948f2
--- /dev/null
+++ b/frontend/tests/offline/us5.js
@@ -0,0 +1,143 @@
+// US5 — Seamless recovery: content created elsewhere while we're offline should
+// show up automatically after reconnect, without a hard reload.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ newApiRequestContext,
+ warmup,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+const MARKER = `offline-us5-${Date.now()}`
+const DISTINCTIVE_TEXT = `US5 recovery probe comment ${MARKER}`
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US5', checks: [] }
+ let createdCommentName = null
+
+ try {
+ result.warmup = await warmup(page)
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+
+ await context.setOffline(true)
+ await page.waitForTimeout(500)
+
+ // Create the comment via a fully separate, still-online APIRequestContext while
+ // the page/context under test is offline.
+ const api = await newApiRequestContext()
+ const insertResp = await api.post('/api/method/frappe.client.insert', {
+ form: {
+ doc: JSON.stringify({
+ doctype: 'GP Comment',
+ reference_doctype: 'GP Discussion',
+ reference_name: '55',
+ content: `${DISTINCTIVE_TEXT}
`,
+ }),
+ },
+ })
+ const insertOk = insertResp.ok()
+ const insertBody = await insertResp.json().catch(() => null)
+ createdCommentName = insertBody?.message?.name ?? null
+ result.apiInsert = { ok: insertOk, status: insertResp.status(), name: createdCommentName }
+
+ if (!insertOk || !createdCommentName) {
+ result.pass = false
+ result.fatalError = `Failed to create probe comment via API while offline: ${JSON.stringify(insertBody)}`
+ } else {
+ // Reconnect.
+ await context.setOffline(false)
+ const reconnectAt = Date.now()
+
+ // Nudge the app the ways a real reconnect plausibly would, without navigating:
+ // browser 'online' event, window focus, and document visibility.
+ await page.evaluate(() => window.dispatchEvent(new Event('online')))
+ await page.bringToFront()
+ await page.evaluate(() => {
+ window.dispatchEvent(new Event('focus'))
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+
+ const deadlineMs = 30000
+ const pollIntervalMs = 1000
+ let appearedAt = null
+ let lastText = ''
+ while (Date.now() - reconnectAt < deadlineMs) {
+ lastText = await page.evaluate(() => document.body.innerText).catch(() => '')
+ if (lastText.includes(DISTINCTIVE_TEXT)) {
+ appearedAt = Date.now()
+ break
+ }
+ await page.waitForTimeout(pollIntervalMs)
+ }
+
+ const screenshot = await shot(page, 'us5-after-reconnect-wait')
+ let check = {
+ name: 'new content appears after reconnect without reload',
+ waitedMs: appearedAt ? appearedAt - reconnectAt : deadlineMs,
+ appeared: Boolean(appearedAt),
+ navigationUsed: false,
+ screenshot,
+ lastBodyTextSnippet: lastText.slice(0, 400),
+ pass: Boolean(appearedAt),
+ }
+ check.symptom = check.pass
+ ? `appeared ${check.waitedMs}ms after reconnect (no reload, only online/focus/visibility events)`
+ : `did not appear within ${deadlineMs}ms of reconnect without a reload`
+ result.checks.push(check)
+
+ // Diagnostic-only follow-up: does a client-side navigation/refetch pick it up,
+ // to distinguish "no revalidation mechanism at all" from "revalidation is slower
+ // than 30s"? Does not affect the US5 pass/fail above.
+ if (!check.pass) {
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ const afterNavText = await page.evaluate(() => document.body.innerText).catch(() => '')
+ result.diagnosticAfterFullReload = {
+ appearedAfterReload: afterNavText.includes(DISTINCTIVE_TEXT),
+ }
+ }
+
+ result.pass = result.checks.every((c) => c.pass)
+ }
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+
+ if (createdCommentName) {
+ try {
+ const api = await newApiRequestContext()
+ await api.post('/api/method/frappe.client.delete', {
+ form: { doctype: 'GP Comment', name: String(createdCommentName) },
+ })
+ await api.dispose()
+ result.cleanup = `deleted GP Comment ${createdCommentName}`
+ } catch (e) {
+ result.cleanup = `FAILED to delete GP Comment ${createdCommentName}: ${e.message}`
+ }
+ } else {
+ result.cleanup = 'no comment created — nothing to delete'
+ }
+ }
+
+ writeResult('us5', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us6.js b/frontend/tests/offline/us6.js
new file mode 100644
index 000000000..54d05fed7
--- /dev/null
+++ b/frontend/tests/offline/us6.js
@@ -0,0 +1,111 @@
+// US6 — Honest dead ends: offline navigation to content never visited before should
+// show a friendly "can't load this while offline" state with retry — not a blank
+// screen, crash, or infinite spinner.
+const {
+ chromium,
+ URLS,
+ newLoggedInContext,
+ warmup,
+ shot,
+ innerTextSafe,
+ appRootInfo,
+ writeResult,
+} = require('./helpers')
+
+async function looksLikeFriendlyFallback(text) {
+ return /offline|not available|can.?t load|unable to load|no connection|try again|retry/i.test(
+ text,
+ )
+}
+
+async function checkSpinnerStillSpinning(page) {
+ // Sample the DOM twice, ~1.5s apart, looking for animate-pulse/animate-spin
+ // markers that never resolve — a crude "infinite spinner" detector.
+ const sample = () =>
+ page.evaluate(() => {
+ const spinners = document.querySelectorAll('.animate-spin, .animate-pulse, [role="status"]')
+ return spinners.length
+ })
+ const a = await sample()
+ await page.waitForTimeout(4000)
+ const b = await sample()
+ return { first: a, second: b, stillPresent: a > 0 && b > 0 }
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US6', checks: [] }
+
+ try {
+ result.warmup = await warmup(page)
+ await context.setOffline(true)
+
+ for (const [label, url] of [
+ ['never-visited discussion', URLS.uncachedDiscussion],
+ ['never-visited space', URLS.uncachedSpace],
+ ]) {
+ let check = { name: label, url }
+ const errCountBefore = pageErrors.length
+ try {
+ await page.goto(url, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ check.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+
+ const info = await appRootInfo(page)
+ const text = await innerTextSafe(page)
+ const spinner = await checkSpinnerStillSpinning(page)
+ const friendly = await looksLikeFriendlyFallback(text)
+ const newPageErrors = pageErrors.slice(errCountBefore)
+ const isBlank = !info.appExists || info.appChildCount === 0 || text.trim().length < 5
+
+ check.info = info
+ check.textSnippet = text.slice(0, 400)
+ check.friendlyFallbackDetected = friendly
+ check.isBlank = isBlank
+ check.infiniteSpinner = spinner.stillPresent
+ check.newPageErrors = newPageErrors
+ check.screenshot = await shot(page, `us6-${label.replace(/\s+/g, '-')}`)
+
+ check.pass = friendly && !isBlank && !spinner.stillPresent && newPageErrors.length === 0
+ check.symptom = check.pass
+ ? 'friendly offline fallback shown with no crash/spinner'
+ : isBlank
+ ? 'blank page (no friendly fallback)'
+ : spinner.stillPresent
+ ? 'infinite spinner — content never resolved'
+ : newPageErrors.length > 0
+ ? `uncaught exception(s): ${newPageErrors.join(' | ')}`
+ : 'no offline/retry/"not available" messaging detected — page rendered something else'
+ } catch (e) {
+ check.pass = false
+ check.symptom = `threw: ${e.message}`
+ check.screenshot = await shot(page, `us6-${label.replace(/\s+/g, '-')}-error`)
+ }
+ result.checks.push(check)
+ }
+
+ result.pass = result.checks.every((c) => c.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us6', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us7a.js b/frontend/tests/offline/us7a.js
new file mode 100644
index 000000000..744e95b1a
--- /dev/null
+++ b/frontend/tests/offline/us7a.js
@@ -0,0 +1,203 @@
+// US7a — Shared-computer safety, logout path: after a real UI logout, every trace of the
+// logged-out user's offline content should be gone from this browser (frappe-ui's shared
+// idb-keyval store, and the service worker's SHELL_CACHE + RUNTIME_CACHE), so the next
+// person on this machine can't read it — EXCEPT gameplan-drafts, which the task's Step 0
+// policy adjustment keeps around on a plain logout (same person may log back in and expect
+// their in-progress draft still there; useDraftSync already guards reads by `record.user`
+// so leaving it isn't a leak). See frontend/src/offline.ts's clearOfflineCaches /
+// data/draftStore.ts's clearDraftStore.
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ EMAIL,
+ newLoggedInContext,
+ waitForPrefetchDone,
+ logoutViaUI,
+ idbKeyvalKeys,
+ draftStoreKeys,
+ cacheStorageNames,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+const MEMBER = PEOPLE.visitedFully // 'maya-iyer'
+const DRAFT_MARKER = `us7a-draft-${Date.now()}`
+
+async function openComposer(page) {
+ const addCommentBtn = page.locator('button:has-text("Add a comment")').first()
+ if (await addCommentBtn.isVisible().catch(() => false)) {
+ await addCommentBtn.click()
+ await page.waitForTimeout(300)
+ }
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors, prefetchLog } =
+ await newLoggedInContext(browser)
+ const result = { story: 'US7a', checks: [] }
+
+ try {
+ // Warm caches: People, a profile, and a discussion, plus give the background
+ // prefetcher (data/offlinePrefetch.ts) and the SW's warmLoadedAssets a chance to run,
+ // same as P1/P2's warmup pattern.
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // `/g` client-side redirects to the community's discussions route once the app has
+ // hydrated (post-327d7ae3, that redirect waits on cache hydration, so it isn't
+ // always instant) — let it land before the next goto(), or that next navigation can
+ // get reported as "interrupted by another navigation" to the redirect's target.
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort
+ }
+ const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 })
+ result.prefetch = prefetch
+ await page.waitForTimeout(2000) // warmLoadedAssets() follow-up timer
+
+ // Create a draft (comment composer, not submitted) so we can assert it survives a
+ // plain logout below — the Step 0 policy this story is specifically verifying.
+ let draftCreated = false
+ try {
+ await openComposer(page)
+ const editor = page.locator('[contenteditable="true"]').last()
+ await editor.click({ timeout: 5000 })
+ await page.keyboard.type(DRAFT_MARKER, { delay: 10 })
+ await page.waitForTimeout(500)
+ draftCreated = true
+ } catch (e) {
+ result.draftCreateError = String(e)
+ }
+
+ result.preLogoutKeyvalKeys = await idbKeyvalKeys(page)
+ result.preLogoutDraftKeys = draftCreated ? await draftStoreKeys(page) : null
+ result.preLogoutCaches = await cacheStorageNames(page)
+
+ result.checks.push({
+ name: 'IndexedDB (keyval-store) has data before logout',
+ pass: result.preLogoutKeyvalKeys.length > 0,
+ symptom:
+ result.preLogoutKeyvalKeys.length > 0
+ ? `${result.preLogoutKeyvalKeys.length} key(s) present pre-logout`
+ : 'keyval-store empty before logout — warmup did not populate any cache, test setup problem',
+ })
+
+ if (draftCreated) {
+ result.checks.push({
+ name: 'draft store has the new draft before logout',
+ pass: result.preLogoutDraftKeys.length > 0,
+ symptom:
+ result.preLogoutDraftKeys.length > 0
+ ? `${result.preLogoutDraftKeys.length} draft record(s) present pre-logout`
+ : 'gameplan-drafts empty even after typing a comment — draft never persisted locally',
+ })
+ } else {
+ result.checks.push({
+ name: 'draft store has the new draft before logout',
+ pass: null,
+ symptom: `skipped — could not create a draft: ${result.draftCreateError}`,
+ })
+ }
+
+ // Log out via the real UI path (UserDropdown.vue -> session.logout.submit()), so
+ // this exercises the actual clearOfflineCaches side effect, not a bypass.
+ let checkLogout = { name: 'logout via UI redirects to /login' }
+ try {
+ await logoutViaUI(page)
+ await page.waitForURL('**/login**', { timeout: 10000 })
+ checkLogout.pass = true
+ checkLogout.symptom = `redirected to ${page.url()}`
+ } catch (e) {
+ checkLogout.pass = false
+ checkLogout.symptom = `threw: ${e.message}`
+ checkLogout.screenshot = await shot(page, 'us7a-logout-error')
+ }
+ result.checks.push(checkLogout)
+
+ if (checkLogout.pass) {
+ // Give clearOfflineCaches's async work (awaited by session.ts before the redirect,
+ // but the redirect itself tears down the page — read state on the /login page
+ // that's already loaded, plus a short grace window for the SW round-trip) a beat.
+ await page.waitForTimeout(1000)
+
+ const postLogoutKeyvalKeys = await idbKeyvalKeys(page)
+ const postLogoutDraftKeys = await draftStoreKeys(page)
+ const postLogoutCaches = await cacheStorageNames(page)
+ result.postLogoutKeyvalKeys = postLogoutKeyvalKeys
+ result.postLogoutDraftKeys = postLogoutDraftKeys
+ result.postLogoutCaches = postLogoutCaches
+
+ result.checks.push({
+ name: 'IndexedDB (keyval-store) empty after logout',
+ pass: postLogoutKeyvalKeys.length === 0,
+ symptom:
+ postLogoutKeyvalKeys.length === 0
+ ? 'keyval-store fully cleared'
+ : `${postLogoutKeyvalKeys.length} key(s) still present after logout: ${JSON.stringify(postLogoutKeyvalKeys.slice(0, 10))}`,
+ })
+
+ const leftoverShellOrRuntime = postLogoutCaches.filter(
+ (name) => name.includes(':shell') || name.includes(':runtime'),
+ )
+ const assetCachesRemain = postLogoutCaches.some((name) => name.includes(':assets'))
+ result.checks.push({
+ name: 'Cache Storage: shell/runtime caches gone, assets cache may remain',
+ pass: leftoverShellOrRuntime.length === 0,
+ symptom:
+ leftoverShellOrRuntime.length === 0
+ ? `shell/runtime caches cleared (remaining: ${JSON.stringify(postLogoutCaches)}, assets present=${assetCachesRemain})`
+ : `shell/runtime cache(s) still present after logout: ${JSON.stringify(leftoverShellOrRuntime)}`,
+ })
+
+ if (draftCreated) {
+ result.checks.push({
+ name: 'gameplan-drafts NOT cleared on plain logout (Step 0 policy)',
+ pass: postLogoutDraftKeys.length > 0,
+ symptom:
+ postLogoutDraftKeys.length > 0
+ ? `${postLogoutDraftKeys.length} draft record(s) survived logout, as intended`
+ : 'draft store was wiped on plain logout — Step 0 policy regression (drafts should only clear on a detected user switch)',
+ })
+ } else {
+ result.checks.push({
+ name: 'gameplan-drafts NOT cleared on plain logout (Step 0 policy)',
+ pass: null,
+ symptom: 'skipped — no draft was created pre-logout to check',
+ })
+ }
+ }
+
+ // Overall pass ignores `pass: null` (skipped) checks.
+ result.pass = result.checks.every((c) => c.pass !== false)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us7a', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us7b.js b/frontend/tests/offline/us7b.js
new file mode 100644
index 000000000..5b84a85e3
--- /dev/null
+++ b/frontend/tests/offline/us7b.js
@@ -0,0 +1,214 @@
+// US7b — Shared-computer safety, user-switch path: a SECOND person logging in on the
+// same browser right after the first must not be able to read anything the first user's
+// session left behind. Continues in the SAME browser context as a fresh login (not a new
+// context) — that's what actually exercises guardAgainstUserSwitch (frontend/src/offline.ts),
+// which compares the incoming user_id cookie against localStorage's
+// 'gameplan:last-seen-user' and clears every offline cache (+ drafts — see US7a's Step 0
+// note) the moment it detects a mismatch.
+const {
+ chromium,
+ URLS,
+ PEOPLE,
+ EMAIL,
+ EMAIL2,
+ PWD2,
+ newLoggedInContext,
+ loginAsInSameContext,
+ waitForPrefetchDone,
+ idbKeyvalKeys,
+ draftStoreKeys,
+ lastSeenUserFromStorage,
+ innerTextSafe,
+ shot,
+ writeResult,
+} = require('./helpers')
+
+// A profile user A visited and cached; used to check user B can't read it offline.
+const MEMBER = PEOPLE.visitedFully // 'maya-iyer'
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors, prefetchLog } =
+ await newLoggedInContext(browser)
+ const result = { story: 'US7b', checks: [] }
+
+ try {
+ // --- User A: warm real caches, including the People page and MEMBER's profile. ---
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ // See us7a.js/loginAsInSameContext's identical comment: let /g's client-side
+ // redirect to the community's discussions route land before the next goto(), or it
+ // can get reported as "interrupted by another navigation".
+ await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {})
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort
+ }
+ await waitForPrefetchDone(prefetchLog, { timeoutMs: 20000 }) // best-effort, not required to pass
+
+ const userAKeyvalKeys = await idbKeyvalKeys(page)
+ const userALastSeen = await lastSeenUserFromStorage(page)
+ result.userAKeyvalCount = userAKeyvalKeys.length
+ result.userALastSeen = userALastSeen
+
+ result.checks.push({
+ name: 'user A caches populated, last-seen-user recorded as A',
+ pass: userAKeyvalKeys.length > 0 && userALastSeen === EMAIL,
+ symptom: `keyvalKeys=${userAKeyvalKeys.length} lastSeen=${userALastSeen}`,
+ })
+
+ // --- Switch: log in as user B in the SAME browser context, no explicit logout first
+ // (worst case for a shared computer — someone just switches accounts). guardAgainstUserSwitch
+ // runs at module boot on the next navigation, so loginAsInSameContext already does the
+ // follow-up page.goto(feed) that triggers it. ---
+ await loginAsInSameContext(context, page, EMAIL2, PWD2)
+ await page.waitForTimeout(1500) // let the async clearOfflineCaches()/clearDraftStore() settle
+
+ const userBLastSeen = await lastSeenUserFromStorage(page)
+ const postSwitchKeyvalKeys = await idbKeyvalKeys(page)
+ // Any key still containing A's identity is the actual leak signal. Keys stamped with
+ // B's own identity are expected here — the app's own useCall/useList singletons start
+ // re-fetching for B the instant the post-switch reload boots, racing (and normally
+ // winning shortly after) the async clear, and correctly write back under B's own
+ // cacheKey. That's not a leak; it's B's data, scoped to B.
+ const leakedAKeys = postSwitchKeyvalKeys.filter((k) => k.includes(EMAIL))
+ result.userBLastSeen = userBLastSeen
+ result.postSwitchKeyvalKeys = postSwitchKeyvalKeys
+ result.leakedAKeys = leakedAKeys
+
+ result.checks.push({
+ name: 'localStorage last-seen-user updated to B after switch',
+ pass: userBLastSeen === EMAIL2,
+ symptom: `lastSeen=${userBLastSeen} (expected ${EMAIL2})`,
+ })
+
+ result.checks.push({
+ name: "user-switch clear ran: no key tagged with A's identity survives the switch",
+ pass: leakedAKeys.length === 0,
+ symptom:
+ leakedAKeys.length === 0
+ ? `A's keys cleared on detected user switch (${postSwitchKeyvalKeys.length} key(s) remain, all B's own: ${JSON.stringify(postSwitchKeyvalKeys)})`
+ : `${leakedAKeys.length} key(s) still tagged with A's identity after switch: ${JSON.stringify(leakedAKeys)}`,
+ })
+
+ // Diagnostic only (not gating US7b's pass/fail, which is about cross-user leakage,
+ // not general offline-shell availability — see the note below and results-round4.md):
+ // CLEAR_USER_CACHES also deletes SHELL_CACHE (gameplan-sw.js's clearUserCaches), and
+ // nothing repopulates it until the NEXT successful online navigation. The online
+ // reload above that triggered this switch detection did write a fresh shell via
+ // networkFirstNavigation - but the switch-clear (fired from this same page's JS,
+ // shortly after) deletes it again moments later, with no further online navigation
+ // in between. Verified independently (ad hoc script) that this leaves the browser
+ // with an empty SHELL_CACHE indefinitely - even a plain reload of /g itself then
+ // fails with net::ERR_FAILED while offline, not the app's own offline UI. This is a
+ // real app-side gap (not present before this round's user-switch clearing feature),
+ // not typo-level, so it is reported here as evidence rather than fixed.
+ const postSwitchCaches = await page.evaluate(() => caches.keys())
+ result.postSwitchCaches = postSwitchCaches
+ result.checks.push({
+ name: '[diagnostic, non-gating] SHELL_CACHE survives the user-switch clear',
+ pass: null,
+ symptom: postSwitchCaches.some((n) => n.includes(':shell'))
+ ? 'shell cache present after switch'
+ : `SHELL_CACHE absent after switch clear (caches now: ${JSON.stringify(postSwitchCaches)}) - a reload while offline from here fails with net::ERR_FAILED instead of the app's offline UI; see results-round4.md`,
+ })
+
+ // --- Now go offline immediately, before B's own prefetch/browsing could plausibly
+ // have cached anything of A's, and check B truly can't see A's content. ---
+ await context.setOffline(true)
+
+ let checkPeople = { name: "People page offline as B: does not show A's cached member list" }
+ try {
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkPeople.gotoError = String(e)
+ })
+ await page.waitForTimeout(2000)
+ const text = await innerTextSafe(page)
+ const honestFallback = /can.?t load|not available while offline|offline/i.test(text)
+ // The only way this could wrongly "succeed" is if A's member list actually rendered
+ // (a real leak) — a large member count with no fallback messaging is the signature.
+ const rendersMemberList = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim())
+ const leaked = rendersMemberList && !honestFallback
+
+ checkPeople.textSnippet = text.slice(0, 400)
+ checkPeople.honestFallback = honestFallback
+ checkPeople.rendersMemberList = rendersMemberList
+ checkPeople.screenshot = await shot(page, 'us7b-people-offline-userB')
+ checkPeople.pass = !leaked
+ checkPeople.symptom = checkPeople.pass
+ ? honestFallback
+ ? 'honest offline fallback shown (no leaked member list)'
+ : 'no member list rendered (empty/loading state, not a leak)'
+ : "user A's cached member list rendered for user B offline — cross-user cache leak"
+ } catch (e) {
+ checkPeople.pass = false
+ checkPeople.symptom = `threw: ${e.message}`
+ checkPeople.screenshot = await shot(page, 'us7b-people-offline-userB-error')
+ }
+ result.checks.push(checkPeople)
+
+ let checkProfile = {
+ name: `profile (${MEMBER}) offline as B: does not show A's cached profile`,
+ }
+ try {
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkProfile.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+ const text = await innerTextSafe(page)
+ const isNotFound = /page not found/i.test(text)
+ const honestFallback =
+ /can.?t load this (profile )?while offline|isn.?t available offline/i.test(text)
+ const hasCardContent = await page.locator('[data-profile-card-wrapper="true"]').count()
+ // Real leak signature: A's bento/profile card content actually rendered for B.
+ const leaked = hasCardContent > 0 && !honestFallback
+
+ checkProfile.textSnippet = text.slice(0, 400)
+ checkProfile.isNotFound = isNotFound
+ checkProfile.honestFallback = honestFallback
+ checkProfile.hasCardContent = hasCardContent > 0
+ checkProfile.screenshot = await shot(page, 'us7b-profile-offline-userB')
+ // Honest fallback, an empty/not-found state, or a stuck skeleton are all acceptable
+ // ("honest fallback/empty is correct" per the task brief) — only a rendered card with
+ // A's content and no fallback messaging is a fail.
+ checkProfile.pass = !leaked
+ checkProfile.symptom = checkProfile.pass
+ ? honestFallback
+ ? 'honest "can\'t load this profile while offline" fallback shown'
+ : 'no profile content rendered for B (not-found/empty/skeleton — not a leak)'
+ : "user A's cached profile content rendered for user B offline — cross-user cache leak"
+ } catch (e) {
+ checkProfile.pass = false
+ checkProfile.symptom = `threw: ${e.message}`
+ checkProfile.screenshot = await shot(page, 'us7b-profile-offline-userB-error')
+ }
+ result.checks.push(checkProfile)
+
+ result.pass = result.checks.every((c) => c.pass !== false)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us7b', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/tests/offline/us8.js b/frontend/tests/offline/us8.js
new file mode 100644
index 000000000..06accdd8e
--- /dev/null
+++ b/frontend/tests/offline/us8.js
@@ -0,0 +1,206 @@
+// US8 — Update flow: when a new service worker build lands while the app is open, the
+// user should see a "new version available" toast (not a silent swap, and not a forced
+// reload out from under them) with a Refresh action; clicking it should activate the new
+// worker and reload exactly once (frontend/src/offline.ts's watchForUpdates/
+// notifyUpdateAvailable/watchForControllerChange, gameplan-sw.js's install handler
+// deliberately not calling self.skipWaiting() so an update sits in `waiting` until the
+// user confirms).
+//
+// This test mutates the real gameplan-sw.js on disk (bumps CACHE_VERSION to the next
+// integer, e.g. v7->v8), rebuilds, and reverts + rebuilds again in a `finally` —
+// regardless of pass/fail — so it leaves the repo and the running dev server exactly as
+// it found them for every other story in the suite. The version is read from the file
+// rather than hardcoded so this doesn't drift out of sync with whatever CACHE_VERSION is
+// actually committed.
+const fs = require('fs')
+const path = require('path')
+const { execSync } = require('child_process')
+const { chromium, URLS, newLoggedInContext, shot, writeResult } = require('./helpers')
+const { APP_DIR } = require('./config')
+
+const SW_FILE = path.join(APP_DIR, 'gameplan/www/gameplan-sw.js')
+
+function readCurrentVersion() {
+ const original = fs.readFileSync(SW_FILE, 'utf8')
+ const m = original.match(/const CACHE_VERSION = "(v\d+)";/)
+ if (!m) {
+ throw new Error(`could not find a CACHE_VERSION line in ${SW_FILE} — file has drifted`)
+ }
+ return m[1]
+}
+
+function nextVersion(version) {
+ const n = parseInt(version.slice(1), 10)
+ return `v${n + 1}`
+}
+
+function bumpVersion(from, to) {
+ const original = fs.readFileSync(SW_FILE, 'utf8')
+ const marker = `const CACHE_VERSION = "${from}";`
+ if (!original.includes(marker)) {
+ throw new Error(`expected to find ${JSON.stringify(marker)} in ${SW_FILE} — file has drifted`)
+ }
+ fs.writeFileSync(SW_FILE, original.replace(marker, `const CACHE_VERSION = "${to}";`))
+}
+
+function build() {
+ execSync('yarn build', { cwd: APP_DIR, stdio: 'pipe', timeout: 120000 })
+}
+
+async function run() {
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US8', checks: [] }
+ let bumped = false
+ const FROM_VERSION = readCurrentVersion()
+ const TO_VERSION = nextVersion(FROM_VERSION)
+
+ try {
+ // --- Baseline: the committed worker version active, controlling the page. ---
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)).catch(() => {})
+ await page.waitForTimeout(4000) // warmLoadedAssets() follow-up timer
+
+ const baselineController = await page.evaluate(() => ({
+ scriptURL: navigator.serviceWorker.controller?.scriptURL ?? null,
+ state: navigator.serviceWorker.controller?.state ?? null,
+ }))
+ const baselineCaches = await page.evaluate(() => caches.keys())
+ result.baselineController = baselineController
+ result.baselineCaches = baselineCaches
+ result.checks.push({
+ name: `baseline: ${FROM_VERSION} worker controls the page before the update`,
+ pass:
+ Boolean(baselineController.scriptURL) &&
+ baselineCaches.some((n) => n.includes(`:${FROM_VERSION}:`)),
+ symptom: `controller=${JSON.stringify(baselineController)} caches=${JSON.stringify(baselineCaches)}`,
+ })
+
+ // --- Ship a new SW build (bump CACHE_VERSION by one). ---
+ bumpVersion(FROM_VERSION, TO_VERSION)
+ bumped = true
+ build()
+
+ // --- Reload: the browser fetches the new SW bytes, byte-diffs, and (since install()
+ // doesn't call skipWaiting()) parks the new worker in `waiting` rather than swapping
+ // the controller. Force an explicit update() check too, rather than relying solely on
+ // the browser's own navigation-triggered check, for determinism. ---
+ await page.reload({ waitUntil: 'load', timeout: 15000 })
+ await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.getRegistration('/g')
+ await reg?.update()
+ })
+
+ let checkToast = { name: 'update toast appears with a Refresh action' }
+ try {
+ const toastText = page.locator('text=/A new version of Gameplan is available/i')
+ await toastText.waitFor({ state: 'visible', timeout: 30000 })
+ const refreshBtn = page.getByRole('button', { name: 'Refresh' })
+ const refreshVisible = await refreshBtn.isVisible().catch(() => false)
+
+ checkToast.toastVisible = true
+ checkToast.refreshVisible = refreshVisible
+ checkToast.screenshot = await shot(page, 'us8-update-toast')
+ checkToast.pass = refreshVisible
+ checkToast.symptom = refreshVisible
+ ? 'toast shown with visible Refresh action'
+ : 'toast text found but no visible "Refresh" button'
+ } catch (e) {
+ checkToast.pass = false
+ checkToast.symptom = `threw/timed out: ${e.message}`
+ checkToast.screenshot = await shot(page, 'us8-update-toast-missing')
+ }
+ result.checks.push(checkToast)
+
+ if (checkToast.pass) {
+ // --- Click Refresh: should postMessage SKIP_WAITING, the new worker activates,
+ // fires `controllerchange`, and offline.ts's watchForControllerChange reloads the
+ // page exactly once (guarded by a once-flag against a reload loop). ---
+ let loadCount = 0
+ page.on('load', () => {
+ loadCount += 1
+ })
+
+ let checkRefresh = {
+ name: `clicking Refresh reloads exactly once and ${TO_VERSION} takes control`,
+ }
+ try {
+ // Wait for the actual reload's `load` event (deterministic) rather than a fixed
+ // sleep — the click -> postMessage -> activate -> controllerchange -> reload
+ // chain has no fixed duration, so a blind timeout is either flaky (too short) or
+ // slow (too generous). Race the click against the reload so a `load` that fires
+ // mid-click isn't missed.
+ await Promise.all([
+ page.waitForEvent('load', { timeout: 15000 }),
+ page.getByRole('button', { name: 'Refresh' }).click({ timeout: 8000 }),
+ ])
+ // Let the reloaded page's own JS (offline.ts re-registering, SW settling) finish
+ // booting before reading its state.
+ await page.waitForTimeout(1500)
+
+ const afterController = await page.evaluate(() => ({
+ scriptURL: navigator.serviceWorker.controller?.scriptURL ?? null,
+ state: navigator.serviceWorker.controller?.state ?? null,
+ }))
+ const afterCaches = await page.evaluate(() => caches.keys())
+
+ checkRefresh.loadCount = loadCount
+ checkRefresh.afterController = afterController
+ checkRefresh.afterCaches = afterCaches
+ const controllerActivated = afterController.state === 'activated'
+ const newCachesPresent = afterCaches.some((n) => n.includes(`:${TO_VERSION}:`))
+ const oldCachesGone = !afterCaches.some((n) => n.includes(`:${FROM_VERSION}:`))
+ // Exactly one reload from the Refresh click. `load` also already fired once for
+ // this listener's own attachment point (the page we're on when we attach), so we
+ // count loads strictly AFTER attaching — loadCount should be exactly 1.
+ checkRefresh.pass =
+ loadCount === 1 && controllerActivated && newCachesPresent && oldCachesGone
+ checkRefresh.symptom = checkRefresh.pass
+ ? `single reload (loadCount=1), ${TO_VERSION} worker activated and controlling, old ${FROM_VERSION} caches evicted`
+ : `loadCount=${loadCount} (expected 1), controllerActivated=${controllerActivated}, newCachesPresent=${newCachesPresent}, oldCachesGone=${oldCachesGone} — caches=${JSON.stringify(afterCaches)}`
+ } catch (e) {
+ checkRefresh.pass = false
+ checkRefresh.symptom = `threw: ${e.message}`
+ checkRefresh.screenshot = await shot(page, 'us8-refresh-error')
+ }
+ result.checks.push(checkRefresh)
+ }
+
+ result.pass = result.checks.every((c) => c.pass !== false)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+
+ // Always restore the original version + rebuild, regardless of outcome, so the rest
+ // of the suite (and the repo state for the eventual commit) is unaffected by this
+ // story having run.
+ if (bumped) {
+ try {
+ bumpVersion(TO_VERSION, FROM_VERSION)
+ build()
+ result.reverted = true
+ } catch (e) {
+ result.revertError = String(e)
+ result.pass = false
+ }
+ }
+ }
+
+ writeResult('us8', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 64df89b1a..8f98dddb4 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2605,6 +2605,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+fsevents@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@@ -3796,6 +3801,20 @@ pkg-types@^2.1.0, pkg-types@^2.3.0:
exsolve "^1.0.8"
pathe "^2.0.3"
+playwright-core@1.62.1:
+ version "1.62.1"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785"
+ integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==
+
+playwright@^1.62.1:
+ version "1.62.1"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700"
+ integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==
+ dependencies:
+ playwright-core "1.62.1"
+ optionalDependencies:
+ fsevents "2.3.2"
+
postcss-import@^15.1.0:
version "15.1.0"
resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70"
From 940cc70ee3df544cfde323a9158cd776e7b27c25 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 7 Sep 2026 06:59:16 +0000
Subject: [PATCH 11/68] fix(frontend): await the user-switch cache clear before
redirecting
guardAgainstUserSwitch used to kick off clearOfflineCaches() in a
fire-and-forget Promise.all().then() and return synchronously. Two
consequences, both flagged by review (PR #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
---
frontend/src/data/session.ts | 11 ++++-
frontend/src/offline.ts | 91 ++++++++++++++++++++++++++++--------
2 files changed, 81 insertions(+), 21 deletions(-)
diff --git a/frontend/src/data/session.ts b/frontend/src/data/session.ts
index 43a0745d1..24e23b9b2 100644
--- a/frontend/src/data/session.ts
+++ b/frontend/src/data/session.ts
@@ -22,7 +22,7 @@ export let session = reactive({
login: useCall({
url: '/api/v2/method/login',
immediate: false,
- onSuccess(data) {
+ async onSuccess(data) {
users.reload()
sessionUser.value = getSessionUserFromCookie()
session.login.reset()
@@ -32,7 +32,14 @@ export let session = reactive({
// them. A plain router.replace would keep those singletons around, so force a full
// reload once a switch is detected and let the app rebuild everything fresh for
// the new user (same reasoning as DevUserSwitcher.vue's own hard reload).
- if (guardAgainstUserSwitch(sessionUser.value)) {
+ //
+ // Awaited (PR #516 review round 4 finding): guardAgainstUserSwitch's cache clear
+ // used to be fire-and-forget, so this redirect could tear the page down before the
+ // previous user's SHELL_CACHE/IndexedDB were actually wiped - the switch marker
+ // would already say "handled" while the old data was still sitting there for the
+ // next offline load to serve. Waiting here means the hard-navigate below only
+ // happens once the clear has actually settled.
+ if (await guardAgainstUserSwitch(sessionUser.value)) {
window.location.href = data.default_route || '/'
} else {
router.replace(data.default_route || '/')
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index 1e659ddc2..a14ff1c52 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -14,15 +14,15 @@ const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user'
export function setupOfflineSupport() {
// Runs even in dev / non-secure contexts (unlike SW registration below): this is what
// makes the dev user switcher (DevUserSwitcher.vue) safe to test with, and it's cheap
- // enough to always run at boot.
- guardAgainstUserSwitch(getSessionUserFromCookie())
-
- if (
- import.meta.env.DEV ||
- typeof navigator === 'undefined' ||
- !window.isSecureContext ||
- !('serviceWorker' in navigator)
- ) {
+ // enough to always run at boot. Fire-and-forget here specifically - nothing after this
+ // call in the boot sequence depends on the switch being fully resolved, unlike
+ // session.ts's login handler, which awaits guardAgainstUserSwitch directly because it
+ // hard-navigates right after.
+ guardAgainstUserSwitch(getSessionUserFromCookie()).catch((error) =>
+ console.error('Failed to run user-switch guard', error),
+ )
+
+ if (!serviceWorkerSupportEnabled()) {
return
}
@@ -49,6 +49,20 @@ export function setupOfflineSupport() {
watchForControllerChange()
}
+// Shared by setupOfflineSupport's own registration gate and clearServiceWorkerCaches'
+// decision whether it's worth waiting for a registration to appear (see getActiveWorker) -
+// in every case this returns false, no worker will ever be registered for this tab, so
+// there's nothing to wait for.
+function serviceWorkerSupportEnabled(): boolean {
+ return (
+ !import.meta.env.DEV &&
+ typeof navigator !== 'undefined' &&
+ typeof window !== 'undefined' &&
+ window.isSecureContext &&
+ 'serviceWorker' in navigator
+ )
+}
+
export function isBrowserOffline() {
return typeof navigator !== 'undefined' && navigator.onLine === false
}
@@ -90,8 +104,7 @@ export async function clearOfflineCaches(): Promise {
async function clearServiceWorkerCaches(): Promise {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return
- const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE)
- const activeWorker = registration?.active
+ const activeWorker = await getActiveWorker()
if (!activeWorker) return
await new Promise((resolve) => {
@@ -106,6 +119,32 @@ async function clearServiceWorkerCaches(): Promise {
})
}
+const REGISTRATION_WAIT_TIMEOUT_MS = 3000
+
+/**
+ * Round-4 follow-up finding: `guardAgainstUserSwitch` runs synchronously at the very top
+ * of `setupOfflineSupport`, before that function's own `navigator.serviceWorker.register()`
+ * call. On a switch detected at boot, a plain `getRegistration()` can come back with no
+ * active worker even though a worker registered by an *earlier* browser session for this
+ * origin is still sitting there holding the previous user's SHELL_CACHE - the clear would
+ * silently no-op and report success. Only fall back to waiting when a worker is actually
+ * going to show up (serviceWorkerSupportEnabled) and bound the wait, so a browser/build
+ * that will never register one (dev, insecure context) doesn't hang the clear.
+ */
+async function getActiveWorker(): Promise {
+ const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE)
+ if (registration?.active) return registration.active
+ if (!serviceWorkerSupportEnabled()) return undefined
+
+ const ready = await Promise.race([
+ navigator.serviceWorker.ready,
+ new Promise((resolve) =>
+ window.setTimeout(() => resolve(undefined), REGISTRATION_WAIT_TIMEOUT_MS),
+ ),
+ ])
+ return ready?.active
+}
+
/**
* Round-4 finding: without this, the SW's SHELL_CACHE stays empty from the moment a
* user-switch clear runs (clearOfflineCaches, above) until the *next* successful online
@@ -125,7 +164,7 @@ async function rewarmShellCache(): Promise {
/**
* Compares `user` against the last user this browser saw (persisted in localStorage so
- * it survives full reloads) and clears every offline cache when they differ. Returns
+ * it survives full reloads) and clears every offline cache when they differ. Resolves to
* whether a switch was detected.
*
* Every cacheKey in the data layer (data/communities.ts, data/users.ts, data/drafts.ts)
@@ -134,21 +173,35 @@ async function rewarmShellCache(): Promise {
* stale for a user switch that happens *without* a reload. Callers that change the
* session user in place (e.g. session.ts's login) must force a reload after a detected
* switch instead of relying on those singletons to pick up the new identity.
+ *
+ * Round-4 finding (PR #516): the clear used to be started and left to run in the
+ * background while this function returned synchronously. session.ts's login handler
+ * hard-navigates the instant it sees `true` back from here, which could tear the page
+ * down mid-clear - and the marker below was written unconditionally, so a switch that
+ * got cut off still looked "handled" on the next boot. This is now async: callers that
+ * are about to navigate away (session.ts) must `await` it, and the marker is only
+ * written once the clear this call kicked off (if any) has itself settled.
*/
-export function guardAgainstUserSwitch(user: string | null): boolean {
+export async function guardAgainstUserSwitch(user: string | null): Promise {
if (typeof localStorage === 'undefined') return false
const lastSeenUser = localStorage.getItem(LAST_SEEN_USER_STORAGE_KEY)
const switched = Boolean(lastSeenUser && user && lastSeenUser !== user)
if (switched) {
- // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a
- // *different* user also wipes gameplan-drafts - the same-user recovery case that
- // policy exists for doesn't apply here.
- Promise.all([clearOfflineCaches(), clearDraftStore()])
- .then(() => rewarmShellCache())
- .catch((error) => console.error('Failed to clear offline caches', error))
+ try {
+ // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a
+ // *different* user also wipes gameplan-drafts - the same-user recovery case that
+ // policy exists for doesn't apply here.
+ await Promise.all([clearOfflineCaches(), clearDraftStore()])
+ await rewarmShellCache()
+ } catch (error) {
+ console.error('Failed to clear offline caches', error)
+ }
}
+ // Written only after the clear above (if any) has settled, successfully or not - see
+ // the round-4 note above. A caller that navigates away on `true` only does so once
+ // this has actually finished.
if (user) {
localStorage.setItem(LAST_SEEN_USER_STORAGE_KEY, user)
} else {
From eb8ddb14c7e54fbf9019e0153576899f9d5200b4 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 7 Sep 2026 06:59:28 +0000
Subject: [PATCH 12/68] fix(frontend): stop offline route validation from
proceeding with a null 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 #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
---
frontend/src/pages/OfflineUnavailable.vue | 31 ++++++++++++++++
frontend/src/router.ts | 17 ++++++---
gameplan/tests/features/test_profiles.py | 43 +++++++++++++++++++++++
3 files changed, 87 insertions(+), 4 deletions(-)
create mode 100644 frontend/src/pages/OfflineUnavailable.vue
diff --git a/frontend/src/pages/OfflineUnavailable.vue b/frontend/src/pages/OfflineUnavailable.vue
new file mode 100644
index 000000000..500d02abe
--- /dev/null
+++ b/frontend/src/pages/OfflineUnavailable.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index 0a5010a4e..64c5060c1 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -268,6 +268,11 @@ const routes: RouteRecordRaw[] = [
name: 'NotFound',
component: () => import('@/pages/NotFound.vue'),
},
+ {
+ path: '/offline-unavailable',
+ name: 'OfflineUnavailable',
+ component: () => import('@/pages/OfflineUnavailable.vue'),
+ },
{
path: '/list',
name: 'Teams',
@@ -809,9 +814,13 @@ router.beforeEach(async (to, from) => {
let space = to.params.spaceId ? getSpace(routeParam(to.params.spaceId)) : null
if (to.params.spaceId && !space) {
+ // Greptile P1 (PR #516, round 3): letting navigation continue here used to leave
+ // `space === null` for every downstream page component that assumes a real space -
+ // this deep link may be genuine (just never cached), so send it to an honest
+ // "not available offline" page instead of either a wrongful NotFound or a route that
+ // silently proceeds with no space to render.
if (isRouteValidationUnavailable()) {
- communityState.scope(communityId)
- return
+ return { name: 'OfflineUnavailable' }
}
return { name: 'NotFound' }
}
@@ -826,9 +835,9 @@ router.beforeEach(async (to, from) => {
// Public communities are visible even when the user has not joined them, so route validity
// cannot be tied to the active sidebar community list.
if (!community) {
+ // Same reasoning as the spaceId branch above: don't proceed with `community === null`.
if (isRouteValidationUnavailable()) {
- communityState.scope(communityId)
- return
+ return { name: 'OfflineUnavailable' }
}
return { name: 'NotFound' }
}
diff --git a/gameplan/tests/features/test_profiles.py b/gameplan/tests/features/test_profiles.py
index e74cd3eb8..af2efdafa 100644
--- a/gameplan/tests/features/test_profiles.py
+++ b/gameplan/tests/features/test_profiles.py
@@ -1,11 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt Ltd and Contributors
# See license.txt
+import json
+
import frappe
from gameplan.api import get_user_info
from gameplan.gameplan.doctype.gp_user_profile.gp_user_profile import (
get_bento_cards,
+ get_list,
get_my_bento_cards,
has_permission,
reset_my_bento_cards,
@@ -691,6 +694,46 @@ def test_bento_cards_accept_the_json_payload_sent_over_http(self):
self.assertEqual([card["id"] for card in response["cards"]], ["intro"])
+class TestGetListQueryParams(GameplanTestCase):
+ """`get_list` is built for frappe-ui's `useList` (PR #516's offline caching), which
+ always fetches over GET - so `fields`/`filters` arrive JSON-stringified and
+ `start`/`limit` arrive as strings, never as the `dict`/`int` shapes the query
+ builder wants. See the comment on `get_list` itself for why they're parsed by hand."""
+
+ def setUp(self):
+ super().setUp()
+ self.alice = create_member("test_alice_getlist@example.com", "Alice Getlist")
+ self.bob = create_member("test_bob_getlist@example.com", "Bob Getlist")
+ frappe.set_user(self.alice.name)
+
+ def test_accepts_json_stringified_fields_and_filters_with_string_pagination(self):
+ result = get_list(
+ fields=json.dumps(["name", "user"]),
+ filters=json.dumps({"user": self.alice.name}),
+ start="0",
+ limit="5",
+ )
+
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["user"], self.alice.name)
+
+ def test_still_accepts_native_dict_filters_and_int_pagination(self):
+ """The direct (non-HTTP) call path other Gameplan code may still use."""
+ result = get_list(fields=["name", "user"], filters={"user": self.bob.name}, start=0, limit=5)
+
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["user"], self.bob.name)
+
+ def test_defaults_still_work_when_every_argument_is_omitted(self):
+ # Just needs to not raise - the default `limit=20` means a specific row (e.g.
+ # self.alice's) isn't guaranteed to be in this page on a site with more than 20
+ # profiles, so this only checks the call succeeds and shapes a list.
+ result = get_list()
+
+ self.assertIsInstance(result, list)
+ self.assertLessEqual(len(result), 20)
+
+
class TestCustomEmojis(GameplanTestCase):
def setUp(self):
super().setUp()
From ec387b4e89305bf1189b82931aba30fadf4f8085 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 7 Sep 2026 08:57:12 +0000
Subject: [PATCH 13/68] fix(frontend): disable post/comment/poll submit buttons
while offline
Posting a comment, a poll, or publishing a new discussion while offline
hit the network and surfaced a raw "TypeError: Failed to fetch" instead
of failing gracefully. Disable the relevant submit buttons outright when
isOnline is false, rather than letting the attempt happen and reporting
the error after the fact:
- CommentsArea.vue: the comment and poll submit buttons' existing
`disabled` bindings now also check isOnline. submitComment/submitPoll
themselves are guarded too, since ctrl/cmd+Enter reaches submitComment
directly and bypasses the disabled button.
- DiscussionHeader.vue: the "Publish" button's disabled condition
(previously just isComposerEditable) is now a canPublish computed that
also requires isOnline, and its tooltip explains why ("You're offline"
alongside the existing "Draft is loading" case). The draft body and
space selector are untouched and stay editable offline - only the
final publish step needs a network round trip.
- useNewDiscussion.ts's publish() gets the same isOnline check as a
backstop, in case it's ever reached another way.
Co-Authored-By: Claude Sonnet 5
---
frontend/src/components/CommentsArea.vue | 12 ++++++---
.../pages/NewDiscussion/DiscussionHeader.vue | 26 ++++++++++++-------
.../pages/NewDiscussion/useNewDiscussion.ts | 8 ++++++
3 files changed, 32 insertions(+), 14 deletions(-)
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index 92509164c..3f6bf4aad 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -209,7 +209,7 @@
variant: 'solid',
onClick: submitComment,
loading: comments.insert.loading,
- disabled: commentEmpty,
+ disabled: commentEmpty || !isOnline,
}"
:discardButtonProps="{
onClick: discardComment,
@@ -237,6 +237,7 @@
:submitButtonProps="{
onClick: submitPoll,
loading: polls.insert.loading,
+ disabled: !isOnline,
}"
:discardButtonProps="{
onClick: discardPoll,
@@ -288,7 +289,7 @@ import { tags } from '@/data/tags'
import { isNewCommentOpen } from '@/data/newComment'
import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes'
import { useDraftSync } from '@/data/useDraftSync'
-import { onReconnect } from '@/data/online'
+import { isOnline, onReconnect } from '@/data/online'
import { useSessionUser } from '@/data/users'
import type { Space } from '@/data/spaces'
import { useIsMobile } from '@/utils/useIsMobile'
@@ -689,7 +690,10 @@ function resetCommentState() {
}
async function submitComment() {
- if (commentEmpty.value || comments.insert.loading) return
+ // The submit button is disabled while offline, but ctrl/cmd+Enter (bound below on
+ // the editor) reaches this directly and isn't gated by that - guard here too, rather
+ // than let it hit the network and surface a raw "Failed to fetch".
+ if (commentEmpty.value || comments.insert.loading || !isOnline.value) return
const comment = await comments.insert.submit({
reference_doctype: props.doctype,
@@ -770,7 +774,7 @@ function wait(ms: number) {
}
function submitPoll() {
- if (props.doctype !== 'GP Discussion') return
+ if (props.doctype !== 'GP Discussion' || !isOnline.value) return
return polls.insert
.submit({
discussion: props.name,
diff --git a/frontend/src/pages/NewDiscussion/DiscussionHeader.vue b/frontend/src/pages/NewDiscussion/DiscussionHeader.vue
index e7edaff33..dad15544a 100644
--- a/frontend/src/pages/NewDiscussion/DiscussionHeader.vue
+++ b/frontend/src/pages/NewDiscussion/DiscussionHeader.vue
@@ -16,16 +16,13 @@
>
-
+
Publish
@@ -59,15 +56,12 @@
>
-
+
Publish
@@ -89,6 +83,7 @@ import {
} from 'frappe-ui'
import { useNewDiscussionContext } from './useNewDiscussion'
import DiscussionSpaceSelector from './DiscussionSpaceSelector.vue'
+import { isOnline } from '@/data/online'
const {
isPersisted,
@@ -105,6 +100,17 @@ const {
const route = useRoute()
const mobileTitle = computed(() => (isPersisted.value ? 'Draft' : 'New Discussion'))
+// Publishing needs a network round trip (flush the draft, then publish_draft/insert)
+// - offline it can only fail with a raw "Failed to fetch", so disable the button
+// outright rather than let someone hit that. The draft itself stays editable offline
+// (it's IndexedDB-backed), this only blocks the final publish step.
+const canPublish = computed(() => isComposerEditable.value && isOnline.value)
+const publishDisabledReason = computed(() => {
+ if (isDraftLoading.value) return 'Draft is loading'
+ if (!isOnline.value) return "You're offline"
+ return 'You cannot publish this draft'
+})
+
// Cold-load fallback only: with any in-app history the back button walks it instead.
// A composer opened straight from a link belongs to a space, so send the user there.
// Drafts is the last resort, for a draft that has not picked a space yet.
diff --git a/frontend/src/pages/NewDiscussion/useNewDiscussion.ts b/frontend/src/pages/NewDiscussion/useNewDiscussion.ts
index 6189c5761..ba6e9b32a 100644
--- a/frontend/src/pages/NewDiscussion/useNewDiscussion.ts
+++ b/frontend/src/pages/NewDiscussion/useNewDiscussion.ts
@@ -6,6 +6,7 @@ import { useDraftSync, type DraftPayload } from '@/data/useDraftSync'
import { drafts } from '@/data/drafts'
import { useGroupedSpaceOptions } from '@/data/groupedSpaces'
import { canPostInSpace, getSpace } from '@/data/spaces'
+import { isOnline } from '@/data/online'
import { useSessionUser, useUser } from '@/data/users'
import { tags } from '@/data/tags'
import { extractServerMessage, isEditorContentEmpty } from '@/utils'
@@ -216,6 +217,13 @@ export function useNewDiscussion() {
async function publish() {
hasInteracted.value = true
publishError.value = null
+ // The Publish button (DiscussionHeader.vue) is disabled offline, so this only
+ // matters as a backstop - defends the same "Failed to fetch" surfacing this was
+ // written to avoid, in case publish() is ever reached another way.
+ if (!isOnline.value) {
+ publishError.value = "You're offline. Reconnect and try again."
+ return
+ }
if (!validateDraft(true)) return
publishing.value = true
From 3ed52d6a8933998db6eb19c28af98df99618d332 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 7 Sep 2026 08:57:26 +0000
Subject: [PATCH 14/68] feat(frontend): redesign offline indicator as a
full-width status banner
Replaces the floating pill (fixed, top-center, rounded, translucent
shadow) with a full-width bar pinned to the true top of the viewport -
gray (bg-surface-gray-8, matching the pill's own tone and
ReadOnlyBanner.vue's status-message convention), with a wifi-off icon
and "Network offline. Showing saved content."
The pill only needed z-index to float over content; this banner needs
to not overlap anything, so going offline pushes the app's own chrome
down instead of covering it: MobileShell and DesktopShell (frappe-ui)
both expose a `data-slot` attribute as a public styling hook, and
index.css uses a `data-offline` attribute on (toggled by
OfflineIndicator.vue, same pattern as useCursorStyle.ts's
data-cursor) to add padding-top equal to the banner's height to both.
MobileShell is `fixed inset-0`, so this only shrinks its scroll region;
DesktopShell is normal flow, so its whole row (rail + sidebar +
content) shifts down together. The banner itself stays at a modest
z-[60] - above normal content, below toasts/dialogs, so either still
displays correctly over it if opened while offline.
Co-Authored-By: Claude Sonnet 5
---
frontend/src/components/OfflineIndicator.vue | 38 +++++++++++++-------
frontend/src/index.css | 14 ++++++++
2 files changed, 40 insertions(+), 12 deletions(-)
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue
index b409c8d28..07091e0b1 100644
--- a/frontend/src/components/OfflineIndicator.vue
+++ b/frontend/src/components/OfflineIndicator.vue
@@ -1,29 +1,43 @@
-
+
-
-
- You're offline — showing saved content
-
+
+
Network offline.
+
Showing saved content.
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 661cc06d1..d91352a35 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -8,12 +8,26 @@
*/
html {
--mobile-header-height: 52px;
+ --offline-banner-height: 28px;
font-feature-settings:
'calt' 1,
'cv01' 1,
'cv11' 1;
}
+/* OfflineIndicator.vue is `position: fixed; top: 0` at the true viewport top, so
+ without this it would paint over the app's own header/search/nav instead of
+ sitting above them. Both shells expose a stable `data-slot` hook for exactly
+ this (MobileShell.vue, DesktopShell.vue in frappe-ui) - push their content down
+ by the banner's height rather than touching the library. MobileShell is `fixed
+ inset-0`, so this only shrinks its scroll region (its own outer box stays
+ viewport-sized); DesktopShell is normal flow, so this shifts its whole row
+ (rail + sidebar + content) down together. */
+html[data-offline] [data-slot='mobile-shell'],
+html[data-offline] [data-slot='desktop-shell'] {
+ padding-top: var(--offline-banner-height);
+}
+
/* ProseMirror disables ligatures by default; restore so the font-feature-settings
set on html are inherited inside the editor */
.ProseMirror {
From 580103f2ccce2b9f7564855ca0e36cc117e02d34 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 7 Sep 2026 09:06:44 +0000
Subject: [PATCH 15/68] fix(frontend): use theme-adaptive gray tokens on the
offline banner
Maintainer review: bg-surface-gray-8 + text-ink-white looked fine in
light mode but broke in dark mode, and switching the background to the
requested bg-surface-gray-3 would have broken light mode instead -
frappe-ui's surface-gray-N tokens invert which raw shade they resolve
to per theme (gray-8 is a medium-dark gray in light mode, a light gray
in dark mode), while ink-white is a fixed color that doesn't follow.
Replaced with ink-gray-N tokens throughout, which invert the same way
the surface token does, so contrast holds in both themes without a
dark: variant needed. Verified via frappe-ui's generated color tokens
(tailwind/generated/colors.json) that gray-3/ink-gray-5/7/8 stay legible
against each other in both lightMode and darkMode.
Co-Authored-By: Claude Sonnet 5
---
frontend/src/components/OfflineIndicator.vue | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue
index 07091e0b1..b079ac735 100644
--- a/frontend/src/components/OfflineIndicator.vue
+++ b/frontend/src/components/OfflineIndicator.vue
@@ -15,11 +15,11 @@
- Network offline.
- Showing saved content.
+ Network offline.
+ Showing saved content.
From 3b6008a66a9a0e5d3e76b174462c7d7f9ba6e4e9 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 9 Sep 2026 09:40:52 +0000
Subject: [PATCH 16/68] fix(frontend): shorten offline banner text to just
"Offline"
Co-Authored-By: Claude Sonnet 5
---
frontend/src/components/OfflineIndicator.vue | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue
index b079ac735..71cd004d0 100644
--- a/frontend/src/components/OfflineIndicator.vue
+++ b/frontend/src/components/OfflineIndicator.vue
@@ -18,8 +18,7 @@
class="fixed inset-x-0 top-0 z-[60] flex h-[var(--offline-banner-height)] items-center justify-center gap-1.5 bg-surface-gray-3 px-3 text-p-sm text-ink-gray-7"
>
- Network offline.
- Showing saved content.
+ Offline
From a6d26a60caaf73cb1c5c3f2f10b1c2d2725ac20f Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 9 Sep 2026 10:01:43 +0000
Subject: [PATCH 17/68] fix(frontend): honor the service worker's cache-clear
success signal
clearServiceWorkerCaches resolved on ANY message from the worker,
discarding the { ok: true | false } payload gameplan-sw.js's
CLEAR_USER_CACHES handler actually sends - a reported failure (or the
2s no-response timeout) was silently treated the same as success, and
guardAgainstUserSwitch would still mark the switch as "handled," so a
genuinely failed clear was never retried on a later boot.
- clearServiceWorkerCaches now resolves to the worker's real answer,
and falls back to deleting the same caches directly via the page's
own Cache Storage API when the worker doesn't confirm one - not
registered yet, unsupported, timed out, or an explicit
{ ok: false }. Matched by the gameplan-sw.js cache-name prefix
(duplicated as a constant, same as the message-type strings already
are - the worker runs in a separate script/global scope), excluding
the content-addressed asset cache, so this fallback doesn't need the
worker's cooperation at all.
- clearOfflineCaches now resolves to whether every store (service
worker caches + IndexedDB) actually confirmed it cleared, instead of
Promise.
- guardAgainstUserSwitch only writes the last-seen-user marker once
clearOfflineCaches confirms success; on failure it returns without
touching the marker, so the same mismatch is seen - and the clear
retried - the next time this runs.
Reported in PR review (frappe/gameplan#571).
Co-Authored-By: Claude Sonnet 5
---
frontend/src/offline.ts | 111 +++++++++++++++++++++++++++++++---------
1 file changed, 88 insertions(+), 23 deletions(-)
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index a14ff1c52..7be9d400c 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -10,6 +10,16 @@ const CLEAR_USER_CACHES_MESSAGE = 'CLEAR_USER_CACHES'
const WARM_SHELL_CACHE_MESSAGE = 'WARM_SHELL_CACHE'
const SKIP_WAITING_MESSAGE = 'SKIP_WAITING'
const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user'
+// Must match gameplan-sw.js's own CACHE_PREFIX - not shared via import, since that file
+// runs in a separate worker global scope with its own script (same reason the message
+// type strings above are duplicated instead of imported). Matched by prefix rather than
+// the exact SHELL_CACHE/RUNTIME_CACHE names (which also embed CACHE_VERSION) so the
+// Cache Storage fallback below doesn't need updating every time that version bumps.
+const CACHE_PREFIX = 'gameplan-readonly-offline'
+// ASSET_CACHE (gameplan-sw.js) is content-addressed /assets build output, identical for
+// every user - the one bucket clearUserCaches() there deliberately leaves alone. The
+// fallback below must leave it alone too.
+const ASSET_CACHE_SUFFIX = ':assets'
export function setupOfflineSupport() {
// Runs even in dev / non-secure contexts (unlike SW registration below): this is what
@@ -93,32 +103,76 @@ export function isNetworkError(error: unknown) {
* `record.user` so leaving another account's draft rows on disk isn't a leak. Drafts are
* only wiped when a genuine user switch is detected - see guardAgainstUserSwitch below,
* which calls clearDraftStore itself alongside this function.
+ *
+ * Resolves to whether every store actually confirmed it was cleared - see
+ * guardAgainstUserSwitch, which only records the switch as handled when this is true.
*/
-export async function clearOfflineCaches(): Promise {
- await Promise.all([
+export async function clearOfflineCaches(): Promise {
+ const [cachesCleared, idbCleared] = await Promise.all([
clearServiceWorkerCaches(),
- clearIdbKeyval().catch((error) => console.error('Failed to clear IndexedDB cache', error)),
+ clearIdbKeyval()
+ .then(() => true)
+ .catch((error) => {
+ console.error('Failed to clear IndexedDB cache', error)
+ return false
+ }),
])
+ return cachesCleared && idbCleared
}
-async function clearServiceWorkerCaches(): Promise {
- if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return
+/**
+ * Review finding (PR #571): this used to resolve on ANY message from the worker,
+ * discarding the `{ ok: true | false }` payload gameplan-sw.js's CLEAR_USER_CACHES
+ * handler actually sends - a reported failure (or the 2s timeout below) was silently
+ * treated the same as success. Now resolves to the worker's real answer, and falls back
+ * to deleting the same caches directly when it doesn't confirm one - not registered yet,
+ * unsupported, timed out, or an explicit `{ ok: false }`. Cache Storage is available
+ * from the page itself, not just inside the worker, so this fallback doesn't need the
+ * worker's cooperation at all.
+ */
+async function clearServiceWorkerCaches(): Promise {
+ if (await requestWorkerClear()) return true
+ return clearCachesDirectly()
+}
- const activeWorker = await getActiveWorker()
- if (!activeWorker) return
+function requestWorkerClear(): Promise {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
+ return Promise.resolve(false)
+ }
- await new Promise((resolve) => {
- const channel = new MessageChannel()
- // Don't let logout hang forever if a stuck/buggy worker never responds.
- const timeoutId = window.setTimeout(resolve, 2000)
- channel.port1.onmessage = () => {
- window.clearTimeout(timeoutId)
- resolve()
- }
- activeWorker.postMessage({ type: CLEAR_USER_CACHES_MESSAGE }, [channel.port2])
+ return getActiveWorker().then((activeWorker) => {
+ if (!activeWorker) return false
+
+ return new Promise((resolve) => {
+ const channel = new MessageChannel()
+ // Don't let logout hang forever if a stuck/buggy worker never responds - the
+ // Cache Storage fallback in clearServiceWorkerCaches covers this either way.
+ const timeoutId = window.setTimeout(() => resolve(false), 2000)
+ channel.port1.onmessage = (event) => {
+ window.clearTimeout(timeoutId)
+ resolve(event.data?.ok === true)
+ }
+ activeWorker.postMessage({ type: CLEAR_USER_CACHES_MESSAGE }, [channel.port2])
+ })
})
}
+async function clearCachesDirectly(): Promise {
+ if (typeof caches === 'undefined') return true // Nothing this context could have cached.
+
+ try {
+ const names = await caches.keys()
+ const userCacheNames = names.filter(
+ (name) => name.startsWith(`${CACHE_PREFIX}:`) && !name.endsWith(ASSET_CACHE_SUFFIX),
+ )
+ const results = await Promise.all(userCacheNames.map((name) => caches.delete(name)))
+ return results.every(Boolean)
+ } catch (error) {
+ console.error('Failed to clear caches directly', error)
+ return false
+ }
+}
+
const REGISTRATION_WAIT_TIMEOUT_MS = 3000
/**
@@ -179,8 +233,15 @@ async function rewarmShellCache(): Promise {
* hard-navigates the instant it sees `true` back from here, which could tear the page
* down mid-clear - and the marker below was written unconditionally, so a switch that
* got cut off still looked "handled" on the next boot. This is now async: callers that
- * are about to navigate away (session.ts) must `await` it, and the marker is only
- * written once the clear this call kicked off (if any) has itself settled.
+ * are about to navigate away (session.ts) must `await` it.
+ *
+ * Review finding (PR #571): the marker used to be written once the clear settled
+ * "successfully or not," which papered over `clearServiceWorkerCaches` silently
+ * discarding the worker's own failure signal - the marker was written even when the
+ * clear had genuinely failed, so a later boot never retried it. The marker is now
+ * written only once `clearOfflineCaches` confirms every store actually cleared; on
+ * failure this returns without touching it, so the same mismatch is seen (and the
+ * clear retried) the next time this runs.
*/
export async function guardAgainstUserSwitch(user: string | null): Promise {
if (typeof localStorage === 'undefined') return false
@@ -188,20 +249,24 @@ export async function guardAgainstUserSwitch(user: string | null): Promise
Date: Wed, 9 Sep 2026 10:09:38 +0000
Subject: [PATCH 18/68] fix(frontend): catch worker communication failures
before the cache fallback
requestWorkerClear() can reject, not just resolve false: postMessage
throws synchronously (auto-rejecting the wrapping Promise) if the
worker became redundant between the registration lookup and the send,
and the lookup itself (getActiveWorker -> getRegistration/ready) can
reject too. clearServiceWorkerCaches awaited it with no catch, so a
rejection skipped clearCachesDirectly() entirely and propagated out of
clearOfflineCaches - breaking session.ts's logout redirect (no
try/catch there), and in guardAgainstUserSwitch's login path, skipping
the one thing (the direct Cache Storage fallback) that could have
cleared the previous user's caches immediately instead of only on a
later retry.
clearServiceWorkerCaches now catches requestWorkerClear() and falls
through to clearCachesDirectly() on any failure, not just a resolved
`false` - the function can no longer reject at all.
Reported in PR review (frappe/gameplan#571).
Co-Authored-By: Claude Sonnet 5
---
frontend/src/offline.ts | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index 7be9d400c..27aa41a5f 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -129,9 +129,20 @@ export async function clearOfflineCaches(): Promise {
* unsupported, timed out, or an explicit `{ ok: false }`. Cache Storage is available
* from the page itself, not just inside the worker, so this fallback doesn't need the
* worker's cooperation at all.
+ *
+ * Follow-up review finding: `requestWorkerClear` can *reject*, not just resolve false -
+ * `postMessage` throws synchronously (auto-rejecting the wrapping Promise) if the worker
+ * became redundant between the registration lookup and the send, and the lookup itself
+ * can reject too. Left uncaught, that used to skip the direct fallback below entirely
+ * and propagate out of `clearOfflineCaches` - breaking session.ts's logout redirect
+ * (no try/catch there) rather than just failing to clear a cache.
*/
async function clearServiceWorkerCaches(): Promise {
- if (await requestWorkerClear()) return true
+ const confirmed = await requestWorkerClear().catch((error) => {
+ console.error('Failed to reach the service worker to clear caches', error)
+ return false
+ })
+ if (confirmed) return true
return clearCachesDirectly()
}
From 680aaff879204c6c906caa7488164d1d49544dc0 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 16 Sep 2026 10:38:34 +0000
Subject: [PATCH 19/68] fix(frontend): invalidate the profile-bento cache after
save/reset
createServerProfileBentoSource's save() and reset() (used by the
Settings dialog's bento-card editor) each discarded the server's
response, including the `profile` field identifying which profile's
layout just changed. Meanwhile useProfileBento() (PersonProfile.vue's
Profile tab) and prefetchProfileBento() (the background offline
prefetcher) share one per-profile cache (bentoCalls) neither save nor
reset had any way to invalidate.
Concretely this broke profile-settings.cy.ts's
"edits the profile, adds a bento card, and sets quick reactions" test:
the background prefetcher (offlinePrefetch.ts) warms every enabled
member's own bento cache too - it does not exclude the session user -
so a save made after that warm-up left a stale, already-`isFinished`
cache entry that useProfileBento's own watch has no reason to refetch.
Visiting the just-edited profile then showed the pre-save layout.
Both mutations now call the new invalidateProfileBentoCall(profile),
using the profile name their own response already identifies
(GP User Profile.get_profile_bento_response returns it) rather than
looking it up separately. It reloads an existing cache entry in place
so an already-mounted PersonProfile.vue viewing that profile (e.g.
behind the settings dialog overlay) picks up the change reactively too.
Co-Authored-By: Claude Sonnet 5
---
.../ProfileBento/profileBentoSource.ts | 26 ++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/ProfileBento/profileBentoSource.ts b/frontend/src/components/ProfileBento/profileBentoSource.ts
index eece2fc48..abf42c00e 100644
--- a/frontend/src/components/ProfileBento/profileBentoSource.ts
+++ b/frontend/src/components/ProfileBento/profileBentoSource.ts
@@ -33,9 +33,10 @@ export function createServerProfileBentoSource(): ProfileBentoCardSource {
return getLoadResultFromResponse(response)
},
async save(cards) {
- await call(saveBentoCardsMethod, {
+ let response = await call(saveBentoCardsMethod, {
cards,
})
+ invalidateProfileBentoCall(response.profile)
},
reset: resetProfileBentoCards,
}
@@ -48,6 +49,7 @@ export function createServerProfileBentoSource(): ProfileBentoCardSource {
*/
export async function resetProfileBentoCards() {
let response = await call(resetBentoCardsMethod)
+ invalidateProfileBentoCall(response.profile)
return getLoadResultFromResponse(response)
}
@@ -88,6 +90,28 @@ function getProfileBentoCall(profile: string) {
return bentoCalls[profile]
}
+/**
+ * Refreshes the shared per-profile cache above after save/reset changes what it holds -
+ * called with the profile name the mutation's own response identifies (both
+ * save_my_bento_cards and reset_my_bento_cards return it via
+ * GP User Profile.get_profile_bento_response), not looked up separately.
+ *
+ * Cypress bug (frontend/tests/... profile-settings.cy.ts): without this, saving a new
+ * bento card and then opening the profile page it belongs to could show the pre-save
+ * layout - the background prefetcher (data/offlinePrefetch.ts) warms every enabled
+ * member's own cache entry too (no exclusion for the session user), so a save made after
+ * that warm-up left a stale, already-`isFinished` entry that useProfileBento's own watch
+ * has no reason to refetch on the next visit.
+ *
+ * Reloads an existing entry in place - so an already-mounted `PersonProfile.vue` viewing
+ * this same profile (e.g. behind the settings dialog overlay) picks up the change
+ * reactively too, not just a later fresh visit - rather than deleting it; if no entry
+ * exists yet there's nothing to refresh, and the next visit fetches fresh regardless.
+ */
+function invalidateProfileBentoCall(profile: string) {
+ bentoCalls[profile]?.reload()
+}
+
/**
* Reactive bento-card read for one profile (`PersonProfile.vue`'s Profile tab). Cards come
* straight off the shared per-profile call above instead of being copied into local refs,
From 3cab53a20d57122ed98bff06915350db6a4d0dfb Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 16 Sep 2026 11:14:44 +0000
Subject: [PATCH 20/68] test(frontend): wait for the activity-feed reload in
the rename test
discussion-actions.cy.ts's "renames a discussion and records the rename
in the activity feed" was failing consistently in CI: "Expected to find
content: 'changed the title from' but never did."
Traced this end to end rather than guessing. Reproduced the exact CI
scenario server-side (gameplan.ui_test_helpers's space_with_discussion
seed, rename as the member persona, then the identical
gameplan.extends.client.get_list call CommentsArea.vue's `activities`
useList makes) - the backend is correct: the "Discussion Title Changed"
activity row exists and is queryable by the member immediately after
the save. Confirmed via the CI run's own failure screenshot (PR #571)
that the activity feed panel renders completely empty at the moment of
timeout, and that DiscussionView.vue's activityVersion prop -> watch ->
activities.reload() mechanism this test depends on is unchanged by this
PR (present on upstream/develop already, not something the offline
caching work touched).
That rules out both a backend bug and a product regression introduced
by this PR. The reload is a separate network request from the title
save, dispatched only after discussion.doc.modified changes reactively
- polling the DOM with cy.contains' fixed retry window races that
request under CI load instead of waiting for it. Now waits on the
actual GP Activity fetch explicitly (once after the initial page load,
once after the save) before asserting on the rendered text, so a
timeout here reads as "the reload didn't happen" instead of a
misleading "text not found" if this everactually breaks for real.
Co-Authored-By: Claude Sonnet 5
---
.../cypress/e2e/discussions/discussion-actions.cy.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/frontend/cypress/e2e/discussions/discussion-actions.cy.ts b/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
index cf60e916f..bd658cbee 100644
--- a/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
+++ b/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
@@ -81,7 +81,16 @@ describe('Discussion actions', () => {
})
it('renames a discussion and records the rename in the activity feed', () => {
+ // DiscussionView.vue bumps CommentsArea.vue's `activityVersion` prop off
+ // discussion.doc.modified, which reloads the activity feed as a *separate*
+ // request from the title save itself. Polling the DOM against cy.contains'
+ // fixed retry window races that reload under load; wait for the actual
+ // network round trip instead - deterministic either way, and a timeout here
+ // reads as "the reload never happened" instead of a misleading "text not
+ // found" if this is ever genuinely broken rather than just slow.
+ cy.intercept('GET', '**/api/v2/document/GP%20Activity*').as('activityFeed')
visitSeededDiscussion()
+ cy.wait('@activityFeed')
cy.selectDropdownOption('Discussion Options', 'Edit')
cy.get('input[placeholder="Title"]')
@@ -91,6 +100,7 @@ describe('Discussion actions', () => {
cy.button('Save').click()
cy.contains('h1', 'Edited Discussion Title').should('be.visible')
+ cy.wait('@activityFeed')
cy.contains('changed the title from').should('exist')
})
From dc70eb7c2b026bfd9bf5cade118d30e833accd75 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 16 Sep 2026 12:27:37 +0000
Subject: [PATCH 21/68] Revert "test(frontend): wait for the activity-feed
reload in the rename test"
The explicit cy.wait('@activityFeed') fix (3cab53a2) did not actually
resolve the CI failure: a fresh CI run showed the wait itself resolving
successfully (a GP Activity fetch did complete) while the DOM still
never showed the new activity - disproving the theory that this was a
simple polling-vs-fixed-timeout race. Reverting since the change adds
complexity without a proven benefit.
While investigating this merge's develop pull, found a likely more
relevant lead not chased down yet: CommentsArea.vue has a *second*,
independent trigger for activities.reload() - a realtime `new_activity`
socket event handler - alongside the activityVersion prop watch this
test depends on. Worth investigating whether the two can race each
other before landing another fix here.
Co-Authored-By: Claude Sonnet 5
---
.../cypress/e2e/discussions/discussion-actions.cy.ts | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/frontend/cypress/e2e/discussions/discussion-actions.cy.ts b/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
index 6572add7c..155b1408f 100644
--- a/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
+++ b/frontend/cypress/e2e/discussions/discussion-actions.cy.ts
@@ -162,16 +162,7 @@ describe('Discussion actions', () => {
})
it('renames a discussion and records the rename in the activity feed', () => {
- // DiscussionView.vue bumps CommentsArea.vue's `activityVersion` prop off
- // discussion.doc.modified, which reloads the activity feed as a *separate*
- // request from the title save itself. Polling the DOM against cy.contains'
- // fixed retry window races that reload under load; wait for the actual
- // network round trip instead - deterministic either way, and a timeout here
- // reads as "the reload never happened" instead of a misleading "text not
- // found" if this is ever genuinely broken rather than just slow.
- cy.intercept('GET', '**/api/v2/document/GP%20Activity*').as('activityFeed')
visitSeededDiscussion()
- cy.wait('@activityFeed')
cy.selectDropdownOption('Discussion Options', 'Edit')
cy.get('input[placeholder="Title"]')
@@ -181,7 +172,6 @@ describe('Discussion actions', () => {
cy.button('Save').click()
cy.contains('h1', 'Edited Discussion Title').should('be.visible')
- cy.wait('@activityFeed')
cy.contains('changed the title from').should('exist')
})
From 8ecc748b9927abcf44f32f4a1aab18cad6fcbc5c Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Wed, 16 Sep 2026 13:52:12 +0000
Subject: [PATCH 22/68] fix(frontend): debounce duplicate activity-feed reload
triggers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CommentsArea.vue's activityVersion watch and the new_activity socket
handler both call activities.reload() for the same server-side event.
Firing both re-enters the list's in-flight fetch, aborting it — and with
staleOnError enabled that abort can leave the timeline stuck showing the
cached (stale) IndexedDB snapshot instead of ever settling on the fresh
response. This is why discussion-actions.cy.ts's rename test fails only
on this branch and not on other open PRs: this PR is what added
cacheKey/staleOnError to the activities list.
Coalesce both triggers into a single debounced reload so only one
execute() ever fires per event.
Co-Authored-By: Claude Sonnet 5
---
frontend/src/components/CommentsArea.vue | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index 09af93072..296b07c11 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -275,7 +275,7 @@ import {
useTemplateRef,
} from 'vue'
import { useRouter, useRoute } from 'vue-router'
-import { useEventListener } from '@vueuse/core'
+import { useDebounceFn, useEventListener } from '@vueuse/core'
import { useList, TabButtons, ErrorMessage, Button, Tooltip } from 'frappe-ui'
import CommentEditor from '@/components/editor/CommentEditor.vue'
import Comment from './Comment.vue'
@@ -474,10 +474,18 @@ const activities = useList({
// The parent bumps `activityVersion` with the doc's `modified` on every such action,
// so reload the timeline when it changes (skipping the initial undefined -> value
// transition on first load, when the list has already fetched on mount).
+//
+// Both this watch and the `new_activity` socket handler (see onMounted) can fire
+// for the same underlying action. Calling `activities.reload()` from both re-enters
+// the list's in-flight fetch, which aborts it — and with `staleOnError` that abort
+// can leave the timeline stuck showing the cached (stale) snapshot instead of ever
+// settling on the fresh one. Debouncing to a single reload avoids the double-fetch.
+const reloadActivities = useDebounceFn(() => activities.reload(), 100)
+
watch(
() => props.activityVersion,
(next, prev) => {
- if (prev !== undefined && next !== prev) activities.reload()
+ if (prev !== undefined && next !== prev) reloadActivities()
},
)
@@ -925,7 +933,7 @@ onMounted(() => {
// integer hand this component a number, so a strict compare never matches and the
// timeline silently stops updating. Compare as strings.
if (data.reference_doctype === props.doctype && data.reference_name === String(props.name)) {
- activities.reload()
+ reloadActivities()
}
})
})
From c9644a747b9e40971855b6bdd97eea8d0840574b Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Thu, 17 Sep 2026 06:04:00 +0000
Subject: [PATCH 23/68] fix(frontend): don't erase the shared-computer switch
marker on a logged-out boot
guardAgainstUserSwitch(null) removed the 'last seen user' marker whenever the
SPA booted without a session cookie - not just after a plain logout (which
never reaches this function; it redirects straight to /login), but on a
logged-out /g load in general: a stale tab, a bookmark, or an already-expired
session (Frappe clears user_id/sid itself the moment a dead sid is presented,
and require_app_access() deliberately lets Guest boot the SPA shell rather
than 403ing server-side).
With the marker erased, the next real sign-in - by anyone - found no marker,
read that as "no switch", and left the previous user's caches (service
worker shell/runtime caches, and the shared IndexedDB store backing
useList/useCall/useDoc) for the next person on the shared machine to read.
useDoc's docStore is keyed only by doctype/name with no per-user scoping at
all, so this reaches further than the (mostly user-scoped) useList caches.
Leaves the marker untouched instead, so it keeps meaning "the last person
actually signed in on this device", which the next real sign-in can still
compare against. A plain logout is unaffected - it clears everything itself,
unconditionally, without going through this function.
Adds US7c to the offline Playwright suite, reproducing the bug via a session
that goes stale without a UI logout rather than US7b's explicit-logout-free
switch, plus a private-space discussion (not just profile content, which
isn't actually secret between community members) to prove B genuinely has no
access to A's leftover docStore cache.
Co-Authored-By: Claude Sonnet 5
---
frontend/src/offline.ts | 7 +-
frontend/tests/offline/README.md | 9 +-
frontend/tests/offline/runner.js | 16 ++-
frontend/tests/offline/us7c.js | 211 +++++++++++++++++++++++++++++++
4 files changed, 238 insertions(+), 5 deletions(-)
create mode 100644 frontend/tests/offline/us7c.js
diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts
index 27aa41a5f..0ce4ff3de 100644
--- a/frontend/src/offline.ts
+++ b/frontend/src/offline.ts
@@ -278,10 +278,13 @@ export async function guardAgainstUserSwitch(user: string | null): Promise {})
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1000)
+ await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ await page.goto(PRIVATE_DISCUSSION_URL, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(1500)
+ try {
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => true))
+ } catch (e) {
+ // best-effort
+ }
+ await waitForPrefetchDone(prefetchLog, { timeoutMs: 20000 })
+
+ const userAKeyvalKeys = await idbKeyvalKeys(page)
+ const userALastSeen = await lastSeenUserFromStorage(page)
+ result.userAKeyvalCount = userAKeyvalKeys.length
+ result.userALastSeen = userALastSeen
+
+ result.checks.push({
+ name: 'user A caches populated, last-seen-user recorded as A',
+ pass: userAKeyvalKeys.length > 0 && userALastSeen === EMAIL,
+ symptom: `keyvalKeys=${userAKeyvalKeys.length} lastSeen=${userALastSeen}`,
+ })
+
+ // A's session ends without an explicit logout.
+ await context.clearCookies()
+
+ // Boot the SPA as Guest — the only place guardAgainstUserSwitch(null) runs here.
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }).catch(() => {})
+ await page.waitForTimeout(1000)
+
+ const lastSeenAfterGuestBoot = await lastSeenUserFromStorage(page)
+ result.lastSeenAfterGuestBoot = lastSeenAfterGuestBoot
+
+ result.checks.push({
+ name: "last-seen-user marker survives a logged-out /g boot (still says A, isn't erased)",
+ pass: lastSeenAfterGuestBoot === EMAIL,
+ symptom:
+ lastSeenAfterGuestBoot === EMAIL
+ ? 'marker still identifies A after the guest boot, as intended'
+ : `marker was erased/changed by the logged-out boot (now ${JSON.stringify(
+ lastSeenAfterGuestBoot,
+ )}) - the next real login won't be recognized as a switch, so A's caches never get cleared`,
+ })
+
+ await loginAsInSameContext(context, page, EMAIL2, PWD2)
+ await page.waitForTimeout(1500)
+
+ const userBLastSeen = await lastSeenUserFromStorage(page)
+ const postSwitchKeyvalKeys = await idbKeyvalKeys(page)
+ const leakedAKeys = postSwitchKeyvalKeys.filter((k) => k.includes(EMAIL))
+ result.userBLastSeen = userBLastSeen
+ result.postSwitchKeyvalKeys = postSwitchKeyvalKeys
+ result.leakedAKeys = leakedAKeys
+
+ result.checks.push({
+ name: 'localStorage last-seen-user updated to B after the switch is detected',
+ pass: userBLastSeen === EMAIL2,
+ symptom: `lastSeen=${userBLastSeen} (expected ${EMAIL2})`,
+ })
+
+ result.checks.push({
+ name: "user-switch clear ran: no useList-cached key tagged with A's identity survives",
+ pass: leakedAKeys.length === 0,
+ symptom:
+ leakedAKeys.length === 0
+ ? `A's keys cleared on the detected switch (${
+ postSwitchKeyvalKeys.length
+ } key(s) remain, all B's own: ${JSON.stringify(postSwitchKeyvalKeys)})`
+ : `${
+ leakedAKeys.length
+ } key(s) still tagged with A's identity after the switch: ${JSON.stringify(
+ leakedAKeys,
+ )}`,
+ })
+
+ // docStore keys carry no per-user identity at all (doc:/ only), so the
+ // filter above can't catch this leak — check the private discussion's key directly.
+ const privateDiscussionKeySurvived = postSwitchKeyvalKeys.includes('doc:GP Discussion/722')
+ result.checks.push({
+ name: "user-switch clear ran: A's private discussion doc is gone from IndexedDB",
+ pass: !privateDiscussionKeySurvived,
+ symptom: privateDiscussionKeySurvived
+ ? "doc:GP Discussion/722 (A's, content B has no access to) is still in IndexedDB after the switch"
+ : 'doc:GP Discussion/722 cleared on the detected switch',
+ })
+
+ await context.setOffline(true)
+
+ let checkPeople = { name: "People page offline as B: does not show A's cached member list" }
+ try {
+ await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkPeople.gotoError = String(e)
+ })
+ await page.waitForTimeout(2000)
+ const text = await innerTextSafe(page)
+ const honestFallback = /can.?t load|not available while offline|offline/i.test(text)
+ const rendersMemberList = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim())
+ const leaked = rendersMemberList && !honestFallback
+
+ checkPeople.textSnippet = text.slice(0, 400)
+ checkPeople.honestFallback = honestFallback
+ checkPeople.rendersMemberList = rendersMemberList
+ checkPeople.screenshot = await shot(page, 'us7c-people-offline-userB')
+ checkPeople.pass = !leaked
+ checkPeople.symptom = checkPeople.pass
+ ? honestFallback
+ ? 'honest offline fallback shown (no leaked member list)'
+ : 'no member list rendered (empty/loading state, not a leak)'
+ : "user A's cached member list rendered for user B offline — cross-user cache leak"
+ } catch (e) {
+ checkPeople.pass = false
+ checkPeople.symptom = `threw: ${e.message}`
+ checkPeople.screenshot = await shot(page, 'us7c-people-offline-userB-error')
+ }
+ result.checks.push(checkPeople)
+
+ let checkPrivate = {
+ name: 'private discussion offline as B: rendered UI does not show it either',
+ }
+ try {
+ await page.goto(PRIVATE_DISCUSSION_URL, { waitUntil: 'load', timeout: 10000 }).catch((e) => {
+ checkPrivate.gotoError = String(e)
+ })
+ await page.waitForTimeout(2500)
+ const text = await innerTextSafe(page)
+ const leaked = text.includes(PRIVATE_MARKER)
+
+ checkPrivate.textSnippet = text.slice(0, 400)
+ checkPrivate.screenshot = await shot(page, 'us7c-private-discussion-offline-userB')
+ checkPrivate.pass = !leaked
+ checkPrivate.symptom = checkPrivate.pass
+ ? 'content not accessible to B (not-found/empty/offline fallback — B was never a member of this space)'
+ : "A's private discussion content rendered for user B offline — cross-user docStore cache leak"
+ } catch (e) {
+ checkPrivate.pass = false
+ checkPrivate.symptom = `threw: ${e.message}`
+ checkPrivate.screenshot = await shot(page, 'us7c-private-discussion-offline-userB-error')
+ }
+ result.checks.push(checkPrivate)
+
+ result.pass = result.checks.every((c) => c.pass !== false)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ await browser.close()
+ }
+
+ writeResult('us7c', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
From 2236b0483c68d0b9bbe08a1583aa83f8d2f857ad Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Thu, 17 Sep 2026 07:23:09 +0000
Subject: [PATCH 24/68] fix(frontend): recover navigation after a chunk failed
to load offline
Browsers cache a failed dynamic import for the life of the page and never
re-fetch it. Opening a space whose page chunk had not been downloaded yet
while offline (always the case without a service worker, e.g. over plain
HTTP on a LAN) left that route failing forever, even after the network came
back - switching spaces appeared frozen until a manual reload.
Track chunk failures via Vite's vite:preloadError event (fires for route and
async-component chunks alike) and, once online, turn the next navigation
into a real page load so the module is fetched fresh.
Co-Authored-By: Claude Opus 5
---
frontend/src/router.ts | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index 64c5060c1..c85cc0de5 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -742,7 +742,29 @@ function saveAndRestoreScrollPosition(to: RouteLocationNormalized, from: RouteLo
}
}
+// Browsers cache a failed dynamic import for the life of the page and never re-fetch it, so a
+// route or component chunk that failed to download while offline keeps failing after the network
+// returns. Only a real page load clears that, so once online, navigate for real instead.
+// `vite:preloadError` fires for every lazy chunk (routes and defineAsyncComponent alike).
+let chunkLoadFailed = false
+window.addEventListener('vite:preloadError', () => {
+ chunkLoadFailed = true
+})
+
+function loadPage(to: RouteLocationNormalized) {
+ window.location.assign(router.resolve(to.fullPath).href)
+}
+
+router.onError((_error, to) => {
+ if (chunkLoadFailed && !isBrowserOffline()) loadPage(to)
+})
+
router.beforeEach(async (to, from) => {
+ if (chunkLoadFailed && !isBrowserOffline()) {
+ loadPage(to)
+ return false
+ }
+
saveAndRestoreScrollPosition(to, from)
if (to.name === 'Login' && session.isLoggedIn) {
From 7026ac3e6f9a788ddb6608a8ea5dde3039c31b6a Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Thu, 17 Sep 2026 07:23:33 +0000
Subject: [PATCH 25/68] fix(frontend): stop lists showing another space's
content when offline
Space, profile and task lists build their useList once, keyed on the space,
person or filter set they were first mounted for. Switching spaces reuses
the route component, so the filters moved on but the cache key did not:
offline, the refetch failed and staleOnError served the previous space's
cached discussions under the new space's name (online, it flashed them).
Remount those lists when their identity changes, the way Discussions.vue
already does. TaskList also keyed its cache on getter filters, which
JSON-stringify to `{}`, so every space (and My Tasks tab) shared a single
cache entry; key on the resolved filters instead.
Co-Authored-By: Claude Opus 5
---
frontend/src/components/TaskList.vue | 6 ++++--
frontend/src/pages/MyTasks.vue | 1 +
frontend/src/pages/PersonProfilePosts.vue | 1 +
frontend/src/pages/PersonProfileReplies.vue | 1 +
frontend/src/pages/SpaceDiscussions.vue | 1 +
frontend/src/pages/SpacePages.vue | 1 +
frontend/src/pages/SpaceTasks.vue | 2 +-
7 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/TaskList.vue b/frontend/src/components/TaskList.vue
index ad0896ced..61892ef35 100644
--- a/frontend/src/components/TaskList.vue
+++ b/frontend/src/components/TaskList.vue
@@ -131,7 +131,7 @@
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue
index 71cd004d0..7344c6c28 100644
--- a/frontend/src/components/OfflineIndicator.vue
+++ b/frontend/src/components/OfflineIndicator.vue
@@ -26,6 +26,8 @@
diff --git a/frontend/src/components/TaskDetail.vue b/frontend/src/components/TaskDetail.vue
index df9f659b4..6552312cf 100644
--- a/frontend/src/components/TaskDetail.vue
+++ b/frontend/src/components/TaskDetail.vue
@@ -181,6 +181,12 @@
+
diff --git a/frontend/src/pages/PersonProfileReplies.vue b/frontend/src/pages/PersonProfileReplies.vue
index d7c90e8bb..70b673bd1 100644
--- a/frontend/src/pages/PersonProfileReplies.vue
+++ b/frontend/src/pages/PersonProfileReplies.vue
@@ -1,30 +1,15 @@
-
-
diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue
index c884d80c2..2bf723d35 100644
--- a/frontend/src/pages/Search.vue
+++ b/frontend/src/pages/Search.vue
@@ -167,9 +167,11 @@
@@ -299,6 +301,7 @@ import { activeCommunities } from '@/data/communities'
import { activeUsers } from '@/data/users'
import { vFocus } from '@/directives'
import { isOnline } from '@/data/online'
+import { isNetworkError } from '@/offline'
// Type Definitions
interface SearchSummary {
diff --git a/frontend/src/pages/SpaceDiscussions.vue b/frontend/src/pages/SpaceDiscussions.vue
index bd385fbf7..a099d8c57 100644
--- a/frontend/src/pages/SpaceDiscussions.vue
+++ b/frontend/src/pages/SpaceDiscussions.vue
@@ -47,24 +47,12 @@
-
-
(null)
const discussionListRef = useTemplateRef('discussionListRef')
const router = useRouter()
-// DiscussionList owns the useList resource; reach into it through its exposed ref rather
-// than duplicating the fetch here, so this page can tell "loaded, genuinely empty" (handled
-// inside DiscussionList already) apart from "fetch failed, nothing cached" (not handled
-// there - see the v-show/OfflineContentFallback pairing below).
-const discussionsResource = computed(() => discussionListRef.value?.discussions)
-const listFailure = computed(() => {
- const discussions = discussionsResource.value
- if (!discussions) return null
- const failed =
- discussions.isFinished && !discussions.loading && discussions.error && discussions.data == null
- if (!failed) return null
-
- const offline = isBrowserOffline() || isNetworkError(discussions.error)
- return offline
- ? {
- title: "Can't load this while offline",
- message: "This space's discussions haven't been saved for offline use yet.",
- }
- : {
- title: 'Could not load discussions',
- message: 'Something went wrong while loading this list. Retry to try again.',
- }
-})
const {
space: currentSpace,
isArchived,
diff --git a/frontend/tests/offline/p2.js b/frontend/tests/offline/p2.js
index 11227fe46..48dd3bc5e 100644
--- a/frontend/tests/offline/p2.js
+++ b/frontend/tests/offline/p2.js
@@ -61,8 +61,7 @@ async function run() {
const info = await appRootInfo(page)
const postLinks = await page.locator('a[href*="/discussion/"]').count()
const isNotFound = /page not found/i.test(text)
- const fallbackShown =
- /can.?t load this while offline|haven.?t been saved for offline use/i.test(text)
+ const fallbackShown = /can.?t load [^\n]*while offline|been saved for offline use/i.test(text)
checkPosts.textSnippet = text.slice(0, 400)
checkPosts.info = info
From 48074faca476480d8ad5d3b0ef05fb111d0a8480 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Thu, 17 Sep 2026 13:58:45 +0000
Subject: [PATCH 29/68] feat(frontend): stale-while-revalidate while offline
- API requests are no longer sent while offline. Resources show their cached copy
or the offline empty state, and the request is refused locally instead.
- On reconnect, resources on screen revalidate, and shared stores whose request
failed offline retry. useList/useDoc/useCall are imported from
data/staleWhileRevalidate instead of frappe-ui, which replaces the per-component
reconnect reloads.
- A discussion being refreshed keeps its cached copy on screen instead of swapping
in the loading skeleton, which also remounted and refetched the comments.
- Visits are recorded, error reports sent and the socket reconnected once the
connection returns, instead of failing offline.
- TaskDetail no longer piles up a track_visit callback on the cached task on
every mount.
Co-Authored-By: Claude Opus 5
---
.../AppRail/CustomizeSidebarDialog.vue | 3 +-
.../CommandPalette/CommandPalette.vue | 3 +-
frontend/src/components/Comment.vue | 2 +-
frontend/src/components/CommentsArea.vue | 18 +----
frontend/src/components/CommentsList.vue | 13 +---
frontend/src/components/DevUserSwitcher.vue | 3 +-
frontend/src/components/DiscussionView.vue | 13 ++--
frontend/src/components/Poll.vue | 2 +-
.../ProfileBento/profileBentoSource.ts | 3 +-
.../ProfileBento/useProfileFieldEditing.ts | 3 +-
frontend/src/components/RevisionsDialog.vue | 3 +-
.../src/components/Settings/InvitePeople.vue | 2 +-
.../components/Settings/MembersSettings.vue | 13 +---
.../components/Settings/ProfileSettings.vue | 2 +-
frontend/src/components/SpaceAccessDialog.vue | 3 +-
frontend/src/components/TaskDetail.vue | 15 ++--
frontend/src/components/TaskList.vue | 2 +-
.../UnsplashPicker/UnsplashPicker.vue | 11 +--
frontend/src/data/apps.ts | 2 +-
frontend/src/data/communities.ts | 3 +-
frontend/src/data/communitySpaces.ts | 2 +-
frontend/src/data/customEmojis.ts | 2 +-
frontend/src/data/discussions.ts | 11 +--
frontend/src/data/drafts.ts | 2 +-
frontend/src/data/notifications.ts | 6 +-
frontend/src/data/online.ts | 15 +++-
frontend/src/data/people.ts | 2 +-
frontend/src/data/reactions.ts | 2 +-
frontend/src/data/session.ts | 2 +-
frontend/src/data/spaces.ts | 3 +-
frontend/src/data/staleWhileRevalidate.ts | 78 +++++++++++++++++++
frontend/src/data/tags.ts | 2 +-
frontend/src/data/tasks.ts | 5 +-
frontend/src/data/unreadCount.ts | 5 +-
frontend/src/data/users.ts | 2 +-
.../pages/Configure/CommunityGuestsList.vue | 3 +-
.../pages/Configure/useCommunitySpaceData.ts | 2 +-
frontend/src/pages/Drafts.vue | 2 +-
frontend/src/pages/MobileHome.vue | 3 +-
frontend/src/pages/Notifications.vue | 3 +-
frontend/src/pages/Onboarding.vue | 3 +-
frontend/src/pages/Page.vue | 8 +-
frontend/src/pages/PageGrid.vue | 3 +-
frontend/src/pages/PersonProfile.vue | 3 +-
frontend/src/pages/ProfileCustomize.vue | 12 +--
frontend/src/pages/Search.vue | 3 +-
frontend/src/pages/Space.vue | 7 +-
frontend/src/pages/SpaceDiscussions.vue | 3 +-
frontend/src/socket.ts | 8 ++
frontend/src/utils/errorReporting.ts | 16 ++--
50 files changed, 214 insertions(+), 123 deletions(-)
create mode 100644 frontend/src/data/staleWhileRevalidate.ts
diff --git a/frontend/src/components/AppRail/CustomizeSidebarDialog.vue b/frontend/src/components/AppRail/CustomizeSidebarDialog.vue
index ed66df097..d74ebb9a5 100644
--- a/frontend/src/components/AppRail/CustomizeSidebarDialog.vue
+++ b/frontend/src/components/AppRail/CustomizeSidebarDialog.vue
@@ -151,7 +151,8 @@
diff --git a/frontend/src/pages/SpaceDiscussions.vue b/frontend/src/pages/SpaceDiscussions.vue
index a099d8c57..c2817191f 100644
--- a/frontend/src/pages/SpaceDiscussions.vue
+++ b/frontend/src/pages/SpaceDiscussions.vue
@@ -85,7 +85,8 @@
diff --git a/frontend/src/components/Settings/SettingsDialog.vue b/frontend/src/components/Settings/SettingsDialog.vue
index 7b7b7d770..e788af28a 100644
--- a/frontend/src/components/Settings/SettingsDialog.vue
+++ b/frontend/src/components/Settings/SettingsDialog.vue
@@ -61,6 +61,7 @@ import NotificationsSettings from './NotificationsSettings.vue'
import ProfileSettings from './ProfileSettings.vue'
import CustomEmojiSettings from './CustomEmojiSettings.vue'
import PreferencesSettings from './PreferencesSettings.vue'
+import OfflineSettings from './OfflineSettings.vue'
interface SettingsTab extends Tab {
// Tabs that drive global role management / invites; these only make sense for
@@ -103,6 +104,13 @@ const allTabs: SettingsTab[] = [
icon: 'lucide-bell',
component: markRaw(NotificationsSettings),
},
+ {
+ label: 'Offline',
+ slug: 'offline',
+ group: 'User settings',
+ icon: 'lucide-cloud-download',
+ component: markRaw(OfflineSettings),
+ },
{
label: 'Communities',
slug: 'communities',
diff --git a/frontend/src/data/discussionTimeline.ts b/frontend/src/data/discussionTimeline.ts
new file mode 100644
index 000000000..4b5e5be7a
--- /dev/null
+++ b/frontend/src/data/discussionTimeline.ts
@@ -0,0 +1,46 @@
+/**
+ * What a discussion's timeline lists fetch and where they cache it. Shared by CommentsArea,
+ * which fetches them, and offline downloads, which fills the same cache entries so a
+ * downloaded discussion opens offline exactly as if it had been visited.
+ */
+
+export const COMMENT_FIELDS = [
+ 'name',
+ 'content',
+ 'owner',
+ 'creation',
+ 'modified',
+ 'edited_at',
+ 'deleted_at',
+ { reactions: ['name', 'user', 'emoji'] },
+]
+
+export const ACTIVITY_FIELDS = ['name', 'user', 'action', 'data', 'creation']
+
+export const POLL_FIELDS = [
+ 'name',
+ 'title',
+ 'anonymous',
+ 'multiple_answers',
+ 'creation',
+ 'owner',
+ 'stopped_at',
+ { options: ['name', 'title', 'idx', 'percentage'] },
+ { votes: ['user', 'option'] },
+ { reactions: ['name', 'user', 'emoji'] },
+]
+
+// Scoped to the session user: a discussion can live in a private space, so a second account
+// on the same browser must not see these cached offline before its own permission-checked
+// fetch resolves (review finding from PR #516).
+export function commentsCacheKey(doctype: string, name: string, user: string) {
+ return ['Comments', doctype, name, user]
+}
+
+export function activitiesCacheKey(doctype: string, name: string, user: string) {
+ return ['Activities', doctype, name, user]
+}
+
+export function pollsCacheKey(discussion: string, user: string) {
+ return ['Polls', discussion, user]
+}
diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts
new file mode 100644
index 000000000..500339e4c
--- /dev/null
+++ b/frontend/src/data/offlineDownloads.ts
@@ -0,0 +1,357 @@
+import { computed, reactive, watch } from 'vue'
+import { useLocalStorage } from '@vueuse/core'
+import { call, dialog, toast } from 'frappe-ui'
+import { delMany, get, set, setMany } from 'idb-keyval'
+import { isOnline, onReconnect } from './online'
+import { session } from './session'
+import {
+ ACTIVITY_FIELDS,
+ COMMENT_FIELDS,
+ POLL_FIELDS,
+ activitiesCacheKey,
+ commentsCacheKey,
+ pollsCacheKey,
+} from './discussionTimeline'
+
+/**
+ * "Download for offline" (Settings > Offline): keeps the discussions from joined spaces with
+ * activity in the chosen window on this device, with their comments, activity and polls.
+ *
+ * Downloads are filed into the same IndexedDB entries frappe-ui's resources read (`doc:` for
+ * documents, `["useList", ...cacheKey]` for lists), so a downloaded discussion opens offline
+ * like a visited one and needs no second copy. frappe-ui has no public API to seed its cache,
+ * hence the key formats below. Everything lives in the default idb-keyval store, so logout and
+ * user switch (offline.ts) wipe it with the rest.
+ */
+
+export type OfflineWindow = 0 | 7 | 30 | 90
+
+export const WINDOW_OPTIONS: { label: string; value: OfflineWindow }[] = [
+ { label: 'Recently viewed only', value: 0 },
+ { label: 'Past week', value: 7 },
+ { label: 'Past month', value: 30 },
+ { label: 'Past 3 months', value: 90 },
+]
+
+const INDEX = 'gameplan.offline_downloads.get_offline_index'
+const BUNDLE = 'gameplan.offline_downloads.get_offline_bundle'
+const META_KEY = 'gameplan:offline-downloads'
+const LOCK_NAME = 'gameplan-offline-downloads'
+// Background syncs only fetch what changed, but still cost an index query each; this keeps
+// them to a few a day per device.
+const SYNC_INTERVAL = 6 * 60 * 60 * 1000
+// Spreads the first sync after load so a team opening the app together doesn't sync together.
+const MAX_START_DELAY = 30 * 1000
+
+interface Meta {
+ user: string
+ window: number
+ /** Server time the last complete sync started; the next one asks for changes since. */
+ since: string | null
+ names: string[]
+ lastSyncedAt: number | null
+ /** Where an interrupted sync stopped, so the next one carries on from there. */
+ cursor: { start: number; since: string | null; syncedAt: string } | null
+}
+
+interface Bundle {
+ discussions: Array & { name: string | number }>
+ comments: Record
+ activities: Record
+ polls: Record
+ has_next_page: boolean
+}
+
+type Row = Record & { name: string | number }
+
+/** What the admin allows, from the boot data; updated in place when an admin changes it. */
+export const policy = reactive({
+ enabled: window.offline_downloads?.enabled ?? false,
+ maxWindow: (window.offline_downloads?.max_window_days ?? 0) as number,
+})
+
+const chosenWindow = useLocalStorage(`gameplan:offline-window:${session.user}`, 0)
+
+/** The window actually downloaded: the user's choice, capped by the admin. */
+export const offlineWindow = computed({
+ get: () => {
+ if (!policy.enabled) return 0
+ return Math.min(chosenWindow.value, policy.maxWindow) as OfflineWindow
+ },
+ set: (value) => {
+ chosenWindow.value = value
+ },
+})
+
+export const downloads = reactive({
+ syncing: false,
+ done: 0,
+ total: 0,
+ count: 0,
+ lastSyncedAt: null as number | null,
+ error: null as string | null,
+})
+
+let meta: Meta | null = null
+
+async function readMeta(): Promise {
+ const stored = (await get(META_KEY).catch(() => null)) as Meta | undefined
+ meta = stored?.user === session.user ? stored : null
+ downloads.count = meta?.names.length ?? 0
+ downloads.lastSyncedAt = meta?.lastSyncedAt ?? null
+ return meta
+}
+
+async function writeMeta(next: Meta) {
+ meta = next
+ downloads.count = next.names.length
+ downloads.lastSyncedAt = next.lastSyncedAt
+ await set(META_KEY, next)
+}
+
+/**
+ * Brings the device in line with the chosen window. Automatic runs are skipped when a sync
+ * happened recently, on Data Saver, or while the tab is hidden; `manual` runs skip those checks.
+ */
+export async function syncOfflineDownloads({ manual = false } = {}): Promise {
+ if (!session.isLoggedIn) return false
+ // A manual run (a new window picked) waits for the current one, then brings it up to date.
+ if (inflight) {
+ await inflight.catch(() => {})
+ if (!manual) return false
+ }
+ inflight = sync(manual).finally(() => (inflight = null))
+ return inflight
+}
+
+let inflight: Promise | null = null
+
+async function sync(manual: boolean): Promise {
+ const days = offlineWindow.value
+ const current = meta ?? (await readMeta())
+
+ if (!days) {
+ if (current) await removeOfflineDownloads()
+ return true
+ }
+ if (!isOnline.value) return false
+ if (!manual) {
+ if (document.visibilityState !== 'visible' || saveData()) return false
+ // An interrupted sync resumes whenever it can, however recent the last complete one.
+ const fresh = current?.window === days && !current.cursor && current.lastSyncedAt
+ if (fresh && Date.now() - fresh < SYNC_INTERVAL) return true
+ }
+
+ // One tab at a time; another tab already syncing covers this one.
+ if (!navigator.locks) return runSync(days)
+ return navigator.locks.request(LOCK_NAME, { ifAvailable: true }, (lock) =>
+ lock ? runSync(days) : false,
+ )
+}
+
+/** Returns whether the sync finished; losing the connection pauses it at the saved cursor. */
+async function runSync(days: OfflineWindow): Promise {
+ downloads.syncing = true
+ downloads.error = null
+ downloads.done = 0
+ downloads.total = 0
+ try {
+ const previous = meta ?? (await readMeta())
+ const index = await call<{ discussions: string[]; synced_at: string }>(INDEX, {
+ window_days: days,
+ })
+ const names = index.discussions
+ const dropped = (previous?.names ?? []).filter((name) => !names.includes(name))
+ await forgetDiscussions(dropped)
+
+ const sameWindow = previous?.window === days
+ const since = sameWindow ? previous.since : null
+ const resume = sameWindow && previous.cursor?.since === since ? previous.cursor : null
+ let start = resume?.start ?? 0
+ const syncedAt = resume?.syncedAt ?? index.synced_at
+ downloads.total = since ? 0 : names.length
+ downloads.done = since ? 0 : start
+
+ const base: Meta = {
+ user: session.user!,
+ window: days,
+ since,
+ names,
+ lastSyncedAt: sameWindow ? previous.lastSyncedAt : null,
+ cursor: { start, since, syncedAt },
+ }
+ await writeMeta(base)
+
+ let hasNext = true
+ while (hasNext) {
+ // Picks up on the next reconnect or app load, from the saved cursor.
+ if (!isOnline.value) return false
+ const bundle = await call(BUNDLE, {
+ window_days: days,
+ since,
+ start,
+ fields: { comments: COMMENT_FIELDS, activities: ACTIVITY_FIELDS, polls: POLL_FIELDS },
+ })
+ await storeBundle(bundle)
+ start += bundle.discussions.length
+ downloads.done += bundle.discussions.length
+ hasNext = bundle.has_next_page
+ await writeMeta({ ...base, cursor: { start, since, syncedAt } })
+ if (hasNext) await idle()
+ }
+
+ await writeMeta({ ...base, since: syncedAt, lastSyncedAt: Date.now(), cursor: null })
+ return true
+ } catch (error) {
+ downloads.error = error instanceof Error ? error.message : String(error)
+ throw error
+ } finally {
+ downloads.syncing = false
+ }
+}
+
+async function storeBundle(bundle: Bundle) {
+ const user = session.user!
+ const entries: [string, string][] = []
+ for (const discussion of bundle.discussions) {
+ const name = String(discussion.name)
+ entries.push([docKey('GP Discussion', name), JSON.stringify({ ...discussion, name })])
+ entries.push(
+ listEntry(commentsCacheKey('GP Discussion', name, user), bundle.comments[name]),
+ listEntry(activitiesCacheKey('GP Discussion', name, user), bundle.activities[name]),
+ listEntry(pollsCacheKey(name, user), bundle.polls[name]),
+ )
+ }
+ await setMany(entries)
+}
+
+async function forgetDiscussions(names: string[]) {
+ if (!names.length) return
+ const user = session.user!
+ await delMany(
+ names.flatMap((name) => [
+ docKey('GP Discussion', name),
+ listKey(commentsCacheKey('GP Discussion', name, user)),
+ listKey(activitiesCacheKey('GP Discussion', name, user)),
+ listKey(pollsCacheKey(name, user)),
+ ]),
+ )
+}
+
+/** Deletes everything downloaded; a discussion opened again online is cached as usual. */
+export async function removeOfflineDownloads() {
+ const current = meta ?? (await readMeta())
+ if (!current) return
+ await forgetDiscussions(current.names)
+ await delMany([META_KEY])
+ meta = null
+ downloads.count = 0
+ downloads.lastSyncedAt = null
+}
+
+/** Picks a window and downloads it now, with a toast for the foreground download. */
+export function downloadForOffline(days: OfflineWindow) {
+ offlineWindow.value = days
+ if (!days) return removeOfflineDownloads()
+ // Without this the browser may evict the downloads under storage pressure (Safari does
+ // after a week of not opening the site).
+ navigator.storage?.persist?.().catch(() => {})
+ const finished = syncOfflineDownloads({ manual: true }).then((done) => {
+ if (!done) throw new Error('Offline download did not finish')
+ })
+ return toast.promise(finished, {
+ loading: 'Downloading discussions for offline reading…',
+ success: 'Discussions are ready to read offline',
+ error: 'Could not finish the offline download. It will retry later.',
+ })
+}
+
+// The key formats frappe-ui's useList and docStore use for IndexedDB.
+function listKey(cacheKey: unknown[]) {
+ return JSON.stringify(['useList', ...cacheKey])
+}
+
+function docKey(doctype: string, name: string) {
+ return `doc:${doctype}/${name}`
+}
+
+function listEntry(cacheKey: unknown[], rows: Row[] = []): [string, string] {
+ // Raw rows, names as strings like useList stores them; each list applies its own transform
+ // when it reads the cache.
+ return [
+ listKey(cacheKey),
+ JSON.stringify(rows.map((row) => ({ ...row, name: String(row.name) }))),
+ ]
+}
+
+function saveData() {
+ return Boolean(
+ (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData,
+ )
+}
+
+function idle() {
+ return new Promise((resolve) =>
+ 'requestIdleCallback' in window
+ ? requestIdleCallback(() => resolve(), { timeout: 2000 })
+ : setTimeout(resolve, 200),
+ )
+}
+
+/** Starts background syncing: once shortly after load, then on reconnect and when the tab returns. */
+export function setupOfflineDownloads() {
+ const background = () => syncOfflineDownloads().catch(() => {})
+ readMeta().then((current) => {
+ if (!policy.enabled && current) removeOfflineDownloads()
+ })
+ setTimeout(background, 5000 + Math.random() * MAX_START_DELAY)
+ onReconnect(background)
+ document.addEventListener('visibilitychange', background)
+ watch(offlineWindow, (days, previous) => {
+ if (!days && previous) removeOfflineDownloads()
+ })
+ setupIntroduction()
+}
+
+const introduction = useLocalStorage<'new' | 'seen-offline' | 'done'>(
+ `gameplan:offline-intro:${session.user}`,
+ 'new',
+)
+
+/**
+ * Introduces downloads once per device: a toast the first time the connection drops (nothing
+ * can be downloaded then), and an offer to download on the next app load. Not on reconnect,
+ * where a dialog would land on top of whatever the person was in the middle of.
+ */
+function setupIntroduction() {
+ const pending = () => policy.enabled && !offlineWindow.value
+ watch(isOnline, (online) => {
+ if (online || !pending() || introduction.value !== 'new') return
+ introduction.value = 'seen-offline'
+ toast.info("You're offline. Only discussions you've opened are available.", {
+ action: { label: 'Set up offline reading', onClick: openOfflineSettings },
+ })
+ })
+ if (introduction.value === 'seen-offline') setTimeout(offerDownload, 3000)
+}
+
+function offerDownload() {
+ if (!policy.enabled || offlineWindow.value || !isOnline.value) return
+ introduction.value = 'done'
+ const days = Math.min(30, policy.maxWindow) as OfflineWindow
+ const period = WINDOW_OPTIONS.find((option) => option.value === days)!.label.toLowerCase()
+ dialog.confirm({
+ title: 'Read Gameplan offline',
+ message: `Keep discussions from the ${period} in your spaces on this device, so they open even without a connection. You can change this in Settings.`,
+ confirmLabel: 'Download',
+ cancelLabel: 'Not now',
+ onConfirm: () => {
+ downloadForOffline(days)
+ },
+ })
+}
+
+function openOfflineSettings() {
+ // Imported on demand: the settings module reaches the router, which reaches this module.
+ import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline'))
+}
diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts
index e2cdce176..88a17cf85 100644
--- a/frontend/src/globals.d.ts
+++ b/frontend/src/globals.d.ts
@@ -34,6 +34,8 @@ declare global {
site_name: string
/** Set from the boot data in `gameplan/www/g.py`; absent when the site has no DSN. */
gameplan_frontend_sentry_dsn?: string
+ /** What the admin allows for offline downloads (GP Settings), from the boot data. */
+ offline_downloads?: { enabled: boolean; max_window_days: number }
}
}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 389c41426..cc4da9e00 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -22,6 +22,7 @@ import { initSocket } from './socket'
import { installErrorReporting } from './utils/errorReporting'
import resetDataMixin from './utils/resetDataMixin'
import { setupOfflineSupport } from './offline'
+import { setupOfflineDownloads } from './data/offlineDownloads'
let globalComponents = {
Button,
@@ -80,6 +81,7 @@ function setupApp() {
app.config.globalProperties.$socket = socket
app.mount('#app')
setupOfflineSupport()
+ if (session.isLoggedIn) setupOfflineDownloads()
}
if (import.meta.env.DEV) {
diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md
index 2a9d66e2a..0015be259 100644
--- a/frontend/tests/offline/README.md
+++ b/frontend/tests/offline/README.md
@@ -8,22 +8,23 @@ scripts, not `@playwright/test` — each story is a `run()` function that return
Originally built as a throwaway harness at `/tmp/offline-mvp/pw`; migrated here so it
survives reboots and can gate regressions in CI/local dev.
-## What's covered (12 stories)
+## What's covered (13 stories)
-| Story | Covers |
-| ----- | ------------------------------------------------------------------------------------------------------------ |
-| US1 | App shell loads offline (reload + deep link) instead of a browser error page |
-| US2 | Previously-viewed feed / space / discussion render from cache while offline |
-| US3 | Offline indicator appears when connectivity drops, clears on reconnect |
-| US4 | A comment typed while offline fails gracefully and isn't lost |
-| US5 | Fresh data appears automatically on reconnect, no manual reload |
-| US6 | Never-cached content shows an honest "can't load this offline" fallback |
-| P2 | A profile visited fully online (incl. Posts tab) is available offline |
-| P3 | A never-cached People page/profile opened offline degrades honestly |
-| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft |
-| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data |
-| US7c | A session that goes stale without a logout (timeout, old tab) still lets the next login detect the switch |
-| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version |
+| Story | Covers |
+| ----- | ---------------------------------------------------------------------------------------------------------------- |
+| US1 | App shell loads offline (reload + deep link) instead of a browser error page |
+| US2 | Previously-viewed feed / space / discussion render from cache while offline |
+| US3 | Offline indicator appears when connectivity drops, clears on reconnect |
+| US4 | A comment typed while offline fails gracefully and isn't lost |
+| US5 | Fresh data appears automatically on reconnect, no manual reload |
+| US6 | Never-cached content shows an honest "can't load this offline" fallback |
+| P2 | A profile visited fully online (incl. Posts tab) is available offline |
+| P3 | A never-cached People page/profile opened offline degrades honestly |
+| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft |
+| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data |
+| US7c | A session that goes stale without a logout (timeout, old tab) still lets the next login detect the switch |
+| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version |
+| US9 | "Download for offline" makes a never-opened discussion readable offline, in about one request per 20 discussions |
## Prerequisites
@@ -52,6 +53,9 @@ survives reboots and can gate regressions in CI/local dev.
one `GP Discussion` in it — used by US7c to check the second user genuinely has no
access to it. Hardcoded to space `1426` / discussion `722` in `us7c.js`; reseed at
those names or edit the story's constants to match your site.
+ - US9 downloads from that same private space (`GAMEPLAN_OFFLINE_JOINED_SPACE_ID`), since
+ downloads only cover spaces the account has joined. It creates and deletes its own
+ discussion.
## Running
@@ -61,14 +65,14 @@ yarn install
yarn test:offline
```
-Runs all 12 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default
+Runs all 13 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default
`http://gameplan.localhost:8003`), writes a summary to `tests/offline/results/summary.json`
and per-story JSON/screenshots under `tests/offline/results/` (gitignored). Exits non-zero
if any story fails.
Run a single story directly: `node tests/offline/us3.js`.
-Online regression smokes (confirm normal online usage isn't broken — not part of the 12
+Online regression smokes (confirm normal online usage isn't broken — not part of the 13
offline stories): `yarn test:offline:smoke`.
## Configuration
diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js
index 0417f41dc..6363d0b44 100644
--- a/frontend/tests/offline/config.js
+++ b/frontend/tests/offline/config.js
@@ -25,6 +25,8 @@ const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4'
const UNCACHED_DISCUSSION_SPACE_ID =
process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5'
const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54'
+// A space the primary account has joined (US9 downloads only cover joined spaces).
+const JOINED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_JOINED_SPACE_ID || '1426'
const URLS = {
feed: `${BASE}/g`,
@@ -55,6 +57,8 @@ const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots')
module.exports = {
BASE,
+ COMMUNITY,
+ JOINED_SPACE_ID,
EMAIL,
PWD,
EMAIL2,
diff --git a/frontend/tests/offline/runner.js b/frontend/tests/offline/runner.js
index f5468cc88..c98256114 100644
--- a/frontend/tests/offline/runner.js
+++ b/frontend/tests/offline/runner.js
@@ -1,6 +1,6 @@
// Runs the full offline suite: US1-US6 (baseline offline UX) + P2-P3 (People/profile
// offline caching) + US7a/US7b/US7c (shared-computer cache scoping) +
-// US8 (service worker update flow). Each story launches its own fresh browser/context
+// US8 (service worker update flow) + US9 (download for offline). Each story launches its own fresh browser/context
// for isolation. Run with: node tests/offline/runner.js (or `yarn test:offline` from
// frontend/).
const fs = require('fs')
@@ -20,6 +20,7 @@ const stories = [
'us7b',
'us7c',
'us8',
+ 'us9',
]
async function main() {
diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js
new file mode 100644
index 000000000..3901a1bb6
--- /dev/null
+++ b/frontend/tests/offline/us9.js
@@ -0,0 +1,153 @@
+// US9 — Download for offline: picking "Past week" in Settings > Offline downloads the
+// discussions from joined spaces with recent activity, so one never opened before reads
+// offline with its comments. Content outside the window still shows the honest offline
+// fallback, the download costs about one request per 20 discussions, and a reload soon after
+// doesn't download again.
+const {
+ chromium,
+ BASE,
+ URLS,
+ newLoggedInContext,
+ newApiRequestContext,
+ idbKeyvalKeys,
+ innerTextSafe,
+ shot,
+ writeResult,
+} = require('./helpers')
+const { COMMUNITY, JOINED_SPACE_ID } = require('./config')
+
+const MARKER = `us9-${Date.now()}`
+const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/
+
+async function createDiscussion(api) {
+ const discussion = await api.post('/api/v2/document/GP Discussion', {
+ data: {
+ title: `${MARKER} downloaded thread`,
+ project: JOINED_SPACE_ID,
+ content: 'Body
',
+ },
+ })
+ if (!discussion.ok()) throw new Error(`create discussion: ${await discussion.text()}`)
+ const name = String((await discussion.json()).data.name)
+ const comment = await api.post('/api/v2/document/GP Comment', {
+ data: {
+ reference_doctype: 'GP Discussion',
+ reference_name: name,
+ content: `${MARKER} reply
`,
+ },
+ })
+ if (!comment.ok()) throw new Error(`create comment: ${await comment.text()}`)
+ return name
+}
+
+function spaNavigate(page, path) {
+ return page.evaluate((p) => {
+ history.pushState({}, '', p)
+ dispatchEvent(new PopStateEvent('popstate', { state: history.state }))
+ }, path)
+}
+
+async function run() {
+ const api = await newApiRequestContext()
+ const browser = await chromium.launch({ headless: true })
+ const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
+ const result = { story: 'US9', checks: [] }
+ const offlineRequests = []
+ page.on('request', (req) => {
+ if (OFFLINE_API.test(req.url())) offlineRequests.push(req.url())
+ })
+ let created = null
+
+ try {
+ created = await createDiscussion(api)
+ const discussionPath = `/g/community/${COMMUNITY}/space/${JOINED_SPACE_ID}/discussion/${created}`
+
+ await page.goto(`${BASE}/g/settings/offline`, { waitUntil: 'load', timeout: 20000 })
+ await page.getByText('Download for offline').waitFor({ timeout: 15000 })
+ await page.getByRole('combobox').first().click()
+ await page.getByRole('option', { name: 'Past week' }).click()
+ await page
+ .getByText('Discussions are ready to read offline')
+ .waitFor({ timeout: 30000 })
+ .catch(() => {})
+
+ const keys = await idbKeyvalKeys(page)
+ const index = await (
+ await api.post('/api/method/gameplan.offline_downloads.get_offline_index', {
+ data: { window_days: 7 },
+ })
+ ).json()
+ const expectedRequests = 1 + Math.max(1, Math.ceil(index.message.discussions.length / 20))
+ result.checks.push({
+ name: 'the new discussion and its comments are stored in the app caches',
+ pass:
+ keys.includes(`doc:GP Discussion/${created}`) &&
+ keys.some((key) => key.includes('"Comments"') && key.includes(`"${created}"`)),
+ symptom: `downloaded keys for ${created}: ${keys.filter((k) => String(k).includes(created)).length}`,
+ })
+ result.checks.push({
+ name: 'the download costs one index call plus one call per 20 discussions',
+ pass: offlineRequests.length === expectedRequests,
+ symptom: `${offlineRequests.length} offline-download requests, expected ${expectedRequests}`,
+ })
+
+ await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
+ await page.waitForTimeout(2000)
+ await context.setOffline(true)
+ await page.waitForTimeout(500)
+
+ await spaNavigate(page, discussionPath)
+ await page.waitForTimeout(2500)
+ const text = await innerTextSafe(page)
+ result.checks.push({
+ name: 'a never-opened discussion in the window reads offline with its reply',
+ pass: text.includes(`${MARKER} downloaded thread`) && text.includes(`${MARKER} reply`),
+ symptom: text.slice(0, 300),
+ screenshot: await shot(page, 'us9-downloaded-discussion-offline'),
+ })
+
+ await spaNavigate(page, new URL(URLS.uncachedDiscussion).pathname)
+ await page.waitForTimeout(2500)
+ const outside = await innerTextSafe(page)
+ result.checks.push({
+ name: 'a discussion outside the window still shows the offline fallback',
+ pass: /isn.?t available offline|can.?t load/i.test(outside),
+ symptom: outside.slice(0, 300),
+ })
+
+ await context.setOffline(false)
+ offlineRequests.length = 0
+ await page.reload({ waitUntil: 'load' })
+ // The first background sync waits up to 35s after load.
+ await page.waitForTimeout(40000)
+ result.checks.push({
+ name: 'a reload soon after a complete sync does not download again',
+ pass: offlineRequests.length === 0,
+ symptom: `${offlineRequests.length} offline-download requests after reload`,
+ })
+
+ result.pass = result.checks.every((check) => check.pass)
+ } catch (e) {
+ result.pass = false
+ result.fatalError = String(e)
+ } finally {
+ result.consoleErrors = consoleErrors
+ result.pageErrors = pageErrors
+ await context.setOffline(false).catch(() => {})
+ if (created) await api.delete(`/api/v2/document/GP Discussion/${created}`).catch(() => {})
+ await browser.close()
+ await api.dispose()
+ }
+
+ writeResult('us9', result)
+ return result
+}
+
+if (require.main === module) {
+ run().then((r) => {
+ console.log(JSON.stringify(r, null, 2))
+ process.exit(r.pass ? 0 : 1)
+ })
+}
+
+module.exports = { run }
From 0c1dd9c7b78836b14c939ff3721ef7724ac30772 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 06:40:54 +0000
Subject: [PATCH 34/68] fix(frontend): quiet failures when the server can't be
reached
- Recording a space visit left its rejection unhandled when the request failed,
which surfaced as an uncaught TypeError whenever the server was down.
- Error reporting skipped network failures only while offline. A server that is
down while the browser counts as online produced reports that couldn't be
delivered either, so skip network failures always.
Co-Authored-By: Claude Opus 5
---
frontend/src/pages/Space.vue | 3 ++-
frontend/src/utils/errorReporting.ts | 7 ++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/frontend/src/pages/Space.vue b/frontend/src/pages/Space.vue
index 205c55231..3da1795b2 100644
--- a/frontend/src/pages/Space.vue
+++ b/frontend/src/pages/Space.vue
@@ -124,5 +124,6 @@ function routeParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value
}
-whenOnline(() => trackSpaceVisit(props.spaceId))
+// A missed visit only leaves the space's read state as it was; the next visit records it.
+whenOnline(() => trackSpaceVisit(props.spaceId).catch(() => {}))
diff --git a/frontend/src/utils/errorReporting.ts b/frontend/src/utils/errorReporting.ts
index 03c2812cb..173a3ac64 100644
--- a/frontend/src/utils/errorReporting.ts
+++ b/frontend/src/utils/errorReporting.ts
@@ -15,7 +15,7 @@
import { call } from 'frappe-ui'
import type { App } from 'vue'
import type { Router } from 'vue-router'
-import { isOnline, whenOnline } from '@/data/online'
+import { whenOnline } from '@/data/online'
import { isNetworkError } from '@/offline'
const LOG_CLIENT_ERROR = 'gameplan.api.log_client_error'
@@ -97,8 +97,9 @@ function sendToServer(error: unknown, context: ErrorContext): void {
// failure, and twenty copies of it say nothing the first one did not.
if (reportedFingerprints.has(fingerprint)) return
if (reportCount >= MAX_REPORTS_PER_PAGE_LOAD) return
- // A failed request while offline is the connection, not a bug.
- if (!isOnline.value && isNetworkError(error)) return
+ // A request that never reached the server is the connection (offline, or the server is
+ // down), not a bug, and a report about it could not be delivered either.
+ if (isNetworkError(error)) return
reportedFingerprints.add(fingerprint)
reportCount += 1
From 891be473313cff3b407bc724ef4da14aa336b3f4 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 07:05:02 +0000
Subject: [PATCH 35/68] feat(offline): remove visited discussions the user can
no longer read
A discussion opened while online stays on the device, so after access to its
space was taken away (or it was deleted) it could still be read offline. Each
download sync now sends the discussions the device holds from the user's own
visits with its index request, and removes the ones the server reports as no
longer readable. No extra request; one more query on the server.
Co-Authored-By: Claude Opus 5
---
frontend/src/data/offlineDownloads.ts | 20 +++++++++----
frontend/tests/offline/us9.js | 29 +++++++++++++++++--
gameplan/offline_downloads.py | 19 ++++++++++--
.../tests/features/test_offline_downloads.py | 17 +++++++++++
4 files changed, 76 insertions(+), 9 deletions(-)
diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts
index 500339e4c..e654bb948 100644
--- a/frontend/src/data/offlineDownloads.ts
+++ b/frontend/src/data/offlineDownloads.ts
@@ -1,7 +1,7 @@
import { computed, reactive, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { call, dialog, toast } from 'frappe-ui'
-import { delMany, get, set, setMany } from 'idb-keyval'
+import { delMany, get, keys, set, setMany } from 'idb-keyval'
import { isOnline, onReconnect } from './online'
import { session } from './session'
import {
@@ -157,12 +157,14 @@ async function runSync(days: OfflineWindow): Promise {
downloads.total = 0
try {
const previous = meta ?? (await readMeta())
- const index = await call<{ discussions: string[]; synced_at: string }>(INDEX, {
- window_days: days,
- })
+ const visited = (await cachedDiscussions()).filter((name) => !previous?.names.includes(name))
+ const index = await call<{ discussions: string[]; revoked: string[]; synced_at: string }>(
+ INDEX,
+ { window_days: days, cached: visited },
+ )
const names = index.discussions
const dropped = (previous?.names ?? []).filter((name) => !names.includes(name))
- await forgetDiscussions(dropped)
+ await forgetDiscussions([...dropped, ...index.revoked])
const sameWindow = previous?.window === days
const since = sameWindow ? previous.since : null
@@ -225,6 +227,14 @@ async function storeBundle(bundle: Bundle) {
await setMany(entries)
}
+/** Discussions saved on this device, whether downloaded or from the user's own visits. */
+async function cachedDiscussions() {
+ const prefix = docKey('GP Discussion', '')
+ return (await keys())
+ .filter((key): key is string => typeof key === 'string' && key.startsWith(prefix))
+ .map((key) => key.slice(prefix.length))
+}
+
async function forgetDiscussions(names: string[]) {
if (!names.length) return
const user = session.user!
diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js
index 3901a1bb6..3e150da02 100644
--- a/frontend/tests/offline/us9.js
+++ b/frontend/tests/offline/us9.js
@@ -1,8 +1,8 @@
// US9 — Download for offline: picking "Past week" in Settings > Offline downloads the
// discussions from joined spaces with recent activity, so one never opened before reads
// offline with its comments. Content outside the window still shows the honest offline
-// fallback, the download costs about one request per 20 discussions, and a reload soon after
-// doesn't download again.
+// fallback, the download costs about one request per 20 discussions, a reload soon after
+// doesn't download again, and a visited copy of a discussion that's since gone is removed.
const {
chromium,
BASE,
@@ -18,6 +18,25 @@ const { COMMUNITY, JOINED_SPACE_ID } = require('./config')
const MARKER = `us9-${Date.now()}`
const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/
+// Stands in for a discussion the user opened that has since been deleted.
+const GONE_KEY = 'doc:GP Discussion/999999999'
+
+function putIdbKey(page, key) {
+ return page.evaluate(
+ (k) =>
+ new Promise((resolve, reject) => {
+ const req = indexedDB.open('keyval-store')
+ req.onsuccess = () => {
+ const tx = req.result.transaction('keyval', 'readwrite')
+ tx.objectStore('keyval').put(JSON.stringify({ name: '999999999' }), k)
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ }
+ req.onerror = () => reject(req.error)
+ }),
+ key,
+ )
+}
async function createDiscussion(api) {
const discussion = await api.post('/api/v2/document/GP Discussion', {
@@ -64,6 +83,7 @@ async function run() {
await page.goto(`${BASE}/g/settings/offline`, { waitUntil: 'load', timeout: 20000 })
await page.getByText('Download for offline').waitFor({ timeout: 15000 })
+ await putIdbKey(page, GONE_KEY)
await page.getByRole('combobox').first().click()
await page.getByRole('option', { name: 'Past week' }).click()
await page
@@ -85,6 +105,11 @@ async function run() {
keys.some((key) => key.includes('"Comments"') && key.includes(`"${created}"`)),
symptom: `downloaded keys for ${created}: ${keys.filter((k) => String(k).includes(created)).length}`,
})
+ result.checks.push({
+ name: 'a visited copy of a discussion that no longer exists is removed',
+ pass: !keys.includes(GONE_KEY),
+ symptom: keys.includes(GONE_KEY) ? `${GONE_KEY} still stored` : 'removed on sync',
+ })
result.checks.push({
name: 'the download costs one index call plus one call per 20 discussions',
pass: offlineRequests.length === expectedRequests,
diff --git a/gameplan/offline_downloads.py b/gameplan/offline_downloads.py
index bf6a8b120..83df0598c 100644
--- a/gameplan/offline_downloads.py
+++ b/gameplan/offline_downloads.py
@@ -21,6 +21,8 @@
PAGE_SIZE = 20
# A 90-day download of a busy site is a few dozen pages. This only stops a runaway client.
REQUESTS_PER_HOUR = 200
+# Visited discussions checked for lost access in one request.
+MAX_CACHED = 2000
CHILD_LISTS = {
"comments": ("GP Comment", "reference"),
"activities": ("GP Activity", "reference"),
@@ -29,12 +31,17 @@
@frappe.whitelist(methods=["POST"])
-def get_offline_index(window_days):
- """Discussions the device should hold for this window, newest activity first."""
+def get_offline_index(window_days, cached=None):
+ """Discussions the device should hold for this window, newest activity first.
+
+ `cached` names other discussions the device holds from the user's own visits; the ones they
+ can no longer read (deleted, or access taken away) come back as `revoked` to be removed.
+ """
window = _allowed_window(window_days)
synced_at = now_datetime()
return {
"discussions": [row.name for row in _discussions_in_window(window)],
+ "revoked": _revoked(frappe.parse_json(cached) or []),
"synced_at": str(synced_at),
}
@@ -112,6 +119,14 @@ def _discussions_in_window(window):
return rows
+def _revoked(names):
+ names = [str(name) for name in names[:MAX_CACHED]]
+ if not names:
+ return []
+ readable = {str(row.name) for row in _query("GP Discussion", ["name"], {"name": ["in", names]})}
+ return [name for name in names if name not in readable]
+
+
def _changed_since(rows, since):
"""Discussions edited, replied to or reacted in after `since`.
diff --git a/gameplan/tests/features/test_offline_downloads.py b/gameplan/tests/features/test_offline_downloads.py
index 679dbe296..a12389d27 100644
--- a/gameplan/tests/features/test_offline_downloads.py
+++ b/gameplan/tests/features/test_offline_downloads.py
@@ -112,6 +112,23 @@ def test_leaving_a_private_space_drops_its_discussions(self):
private.save(ignore_permissions=True)
self.assertNotIn(str(secret.name), self.index(90))
+ def test_reports_visited_discussions_the_user_can_no_longer_read(self):
+ private = create_space("Secret", self.community, is_private=1, members=[self.member])
+ secret = create_discussion("Secret thread", private, owner=self.member)
+ gone = create_discussion("Gone thread", self.joined, owner=self.member)
+ cached = [str(self.elsewhere.name), str(secret.name), str(gone.name)]
+ with self.as_user(self.member):
+ self.assertEqual(get_offline_index(30, json.dumps(cached))["revoked"], [])
+
+ private.reload()
+ private.members = []
+ private.save(ignore_permissions=True)
+ frappe.delete_doc("GP Discussion", gone.name, ignore_permissions=True)
+ with self.as_user(self.member):
+ revoked = get_offline_index(30, json.dumps(cached))["revoked"]
+ # The public space's discussion stays: reading it never needed membership.
+ self.assertEqual(sorted(revoked), sorted([str(secret.name), str(gone.name)]))
+
def test_rejects_an_empty_window(self):
with self.as_user(self.member), self.assertRaises(frappe.ValidationError):
get_offline_index(0)
From b81245b5ee3bf368848ee884a921a5d56fccdb20 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 07:20:51 +0000
Subject: [PATCH 36/68] fix(offline): download discussions missing from the
device, not just changed ones
After the first download, a sync only fetched discussions that changed since the
last one. A space joined later brought discussions that were in the index but
unchanged, so they were listed as downloaded without ever being fetched, and
opened offline as unavailable.
A sync now counts only what is really on the device as downloaded. It fetches
changes to those, then everything else in the index by name, which also covers a
first download and an interrupted one. This replaces the resume cursor.
Co-Authored-By: Claude Opus 5
---
frontend/src/data/offlineDownloads.ts | 83 ++++++++++++-------
frontend/tests/offline/us9.js | 38 ++++++++-
gameplan/offline_downloads.py | 11 ++-
.../tests/features/test_offline_downloads.py | 8 ++
4 files changed, 108 insertions(+), 32 deletions(-)
diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts
index e654bb948..b99249b40 100644
--- a/frontend/src/data/offlineDownloads.ts
+++ b/frontend/src/data/offlineDownloads.ts
@@ -37,6 +37,8 @@ const INDEX = 'gameplan.offline_downloads.get_offline_index'
const BUNDLE = 'gameplan.offline_downloads.get_offline_bundle'
const META_KEY = 'gameplan:offline-downloads'
const LOCK_NAME = 'gameplan-offline-downloads'
+// Matches the server's page size (gameplan/offline_downloads.py).
+const PAGE_SIZE = 20
// Background syncs only fetch what changed, but still cost an index query each; this keeps
// them to a few a day per device.
const SYNC_INTERVAL = 6 * 60 * 60 * 1000
@@ -48,10 +50,11 @@ interface Meta {
window: number
/** Server time the last complete sync started; the next one asks for changes since. */
since: string | null
+ /** Discussions downloaded to this device. */
names: string[]
lastSyncedAt: number | null
- /** Where an interrupted sync stopped, so the next one carries on from there. */
- cursor: { start: number; since: string | null; syncedAt: string } | null
+ /** The last sync stopped before finishing, so the next one runs whenever it can. */
+ incomplete: boolean
}
interface Bundle {
@@ -137,8 +140,7 @@ async function sync(manual: boolean): Promise {
if (!isOnline.value) return false
if (!manual) {
if (document.visibilityState !== 'visible' || saveData()) return false
- // An interrupted sync resumes whenever it can, however recent the last complete one.
- const fresh = current?.window === days && !current.cursor && current.lastSyncedAt
+ const fresh = current?.window === days && !current.incomplete && current.lastSyncedAt
if (fresh && Date.now() - fresh < SYNC_INTERVAL) return true
}
@@ -149,7 +151,10 @@ async function sync(manual: boolean): Promise {
)
}
-/** Returns whether the sync finished; losing the connection pauses it at the saved cursor. */
+/**
+ * Returns whether the sync finished. Losing the connection stops it; the next run fetches
+ * whatever is still missing.
+ */
async function runSync(days: OfflineWindow): Promise {
downloads.syncing = true
downloads.error = null
@@ -157,7 +162,8 @@ async function runSync(days: OfflineWindow): Promise {
downloads.total = 0
try {
const previous = meta ?? (await readMeta())
- const visited = (await cachedDiscussions()).filter((name) => !previous?.names.includes(name))
+ const onDevice = new Set(await cachedDiscussions())
+ const visited = [...onDevice].filter((name) => !previous?.names.includes(name))
const index = await call<{ discussions: string[]; revoked: string[]; synced_at: string }>(
INDEX,
{ window_days: days, cached: visited },
@@ -166,43 +172,64 @@ async function runSync(days: OfflineWindow): Promise {
const dropped = (previous?.names ?? []).filter((name) => !names.includes(name))
await forgetDiscussions([...dropped, ...index.revoked])
+ // Only what is really on the device counts as downloaded. A new window starts over.
const sameWindow = previous?.window === days
- const since = sameWindow ? previous.since : null
- const resume = sameWindow && previous.cursor?.since === since ? previous.cursor : null
- let start = resume?.start ?? 0
- const syncedAt = resume?.syncedAt ?? index.synced_at
- downloads.total = since ? 0 : names.length
- downloads.done = since ? 0 : start
-
+ const stored = new Set(
+ sameWindow ? previous.names.filter((name) => names.includes(name) && onDevice.has(name)) : [],
+ )
const base: Meta = {
user: session.user!,
window: days,
- since,
- names,
+ since: sameWindow ? previous.since : null,
+ names: [...stored],
lastSyncedAt: sameWindow ? previous.lastSyncedAt : null,
- cursor: { start, since, syncedAt },
+ incomplete: true,
}
await writeMeta(base)
- let hasNext = true
- while (hasNext) {
- // Picks up on the next reconnect or app load, from the saved cursor.
- if (!isOnline.value) return false
+ const fetchPage = async (params: Record) => {
const bundle = await call(BUNDLE, {
window_days: days,
- since,
- start,
fields: { comments: COMMENT_FIELDS, activities: ACTIVITY_FIELDS, polls: POLL_FIELDS },
+ ...params,
})
await storeBundle(bundle)
- start += bundle.discussions.length
- downloads.done += bundle.discussions.length
- hasNext = bundle.has_next_page
- await writeMeta({ ...base, cursor: { start, since, syncedAt } })
- if (hasNext) await idle()
+ for (const discussion of bundle.discussions) stored.add(String(discussion.name))
+ await writeMeta({ ...base, names: [...stored] })
+ return bundle
}
- await writeMeta({ ...base, since: syncedAt, lastSyncedAt: Date.now(), cursor: null })
+ // Changes to what the device already holds.
+ if (base.since && stored.size) {
+ let start = 0
+ let hasNext = true
+ while (hasNext) {
+ if (!isOnline.value) return false
+ const bundle = await fetchPage({ since: base.since, start })
+ start += bundle.discussions.length
+ hasNext = bundle.has_next_page
+ if (hasNext) await idle()
+ }
+ }
+
+ // Everything not on the device yet: a first download, a space joined since the last sync,
+ // or what an interrupted sync didn't reach.
+ const missing = names.filter((name) => !stored.has(name))
+ downloads.total = missing.length
+ for (let i = 0; i < missing.length; i += PAGE_SIZE) {
+ if (!isOnline.value) return false
+ if (i) await idle()
+ await fetchPage({ names: missing.slice(i, i + PAGE_SIZE) })
+ downloads.done = Math.min(i + PAGE_SIZE, missing.length)
+ }
+
+ await writeMeta({
+ ...base,
+ names: [...stored],
+ since: index.synced_at,
+ lastSyncedAt: Date.now(),
+ incomplete: false,
+ })
return true
} catch (error) {
downloads.error = error instanceof Error ? error.message : String(error)
diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js
index 3e150da02..948b369a2 100644
--- a/frontend/tests/offline/us9.js
+++ b/frontend/tests/offline/us9.js
@@ -2,7 +2,9 @@
// discussions from joined spaces with recent activity, so one never opened before reads
// offline with its comments. Content outside the window still shows the honest offline
// fallback, the download costs about one request per 20 discussions, a reload soon after
-// doesn't download again, and a visited copy of a discussion that's since gone is removed.
+// doesn't download again, a visited copy of a discussion that's since gone is removed, and
+// Sync now fetches a discussion missing from the device even though it hasn't changed (a
+// space joined after the first download).
const {
chromium,
BASE,
@@ -21,6 +23,23 @@ const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/
// Stands in for a discussion the user opened that has since been deleted.
const GONE_KEY = 'doc:GP Discussion/999999999'
+function deleteIdbKey(page, key) {
+ return page.evaluate(
+ (k) =>
+ new Promise((resolve, reject) => {
+ const req = indexedDB.open('keyval-store')
+ req.onsuccess = () => {
+ const tx = req.result.transaction('keyval', 'readwrite')
+ tx.objectStore('keyval').delete(k)
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ }
+ req.onerror = () => reject(req.error)
+ }),
+ key,
+ )
+}
+
function putIdbKey(page, key) {
return page.evaluate(
(k) =>
@@ -116,6 +135,23 @@ async function run() {
symptom: `${offlineRequests.length} offline-download requests, expected ${expectedRequests}`,
})
+ const createdKey = `doc:GP Discussion/${created}`
+ await deleteIdbKey(page, createdKey)
+ await page
+ .getByText('Discussions are ready to read offline')
+ .waitFor({ state: 'detached', timeout: 15000 })
+ .catch(() => {})
+ await page.getByRole('button', { name: 'Sync now' }).click()
+ await page
+ .getByText('Discussions are ready to read offline')
+ .waitFor({ timeout: 30000 })
+ .catch(() => {})
+ result.checks.push({
+ name: 'Sync now fetches a discussion missing from the device though it has not changed',
+ pass: (await idbKeyvalKeys(page)).includes(createdKey),
+ symptom: `${createdKey} after Sync now: ${(await idbKeyvalKeys(page)).includes(createdKey) ? 'present' : 'missing'}`,
+ })
+
await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 })
await page.waitForTimeout(2000)
await context.setOffline(true)
diff --git a/gameplan/offline_downloads.py b/gameplan/offline_downloads.py
index 83df0598c..219fffee0 100644
--- a/gameplan/offline_downloads.py
+++ b/gameplan/offline_downloads.py
@@ -47,19 +47,24 @@ def get_offline_index(window_days, cached=None):
@frappe.whitelist(methods=["POST"])
-def get_offline_bundle(window_days, fields, since=None, start=0):
+def get_offline_bundle(window_days, fields, since=None, start=0, names=None):
"""One page of discussions in the window, each with its comments, activity and polls.
`fields` maps comments/activities/polls to the field lists the app's own lists request,
so the rows come back in exactly the shape those lists cache. With `since`, only
- discussions that changed after it are included.
+ discussions that changed after it are included. With `names`, just those (a page's worth),
+ for a device fetching what it doesn't hold yet.
"""
window = _allowed_window(window_days)
fields = frappe.parse_json(fields)
start = cint(start)
rows = _discussions_in_window(window)
- if since:
+ if names:
+ wanted = {str(name) for name in frappe.parse_json(names)[:PAGE_SIZE]}
+ rows = [row for row in rows if row.name in wanted]
+ start = 0
+ elif since:
rows = _changed_since(rows, get_datetime(since))
page = rows[start : start + PAGE_SIZE]
names = [row.name for row in page]
diff --git a/gameplan/tests/features/test_offline_downloads.py b/gameplan/tests/features/test_offline_downloads.py
index a12389d27..656aba01d 100644
--- a/gameplan/tests/features/test_offline_downloads.py
+++ b/gameplan/tests/features/test_offline_downloads.py
@@ -169,6 +169,14 @@ def test_pages_through_the_window(self):
self.assertEqual(len(names), offline_downloads.PAGE_SIZE + 1)
self.assertEqual(len(set(names)), len(names))
+ def test_names_fetches_just_those_within_the_window(self):
+ other = create_discussion("Another thread", self.joined, owner=self.member)
+ wanted = [str(other.name), str(self.old.name), str(self.elsewhere.name)]
+ bundle = self.bundle(30, names=json.dumps(wanted))
+ # The old thread is outside 30 days and the other space isn't joined.
+ self.assertEqual([str(d["name"]) for d in bundle["discussions"]], [str(other.name)])
+ self.assertFalse(bundle["has_next_page"])
+
def test_since_returns_only_changed_discussions(self):
since = add_to_date(now_datetime(), minutes=-5)
set_modified("GP Discussion", self.recent.name, add_to_date(since, minutes=-10))
From ca8db14c77acf72bae075b29115f6f7937e995f1 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 07:30:43 +0000
Subject: [PATCH 37/68] feat(offline): download images and custom emojis with
discussions
A downloaded discussion opened offline showed broken images, and custom emojis
never seen in the browser were missing everywhere. Each sync now asks the
service worker to save the uploaded images inside downloaded discussions and
comments, plus the custom emojis, in the runtime cache. Images already saved are
skipped, and at most 300 are added per sync. Images of discussions the device
drops (deleted, access lost, out of the window) are removed with them, and
logout clears them with the rest of the runtime cache.
Co-Authored-By: Claude Opus 5
---
frontend/src/data/offlineDownloads.ts | 53 ++++++++++++++++++++++-----
gameplan/www/gameplan-sw.js | 50 +++++++++++++++++++++++++
2 files changed, 94 insertions(+), 9 deletions(-)
diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts
index b99249b40..50e9df49e 100644
--- a/frontend/src/data/offlineDownloads.ts
+++ b/frontend/src/data/offlineDownloads.ts
@@ -1,9 +1,10 @@
import { computed, reactive, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { call, dialog, toast } from 'frappe-ui'
-import { delMany, get, keys, set, setMany } from 'idb-keyval'
+import { delMany, get, getMany, keys, set, setMany } from 'idb-keyval'
import { isOnline, onReconnect } from './online'
import { session } from './session'
+import { customEmojis } from './customEmojis'
import {
ACTIVITY_FIELDS,
COMMENT_FIELDS,
@@ -39,6 +40,8 @@ const META_KEY = 'gameplan:offline-downloads'
const LOCK_NAME = 'gameplan-offline-downloads'
// Matches the server's page size (gameplan/offline_downloads.py).
const PAGE_SIZE = 20
+// Images one sync may add. Already saved ones are skipped, so later syncs add only new ones.
+const MAX_IMAGES = 300
// Background syncs only fetch what changed, but still cost an index query each; this keeps
// them to a few a day per device.
const SYNC_INTERVAL = 6 * 60 * 60 * 1000
@@ -187,6 +190,7 @@ async function runSync(days: OfflineWindow): Promise {
}
await writeMeta(base)
+ const images = new Set()
const fetchPage = async (params: Record) => {
const bundle = await call(BUNDLE, {
window_days: days,
@@ -195,6 +199,7 @@ async function runSync(days: OfflineWindow): Promise {
})
await storeBundle(bundle)
for (const discussion of bundle.discussions) stored.add(String(discussion.name))
+ for (const url of bundleImages(bundle)) images.add(url)
await writeMeta({ ...base, names: [...stored] })
return bundle
}
@@ -223,6 +228,9 @@ async function runSync(days: OfflineWindow): Promise {
downloads.done = Math.min(i + PAGE_SIZE, missing.length)
}
+ const emojis = (customEmojis.data ?? []).map((emoji) => emoji.image).filter(Boolean)
+ saveImages([...emojis, ...images].slice(0, MAX_IMAGES) as string[])
+
await writeMeta({
...base,
names: [...stored],
@@ -265,14 +273,41 @@ async function cachedDiscussions() {
async function forgetDiscussions(names: string[]) {
if (!names.length) return
const user = session.user!
- await delMany(
- names.flatMap((name) => [
- docKey('GP Discussion', name),
- listKey(commentsCacheKey('GP Discussion', name, user)),
- listKey(activitiesCacheKey('GP Discussion', name, user)),
- listKey(pollsCacheKey(name, user)),
- ]),
- )
+ const docKeys = names.map((name) => docKey('GP Discussion', name))
+ const commentKeys = names.map((name) => listKey(commentsCacheKey('GP Discussion', name, user)))
+ // Their images go too, so a discussion the user lost access to leaves nothing behind.
+ const stored = await getMany([...docKeys, ...commentKeys]).catch(() => [])
+ forgetImages(stored.flatMap((value) => (value ? htmlImages(value) : [])))
+ await delMany([
+ ...docKeys,
+ ...commentKeys,
+ ...names.map((name) => listKey(activitiesCacheKey('GP Discussion', name, user))),
+ ...names.map((name) => listKey(pollsCacheKey(name, user))),
+ ])
+}
+
+function bundleImages(bundle: Bundle) {
+ return [
+ ...bundle.discussions.flatMap((discussion) => htmlImages(discussion.content)),
+ ...Object.values(bundle.comments)
+ .flat()
+ .flatMap((comment) => htmlImages(comment.content)),
+ ]
+}
+
+/** Uploaded images an HTML string (or a stored JSON copy of rows holding HTML) shows. */
+function htmlImages(html: unknown) {
+ if (typeof html !== 'string') return []
+ return [...html.matchAll(/ ]+src=\\?["']([^"'\\]+)/g)].map(([, src]) => src)
+}
+
+// The service worker keeps the images (gameplan-sw.js); without one there's nowhere to put them.
+function saveImages(urls: string[]) {
+ if (urls.length) navigator.serviceWorker?.controller?.postMessage({ type: 'CACHE_IMAGES', urls })
+}
+
+function forgetImages(urls: string[]) {
+ if (urls.length) navigator.serviceWorker?.controller?.postMessage({ type: 'FORGET_IMAGES', urls })
}
/** Deletes everything downloaded; a discussion opened again online is cached as usual. */
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
index 16b9e42fb..d74516802 100644
--- a/gameplan/www/gameplan-sw.js
+++ b/gameplan/www/gameplan-sw.js
@@ -66,6 +66,16 @@ self.addEventListener("message", (event) => {
return;
}
+ if (type === "CACHE_IMAGES" && Array.isArray(event.data.urls)) {
+ event.waitUntil(cacheImages(event.data.urls));
+ return;
+ }
+
+ if (type === "FORGET_IMAGES" && Array.isArray(event.data.urls)) {
+ event.waitUntil(forgetImages(event.data.urls));
+ return;
+ }
+
if (type === "SKIP_WAITING") {
self.skipWaiting();
return;
@@ -215,6 +225,46 @@ async function cacheUrls(urls) {
);
}
+// Offline downloads (offlineDownloads.ts): images inside downloaded discussions and custom
+// emojis. Kept in the runtime cache with images seen while browsing, so logout clears them.
+const IMAGE_FETCHES_AT_ONCE = 4;
+
+async function cacheImages(urls) {
+ const cache = await caches.open(RUNTIME_CACHE);
+ const queue = urls.filter(isUploadedFileUrl);
+ // A few at a time, so a first download doesn't send every image request at once.
+ const next = async () => {
+ while (queue.length) {
+ const request = new Request(queue.shift(), { credentials: "include" });
+ try {
+ if (await cache.match(request)) continue;
+ const response = await fetch(request);
+ if (isCacheableResponse(response)) await cache.put(request, response);
+ } catch {
+ // The next sync, or viewing the image online, tries again.
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: IMAGE_FETCHES_AT_ONCE }, next));
+}
+
+async function forgetImages(urls) {
+ const cache = await caches.open(RUNTIME_CACHE);
+ await Promise.all(urls.filter(isUploadedFileUrl).map((url) => cache.delete(url)));
+}
+
+function isUploadedFileUrl(url) {
+ try {
+ const { origin, pathname } = new URL(url, self.location.origin);
+ return (
+ origin === self.location.origin &&
+ (pathname.startsWith("/files/") || pathname.startsWith("/private/files/"))
+ );
+ } catch {
+ return false;
+ }
+}
+
async function cacheOfflineAssetManifest(response) {
try {
const manifestResponse =
From 460401b29c1f1bdb5089719e575744f38f4e391d Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 07:49:26 +0000
Subject: [PATCH 38/68] fix(sw): delete old build files from the asset cache
Build files are content-hashed, so every deploy added a new ~5 MB set under the
same asset cache and nothing ever removed the previous ones. After caching the
current build's manifest, the worker now deletes files under Gameplan's build
folder that the manifest no longer lists. Icons and Frappe's own assets are left
alone.
Co-Authored-By: Claude Opus 5
---
gameplan/www/gameplan-sw.js | 23 ++++++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
index d74516802..369351d97 100644
--- a/gameplan/www/gameplan-sw.js
+++ b/gameplan/www/gameplan-sw.js
@@ -273,14 +273,35 @@ async function cacheOfflineAssetManifest(response) {
if (!isCacheableResponse(manifestResponse)) return;
const urls = await manifestResponse.clone().json();
- if (Array.isArray(urls)) {
+ if (Array.isArray(urls) && urls.length) {
await cacheUrls(urls);
+ await deleteOldBuildAssets(urls);
}
} catch {
// Older builds do not have the manifest; route assets will still be cached as they load.
}
}
+// Build files are content-hashed, so every deploy adds a new set (~5 MB) under the same
+// ASSET_CACHE. Keep only the current build's; the server no longer has the old ones anyway.
+const BUILD_ASSETS_PATH = "/assets/gameplan/frontend/assets/";
+
+async function deleteOldBuildAssets(currentUrls) {
+ const current = new Set(
+ currentUrls.map((url) => new URL(url, self.location.origin).pathname),
+ );
+ const cache = await caches.open(ASSET_CACHE);
+ const requests = await cache.keys();
+ await Promise.all(
+ requests
+ .filter((request) => {
+ const { pathname } = new URL(request.url);
+ return pathname.startsWith(BUILD_ASSETS_PATH) && !current.has(pathname);
+ })
+ .map((request) => cache.delete(request)),
+ );
+}
+
function isSameOriginAssetUrl(url) {
try {
const assetUrl = new URL(url, self.location.origin);
From abeab0b55d81a67437bb5d5b5bdb7b3ef0f51da1 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 07:59:11 +0000
Subject: [PATCH 39/68] test(offline): fix US2, US3 and US4
- US2: the visited space was set to Game Design while the visited discussion
lives in Art, so the link it clicks was never in the list (the space checks
passed only because "Art" is also in the sidebar). The space defaults now
match the seeded discussions, and the link uses DISCUSSION_ID.
- US3: looked for any text matching /offline/i, which the test account's name
("Offline Tester") always matched. It now looks for the banner itself.
- US4: expected an error after submitting offline, but Submit is now disabled
offline. It checks that the button is disabled, that tapping it shows the
offline toast, and that the typed text is kept.
The full offline suite now passes (13 of 13).
Co-Authored-By: Claude Opus 5
---
frontend/tests/offline/README.md | 6 +--
frontend/tests/offline/config.js | 6 ++-
frontend/tests/offline/us2.js | 3 +-
frontend/tests/offline/us3.js | 70 ++++++++------------------------
frontend/tests/offline/us4.js | 46 ++++++++-------------
5 files changed, 43 insertions(+), 88 deletions(-)
diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md
index 0015be259..164cba6be 100644
--- a/frontend/tests/offline/README.md
+++ b/frontend/tests/offline/README.md
@@ -15,7 +15,7 @@ survives reboots and can gate regressions in CI/local dev.
| US1 | App shell loads offline (reload + deep link) instead of a browser error page |
| US2 | Previously-viewed feed / space / discussion render from cache while offline |
| US3 | Offline indicator appears when connectivity drops, clears on reconnect |
-| US4 | A comment typed while offline fails gracefully and isn't lost |
+| US4 | While offline, Submit is disabled and says why; the typed comment is kept and posts on reconnect |
| US5 | Fresh data appears automatically on reconnect, no manual reload |
| US6 | Never-cached content shows an honest "can't load this offline" fallback |
| P2 | A profile visited fully online (incl. Posts tab) is available offline |
@@ -85,9 +85,9 @@ All seeded-content coupling lives in `config.js`, overridable via env vars:
| `GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD` | `offline-tester@example.com` / `offline-test-1234` | Primary test account |
| `GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2` | `offline-tester-2@example.com` / `offline-test-1234` | Second account (US7b) |
| `GAMEPLAN_OFFLINE_COMMUNITY` | `common-room` | `GP Team` name both accounts belong to |
-| `GAMEPLAN_OFFLINE_SPACE_ID` | `3` | `GP Project` name for the visited/cached space |
+| `GAMEPLAN_OFFLINE_SPACE_ID` | `5` | `GP Project` holding the visited discussion |
| `GAMEPLAN_OFFLINE_DISCUSSION_ID` | `55` | `GP Discussion` name for the visited/cached discussion |
-| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `5` / `54` | Content never visited by any story before going offline (US6) |
+| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `7` / `54` | Content never visited by any story before going offline (US6) |
| `GAMEPLAN_OFFLINE_PERSON_VISITED` / `GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH` | `maya-iyer` / `hana-suzuki` | `GP User Profile` IDs for P2/P3 |
| `GAMEPLAN_OFFLINE_RESULTS_DIR` | `tests/offline/results` | Where JSON results + screenshots are written |
diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js
index 6363d0b44..47d4db022 100644
--- a/frontend/tests/offline/config.js
+++ b/frontend/tests/offline/config.js
@@ -18,12 +18,13 @@ const FULL_NAME2 = process.env.GAMEPLAN_OFFLINE_FULL_NAME2 || 'Offline Tester Tw
// Seeded content coordinates (GP Team/GP Project/GP Discussion names) — see README.md.
const COMMUNITY = process.env.GAMEPLAN_OFFLINE_COMMUNITY || 'common-room'
-const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '3'
+// The space discussion DISCUSSION_ID belongs to (Art on the seeded site).
+const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '5'
const DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_DISCUSSION_ID || '55'
// Never visited by any story before going offline -> used for US6 "uncached content".
const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4'
const UNCACHED_DISCUSSION_SPACE_ID =
- process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5'
+ process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '7'
const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54'
// A space the primary account has joined (US9 downloads only cover joined spaces).
const JOINED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_JOINED_SPACE_ID || '1426'
@@ -58,6 +59,7 @@ const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots')
module.exports = {
BASE,
COMMUNITY,
+ DISCUSSION_ID,
JOINED_SPACE_ID,
EMAIL,
PWD,
diff --git a/frontend/tests/offline/us2.js b/frontend/tests/offline/us2.js
index 9d9bdbd4b..04ad08fdc 100644
--- a/frontend/tests/offline/us2.js
+++ b/frontend/tests/offline/us2.js
@@ -11,6 +11,7 @@ const {
appRootInfo,
writeResult,
} = require('./helpers')
+const { DISCUSSION_ID } = require('./config')
const EXPECTED = {
discussionTitle: 'Capsule art, near-final, need eyes before it goes on the page',
@@ -144,7 +145,7 @@ async function run() {
try {
await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 8000 })
await page.waitForTimeout(1500)
- const link = page.locator(`a[href*="/discussion/55"]`).first()
+ const link = page.locator(`a[href*="/discussion/${DISCUSSION_ID}/"]`).first()
const linkVisible = await link.isVisible().catch(() => false)
clickCheck.linkVisible = linkVisible
if (linkVisible) {
diff --git a/frontend/tests/offline/us3.js b/frontend/tests/offline/us3.js
index 00d1a2196..da3600814 100644
--- a/frontend/tests/offline/us3.js
+++ b/frontend/tests/offline/us3.js
@@ -1,52 +1,18 @@
// US3 — Know I'm offline: an indicator should appear when offline and clear on
-// reconnect. We search broadly (role=status/alert, common banner/toast/pill classes,
-// and any element whose text matches /offline/i) since we don't know the exact
-// implementation up front.
-const {
- chromium,
- URLS,
- EMAIL,
- newLoggedInContext,
- warmup,
- shot,
- writeResult,
-} = require('./helpers')
+// reconnect. Looks for the banner OfflineIndicator.vue renders (a role=status element
+// reading "Offline") rather than any text matching /offline/i, which also matched the test
+// account's own name ("Offline Tester") and so always looked offline.
+const { chromium, newLoggedInContext, warmup, shot, writeResult } = require('./helpers')
-// The seeded test account's own username ("offline-tester") literally contains
-// "offline" and renders on the page regardless of connectivity (header/sidebar/hover
-// cards showing the signed-in user's name) — exclude an exact match on it so it can't
-// masquerade as the real connectivity indicator below.
-const USERNAME = EMAIL.split('@')[0]
-
-async function findOfflineIndicator(page, excludeExact) {
- return page.evaluate((exclude) => {
- const re = /offline|you.?re offline|showing saved|no connection|reconnect/i
- const candidates = []
- const all = document.querySelectorAll('body *')
- for (const el of all) {
- // Only leaf-ish elements with direct text, to avoid matching giant containers.
- const text = el.textContent?.trim() || ''
- if (!text || text.length > 200) continue
- if (re.test(text)) {
- const ownText = Array.from(el.childNodes)
- .filter((n) => n.nodeType === Node.TEXT_NODE)
- .map((n) => n.textContent)
- .join('')
- .trim()
- if (ownText && re.test(ownText) && ownText.toLowerCase() !== exclude.toLowerCase()) {
- const rect = el.getBoundingClientRect()
- candidates.push({
- tag: el.tagName,
- class: el.className?.toString?.() || '',
- text: ownText.slice(0, 200),
- visible: rect.width > 0 && rect.height > 0,
- role: el.getAttribute('role'),
- })
- }
- }
- }
- return candidates
- }, excludeExact)
+function findOfflineBanner(page) {
+ return page.evaluate(() =>
+ [...document.querySelectorAll('[role="status"]')]
+ .filter((el) => /^offline$/i.test(el.textContent.trim()))
+ .map((el) => {
+ const rect = el.getBoundingClientRect()
+ return { text: el.textContent.trim(), visible: rect.width > 0 && rect.height > 0 }
+ }),
+ )
}
async function run() {
@@ -58,7 +24,7 @@ async function run() {
result.warmup = await warmup(page)
// Baseline (online): no offline indicator should be present.
- const onlineCandidates = await findOfflineIndicator(page, USERNAME)
+ const onlineCandidates = await findOfflineBanner(page)
result.onlineBaseline = { candidates: onlineCandidates }
await context.setOffline(true)
@@ -70,7 +36,7 @@ async function run() {
await page.evaluate(() => window.dispatchEvent(new Event('offline')))
await page.waitForTimeout(1000)
- const offlineCandidates = await findOfflineIndicator(page, USERNAME)
+ const offlineCandidates = await findOfflineBanner(page)
const offlineShot = await shot(page, 'us3-offline-indicator-search')
const visibleOfflineCandidates = offlineCandidates.filter((c) => c.visible)
@@ -81,15 +47,15 @@ async function run() {
pass: visibleOfflineCandidates.length > 0,
}
check1.symptom = check1.pass
- ? `found ${visibleOfflineCandidates.length} visible offline-related element(s)`
- : 'no offline indicator UI exists anywhere in the DOM (searched all elements for /offline|reconnect|no connection/i text)'
+ ? 'offline banner shown'
+ : 'no visible offline banner (role=status reading "Offline")'
result.checks.push(check1)
// Go back online and check the indicator clears (only meaningful if one appeared).
await context.setOffline(false)
await page.evaluate(() => window.dispatchEvent(new Event('online')))
await page.waitForTimeout(3000)
- const afterOnlineCandidates = await findOfflineIndicator(page, USERNAME)
+ const afterOnlineCandidates = await findOfflineBanner(page)
const stillVisible = afterOnlineCandidates.filter((c) => c.visible)
const afterOnlineShot = await shot(page, 'us3-after-reconnect')
diff --git a/frontend/tests/offline/us4.js b/frontend/tests/offline/us4.js
index 44db5060c..c6f5d72b1 100644
--- a/frontend/tests/offline/us4.js
+++ b/frontend/tests/offline/us4.js
@@ -1,5 +1,6 @@
-// US4 — Don't lose my words: offline comment submit should fail gracefully (error
-// surfaced, text preserved in the editor), then succeed once back online.
+// US4 — Don't lose my words: while offline, Submit is disabled and tapping it says why
+// (instead of failing the request), the typed text stays in the editor, and it submits
+// once back online.
const {
chromium,
URLS,
@@ -41,21 +42,6 @@ async function bodyTextSnapshot(page) {
}
}
-function findNewErrorText(before, after) {
- const re = /(fail|error|offline|network|could not|try again|unable|no internet|not connected)/i
- const beforeLines = new Set(
- before
- .split('\n')
- .map((l) => l.trim())
- .filter(Boolean),
- )
- const afterLines = after
- .split('\n')
- .map((l) => l.trim())
- .filter(Boolean)
- return afterLines.filter((l) => re.test(l) && !beforeLines.has(l))
-}
-
async function run() {
const browser = await chromium.launch({ headless: true })
const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser)
@@ -71,34 +57,34 @@ async function run() {
await context.setOffline(true)
const editor = await getEditor(page)
- let check1 = { name: 'offline submit fails gracefully, text preserved' }
+ let check1 = { name: 'offline: Submit is disabled, tapping it says why, text is kept' }
try {
await editor.click({ timeout: 8000 })
await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 })
await page.waitForTimeout(300)
- const beforeSubmitText = await bodyTextSnapshot(page)
const submitBtn = await getVisibleSubmitButton(page)
- await submitBtn.click({ timeout: 8000 }).catch((e) => {
+ const disabled = await submitBtn.isDisabled()
+ // force: a disabled button takes no clicks, but the tap still reaches the page.
+ await submitBtn.click({ force: true, timeout: 8000 }).catch((e) => {
check1.clickError = String(e)
})
- await page.waitForTimeout(2000)
- const afterSubmitText = await bodyTextSnapshot(page)
-
- const newErrorLines = findNewErrorText(beforeSubmitText, afterSubmitText)
+ await page.waitForTimeout(1000)
+ const toldOffline = (await bodyTextSnapshot(page)).includes("You're offline")
const editorTextAfter = await editor.innerText().catch(() => '')
const textPreserved = editorTextAfter.includes(DISTINCTIVE_TEXT)
- check1.newErrorLines = newErrorLines
- check1.textPreserved = textPreserved
+ Object.assign(check1, { disabled, toldOffline, textPreserved })
check1.editorTextAfter = editorTextAfter.slice(0, 300)
check1.screenshot = await shot(page, 'us4-offline-submit-attempt')
- check1.pass = newErrorLines.length > 0 && textPreserved
+ check1.pass = disabled && toldOffline && textPreserved
check1.symptom = check1.pass
- ? `error shown (${newErrorLines[0]}) and text preserved`
+ ? 'Submit disabled, offline toast shown, text kept'
: !textPreserved
- ? 'typed text was lost from the editor after failed submit (silent data loss)'
- : 'no visible error/toast surfaced after offline submit attempt'
+ ? 'typed text was lost from the editor'
+ : !disabled
+ ? 'Submit stayed enabled while offline'
+ : 'no "You\'re offline" toast after tapping the disabled Submit'
} catch (e) {
check1.pass = false
check1.symptom = `threw: ${e.message}`
From 748f387966b6e96954b9231675f7d0a17e72b72b Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 08:10:40 +0000
Subject: [PATCH 40/68] fix(sw): fetch the build manifest only when the build
changes
Every full page load re-downloaded the offline asset manifest with the HTTP
cache bypassed, one request per load that develop never makes. The page's own
build files are renamed by each build, so the worker now fetches the manifest
only when one of them isn't cached yet (a new build or a first visit), or when
it has no stored copy of the manifest. Measured: five reloads went from five
manifest requests to none; a new build still fetches it once.
Co-Authored-By: Claude Opus 5
---
gameplan/www/gameplan-sw.js | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js
index 369351d97..edb01429f 100644
--- a/gameplan/www/gameplan-sw.js
+++ b/gameplan/www/gameplan-sw.js
@@ -182,8 +182,18 @@ async function networkFirstNavigation(request) {
async function cacheShellAssets(response) {
const urls = getShellAssetUrls(await response.text());
+ const [assets, shell] = await Promise.all([
+ caches.open(ASSET_CACHE),
+ caches.open(SHELL_CACHE),
+ ]);
+ // The page's own build files are renamed by every build, so all of them being cached means
+ // this build was seen before and its manifest already fetched. Fetching it again on every
+ // page load was a request develop never made.
+ const cached = await Promise.all(urls.map((url) => assets.match(url)));
+ const newBuild =
+ cached.some((hit) => !hit) || !(await shell.match(OFFLINE_ASSET_MANIFEST_URL));
await cacheUrls(urls);
- await cacheOfflineAssetManifest();
+ if (newBuild) await cacheOfflineAssetManifest();
}
function getShellAssetUrls(html) {
@@ -276,6 +286,8 @@ async function cacheOfflineAssetManifest(response) {
if (Array.isArray(urls) && urls.length) {
await cacheUrls(urls);
await deleteOldBuildAssets(urls);
+ const shell = await caches.open(SHELL_CACHE);
+ await shell.put(OFFLINE_ASSET_MANIFEST_URL, manifestResponse);
}
} catch {
// Older builds do not have the manifest; route assets will still be cached as they load.
From 3034f5f732383d80d07d4766edb1f6ca30eaa699 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 08:15:58 +0000
Subject: [PATCH 41/68] feat(offline): offline settings on mobile
Mobile has no settings dialog, so Settings > Offline was unreachable there. The
tab's content moves into OfflineSettingsPanel, shared by the desktop tab and a
new mobile Offline page, opened from a row under Settings on the More page that
shows the current choice. The first-offline toast opens that page on mobile.
The Sync now and Remove downloads buttons sit under the status line, which a
phone-width row had no room for beside it.
Co-Authored-By: Claude Opus 5
---
frontend/src/components/MobileMoreMenu.vue | 12 ++
.../components/Settings/OfflineSettings.vue | 164 +-----------------
.../Settings/OfflineSettingsPanel.vue | 163 +++++++++++++++++
frontend/src/data/offlineDownloads.ts | 9 +-
frontend/src/pages/OfflineSettingsPage.vue | 17 ++
frontend/src/router.ts | 6 +
frontend/src/utils/useIsMobile.ts | 5 +
7 files changed, 213 insertions(+), 163 deletions(-)
create mode 100644 frontend/src/components/Settings/OfflineSettingsPanel.vue
create mode 100644 frontend/src/pages/OfflineSettingsPage.vue
diff --git a/frontend/src/components/MobileMoreMenu.vue b/frontend/src/components/MobileMoreMenu.vue
index a0312adde..131b17c94 100644
--- a/frontend/src/components/MobileMoreMenu.vue
+++ b/frontend/src/components/MobileMoreMenu.vue
@@ -76,6 +76,7 @@ import { useSessionUser } from '@/data/users'
import { session } from '@/data/session'
import { isOnline } from '@/data/online'
import { useTheme, type Theme } from '@/utils/useTheme'
+import { WINDOW_OPTIONS, offlineWindow, policy } from '@/data/offlineDownloads'
interface MoreItem {
label: string
@@ -114,6 +115,11 @@ const avatarStyle = computed(() => ({
backgroundColor: sessionUser.image_background_color || undefined,
}))
const userBio = computed(() => sessionUser.bio?.trim())
+const offlineLabel = computed(() =>
+ policy.enabled
+ ? WINDOW_OPTIONS.find((option) => option.value === offlineWindow.value)?.label
+ : 'Off',
+)
const itemGroups = computed(() => {
const workspaceItems: MoreItem[] = [
@@ -139,6 +145,12 @@ const itemGroups = computed(() => {
onClick: cycleTheme,
value: THEME_META[currentTheme.value].label,
},
+ {
+ label: 'Offline',
+ icon: 'lucide-cloud-download',
+ route: { name: 'OfflineSettings' },
+ value: offlineLabel.value,
+ },
{
label: 'Log out',
icon: 'lucide-log-out',
diff --git a/frontend/src/components/Settings/OfflineSettings.vue b/frontend/src/components/Settings/OfflineSettings.vue
index 66452ac11..10c8a25d7 100644
--- a/frontend/src/components/Settings/OfflineSettings.vue
+++ b/frontend/src/components/Settings/OfflineSettings.vue
@@ -4,169 +4,11 @@
-
-
-
- Offline downloads are turned off for this site. Discussions you open are still kept for
- offline reading.
-
-
-
-
-
-
-
-
-
- Sync now
-
-
- Remove downloads
-
-
-
-
-
-
-
-
- Site
-
-
- savePolicy({ enabled })"
- />
-
-
- savePolicy({ maxWindow: Number(value) })"
- />
-
-
-
-
+
diff --git a/frontend/src/components/Settings/OfflineSettingsPanel.vue b/frontend/src/components/Settings/OfflineSettingsPanel.vue
new file mode 100644
index 000000000..17227c192
--- /dev/null
+++ b/frontend/src/components/Settings/OfflineSettingsPanel.vue
@@ -0,0 +1,163 @@
+
+
+
+
+ Offline downloads are turned off for this site. Discussions you open are still kept for
+ offline reading.
+
+
+
+
+
+
+
+
+
+
+
+ Sync now
+
+
+ Remove downloads
+
+
+
+
+
+
+
+ Site
+
+
+ savePolicy({ enabled })"
+ />
+
+
+ savePolicy({ maxWindow: Number(value) })"
+ />
+
+
+
+
+
+
+
diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts
index 50e9df49e..979c8b740 100644
--- a/frontend/src/data/offlineDownloads.ts
+++ b/frontend/src/data/offlineDownloads.ts
@@ -5,6 +5,7 @@ import { delMany, get, getMany, keys, set, setMany } from 'idb-keyval'
import { isOnline, onReconnect } from './online'
import { session } from './session'
import { customEmojis } from './customEmojis'
+import { isMobileViewport } from '@/utils/useIsMobile'
import {
ACTIVITY_FIELDS,
COMMENT_FIELDS,
@@ -424,6 +425,10 @@ function offerDownload() {
}
function openOfflineSettings() {
- // Imported on demand: the settings module reaches the router, which reaches this module.
- import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline'))
+ // Imported on demand: the settings module and the router both reach this module.
+ if (isMobileViewport()) {
+ import('@/router').then(({ default: router }) => router.push({ name: 'OfflineSettings' }))
+ } else {
+ import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline'))
+ }
}
diff --git a/frontend/src/pages/OfflineSettingsPage.vue b/frontend/src/pages/OfflineSettingsPage.vue
new file mode 100644
index 000000000..c7c53953b
--- /dev/null
+++ b/frontend/src/pages/OfflineSettingsPage.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/router.ts b/frontend/src/router.ts
index c85cc0de5..d8bfffd45 100644
--- a/frontend/src/router.ts
+++ b/frontend/src/router.ts
@@ -431,6 +431,12 @@ const routes: RouteRecordRaw[] = [
name: 'More',
component: () => import('@/pages/MoreMenu.vue'),
},
+ {
+ // Mobile's way into Settings > Offline (desktop uses the settings dialog).
+ path: '/offline',
+ name: 'OfflineSettings',
+ component: () => import('@/pages/OfflineSettingsPage.vue'),
+ },
// Keep old shared space links working while moving canonical URLs under `/community/:communityId/...`.
{
path: '/space/:spaceId',
diff --git a/frontend/src/utils/useIsMobile.ts b/frontend/src/utils/useIsMobile.ts
index b63afe94a..e80ed1c9a 100644
--- a/frontend/src/utils/useIsMobile.ts
+++ b/frontend/src/utils/useIsMobile.ts
@@ -16,3 +16,8 @@ const MOBILE_QUERY = '(max-width: 639.98px)'
export function useIsMobile(): Ref {
return useMediaQuery(MOBILE_QUERY)
}
+
+/** The same check read once, for code that runs outside a component (useMediaQuery needs one). */
+export function isMobileViewport(): boolean {
+ return window.matchMedia(MOBILE_QUERY).matches
+}
From 5cc503622860f84b5e858cc9d58716394ed01b34 Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Fri, 18 Sep 2026 08:35:07 +0000
Subject: [PATCH 42/68] refactor(offline): trim comments, drop dead router
checks, share failure copy
- Comments that retold review history ("Round-4 finding", "Greptile P1",
"review finding from PR #516") are rewritten as short explanations of why, or
removed; that history lives in the commits and the PR. offline.ts goes from 135
comment lines out of 404 to 42 out of 311.
- router.ts: isRouteValidationUnavailable() only called isNetworkUnreliable(), a
second offline check could never change the outcome, and an if/else returned
the same way on both branches.
- loadFailure.ts now owns the offline and error copy (loadFailureCopy) and the
offline check (isOfflineError). The discussion page, profile page and the
offline space page use them instead of their own wording, so every "not
available offline" state says the same thing.
Co-Authored-By: Claude Opus 5
---
frontend/src/components/CommentsArea.vue | 8 +-
frontend/src/components/CommentsList.vue | 4 +-
frontend/src/components/DiscussionView.vue | 7 +-
frontend/src/components/LastPostReminder.vue | 3 +-
.../src/components/OfflineContentFallback.vue | 6 +-
.../ProfileBento/profileBentoSource.ts | 37 +----
frontend/src/components/TaskList.vue | 4 +-
frontend/src/data/communities.ts | 3 +-
frontend/src/data/communitySpaces.ts | 3 +-
frontend/src/data/discussionTimeline.ts | 4 +-
frontend/src/data/discussions.ts | 4 +-
frontend/src/data/draftStore.ts | 8 +-
frontend/src/data/loadFailure.ts | 29 ++--
frontend/src/data/notifications.ts | 3 +-
frontend/src/data/online.ts | 7 +-
frontend/src/data/people.ts | 10 +-
frontend/src/data/session.ts | 20 +--
frontend/src/data/spaces.ts | 3 +-
frontend/src/data/users.ts | 9 +-
frontend/src/offline.ts | 147 ++++--------------
.../pages/Configure/useCommunitySpaceData.ts | 4 +-
frontend/src/pages/Notifications.vue | 3 +-
frontend/src/pages/OfflineUnavailable.vue | 10 +-
frontend/src/pages/PageGrid.vue | 4 +-
frontend/src/pages/PersonProfile.vue | 41 ++---
frontend/src/router.ts | 34 ++--
.../gp_user_profile/gp_user_profile.py | 10 +-
gameplan/www/gameplan-sw.js | 30 +---
28 files changed, 113 insertions(+), 342 deletions(-)
diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue
index b3c2a61c0..9685ba8e7 100644
--- a/frontend/src/components/CommentsArea.vue
+++ b/frontend/src/components/CommentsArea.vue
@@ -471,12 +471,8 @@ const activities = useList({
// The parent bumps `activityVersion` with the doc's `modified` on every such action,
// so reload the timeline when it changes (skipping the initial undefined -> value
// transition on first load, when the list has already fetched on mount).
-//
-// Both this watch and the `new_activity` socket handler (see onMounted) can fire
-// for the same underlying action. Calling `activities.reload()` from both re-enters
-// the list's in-flight fetch, which aborts it — and with `staleOnError` that abort
-// can leave the timeline stuck showing the cached (stale) snapshot instead of ever
-// settling on the fresh one. Debouncing to a single reload avoids the double-fetch.
+// This and the `new_activity` socket event can fire for the same action; one debounced
+// reload keeps the second from aborting the first and leaving the cached timeline up.
const reloadActivities = useDebounceFn(() => activities.reload(), 100)
watch(
diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue
index 077965cae..31a4956cd 100644
--- a/frontend/src/components/CommentsList.vue
+++ b/frontend/src/components/CommentsList.vue
@@ -180,9 +180,7 @@ const comments = useList<
>
>({
doctype: 'GP Comment',
- // Scoped to the session user: a discussion's comments can live in a private space,
- // so a second account on the same browser must not see them cached offline before
- // its own permission-checked fetch resolves (review finding from PR #516).
+ // Per user, so another account on this browser can't read it offline.
cacheKey: ['Comments', props.doctype, props.name, session.user],
staleOnError: true,
fields: [
diff --git a/frontend/src/components/DiscussionView.vue b/frontend/src/components/DiscussionView.vue
index 8dca85f7c..812c0be2d 100644
--- a/frontend/src/components/DiscussionView.vue
+++ b/frontend/src/components/DiscussionView.vue
@@ -300,8 +300,7 @@
-
+
@@ -231,7 +229,14 @@
Thanks for your feedback!
-
+
+
+
({
},
})
+// Searching needs the server, so offline it gets the same fallback as the other pages.
+const offlineFailure = computed(() =>
+ search.error && isOfflineError(search.error)
+ ? {
+ title: 'Search needs a connection',
+ message: "You're offline. Reconnect and retry to search.",
+ }
+ : null,
+)
+
const filterOptions = useCall({
url: '/api/v2/method/gameplan.api.get_search_filter_options',
immediate: true,
From 0c06eb6db0d463d9ccb9533f6c2b7782e4e878cf Mon Sep 17 00:00:00 2001
From: ebrahimgamdiwala
Date: Mon, 21 Sep 2026 05:45:55 +0000
Subject: [PATCH 50/68] fix(offline): route the last-post reminder through the
offline wrappers
The reminder's call still imported useCall from frappe-ui, so it was attempted
while offline and never refreshed on reconnect, unlike every other resource.
Co-Authored-By: Claude Opus 5
---
frontend/src/components/LastPostReminder.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/LastPostReminder.vue b/frontend/src/components/LastPostReminder.vue
index bba7bd95b..31599cee8 100644
--- a/frontend/src/components/LastPostReminder.vue
+++ b/frontend/src/components/LastPostReminder.vue
@@ -20,8 +20,8 @@