Skip to content

Commit 49b55a6

Browse files
authored
Merge pull request #14 from GilbN/feat/update-reload-notifier
Implement update notification system with modal and service worker integration
2 parents 7c24abb + 09e32b0 commit 49b55a6

10 files changed

Lines changed: 318 additions & 13 deletions

src/App.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import { SocketHost } from './lib/peer/SocketHost.js'
55
import { SocketClient } from './lib/peer/SocketClient.js'
66
import { TimerScheduler } from './lib/timer/TimerScheduler.js'
7+
import { registerUpdateHandler } from './lib/updateAvailable.js'
78
import HomeView from './views/HomeView.svelte'
89
import LobbyView from './views/LobbyView.svelte'
910
import TimerView from './views/TimerView.svelte'
1011
import StopwatchView from './views/StopwatchView.svelte'
1112
import DisplayView from './views/DisplayView.svelte'
13+
import UpdateModal from './components/UpdateModal.svelte'
1214
1315
// Restore preferences on load
1416
const savedPrefs = loadPreferences()
@@ -25,6 +27,9 @@
2527
// Attempt session restore on page load
2628
restoreSession()
2729
30+
// Start listening for service-worker updates
31+
registerUpdateHandler()
32+
2833
async function restoreSession() {
2934
const savedRoom = loadRoomState()
3035
if (!savedRoom?.code && !savedRoom?.isSolo) return
@@ -110,3 +115,5 @@
110115
{:else if $currentView === 'display'}
111116
<DisplayView />
112117
{/if}
118+
119+
<UpdateModal />

src/components/PeerList.svelte

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
77
let peers = $derived($roomState.connectedPeers || [])
88
let canReshoot = $derived($timerState.phase === 'stopped' || $timerState.phase === 'idle')
9+
let panelEl = $state(null)
910
1011
$effect(() => {
12+
const previouslyFocused = document.activeElement
13+
panelEl?.focus()
1114
function onKey(e) { if (e.key === 'Escape') onClose() }
1215
document.addEventListener('keydown', onKey)
13-
return () => document.removeEventListener('keydown', onKey)
16+
return () => {
17+
document.removeEventListener('keydown', onKey)
18+
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
19+
}
1420
})
1521
</script>
1622
1723
<div class="modal-backdrop" role="presentation" onclick={onClose}>
18-
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="peer-list-title" tabindex="-1" onclick={(e) => e.stopPropagation()}>
24+
<!-- svelte-ignore a11y_click_events_have_key_events -->
25+
<div class="modal-panel" role="dialog" aria-modal="true" aria-labelledby="peer-list-title" tabindex="-1" bind:this={panelEl} onclick={(e) => e.stopPropagation()}>
1926
<div class="modal-header">
2027
<h2 id="peer-list-title" class="modal-title">{$t('shooters')}</h2>
2128
<span class="peer-count">{peers.length}</span>

src/components/ProgramEditor.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
55
let { onSave, onCancel, editProgram = null } = $props()
66
7+
// Initial state is seeded from editProgram at mount time. The component is
8+
// re-mounted whenever the editor opens (via {#if showEditor} in LobbyView),
9+
// so capturing the initial prop value is exactly the intended behavior —
10+
// the user's subsequent edits to `name`/`stages` must not be overwritten
11+
// by prop reactivity.
12+
/* svelte-ignore state_referenced_locally */
713
let name = $state(editProgram ? editProgram.name.no : '')
14+
/* svelte-ignore state_referenced_locally */
815
let stages = $state(editProgram ? JSON.parse(JSON.stringify(editProgram.stages)) : [createStage()])
916
1017
function createStage() {

src/components/SettingsMenu.svelte

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { preferences } from '../lib/stores.js'
33
import { savePreferences } from '../lib/storage.js'
44
import { t } from '../lib/i18n.js'
5+
import { updateAvailable, updateDismissed } from '../lib/updateAvailable.js'
56
67
let open = $state(false)
78
let showAbout = $state(false)
@@ -62,6 +63,11 @@
6263
open = false
6364
}
6465
66+
function openUpdateModal() {
67+
updateDismissed.set(false)
68+
close()
69+
}
70+
6571
$effect(() => {
6672
if (open) {
6773
document.addEventListener('click', handleOutside)
@@ -86,11 +92,30 @@
8692
<line x1="4" y1="12" x2="20" y2="12"/>
8793
<line x1="4" y1="18" x2="20" y2="18"/>
8894
</svg>
95+
{#if $updateAvailable}
96+
<span class="update-dot" aria-hidden="true"></span>
97+
{/if}
8998
</button>
9099
91100
{#if open}
92101
<div class="panel" role="menu">
93102
103+
{#if $updateAvailable}
104+
<button class="row row-update" onclick={openUpdateModal} role="menuitem">
105+
<span class="row-icon">
106+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
107+
<polyline points="23 4 23 10 17 10"/>
108+
<path d="M20.49 15A9 9 0 1 1 18 6.3L23 10"/>
109+
</svg>
110+
</span>
111+
<span class="row-label">
112+
{$t('updateAvailableReload')}
113+
<span class="update-dot update-dot-inline" aria-hidden="true"></span>
114+
</span>
115+
</button>
116+
<div class="divider"></div>
117+
{/if}
118+
94119
<!-- Wake lock -->
95120
<button class="row" onclick={() => toggle('wakeLockEnabled')} role="menuitem">
96121
<span class="row-icon">
@@ -229,6 +254,7 @@
229254
230255
/* icon-btn is defined in TimerView — replicate here for portability */
231256
.icon-btn {
257+
position: relative;
232258
background: var(--bg-surface);
233259
border: 1px solid rgba(255,255,255,0.06);
234260
padding: 0;
@@ -256,6 +282,52 @@
256282
color: var(--text-primary);
257283
}
258284
285+
.update-dot {
286+
position: absolute;
287+
top: 4px;
288+
right: 4px;
289+
width: 8px;
290+
height: 8px;
291+
border-radius: 50%;
292+
background: var(--warning);
293+
box-shadow: 0 0 0 2px var(--bg-primary);
294+
animation: update-dot-pulse 1.6s ease-in-out infinite;
295+
}
296+
297+
@keyframes update-dot-pulse {
298+
0%, 100% {
299+
transform: scale(1);
300+
box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 0 rgba(255, 181, 71, 0.55);
301+
}
302+
50% {
303+
transform: scale(1.15);
304+
box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 6px rgba(255, 181, 71, 0);
305+
}
306+
}
307+
308+
@media (prefers-reduced-motion: reduce) {
309+
.update-dot {
310+
animation: none;
311+
}
312+
}
313+
314+
.row-update {
315+
color: var(--accent);
316+
}
317+
318+
.row-update .row-label {
319+
color: var(--accent);
320+
font-weight: 700;
321+
display: inline-flex;
322+
align-items: center;
323+
gap: 0.5rem;
324+
}
325+
326+
.update-dot-inline {
327+
position: static;
328+
box-shadow: 0 0 0 0 rgba(255, 181, 71, 0.55);
329+
}
330+
259331
/* ── Panel ── */
260332
.panel {
261333
position: absolute;

src/components/UpdateModal.svelte

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<script>
2+
import { updateAvailable, updateDismissed, applyUpdate } from '../lib/updateAvailable.js'
3+
import { timerState } from '../lib/stores.js'
4+
import { t } from '../lib/i18n.js'
5+
6+
let visible = $derived($updateAvailable && !$updateDismissed)
7+
let panelEl = $state(null)
8+
let matchInProgress = $derived(
9+
$timerState?.phase === 'loading' || $timerState?.phase === 'shooting'
10+
)
11+
12+
function dismiss() {
13+
updateDismissed.set(true)
14+
}
15+
16+
function reload() {
17+
applyUpdate()
18+
}
19+
20+
$effect(() => {
21+
if (!visible) return
22+
const previouslyFocused = document.activeElement
23+
panelEl?.focus()
24+
function onKey(e) { if (e.key === 'Escape') dismiss() }
25+
document.addEventListener('keydown', onKey)
26+
return () => {
27+
document.removeEventListener('keydown', onKey)
28+
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus()
29+
}
30+
})
31+
</script>
32+
33+
{#if visible}
34+
<div class="modal-backdrop" role="presentation" onclick={dismiss}>
35+
<!-- svelte-ignore a11y_click_events_have_key_events -->
36+
<div
37+
class="modal-panel"
38+
role="dialog"
39+
aria-modal="true"
40+
aria-labelledby="update-modal-title"
41+
tabindex="-1"
42+
bind:this={panelEl}
43+
onclick={(e) => e.stopPropagation()}
44+
>
45+
<div class="modal-header">
46+
<h2 id="update-modal-title" class="modal-title">{$t('updateAvailable')}</h2>
47+
</div>
48+
49+
<p class="modal-body">{$t('updateAvailableBody')}</p>
50+
51+
{#if matchInProgress}
52+
<p class="modal-warning">{$t('reloadDuringMatchWarning')}</p>
53+
{/if}
54+
55+
<div class="modal-actions">
56+
<button class="btn-secondary" onclick={dismiss}>{$t('later')}</button>
57+
<button class="btn-primary" onclick={reload}>{$t('reloadNow')}</button>
58+
</div>
59+
</div>
60+
</div>
61+
{/if}
62+
63+
<style>
64+
.modal-backdrop {
65+
position: fixed;
66+
inset: 0;
67+
z-index: 300;
68+
background: rgba(0, 0, 0, 0.6);
69+
display: flex;
70+
align-items: center;
71+
justify-content: center;
72+
}
73+
74+
.modal-panel {
75+
background: var(--bg-secondary);
76+
border: 1px solid rgba(255, 255, 255, 0.08);
77+
border-radius: var(--radius-lg, var(--radius));
78+
padding: 1.25rem;
79+
width: min(90vw, 420px);
80+
display: flex;
81+
flex-direction: column;
82+
gap: 0.75rem;
83+
}
84+
85+
.modal-header {
86+
display: flex;
87+
align-items: center;
88+
gap: 0.5rem;
89+
}
90+
91+
.modal-title {
92+
font-size: 1rem;
93+
font-weight: 700;
94+
color: var(--text-primary);
95+
margin: 0;
96+
letter-spacing: 0.04em;
97+
}
98+
99+
.modal-body {
100+
margin: 0;
101+
font-size: 0.9rem;
102+
color: var(--text-secondary);
103+
line-height: 1.45;
104+
}
105+
106+
.modal-warning {
107+
margin: 0;
108+
padding: 0.6rem 0.75rem;
109+
font-size: 0.82rem;
110+
color: var(--warning);
111+
background: rgba(255, 181, 71, 0.12);
112+
border: 1px solid rgba(255, 181, 71, 0.3);
113+
border-radius: var(--radius);
114+
line-height: 1.4;
115+
}
116+
117+
.modal-actions {
118+
display: flex;
119+
justify-content: flex-end;
120+
gap: 0.5rem;
121+
}
122+
123+
.btn-primary {
124+
padding: 0.5rem 1rem;
125+
font-size: 0.85rem;
126+
font-weight: 700;
127+
background: var(--accent);
128+
color: var(--bg-primary);
129+
border: none;
130+
border-radius: var(--radius);
131+
letter-spacing: 0.04em;
132+
}
133+
134+
.btn-primary:hover {
135+
filter: brightness(1.1);
136+
}
137+
138+
.btn-secondary {
139+
padding: 0.5rem 1rem;
140+
font-size: 0.85rem;
141+
font-weight: 600;
142+
background: var(--bg-surface);
143+
color: var(--text-secondary);
144+
border: 1px solid rgba(255, 255, 255, 0.08);
145+
border-radius: var(--radius);
146+
}
147+
148+
.btn-secondary:hover {
149+
color: var(--text-primary);
150+
border-color: rgba(255, 255, 255, 0.15);
151+
}
152+
</style>

src/lib/i18n.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ const translations = {
55
no: {
66
appName: 'NSF Timer',
77
shareRoomPrefix: 'Bli med i NSF Timer-rom:',
8+
updateAvailable: 'Ny versjon tilgjengelig',
9+
updateAvailableBody: 'En nyere versjon av appen er installert i bakgrunnen. Last inn siden på nytt for å ta den i bruk.',
10+
reloadDuringMatchWarning: 'En konkurranse pågår. Hvis du laster inn på nytt midt i en duell-serie vil klokka pauses og må fortsettes manuelt.',
11+
updateAvailableReload: 'Last inn for ny versjon',
12+
reloadNow: 'Last inn nå',
13+
later: 'Senere',
814
installApp: 'Installer app',
915
installIosHint: 'Trykk på Del-knappen og velg «Legg til på Hjem-skjerm»',
1016
shareToInstall: 'Del',
@@ -145,6 +151,12 @@ const translations = {
145151
en: {
146152
appName: 'NSF Timer',
147153
shareRoomPrefix: 'Join NSF Timer room:',
154+
updateAvailable: 'New version available',
155+
updateAvailableBody: 'A newer version of the app has been installed in the background. Reload the page to start using it.',
156+
reloadDuringMatchWarning: 'A competition is in progress. Reloading mid-duell-cycle will pause the timer and you will need to resume it manually.',
157+
updateAvailableReload: 'Reload for new version',
158+
reloadNow: 'Reload now',
159+
later: 'Later',
148160
installApp: 'Install App',
149161
installIosHint: 'Tap the Share button and select "Add to Home Screen"',
150162
shareToInstall: 'Share',

0 commit comments

Comments
 (0)