feat: build initial pomodoro timer
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dmitrii Krosh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Pomodoro
|
||||
|
||||
A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite.
|
||||
|
||||
## Stage 1
|
||||
|
||||
- Live clock
|
||||
- 25-minute focus sessions
|
||||
- 5-minute short breaks and 15-minute long breaks
|
||||
- Start, pause, resume, and reset controls
|
||||
- Long break after every four completed focus sessions
|
||||
- Accurate countdown after background-tab throttling
|
||||
- Sound notification when a segment ends
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Quality checks:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,25 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage', 'playwright-report', 'test-results'] },
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#17162c" />
|
||||
<meta name="description" content="A calm browser-based Pomodoro timer." />
|
||||
<title>Pomodoro — focus gently</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3961
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "pomodoro",
|
||||
"version": "0.1.0",
|
||||
"description": "A browser-based Pomodoro timer and clock",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [
|
||||
"pomodoro",
|
||||
"timer",
|
||||
"react",
|
||||
"productivity"
|
||||
],
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
.app {
|
||||
--accent: #8d6bff;
|
||||
--accent-strong: #7250ed;
|
||||
--glow: rgba(141, 107, 255, 0.38);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 34px clamp(24px, 5vw, 76px) 28px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(76, 68, 135, 0.25), transparent 32%),
|
||||
linear-gradient(135deg, #15142a 0%, #242044 48%, #16152b 100%);
|
||||
isolation: isolate;
|
||||
transition: background 400ms ease;
|
||||
}
|
||||
|
||||
.app.phase-shortBreak {
|
||||
--accent: #42d6ad;
|
||||
--accent-strong: #20ad87;
|
||||
--glow: rgba(66, 214, 173, 0.32);
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(41, 151, 129, 0.22), transparent 32%),
|
||||
linear-gradient(135deg, #101f28 0%, #183c42 48%, #10232c 100%);
|
||||
}
|
||||
|
||||
.app.phase-longBreak {
|
||||
--accent: #ff9d66;
|
||||
--accent-strong: #ef754c;
|
||||
--glow: rgba(255, 157, 102, 0.32);
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(192, 88, 73, 0.2), transparent 32%),
|
||||
linear-gradient(135deg, #2b1720 0%, #4e2834 48%, #271722 100%);
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: min(55vw, 720px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: 0.24;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb-one {
|
||||
top: -35%;
|
||||
right: -15%;
|
||||
background: #ff5d8f;
|
||||
}
|
||||
|
||||
.orb-two {
|
||||
bottom: -45%;
|
||||
left: -18%;
|
||||
background: var(--accent);
|
||||
transition: background 400ms ease;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #fff;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.clock {
|
||||
padding: 9px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: rgba(13, 12, 30, 0.25);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.timer-card {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: min(100%, 620px);
|
||||
padding: clamp(30px, 5vw, 54px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 36px;
|
||||
text-align: center;
|
||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.045));
|
||||
box-shadow: 0 28px 90px rgba(6, 5, 22, 0.42), inset 0 1px rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(30px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
transition: color 400ms ease;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 3.1rem);
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.supporting-copy {
|
||||
margin: 10px 0 28px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.phase-switcher {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
background: rgba(5, 5, 18, 0.23);
|
||||
}
|
||||
|
||||
.phase-switcher button {
|
||||
padding: 10px 15px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.phase-switcher button:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.phase-switcher button.active {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
box-shadow: inset 0 1px rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.session-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-dots span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.session-dots span.complete {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--glow);
|
||||
}
|
||||
|
||||
.countdown {
|
||||
display: block;
|
||||
margin: 6px 0 18px;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: clamp(5rem, 15vw, 8.8rem);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.075em;
|
||||
text-shadow: 0 10px 50px rgba(0, 0, 0, 0.22);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
min-width: 124px;
|
||||
padding: 13px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, background 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
border: 0;
|
||||
background: var(--accent-strong);
|
||||
box-shadow: 0 12px 32px var(--glow);
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: 22px 0 0;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--glow);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.app {
|
||||
padding: 22px 16px 20px;
|
||||
}
|
||||
|
||||
.timer-card {
|
||||
padding: 28px 18px;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.supporting-copy {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.phase-switcher {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.phase-switcher button {
|
||||
flex: 1;
|
||||
padding-inline: 7px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from './App'
|
||||
import type { CompletionSound } from './features/timer/sound'
|
||||
|
||||
function renderApp() {
|
||||
const sound: CompletionSound = {
|
||||
prepare: vi.fn(),
|
||||
play: vi.fn(),
|
||||
}
|
||||
render(<App sound={sound} />)
|
||||
return sound
|
||||
}
|
||||
|
||||
describe('Pomodoro app', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-22T10:08:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows a live clock and the initial focus timer', () => {
|
||||
renderApp()
|
||||
|
||||
expect(screen.getByLabelText('Current time')).toHaveTextContent('10:08')
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
||||
expect(screen.getByText('Focus session')).toBeInTheDocument()
|
||||
expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts, pauses, resumes, and resets the countdown', () => {
|
||||
const sound = renderApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||
expect(sound.prepare).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
|
||||
|
||||
act(() => vi.advanceTimersByTime(60_000))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('24:00')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
||||
act(() => vi.advanceTimersByTime(60_000))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('24:00')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('23:59')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
||||
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces completion and switches to a short break', () => {
|
||||
const sound = renderApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||
act(() => vi.advanceTimersByTime(25 * 60_000))
|
||||
|
||||
expect(sound.play).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('heading', { name: 'Short break' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00')
|
||||
expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the user select focus and break segments', () => {
|
||||
renderApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Long break' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('15:00')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Short break' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('05:00')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Focus' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
||||
})
|
||||
})
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import './App.css'
|
||||
import {
|
||||
createTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
selectPhase,
|
||||
startTimer,
|
||||
tickTimer,
|
||||
type TimerPhase,
|
||||
} from './features/timer/timer'
|
||||
import {
|
||||
completionSound,
|
||||
type CompletionSound,
|
||||
} from './features/timer/sound'
|
||||
|
||||
const PHASE_LABELS: Record<TimerPhase, string> = {
|
||||
focus: 'Focus session',
|
||||
shortBreak: 'Short break',
|
||||
longBreak: 'Long break',
|
||||
}
|
||||
|
||||
const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [
|
||||
{ phase: 'focus', label: 'Focus' },
|
||||
{ phase: 'shortBreak', label: 'Short break' },
|
||||
{ phase: 'longBreak', label: 'Long break' },
|
||||
]
|
||||
|
||||
interface AppProps {
|
||||
sound?: CompletionSound
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds: number): string {
|
||||
const totalSeconds = Math.ceil(milliseconds / 1_000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function App({ sound = completionSound }: AppProps) {
|
||||
const [timer, setTimer] = useState(createTimer)
|
||||
const [currentTime, setCurrentTime] = useState(() => new Date())
|
||||
const completionPending = useRef(false)
|
||||
|
||||
const updateTimer = useCallback(() => {
|
||||
setTimer((current) => {
|
||||
const next = tickTimer(current, Date.now())
|
||||
if (
|
||||
current.status === 'running' &&
|
||||
next.status === 'idle' &&
|
||||
current.phase !== next.phase
|
||||
) {
|
||||
completionPending.current = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (completionPending.current) {
|
||||
completionPending.current = false
|
||||
sound.play()
|
||||
}
|
||||
}, [sound, timer])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
updateTimer()
|
||||
}, 1_000)
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
setCurrentTime(new Date())
|
||||
updateTimer()
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [updateTimer])
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (timer.status === 'running') {
|
||||
setTimer((current) => pauseTimer(current, Date.now()))
|
||||
return
|
||||
}
|
||||
|
||||
sound.prepare()
|
||||
setTimer((current) => startTimer(current, Date.now()))
|
||||
}
|
||||
|
||||
const cycleProgress = timer.completedFocusSessions % 4
|
||||
const visibleProgress =
|
||||
timer.phase === 'longBreak' && cycleProgress === 0 && timer.completedFocusSessions > 0
|
||||
? 4
|
||||
: cycleProgress
|
||||
|
||||
return (
|
||||
<main className={`app phase-${timer.phase}`}>
|
||||
<div className="orb orb-one" aria-hidden="true" />
|
||||
<div className="orb orb-two" aria-hidden="true" />
|
||||
|
||||
<header className="topbar">
|
||||
<a className="brand" href="/" aria-label="Pomodoro home">
|
||||
<span className="brand-mark" aria-hidden="true">P</span>
|
||||
<span>pomodoro</span>
|
||||
</a>
|
||||
<time className="clock" aria-label="Current time">
|
||||
{formatClock(currentTime)}
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<section className="timer-card" aria-live="polite">
|
||||
<p className="eyebrow">Stay present</p>
|
||||
<h1>{PHASE_LABELS[timer.phase]}</h1>
|
||||
<p className="supporting-copy">
|
||||
{timer.phase === 'focus'
|
||||
? 'One task. One quiet block of time.'
|
||||
: 'Step away, breathe, and come back clear.'}
|
||||
</p>
|
||||
|
||||
<div className="phase-switcher" aria-label="Timer segment">
|
||||
{PHASE_BUTTONS.map(({ phase, label }) => (
|
||||
<button
|
||||
className={timer.phase === phase ? 'active' : ''}
|
||||
key={phase}
|
||||
onClick={() => setTimer((current) => selectPhase(current, phase))}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="session-row">
|
||||
<div className="session-dots" aria-hidden="true">
|
||||
{[1, 2, 3, 4].map((session) => (
|
||||
<span className={session <= visibleProgress ? 'complete' : ''} key={session} />
|
||||
))}
|
||||
</div>
|
||||
<span>{visibleProgress} / 4 sessions</span>
|
||||
</div>
|
||||
|
||||
<output className="countdown" aria-label="Time remaining">
|
||||
{formatDuration(timer.remainingMs)}
|
||||
</output>
|
||||
|
||||
<div className="actions">
|
||||
<button className="primary-action" onClick={handlePrimaryAction} type="button">
|
||||
{timer.status === 'running'
|
||||
? 'Pause'
|
||||
: timer.status === 'paused'
|
||||
? 'Resume'
|
||||
: 'Start'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
onClick={() => setTimer((current) => resetTimer(current))}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="status-line">
|
||||
<span className={`status-dot ${timer.status}`} aria-hidden="true" />
|
||||
{timer.status === 'running'
|
||||
? 'Timer is running'
|
||||
: timer.status === 'paused'
|
||||
? 'Paused — your place is saved'
|
||||
: 'Ready when you are'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>25 min focus</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>5 min short break</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>15 min long break</span>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface CompletionSound {
|
||||
prepare: () => void
|
||||
play: () => void
|
||||
}
|
||||
|
||||
let context: AudioContext | null = null
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window === 'undefined' || !window.AudioContext) {
|
||||
return null
|
||||
}
|
||||
context ??= new window.AudioContext()
|
||||
return context
|
||||
}
|
||||
|
||||
export const completionSound: CompletionSound = {
|
||||
prepare() {
|
||||
const audioContext = getAudioContext()
|
||||
if (audioContext?.state === 'suspended') {
|
||||
void audioContext.resume()
|
||||
}
|
||||
},
|
||||
|
||||
play() {
|
||||
const audioContext = getAudioContext()
|
||||
if (!audioContext) {
|
||||
return
|
||||
}
|
||||
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gain = audioContext.createGain()
|
||||
const now = audioContext.currentTime
|
||||
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.setValueAtTime(659.25, now)
|
||||
oscillator.frequency.setValueAtTime(880, now + 0.18)
|
||||
gain.gain.setValueAtTime(0.0001, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.22, now + 0.02)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.65)
|
||||
|
||||
oscillator.connect(gain)
|
||||
gain.connect(audioContext.destination)
|
||||
oscillator.start(now)
|
||||
oscillator.stop(now + 0.7)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DURATIONS,
|
||||
createTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
selectPhase,
|
||||
startTimer,
|
||||
tickTimer,
|
||||
} from './timer'
|
||||
|
||||
describe('pomodoro timer model', () => {
|
||||
it('starts with a 25-minute focus segment', () => {
|
||||
const timer = createTimer()
|
||||
|
||||
expect(timer).toMatchObject({
|
||||
phase: 'focus',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.focus,
|
||||
completedFocusSessions: 0,
|
||||
endAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives remaining time from the absolute end timestamp', () => {
|
||||
const running = startTimer(createTimer(), 1_000)
|
||||
|
||||
expect(tickTimer(running, 61_000).remainingMs).toBe(DURATIONS.focus - 60_000)
|
||||
})
|
||||
|
||||
it('pauses and resumes without losing the remaining duration', () => {
|
||||
const running = startTimer(createTimer(), 1_000)
|
||||
const paused = pauseTimer(running, 61_000)
|
||||
const resumed = startTimer(paused, 121_000)
|
||||
|
||||
expect(paused.status).toBe('paused')
|
||||
expect(paused.remainingMs).toBe(DURATIONS.focus - 60_000)
|
||||
expect(resumed.endAt).toBe(121_000 + DURATIONS.focus - 60_000)
|
||||
})
|
||||
|
||||
it('moves to a short break after a completed focus session', () => {
|
||||
const running = startTimer(createTimer(), 0)
|
||||
const completed = tickTimer(running, DURATIONS.focus)
|
||||
|
||||
expect(completed).toMatchObject({
|
||||
phase: 'shortBreak',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.shortBreak,
|
||||
completedFocusSessions: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('moves to a long break after every fourth focus session', () => {
|
||||
let timer = createTimer()
|
||||
|
||||
for (let session = 0; session < 4; session += 1) {
|
||||
timer = tickTimer(startTimer(timer, 0), timer.remainingMs)
|
||||
if (session < 3) {
|
||||
timer = tickTimer(startTimer(timer, 0), timer.remainingMs)
|
||||
}
|
||||
}
|
||||
|
||||
expect(timer).toMatchObject({
|
||||
phase: 'longBreak',
|
||||
completedFocusSessions: 4,
|
||||
remainingMs: DURATIONS.longBreak,
|
||||
})
|
||||
})
|
||||
|
||||
it('resets the active segment while preserving completed sessions', () => {
|
||||
const timer = {
|
||||
...selectPhase(createTimer(), 'shortBreak'),
|
||||
completedFocusSessions: 2,
|
||||
remainingMs: 12_000,
|
||||
status: 'paused' as const,
|
||||
}
|
||||
|
||||
expect(resetTimer(timer)).toMatchObject({
|
||||
phase: 'shortBreak',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.shortBreak,
|
||||
completedFocusSessions: 2,
|
||||
endAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('allows selecting any segment without changing the session count', () => {
|
||||
const selected = selectPhase(
|
||||
{ ...createTimer(), completedFocusSessions: 3 },
|
||||
'longBreak',
|
||||
)
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
phase: 'longBreak',
|
||||
remainingMs: DURATIONS.longBreak,
|
||||
completedFocusSessions: 3,
|
||||
status: 'idle',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
export type TimerPhase = 'focus' | 'shortBreak' | 'longBreak'
|
||||
export type TimerStatus = 'idle' | 'running' | 'paused'
|
||||
|
||||
export const DURATIONS: Record<TimerPhase, number> = {
|
||||
focus: 25 * 60 * 1_000,
|
||||
shortBreak: 5 * 60 * 1_000,
|
||||
longBreak: 15 * 60 * 1_000,
|
||||
}
|
||||
|
||||
export interface TimerState {
|
||||
phase: TimerPhase
|
||||
status: TimerStatus
|
||||
remainingMs: number
|
||||
completedFocusSessions: number
|
||||
endAt: number | null
|
||||
}
|
||||
|
||||
export function createTimer(): TimerState {
|
||||
return {
|
||||
phase: 'focus',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.focus,
|
||||
completedFocusSessions: 0,
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function startTimer(timer: TimerState, now: number): TimerState {
|
||||
if (timer.status === 'running') {
|
||||
return timer
|
||||
}
|
||||
|
||||
return {
|
||||
...timer,
|
||||
status: 'running',
|
||||
endAt: now + timer.remainingMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseTimer(timer: TimerState, now: number): TimerState {
|
||||
if (timer.status !== 'running' || timer.endAt === null) {
|
||||
return timer
|
||||
}
|
||||
|
||||
return {
|
||||
...timer,
|
||||
status: 'paused',
|
||||
remainingMs: Math.max(0, timer.endAt - now),
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetTimer(timer: TimerState): TimerState {
|
||||
return {
|
||||
...timer,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[timer.phase],
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectPhase(
|
||||
timer: TimerState,
|
||||
phase: TimerPhase,
|
||||
): TimerState {
|
||||
return {
|
||||
...timer,
|
||||
phase,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[phase],
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function tickTimer(timer: TimerState, now: number): TimerState {
|
||||
if (timer.status !== 'running' || timer.endAt === null) {
|
||||
return timer
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, timer.endAt - now)
|
||||
if (remainingMs > 0) {
|
||||
return { ...timer, remainingMs }
|
||||
}
|
||||
|
||||
return completeSegment(timer)
|
||||
}
|
||||
|
||||
function completeSegment(timer: TimerState): TimerState {
|
||||
if (timer.phase === 'focus') {
|
||||
const completedFocusSessions = timer.completedFocusSessions + 1
|
||||
const phase =
|
||||
completedFocusSessions % 4 === 0 ? 'longBreak' : 'shortBreak'
|
||||
|
||||
return {
|
||||
phase,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[phase],
|
||||
completedFocusSessions,
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'focus',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.focus,
|
||||
completedFocusSessions: timer.completedFocusSessions,
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
color: #f8f7ff;
|
||||
background: #15142a;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 3px solid rgba(255, 255, 255, 0.92);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
afterEach(cleanup)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user