Skip to content

Commit 8cfec96

Browse files
committed
fix timer
Signed-off-by: Hoang Pham <[email protected]>
1 parent f9675bc commit 8cfec96

File tree

4 files changed

+200
-47
lines changed

4 files changed

+200
-47
lines changed

playwright/e2e/timer.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect } from '@playwright/test'
7+
import type { Page } from '@playwright/test'
8+
import { test } from '../support/fixtures/random-user'
9+
import {
10+
createWhiteboard,
11+
newLoggedInPage,
12+
openFilesApp,
13+
openWhiteboardFromFiles,
14+
} from '../support/utils'
15+
16+
async function ensureTimerVisible(page: Page) {
17+
const timerOverlay = page.locator('.timer-overlay')
18+
if (await timerOverlay.count()) {
19+
await expect(timerOverlay).toBeVisible({ timeout: 15000 })
20+
return timerOverlay
21+
}
22+
23+
await page.getByTestId('main-menu-trigger').click()
24+
const toggleItem = page.getByText(/Show timer|Hide timer/).first()
25+
await expect(toggleItem).toBeVisible({ timeout: 15000 })
26+
await toggleItem.click()
27+
28+
await expect(timerOverlay).toBeVisible({ timeout: 15000 })
29+
return timerOverlay
30+
}
31+
32+
async function waitForCollaboration(page: Page) {
33+
await expect(page.locator('.network-status')).toHaveCount(0, { timeout: 30000 })
34+
}
35+
36+
test.beforeEach(async ({ page }) => {
37+
await openFilesApp(page)
38+
})
39+
40+
test('timer can start, pause, resume, and reset', async ({ page }) => {
41+
const boardName = `Timer board ${Date.now()}`
42+
43+
await createWhiteboard(page, { name: boardName })
44+
await waitForCollaboration(page)
45+
46+
const timer = await ensureTimerVisible(page)
47+
48+
await timer.getByLabel('Minutes').fill('1')
49+
await timer.getByLabel('Seconds').fill('0')
50+
await timer.getByRole('button', { name: 'Start' }).click()
51+
52+
await expect(timer.getByText('Running')).toBeVisible({ timeout: 15000 })
53+
await expect(timer.getByRole('button', { name: 'Pause' })).toBeVisible()
54+
55+
await timer.getByRole('button', { name: 'Pause' }).click()
56+
await expect(timer.getByText('Paused')).toBeVisible({ timeout: 15000 })
57+
await expect(timer.getByRole('button', { name: 'Resume' })).toBeVisible()
58+
59+
await timer.getByRole('button', { name: 'Resume' }).click()
60+
await expect(timer.getByText('Running')).toBeVisible({ timeout: 15000 })
61+
62+
await timer.getByRole('button', { name: 'Reset' }).click()
63+
await expect(timer.getByText('Ready')).toBeVisible({ timeout: 15000 })
64+
await expect(timer.getByLabel('Minutes')).toHaveValue('0')
65+
await expect(timer.getByLabel('Seconds')).toHaveValue('0')
66+
})
67+
68+
test('timer state syncs across sessions', async ({ page, browser }) => {
69+
test.setTimeout(90000)
70+
const boardName = `Timer sync ${Date.now()}`
71+
72+
await createWhiteboard(page, { name: boardName })
73+
await waitForCollaboration(page)
74+
75+
const timer = await ensureTimerVisible(page)
76+
await timer.getByLabel('Minutes').fill('1')
77+
await timer.getByRole('button', { name: 'Start' }).click()
78+
await expect(timer.getByText('Running')).toBeVisible({ timeout: 15000 })
79+
80+
const viewerPage = await newLoggedInPage(page, browser)
81+
await openWhiteboardFromFiles(viewerPage, boardName)
82+
await waitForCollaboration(viewerPage)
83+
84+
const viewerTimer = await ensureTimerVisible(viewerPage)
85+
await expect(viewerTimer.getByText('Running')).toBeVisible({ timeout: 20000 })
86+
await viewerTimer.getByRole('button', { name: 'Pause' }).click()
87+
88+
await expect(viewerTimer.getByText('Paused')).toBeVisible({ timeout: 20000 })
89+
await expect(timer.getByText('Paused')).toBeVisible({ timeout: 20000 })
90+
91+
await viewerPage.close()
92+
})

