diff --git a/frontend/src/components/SyncBanner.jsx b/frontend/src/components/SyncBanner.jsx index 24026d434..5b0a0d7a4 100644 --- a/frontend/src/components/SyncBanner.jsx +++ b/frontend/src/components/SyncBanner.jsx @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useStore } from '../store/useStore.js' +import { useUI } from '../store/useUI.js' import { DEMO } from '../lib/demo.js' import { connectionView, actionLabel, syncNowWithToast, pairAgain, connectServer, signInAgain, useOnline } from './ServerSync.jsx' import Icon from './Icon.jsx' @@ -10,10 +11,12 @@ import Icon from './Icon.jsx' // every one of them would only teach people to ignore the line. export const PENDING_GRACE_MS = 5000 -/* The connection, always in view while the app is not connected to a server: offline, the server - unreachable or answering with an error (its HTTP code, for whoever runs it), a server that no - longer accepts this device, an answer that is not openGym's, and no server at all — a phone - kept local, a guest in a browser. It cannot be dismissed; it goes when the condition does. +/* The connection, in view while the app is not connected to a server unless hidden in Settings: + offline, the server unreachable or answering with an error (its HTTP code, for whoever runs + it), a server that no longer accepts this device, an answer that is not openGym's, and no + server at all — a phone kept local or a guest in a browser. The device-local display preference + can hide it; its details + remain in Settings → Server & sync. The first ones say what is wrong and that the changes are kept here, with the one thing to do about it (retry, pair again, sign in); the deliberate local setup only says so, quietly. @@ -27,6 +30,7 @@ export default function SyncBanner() { const sync = useStore(s => s.sync) const guest = useStore(s => s.isGuest()) const onboarding = useStore(s => s.needsMobileOnboarding) + const showSyncBanner = useUI(s => s.showSyncBanner) const online = useOnline() const [waited, setWaited] = useState(false) const row = useRef(null) @@ -41,7 +45,7 @@ export default function SyncBanner() { const view = connectionView(sync, { online }) // Signed out on the web, the sign-in screen is the whole app: it hears only that the server // ended the session, and that the changes are still here. - const show = !DEMO && !onboarding && !!view?.banner && (!!user || guest || status === 'auth') && (status !== 'pending' || waited) + const show = showSyncBanner && !DEMO && !onboarding && !!view?.banner && (!!user || guest || status === 'auth') && (status !== 'pending' || waited) useLayoutEffect(() => { const root = document.documentElement diff --git a/frontend/src/components/SyncBanner.test.jsx b/frontend/src/components/SyncBanner.test.jsx index 2fa4ec48d..c152c7aca 100644 --- a/frontend/src/components/SyncBanner.test.jsx +++ b/frontend/src/components/SyncBanner.test.jsx @@ -7,20 +7,22 @@ import { connectionView } from './ServerSync.jsx' globalThis.IS_REACT_ACT_ENVIRONMENT = true -/* The connection indicator: every way the app can be without its server gets a line that stays - while the condition lasts — offline, an error with its HTTP code, a server that refuses this - device, an answer that is not openGym's, no server at all — and says what to do about it. - Never in the public demo, never during the phone's first-launch choice, and a change merely - waiting for its push does not flash it. The store is a stand-in: its `sync` is what each test - sets; ServerSync.jsx (the words and the actions) is the real one. */ +/* By default, the connection indicator gives every way the app can be without its server a line + that stays while the condition lasts — offline, an error with its HTTP code, a server that + refuses this device, an answer that is not openGym's, no server at all — and says what to do + about it. The device can hide it in Settings, where the full status remains. Never in the public + demo, never during the phone's first-launch choice, and a change merely waiting for its push + does not flash it. The store is a stand-in: its `sync` is what each test sets; ServerSync.jsx + (the words and the actions) is the real one. */ const mocks = vi.hoisted(() => { - const state = { MOBILE: false, DEMO: false, webauthn: true, user: null, guest: false, onboarding: false, sync: null, sheets: [], navs: [] } + const state = { MOBILE: false, DEMO: false, webauthn: true, user: null, guest: false, onboarding: false, sync: null, showSyncBanner: true, sheets: [], navs: [] } state.toast = vi.fn() state.syncNow = vi.fn(async () => state.sync) state.passkeyLogin = vi.fn(async () => ({ id: 'u1', name: 'andi' })) state.snapshot = () => ({ user: state.user, sync: state.sync, needsMobileOnboarding: state.onboarding, isGuest: () => state.guest, syncNow: state.syncNow, + showSyncBanner: state.showSyncBanner, setUser: vi.fn(), adoptProfile: vi.fn(async () => ({})), }) return state @@ -31,7 +33,7 @@ vi.mock('../store/useStore.js', () => { return { useStore } }) vi.mock('../store/useUI.js', () => { - const snap = () => ({ toast: (...a) => mocks.toast(...a), openSheet: (render, opts) => { mocks.sheets.push({ render, opts }); return {} } }) + const snap = () => ({ showSyncBanner: mocks.showSyncBanner, toast: (...a) => mocks.toast(...a), openSheet: (render, opts) => { mocks.sheets.push({ render, opts }); return {} } }) const useUI = selector => selector ? selector(snap()) : snap() useUI.getState = snap return { useUI } @@ -56,7 +58,7 @@ const network = on => { let host, root beforeEach(() => { network(true) - Object.assign(mocks, { MOBILE: false, DEMO: false, webauthn: true, user: { id: 'u1', name: 'andi' }, guest: false, onboarding: false, sync: sync('ok') }) + Object.assign(mocks, { MOBILE: false, DEMO: false, webauthn: true, user: { id: 'u1', name: 'andi' }, guest: false, onboarding: false, sync: sync('ok'), showSyncBanner: true }) mocks.sheets.length = 0 mocks.navs.length = 0 mocks.toast.mockClear(); mocks.syncNow.mockClear(); mocks.passkeyLogin.mockClear() @@ -110,6 +112,16 @@ describe('connected and in step', () => { }) }) +describe('the device-local display preference', () => { + it('hides the banner and gives its space back when switched off', () => { + mocks.showSyncBanner = false + network(false) + render() + expect(bar()).toBeNull() + expect(conn()).toBe('') + }) +}) + describe('not connected — it says so, and what to do', () => { it('offline with changes waiting: kept on this device, and a retry that reports back', async () => { network(false) diff --git a/frontend/src/locales/ar.js b/frontend/src/locales/ar.js index 07697206c..3bc6aa264 100644 --- a/frontend/src/locales/ar.js +++ b/frontend/src/locales/ar.js @@ -26,6 +26,9 @@ export default { 'Exercises': 'التمارين', 'History': 'السجل', 'Settings': 'الإعدادات', + 'Display': 'العرض', + 'Show sync banner': 'إظهار شريط المزامنة', + 'Show connection and sync status at the top of the screen.': 'إظهار حالة الاتصال والمزامنة أعلى الشاشة.', 'Freestyle': 'تمرين حر', 'Routine': 'روتين', 'routine': 'روتين', diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 663a5a5bb..a6dd961a6 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -33,6 +33,9 @@ export default { 'Also trains': 'Trainiert auch', 'History': 'Verlauf', 'Settings': 'Einstellungen', + 'Display': 'Anzeige', + 'Show sync banner': 'Sync-Banner anzeigen', + 'Show connection and sync status at the top of the screen.': 'Verbindungs- und Synchronisierungsstatus oben auf dem Bildschirm anzeigen.', 'Freestyle': 'Freies Training', 'Routine': 'Routine', 'routine': 'Routine', diff --git a/frontend/src/locales/es.js b/frontend/src/locales/es.js index 8e1d9aa99..da0f2a34c 100644 --- a/frontend/src/locales/es.js +++ b/frontend/src/locales/es.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'También trabaja', 'History': 'Historial', 'Settings': 'Ajustes', + 'Display': 'Visualización', + 'Show sync banner': 'Mostrar banner de sincronización', + 'Show connection and sync status at the top of the screen.': 'Mostrar el estado de conexión y sincronización en la parte superior de la pantalla.', 'Freestyle': 'Libre', 'Routine': 'Rutina', 'routine': 'rutina', diff --git a/frontend/src/locales/fr.js b/frontend/src/locales/fr.js index ee904194c..412473b86 100644 --- a/frontend/src/locales/fr.js +++ b/frontend/src/locales/fr.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Sollicite aussi', 'History': 'Historique', 'Settings': 'Réglages', + 'Display': 'Affichage', + 'Show sync banner': 'Afficher la bannière de synchronisation', + 'Show connection and sync status at the top of the screen.': 'Afficher l’état de la connexion et de la synchronisation en haut de l’écran.', 'Freestyle': 'Libre', 'Routine': 'Routine', 'routine': 'routine', diff --git a/frontend/src/locales/hi.js b/frontend/src/locales/hi.js index ccb0ca2aa..6bbbdd38a 100644 --- a/frontend/src/locales/hi.js +++ b/frontend/src/locales/hi.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'यह भी प्रशिक्षित करता है', 'History': 'इतिहास', 'Settings': 'सेटिंग्स', + 'Display': 'दिखावट', + 'Show sync banner': 'सिंक बैनर दिखाएँ', + 'Show connection and sync status at the top of the screen.': 'स्क्रीन के ऊपर कनेक्शन और सिंक की स्थिति दिखाएँ।', 'Freestyle': 'फ्रीस्टाइल', 'Routine': 'रूटीन', 'routine': 'रूटीन', diff --git a/frontend/src/locales/hu.js b/frontend/src/locales/hu.js index 61b151edc..237273feb 100644 --- a/frontend/src/locales/hu.js +++ b/frontend/src/locales/hu.js @@ -34,6 +34,9 @@ export default { 'Also trains': 'Emellett edzi', 'History': 'Előzmények', 'Settings': 'Beállítások', + 'Display': 'Megjelenítés', + 'Show sync banner': 'Szinkronizálási sáv megjelenítése', + 'Show connection and sync status at the top of the screen.': 'A kapcsolat és a szinkronizálás állapotának megjelenítése a képernyő tetején.', 'Freestyle': 'Szabad edzés', 'Routine': 'Rutin', 'routine': 'rutin', diff --git a/frontend/src/locales/it.js b/frontend/src/locales/it.js index 48e5dd34d..5c59b79f2 100644 --- a/frontend/src/locales/it.js +++ b/frontend/src/locales/it.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Allena anche', 'History': 'Cronologia', 'Settings': 'Impostazioni', + 'Display': 'Visualizzazione', + 'Show sync banner': 'Mostra il banner di sincronizzazione', + 'Show connection and sync status at the top of the screen.': 'Mostra lo stato della connessione e della sincronizzazione in alto sullo schermo.', 'Freestyle': 'Libero', 'Routine': 'Routine', 'routine': 'routine', diff --git a/frontend/src/locales/ko.js b/frontend/src/locales/ko.js index 02628fa0a..74f48dba2 100644 --- a/frontend/src/locales/ko.js +++ b/frontend/src/locales/ko.js @@ -32,6 +32,9 @@ export default { 'Also trains': '함께 훈련', 'History': '기록', 'Settings': '설정', + 'Display': '화면 표시', + 'Show sync banner': '동기화 배너 표시', + 'Show connection and sync status at the top of the screen.': '화면 상단에 연결 및 동기화 상태를 표시합니다.', 'Freestyle': '자유 운동', 'Routine': '루틴', 'routine': '루틴', diff --git a/frontend/src/locales/pl.js b/frontend/src/locales/pl.js index f5910a684..4257529ee 100644 --- a/frontend/src/locales/pl.js +++ b/frontend/src/locales/pl.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Angażuje również', 'History': 'Historia', 'Settings': 'Ustawienia', + 'Display': 'Wyświetlanie', + 'Show sync banner': 'Pokaż baner synchronizacji', + 'Show connection and sync status at the top of the screen.': 'Pokazuj stan połączenia i synchronizacji u góry ekranu.', 'Freestyle': 'Dowolny', 'Routine': 'Plan treningowy', 'routine': 'planu', diff --git a/frontend/src/locales/pt-BR.js b/frontend/src/locales/pt-BR.js index 41505d76e..2e92324c2 100644 --- a/frontend/src/locales/pt-BR.js +++ b/frontend/src/locales/pt-BR.js @@ -24,6 +24,9 @@ export const PT_BR_OVERRIDES = { 'Save': 'Salvar', 'Log': 'Registrar', 'Settings': 'Configurações', + 'Display': 'Exibição', + 'Show sync banner': 'Mostrar faixa de sincronização', + 'Show connection and sync status at the top of the screen.': 'Mostrar o status da conexão e da sincronização no topo da tela.', 'English exercise names': 'Nomes de exercícios em inglês', 'Show the English name in parentheses next to the translated one.': 'Mostra o nome em inglês entre parênteses ao lado do traduzido.', 'English names only': 'Apenas nomes em inglês', diff --git a/frontend/src/locales/pt.js b/frontend/src/locales/pt.js index 65dd8ff54..59ad5ebdd 100644 --- a/frontend/src/locales/pt.js +++ b/frontend/src/locales/pt.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Também trabalha', 'History': 'Histórico', 'Settings': 'Definições', + 'Display': 'Apresentação', + 'Show sync banner': 'Mostrar faixa de sincronização', + 'Show connection and sync status at the top of the screen.': 'Mostrar o estado da ligação e da sincronização no topo do ecrã.', 'Freestyle': 'Livre', 'Routine': 'Rotina', 'routine': 'rotina', diff --git a/frontend/src/locales/ru.js b/frontend/src/locales/ru.js index 2df1568f7..689f37524 100644 --- a/frontend/src/locales/ru.js +++ b/frontend/src/locales/ru.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Также тренирует', 'History': 'История', 'Settings': 'Настройки', + 'Display': 'Отображение', + 'Show sync banner': 'Показывать баннер синхронизации', + 'Show connection and sync status at the top of the screen.': 'Показывать состояние подключения и синхронизации вверху экрана.', 'Freestyle': 'Свободная', 'Routine': 'Программа', 'routine': 'программу', diff --git a/frontend/src/locales/th.js b/frontend/src/locales/th.js index 3604d80fa..2c70594cd 100644 --- a/frontend/src/locales/th.js +++ b/frontend/src/locales/th.js @@ -34,6 +34,9 @@ export default { 'Also trains': 'ฝึกกล้ามเนื้อนี้ด้วย', 'History': 'ประวัติ', 'Settings': 'ตั้งค่า', + 'Display': 'การแสดงผล', + 'Show sync banner': 'แสดงแบนเนอร์การซิงค์', + 'Show connection and sync status at the top of the screen.': 'แสดงสถานะการเชื่อมต่อและการซิงค์ที่ด้านบนของหน้าจอ', 'Freestyle': 'ฟรีสไตล์', 'Routine': 'รูทีน', 'routine': 'รูทีน', diff --git a/frontend/src/locales/tr.js b/frontend/src/locales/tr.js index 1c96ebc30..ec6af5409 100644 --- a/frontend/src/locales/tr.js +++ b/frontend/src/locales/tr.js @@ -32,6 +32,9 @@ export default { 'Also trains': 'Ayrıca çalıştırır', 'History': 'Geçmiş', 'Settings': 'Ayarlar', + 'Display': 'Görünüm', + 'Show sync banner': 'Senkronizasyon bandını göster', + 'Show connection and sync status at the top of the screen.': 'Bağlantı ve senkronizasyon durumunu ekranın üst kısmında göster.', 'Freestyle': 'Serbest', 'Routine': 'Rutin', 'routine': 'rutine', diff --git a/frontend/src/locales/uk.js b/frontend/src/locales/uk.js index ba239e0c8..858adc8b3 100644 --- a/frontend/src/locales/uk.js +++ b/frontend/src/locales/uk.js @@ -25,6 +25,9 @@ export default { 'Exercises': 'Вправи', 'History': 'Історія', 'Settings': 'Налаштування', + 'Display': 'Вигляд', + 'Show sync banner': 'Показувати банер синхронізації', + 'Show connection and sync status at the top of the screen.': 'Показувати стан з’єднання та синхронізації у верхній частині екрана.', 'Freestyle': 'Довільна', 'Routine': 'Програма', 'routine': 'програму', diff --git a/frontend/src/locales/zh.js b/frontend/src/locales/zh.js index 84237894a..bac08b4a1 100644 --- a/frontend/src/locales/zh.js +++ b/frontend/src/locales/zh.js @@ -32,6 +32,9 @@ export default { 'Also trains': '同时训练', 'History': '历史', 'Settings': '设置', + 'Display': '显示', + 'Show sync banner': '显示同步横幅', + 'Show connection and sync status at the top of the screen.': '在屏幕顶部显示连接和同步状态。', 'Freestyle': '自由训练', 'Routine': '训练日', 'routine': '训练日', diff --git a/frontend/src/store/useUI.js b/frontend/src/store/useUI.js index 74f803eb4..0db53ea3a 100644 --- a/frontend/src/store/useUI.js +++ b/frontend/src/store/useUI.js @@ -77,6 +77,10 @@ let workInt = null let workTick = null let workDone = null const MAX_WORK_OVERTIME_SEC = 15 * 60 +const SYNC_BANNER_KEY = 'gym_show_sync_banner' +const readShowSyncBanner = () => { + try { return localStorage.getItem(SYNC_BANNER_KEY) !== '0' } catch { return true } +} const stopRestTicking = () => { if (timerInt) clearInterval(timerInt); timerInt = null @@ -123,6 +127,7 @@ const runRest = (set, get) => { export const useUI = create((set, get) => ({ sheets: [], // { id, render:(close)=>JSX, kind:'sheet'|'center', locked } toastMsg: '', + showSyncBanner: readShowSyncBanner(), // device-local display choice; never part of the synced profile timer: null, // rest countdown between sets — { left, total, endsAt, forIdx, ready?, paused? } // forIdx: index of the active entry whose set started the rest (undefined when unknown) // paused: held at `left`; `endsAt` means nothing until resumeRest sets it again @@ -143,6 +148,13 @@ export const useUI = create((set, get) => ({ closeSheet(id) { set(s => ({ sheets: s.sheets.filter(x => x.id !== id) })) }, closeAll() { set({ sheets: [] }) }, + setShowSyncBanner(show) { + const value = !!show + if (get().showSyncBanner === value) return + try { localStorage.setItem(SYNC_BANNER_KEY, value ? '1' : '0') } catch { /* keep the in-memory choice */ } + set({ showSyncBanner: value }) + }, + toast(msg) { set({ toastMsg: msg }) clearTimeout(toastTm) diff --git a/frontend/src/store/useUI.test.js b/frontend/src/store/useUI.test.js index cf9faee01..5912912a1 100644 --- a/frontend/src/store/useUI.test.js +++ b/frontend/src/store/useUI.test.js @@ -4,6 +4,21 @@ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' import { useUI } from './useUI.js' import { useStore } from './useStore.js' +describe('device-local sync banner preference', () => { + afterEach(() => { + useUI.getState().setShowSyncBanner(true) + localStorage.removeItem('gym_show_sync_banner') + }) + + it('persists the display choice on this device', () => { + useUI.getState().setShowSyncBanner(false) + expect(useUI.getState().showSyncBanner).toBe(false) + expect(localStorage.getItem('gym_show_sync_banner')).toBe('0') + useUI.getState().setShowSyncBanner(true) + expect(localStorage.getItem('gym_show_sync_banner')).toBe('1') + }) +}) + // "Off" has to hold at the timer itself, not at the four places that start one — the same // reason the rest-after-a-set rule is a shared condition rather than four copies. describe('rest timer set to Off', () => { diff --git a/frontend/src/views/Settings.jsx b/frontend/src/views/Settings.jsx index d04cc7246..445551386 100644 --- a/frontend/src/views/Settings.jsx +++ b/frontend/src/views/Settings.jsx @@ -49,6 +49,8 @@ export default function Settings() { const credsChanged = () => { passkeys.load(); setCredsV(v => v + 1) } const { update, importConflict, importBackup, setUnit, resetEverything: resetAll, setUser, pullState, pushState, resetDemo } = useStore() const toast = useUI(s => s.toast) + const showSyncBanner = useUI(s => s.showSyncBanner) + const setShowSyncBanner = useUI(s => s.setShowSyncBanner) const fileRef = useRef(null) const importRef = useRef(null) const wakeOK = wakeLockSupported() @@ -541,6 +543,14 @@ export default function Settings() { + {/* The sync banner is a per-device display choice; the full status remains in Settings. */} +
+ + + +
+ {/* ---------- data: fill it, bring things over, back it up, wipe it ---------- */}
diff --git a/frontend/src/views/Settings.server-sync.test.jsx b/frontend/src/views/Settings.server-sync.test.jsx index e63008b13..6c7be48d5 100644 --- a/frontend/src/views/Settings.server-sync.test.jsx +++ b/frontend/src/views/Settings.server-sync.test.jsx @@ -12,7 +12,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true it never checked, and which asks before leaving changes the server has not got: try again, export a backup, or go ahead anyway with the changes kept on the device. */ const mocks = vi.hoisted(() => { - const state = { S: null, user: null, sync: null, MOBILE: false, unsynced: { owed: false, count: 0 }, kept: [], sheets: [], navs: [] } + const state = { S: null, user: null, sync: null, MOBILE: false, showSyncBanner: true, unsynced: { owed: false, count: 0 }, kept: [], sheets: [], navs: [] } state.toast = vi.fn() state.confirmSheet = vi.fn() state.syncNow = vi.fn(async () => state.sync) @@ -21,7 +21,7 @@ const mocks = vi.hoisted(() => { state.signOutAll = vi.fn() state.shareExport = vi.fn(async () => {}) state.snapshot = () => ({ - S: state.S, user: state.user, sync: state.sync, coachLocal: null, + S: state.S, user: state.user, sync: state.sync, coachLocal: null, showSyncBanner: state.showSyncBanner, update: vi.fn(), replaceState: vi.fn(), setUser: vi.fn(), pullState: vi.fn(), pushState: vi.fn(), resetDemo: vi.fn(), syncNow: state.syncNow, unsyncedChanges: () => state.unsynced, keptChanges: async () => state.kept, disconnectServer: state.disconnectServer, signOut: state.signOut, signOutAll: state.signOutAll, @@ -35,7 +35,7 @@ vi.mock('../store/useStore.js', () => { return { useStore, DEF: { reminder: { time: '17:30' } }, hasData: () => false } }) vi.mock('../store/useUI.js', () => { - const snap = () => ({ toast: (...a) => mocks.toast(...a), openSheet: (render, opts) => { mocks.sheets.push({ render, opts }); return {} } }) + const snap = () => ({ showSyncBanner: mocks.showSyncBanner, toast: (...a) => mocks.toast(...a), setShowSyncBanner: v => { mocks.showSyncBanner = v }, openSheet: (render, opts) => { mocks.sheets.push({ render, opts }); return {} } }) const useUI = selector => selector ? selector(snap()) : snap() useUI.getState = snap return { useUI } @@ -76,6 +76,7 @@ beforeEach(() => { mocks.S = { unit: 'kg', restSec: 90, restPauseSec: 15, sound: false, effort: 'none', gifSize: 'full', workouts: [], routines: [], exWeights: {} } mocks.user = USER mocks.MOBILE = true + mocks.showSyncBanner = true mocks.sync = sync('ok', { lastSynced: Date.now() - 5 * 60000 }) mocks.unsynced = { owed: false, count: 0 } mocks.kept = [] @@ -174,6 +175,17 @@ describe('Server & sync', () => { }) }) +describe('the sync banner display preference', () => { + it('is available in Settings and can be switched off on this device', () => { + const page = mount() + const row = rowByTitle(page, 'Show sync banner') + expect(row).toBeTruthy() + expect(row.querySelector('[role="switch"]').getAttribute('aria-checked')).toBe('true') + act(() => row.querySelector('[role="switch"]').click()) + expect(mocks.showSyncBanner).toBe(false) + }) +}) + describe('leaving the server', () => { const confirm = () => mocks.confirmSheet.mock.calls.at(-1)[0]