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