src/App.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,30 @@ export default function App({
203203
const presentationState = usePresentation({ fileId: normalizedFileId })
204204
const timerState = useTimer({ fileId: normalizedFileId })
205205
const [isTimerPinned, setIsTimerPinned] = useState(false)
206+
const [isTimerDismissed, setIsTimerDismissed] = useState(false)
207+
const isTimerActive = timerState.status !== 'idle'
208+
const isTimerVisible = isTimerPinned || (isTimerActive && !isTimerDismissed)
209+
210+
useEffect(() => {
211+
if (!isTimerActive) {
212+
setIsTimerDismissed(false)
213+
}
214+
}, [isTimerActive])
215+
216+
const handleToggleTimer = useCallback(() => {
217+
if (isTimerVisible) {
218+
setIsTimerPinned(false)
219+
if (isTimerActive) {
220+
setIsTimerDismissed(true)
221+
}
222+
return
223+
}
224+
225+
setIsTimerDismissed(false)
226+
if (!isTimerActive) {
227+
setIsTimerPinned(true)
228+
}
229+
}, [isTimerVisible, isTimerActive])
206230

207231
// Voting
208232
const { startVoting, vote, endVoting } = useVoting()
@@ -518,8 +542,8 @@ export default function App({
518542
fileNameWithoutExtension={fileNameWithoutExtension}
519543
recordingState={recordingState}
520544
presentationState={presentationState}
521-
isTimerVisible={isTimerPinned || timerState.status !== 'idle'}
522-
onToggleTimer={() => setIsTimerPinned(prev => !prev)}
545+
isTimerVisible={isTimerVisible}
546+
onToggleTimer={handleToggleTimer}
523547
gridModeEnabled={gridModeEnabled}
524548
onToggleGrid={() => setGridModeEnabled(!gridModeEnabled)}
525549
/>
@@ -540,7 +564,7 @@ export default function App({
540564
presentationState={presentationState}
541565
/>
542566
)}
543-
{!isVersionPreview && (isTimerPinned || timerState.status !== 'idle') && (
567+
{!isVersionPreview && isTimerVisible && (
544568
<TimerOverlay
545569
timer={timerState}
546570
/>

src/components/Timer.scss

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@
129129
gap: 8px;
130130
}
131131

132+
.timer__time-input-wrapper {
133+
position: relative;
134+
display: inline-flex;
135+
align-items: center;
136+
justify-content: center;
137+
}
138+
139+
.timer__time-input-wrapper::after {
140+
content: attr(data-label);
141+
position: absolute;
142+
top: -6px;
143+
inset-inline-start: 50%;
144+
transform: translateX(-50%);
145+
padding: 0 2px;
146+
font-size: 9px;
147+
line-height: 1;
148+
font-weight: 600;
149+
color: var(--color-text-lighter);
150+
background: transparent;
151+
text-shadow: 0 0 2px var(--color-background-hover);
152+
pointer-events: none;
153+
}
154+
132155
.timer__time-input {
133156
width: 54px;
134157
padding: 6px 4px;
@@ -196,16 +219,18 @@
196219
border: 1px solid var(--color-border);
197220
background: var(--color-background-hover);
198221
border-radius: 12px;
199-
padding: 8px 6px;
222+
padding: 6px 6px;
200223
cursor: pointer;
201224
transition: background-color 0.2s ease, border-color 0.2s ease;
202225
font-weight: 700;
226+
font-size: 12px;
203227
text-align: center;
204228
white-space: nowrap;
205-
min-height: 40px;
229+
min-height: 36px;
206230
display: inline-flex;
207231
align-items: center;
208232
justify-content: center;
233+
min-width: 0;
209234
}
210235

211236
.timer__chip:hover:enabled {
@@ -232,28 +257,34 @@
232257
.timer__grid {
233258
display: grid;
234259
gap: 6px;
260+
width: 100%;
235261
}
236262

237263
.timer__grid--primary {
238-
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
264+
grid-auto-flow: column;
265+
grid-auto-columns: minmax(0, 1fr);
239266
}
240267

241268
.timer__grid--presets {
242-
grid-template-columns: repeat(auto-fit, minmax(72px, 1fr));
269+
grid-auto-flow: column;
270+
grid-auto-columns: minmax(0, 1fr);
243271
}
244272

245273
.timer__button {
246274
display: inline-flex;
247275
align-items: center;
248-
gap: 6px;
249-
padding: 8px 10px;
276+
gap: 4px;
277+
padding: 6px 8px;
250278
border-radius: var(--border-radius);
251279
border: 1px solid var(--color-border);
252280
background: var(--color-background-hover);
253281
cursor: pointer;
254282
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
255-
min-height: 40px;
283+
min-height: 36px;
256284
font-weight: 700;
285+
font-size: 12px;
286+
min-width: 0;
287+
white-space: nowrap;
257288
}
258289

259290
.timer__button--start {
@@ -274,7 +305,7 @@
274305
}
275306

276307
.timer__button-label {
277-
font-size: 13px;
308+
font-size: 12px;
278309
}
279310

280311
.timer__button:hover:enabled {

src/components/Timer.tsx

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -169,44 +169,50 @@ export const TimerOverlay = memo(function TimerOverlay({ timer }: TimerOverlayPr
169169
)
170170
: (
171171
<div className="timer__time-inputs">
172-
<input
173-
id="timer-hours"
174-
type="number"
175-
inputMode="numeric"
176-
min={0}
177-
max={4}
178-
value={timeInputs.hours}
179-
onChange={(event) => handleTimeChange('hours', Number(event.target.value) || 0)}
180-
disabled={!canControl}
181-
aria-label={t('whiteboard', 'Hours')}
182-
className="timer__time-input"
183-
/>
172+
<div className="timer__time-input-wrapper" data-label="hh">
173+
<input
174+
id="timer-hours"
175+
type="number"
176+
inputMode="numeric"
177+
min={0}
178+
max={4}
179+
value={timeInputs.hours}
180+
onChange={(event) => handleTimeChange('hours', Number(event.target.value) || 0)}
181+
disabled={!canControl}
182+
aria-label={t('whiteboard', 'Hours')}
183+
className="timer__time-input"
184+
/>
185+
</div>
184186
<span className="timer__time-separator">:</span>
185-
<input
186-
id="timer-minutes"
187-
type="number"
188-
inputMode="numeric"
189-
min={0}
190-
max={59}
191-
value={timeInputs.minutes}
192-
onChange={(event) => handleTimeChange('minutes', Number(event.target.value) || 0)}
193-
disabled={!canControl}
194-
aria-label={t('whiteboard', 'Minutes')}
195-
className="timer__time-input"
196-
/>
187+
<div className="timer__time-input-wrapper" data-label="mm">
188+
<input
189+
id="timer-minutes"
190+
type="number"
191+
inputMode="numeric"
192+
min={0}
193+
max={59}
194+
value={timeInputs.minutes}
195+
onChange={(event) => handleTimeChange('minutes', Number(event.target.value) || 0)}
196+
disabled={!canControl}
197+
aria-label={t('whiteboard', 'Minutes')}
198+
className="timer__time-input"
199+
/>
200+
</div>
197201
<span className="timer__time-separator">:</span>
198-
<input
199-
id="timer-seconds"
200-
type="number"
201-
inputMode="numeric"
202-
min={0}
203-
max={59}
204-
value={timeInputs.seconds}
205-
onChange={(event) => handleTimeChange('seconds', Number(event.target.value) || 0)}
206-
disabled={!canControl}
207-
aria-label={t('whiteboard', 'Seconds')}
208-
className="timer__time-input"
209-
/>
202+
<div className="timer__time-input-wrapper" data-label="ss">
203+
<input
204+
id="timer-seconds"
205+
type="number"
206+
inputMode="numeric"
207+
min={0}
208+
max={59}
209+
value={timeInputs.seconds}
210+
onChange={(event) => handleTimeChange('seconds', Number(event.target.value) || 0)}
211+
disabled={!canControl}
212+
aria-label={t('whiteboard', 'Seconds')}
213+
className="timer__time-input"
214+
/>
215+
</div>
210216
</div>
211217
)}
212218
</div>

0 commit comments

Comments
 (0)