feat: build initial pomodoro timer

This commit is contained in:
2026-07-22 22:38:17 +03:00
commit 128bdc5e16
22 changed files with 5058 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
playwright-report/
test-results/
*.local
.DS_Store
+1
View File
@@ -0,0 +1 @@
This project is attempt to create personal pomodoro timer.
+21
View File
@@ -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.
+32
View File
@@ -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)
+25
View File
@@ -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
View File
@@ -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>
+3961
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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
View File
@@ -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;
}
}
+81
View File
@@ -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
View File
@@ -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
+46
View File
@@ -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)
},
}
+100
View File
@@ -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',
})
})
})
+110
View File
@@ -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,
}
}
+36
View File
@@ -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;
}
+10
View File
@@ -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>,
)
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(cleanup)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -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"]
}
+11
View File
@@ -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,
},
})