[verified] feat: add timer customization and themes
This commit is contained in:
@@ -12,6 +12,17 @@ A browser-based Pomodoro timer and clock built with React, TypeScript, and Vite.
|
||||
- Accurate countdown after background-tab throttling
|
||||
- Sound notification when a segment ends
|
||||
|
||||
## Stage 2
|
||||
|
||||
- Custom focus, short-break, and long-break durations
|
||||
- Six built-in backgrounds: three gradients and three local illustrated scenes
|
||||
- Adjustable background overlay and text color
|
||||
- Four clock and timer font styles
|
||||
- Live customization preview with one-click reset
|
||||
- Automatic settings persistence in IndexedDB
|
||||
|
||||
All preferences stay in the browser. No account or backend is required.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -27,6 +38,14 @@ npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- React and TypeScript for the interface and timer state
|
||||
- Absolute timestamps for accurate countdowns after tab throttling
|
||||
- Native IndexedDB for persistent browser settings
|
||||
- CSS custom properties for live themes, fonts, colors, and overlays
|
||||
- Vitest and Testing Library for model, persistence, and interface tests
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
Generated
+11
@@ -23,6 +23,7 @@
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -2236,6 +2237,16 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
|
||||
<title id="title">Mountain dusk</title>
|
||||
<desc id="description">Layered mountains beneath a warm dusk sky</desc>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#27335d"/>
|
||||
<stop offset="0.52" stop-color="#8c5e78"/>
|
||||
<stop offset="1" stop-color="#e9a67e"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="sun" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0" stop-color="#ffe6b5" stop-opacity=".92"/>
|
||||
<stop offset="1" stop-color="#ffbd8a" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="front" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop stop-color="#202644"/>
|
||||
<stop offset="1" stop-color="#12172e"/>
|
||||
</linearGradient>
|
||||
<filter id="blur"><feGaussianBlur stdDeviation="28"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#sky)"/>
|
||||
<circle cx="1150" cy="275" r="210" fill="url(#sun)" filter="url(#blur)"/>
|
||||
<circle cx="1150" cy="275" r="62" fill="#ffe0b3" opacity=".86"/>
|
||||
<path d="M0 610 220 438l180 122 255-266 235 240 200-178 290 254 220-118v408H0Z" fill="#48506d" opacity=".72"/>
|
||||
<path d="M0 702 280 520l212 148 302-250 238 202 190-116 378 206v190H0Z" fill="#2c324e" opacity=".9"/>
|
||||
<path d="M0 790 260 650l255 102 260-194 284 144 212-86 329 132v152H0Z" fill="url(#front)"/>
|
||||
<path d="M0 730c240-56 410-38 634 8s409 45 966-38v200H0Z" fill="#12162a" opacity=".58"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
|
||||
<title id="title">Forest stillness</title>
|
||||
<desc id="description">A quiet layered evergreen forest in soft mist</desc>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#507c78"/>
|
||||
<stop offset=".55" stop-color="#8daf93"/>
|
||||
<stop offset="1" stop-color="#d2c6a4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#315b52"/>
|
||||
<stop offset="1" stop-color="#102c2c"/>
|
||||
</linearGradient>
|
||||
<filter id="mist"><feGaussianBlur stdDeviation="22"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#sky)"/>
|
||||
<circle cx="1160" cy="210" r="165" fill="#f7e5b5" opacity=".32" filter="url(#mist)"/>
|
||||
<g fill="#547c68" opacity=".6">
|
||||
<path d="m80 590 130-300 130 300h-76l95 196H61l96-196Z"/>
|
||||
<path d="m390 610 110-260 110 260h-66l84 174H372l83-174Z"/>
|
||||
<path d="m1260 600 125-300 125 300h-74l90 184h-282l91-184Z"/>
|
||||
<path d="m980 605 100-235 101 235h-60l75 154H966l74-154Z"/>
|
||||
</g>
|
||||
<path d="M0 590c340-90 634-45 920 17s489 25 680-34v327H0Z" fill="#b9c3a5" opacity=".32"/>
|
||||
<g fill="#234b43" opacity=".88">
|
||||
<path d="m-20 725 178-430 179 430H224l128 175H-35l128-175Z"/>
|
||||
<path d="m525 725 160-388 161 388H744l113 175H514l114-175Z"/>
|
||||
<path d="m1110 725 183-445 184 445h-116l130 175h-397l130-175Z"/>
|
||||
</g>
|
||||
<path d="M0 760c270-78 540-46 790 12 252 59 498 45 810-30v158H0Z" fill="url(#ground)"/>
|
||||
<g fill="#153934">
|
||||
<path d="m240 820 92-233 92 233h-58l62 80H236l62-80Z"/>
|
||||
<path d="m880 830 105-269 106 269h-67l72 70H874l72-70Z"/>
|
||||
<path d="m1430 835 75-190 76 190h-48l50 65h-156l51-65Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title description">
|
||||
<title id="title">Open ocean</title>
|
||||
<desc id="description">A calm blue ocean beneath a glowing horizon</desc>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#122b52"/>
|
||||
<stop offset=".62" stop-color="#397f9c"/>
|
||||
<stop offset="1" stop-color="#9bd1ca"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#2d8096"/>
|
||||
<stop offset="1" stop-color="#092944"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow">
|
||||
<stop stop-color="#f7e6bd" stop-opacity=".74"/>
|
||||
<stop offset="1" stop-color="#f7e6bd" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="blur"><feGaussianBlur stdDeviation="28"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#sky)"/>
|
||||
<ellipse cx="480" cy="390" rx="280" ry="185" fill="url(#glow)" filter="url(#blur)"/>
|
||||
<path d="M0 468c167 12 273-19 422 1s247 2 389-4 256 19 409 1 238-8 380 7v427H0Z" fill="url(#sea)"/>
|
||||
<g fill="none" stroke="#a4d7d3" stroke-linecap="round">
|
||||
<path d="M70 555c164-27 270 18 422-4s277 17 450-4 291 17 574-2" opacity=".34" stroke-width="5"/>
|
||||
<path d="M-20 655c191-35 322 30 500 1s334 20 532-7 314 25 626-5" opacity=".22" stroke-width="7"/>
|
||||
<path d="M80 765c152-30 299 18 459-5s292 23 470-3 295 18 516-4" opacity=".14" stroke-width="8"/>
|
||||
</g>
|
||||
<path d="M424 468h112l118 432H275Z" fill="#f2d8ac" opacity=".08"/>
|
||||
<g fill="none" stroke="#d9edf0" stroke-width="4" opacity=".38">
|
||||
<path d="m1190 230 28-18 28 18"/>
|
||||
<path d="m1250 270 23-15 24 15"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
+336
-18
@@ -8,29 +8,44 @@
|
||||
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%);
|
||||
background: #15142a;
|
||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||
isolation: isolate;
|
||||
transition: background 400ms ease;
|
||||
transition: color 250ms ease;
|
||||
}
|
||||
|
||||
.app::before,
|
||||
.app::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -3;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app::before {
|
||||
background-image: var(--background-image);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
transition: background-image 350ms ease;
|
||||
}
|
||||
|
||||
.app::after {
|
||||
z-index: -2;
|
||||
background: rgb(7 8 20 / var(--overlay-opacity, 0.18));
|
||||
transition: background 250ms 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 {
|
||||
@@ -63,12 +78,43 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.customize-button,
|
||||
.icon-button,
|
||||
.reset-settings {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.customize-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 999px;
|
||||
background: rgba(13, 12, 30, 0.25);
|
||||
backdrop-filter: blur(14px);
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.customize-button:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #fff;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
color: inherit;
|
||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -90,9 +136,9 @@
|
||||
padding: 9px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
color: inherit;
|
||||
background: rgba(13, 12, 30, 0.25);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||
font-variant-numeric: tabular-nums;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
@@ -122,7 +168,7 @@
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||
font-size: clamp(2rem, 5vw, 3.1rem);
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
@@ -192,7 +238,7 @@ h1 {
|
||||
.countdown {
|
||||
display: block;
|
||||
margin: 6px 0 18px;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-family: var(--app-font, 'Space Grotesk', sans-serif);
|
||||
font-size: clamp(5rem, 15vw, 8.8rem);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
@@ -254,7 +300,7 @@ h1 {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
footer {
|
||||
.app > footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
@@ -262,15 +308,287 @@ footer {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(4, 5, 15, 0.54);
|
||||
backdrop-filter: blur(10px);
|
||||
animation: fade-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
width: min(100%, 480px);
|
||||
height: 100%;
|
||||
color: #f8f7ff;
|
||||
background: linear-gradient(160deg, rgba(34, 32, 58, 0.98), rgba(17, 17, 33, 0.99));
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: -30px 0 90px rgba(2, 3, 12, 0.4);
|
||||
animation: slide-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.settings-header,
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.settings-kicker {
|
||||
margin: 0 0 3px;
|
||||
color: #a990ff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.65rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0 0 3px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-scroll {
|
||||
overflow-y: auto;
|
||||
padding: 6px 28px 28px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.section-heading > span,
|
||||
.settings-footer > span {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.duration-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.duration-grid label,
|
||||
.visual-controls > label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.duration-grid input,
|
||||
.visual-controls select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.065);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.duration-grid input:focus,
|
||||
.visual-controls select:focus {
|
||||
border-color: #9175f7;
|
||||
box-shadow: 0 0 0 3px rgba(145, 117, 247, 0.14);
|
||||
}
|
||||
|
||||
.background-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.background-option {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 5px 5px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 13px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
transition: 160ms ease;
|
||||
}
|
||||
|
||||
.background-option:hover,
|
||||
.background-option.selected {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.background-option:focus-within {
|
||||
outline: 2px solid #b6a5ff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.background-option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.background-swatch {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1.55;
|
||||
border-radius: 9px;
|
||||
background: var(--preview);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.background-option.selected .background-swatch {
|
||||
box-shadow: inset 0 0 0 2px #b6a5ff, 0 5px 18px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.visual-controls {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.visual-controls label > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.visual-controls strong {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.visual-controls input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: #8d6bff;
|
||||
}
|
||||
|
||||
.color-control {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-input-wrap {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.color-input-wrap input {
|
||||
width: 34px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input-wrap code {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.reset-settings {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.reset-settings:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(24px); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.app {
|
||||
padding: 22px 16px 20px;
|
||||
}
|
||||
|
||||
.customize-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.customize-button span {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-header,
|
||||
.settings-footer {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-scroll {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.duration-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timer-card {
|
||||
padding: 28px 18px;
|
||||
border-radius: 28px;
|
||||
@@ -295,7 +613,7 @@ footer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
.app > footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
+213
-9
@@ -1,14 +1,53 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from './App'
|
||||
import type { AppSettings } from './features/settings/settings'
|
||||
import { DEFAULT_SETTINGS } from './features/settings/settings'
|
||||
import type { SettingsStore } from './features/settings/settings-store'
|
||||
import type { CompletionSound } from './features/timer/sound'
|
||||
import appCss from './App.css?raw'
|
||||
|
||||
function renderApp() {
|
||||
class MemorySettingsStore implements SettingsStore {
|
||||
constructor(public settings: AppSettings | null = null) {}
|
||||
|
||||
async load() {
|
||||
return this.settings
|
||||
}
|
||||
|
||||
async save(settings: AppSettings) {
|
||||
this.settings = settings
|
||||
}
|
||||
}
|
||||
|
||||
const pendingStore: SettingsStore = {
|
||||
load: () => new Promise(() => undefined),
|
||||
save: async () => undefined,
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function renderApp(settingsStore: SettingsStore = pendingStore) {
|
||||
const sound: CompletionSound = {
|
||||
prepare: vi.fn(),
|
||||
play: vi.fn(),
|
||||
}
|
||||
render(<App sound={sound} />)
|
||||
render(<App settingsStore={settingsStore} sound={sound} />)
|
||||
return sound
|
||||
}
|
||||
|
||||
async function renderHydratedApp(settingsStore: SettingsStore = new MemorySettingsStore()) {
|
||||
const sound = renderApp(settingsStore)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
return sound
|
||||
}
|
||||
|
||||
@@ -31,8 +70,8 @@ describe('Pomodoro app', () => {
|
||||
expect(screen.getByText('0 / 4 sessions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts, pauses, resumes, and resets the countdown', () => {
|
||||
const sound = renderApp()
|
||||
it('starts, pauses, resumes, and resets the countdown', async () => {
|
||||
const sound = await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||
expect(sound.prepare).toHaveBeenCalledOnce()
|
||||
@@ -54,8 +93,8 @@ describe('Pomodoro app', () => {
|
||||
expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('announces completion and switches to a short break', () => {
|
||||
const sound = renderApp()
|
||||
it('announces completion and switches to a short break', async () => {
|
||||
const sound = await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start' }))
|
||||
act(() => vi.advanceTimersByTime(25 * 60_000))
|
||||
@@ -66,8 +105,8 @@ describe('Pomodoro app', () => {
|
||||
expect(screen.getByText('1 / 4 sessions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the user select focus and break segments', () => {
|
||||
renderApp()
|
||||
it('lets the user select focus and break segments', async () => {
|
||||
await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Long break' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('15:00')
|
||||
@@ -78,4 +117,169 @@ describe('Pomodoro app', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Focus' }))
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
||||
})
|
||||
|
||||
it('customizes timer durations from the settings panel', async () => {
|
||||
await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } })
|
||||
fireEvent.change(screen.getByLabelText('Short break minutes'), { target: { value: '8' } })
|
||||
fireEvent.change(screen.getByLabelText('Long break minutes'), { target: { value: '25' } })
|
||||
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00')
|
||||
expect(screen.getByText('40 min focus')).toBeInTheDocument()
|
||||
expect(screen.getByText('8 min short break')).toBeInTheDocument()
|
||||
expect(screen.getByText('25 min long break')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies background, overlay, font, and text color customization', async () => {
|
||||
await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Forest stillness' }))
|
||||
fireEvent.change(screen.getByLabelText('Clock font'), { target: { value: 'serif' } })
|
||||
fireEvent.change(screen.getByLabelText('Overlay darkness'), { target: { value: '0.4' } })
|
||||
fireEvent.change(screen.getByLabelText('Text color'), { target: { value: '#fff2d6' } })
|
||||
|
||||
const app = screen.getByTestId('app')
|
||||
expect(app.style.getPropertyValue('--background-image')).toContain('forest.svg')
|
||||
expect(app.style.getPropertyValue('--overlay-opacity')).toBe('0.4')
|
||||
expect(app.style.getPropertyValue('--app-font')).toContain('Georgia')
|
||||
expect(app).toHaveStyle({ color: '#fff2d6' })
|
||||
})
|
||||
|
||||
it('restores and persists customization through the settings store', async () => {
|
||||
vi.useRealTimers()
|
||||
const store = new MemorySettingsStore({
|
||||
...DEFAULT_SETTINGS,
|
||||
focusMinutes: 45,
|
||||
backgroundId: 'ocean',
|
||||
textColor: '#dff8ff',
|
||||
})
|
||||
|
||||
await renderHydratedApp(store)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('45:00')
|
||||
})
|
||||
expect(screen.getByTestId('app').style.getPropertyValue('--background-image')).toContain('ocean.svg')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '50' } })
|
||||
|
||||
await waitFor(() => expect(store.settings?.focusMinutes).toBe(50))
|
||||
})
|
||||
|
||||
it('resets customization to defaults', async () => {
|
||||
await renderHydratedApp()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset customization' }))
|
||||
|
||||
expect(screen.getByLabelText('Focus minutes')).toHaveValue(25)
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('25:00')
|
||||
})
|
||||
|
||||
it('gates timer and customization controls until settings hydration completes', async () => {
|
||||
const load = deferred<AppSettings | null>()
|
||||
const store: SettingsStore = {
|
||||
load: () => load.promise,
|
||||
save: vi.fn(async () => undefined),
|
||||
}
|
||||
renderApp(store)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Customize' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Long break' })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ ...DEFAULT_SETTINGS, focusMinutes: 40 })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Customize' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00')
|
||||
})
|
||||
|
||||
it('does not persist defaults after load failure and reports that changes will not be saved', async () => {
|
||||
const save = vi.fn(async () => undefined)
|
||||
const store: SettingsStore = {
|
||||
load: async () => { throw new Error('IndexedDB unavailable') },
|
||||
save,
|
||||
}
|
||||
|
||||
await renderHydratedApp(store)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
expect(screen.getByText(/settings unavailable/i)).toHaveAttribute('role', 'status')
|
||||
expect(screen.queryByText('Changes save automatically')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } })
|
||||
expect(screen.getByLabelText('Time remaining')).toHaveTextContent('40:00')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps settings saves serialized so the latest change wins', async () => {
|
||||
const pendingUserSaves: Array<ReturnType<typeof deferred<void>>> = []
|
||||
const savedFocusMinutes: number[] = []
|
||||
const store: SettingsStore = {
|
||||
load: async () => null,
|
||||
save: (settings) => {
|
||||
if (settings.focusMinutes === DEFAULT_SETTINGS.focusMinutes) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
savedFocusMinutes.push(settings.focusMinutes)
|
||||
const pending = deferred<void>()
|
||||
pendingUserSaves.push(pending)
|
||||
return pending.promise
|
||||
},
|
||||
}
|
||||
await renderHydratedApp(store)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize' }))
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '40' } })
|
||||
fireEvent.change(screen.getByLabelText('Focus minutes'), { target: { value: '41' } })
|
||||
|
||||
expect(savedFocusMinutes).toEqual([40])
|
||||
|
||||
await act(async () => {
|
||||
pendingUserSaves[0].resolve()
|
||||
await pendingUserSaves[0].promise
|
||||
})
|
||||
expect(savedFocusMinutes).toEqual([40, 41])
|
||||
|
||||
await act(async () => {
|
||||
pendingUserSaves[1].resolve()
|
||||
await pendingUserSaves[1].promise
|
||||
})
|
||||
})
|
||||
|
||||
it('contains keyboard focus in the modal, closes on Escape, and restores the trigger', async () => {
|
||||
await renderHydratedApp()
|
||||
const customize = screen.getByRole('button', { name: 'Customize' })
|
||||
fireEvent.click(customize)
|
||||
|
||||
const close = screen.getByRole('button', { name: 'Close customization' })
|
||||
const reset = screen.getByRole('button', { name: 'Reset customization' })
|
||||
expect(close).toHaveFocus()
|
||||
|
||||
reset.focus()
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Tab' })
|
||||
expect(close).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Tab', shiftKey: true })
|
||||
expect(reset).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(customize).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows a keyboard focus indicator around visually hidden background radios', () => {
|
||||
expect(appCss).toMatch(/\.background-option:focus-within\s*\{/)
|
||||
})
|
||||
})
|
||||
|
||||
+136
-7
@@ -1,5 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import './App.css'
|
||||
import SettingsPanel from './features/settings/SettingsPanel'
|
||||
import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './features/settings/options'
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
normalizeSettings,
|
||||
settingsToDurations,
|
||||
type AppSettings,
|
||||
} from './features/settings/settings'
|
||||
import {
|
||||
indexedDbSettingsStore,
|
||||
type SettingsStore,
|
||||
} from './features/settings/settings-store'
|
||||
import {
|
||||
createTimer,
|
||||
pauseTimer,
|
||||
@@ -7,6 +19,7 @@ import {
|
||||
selectPhase,
|
||||
startTimer,
|
||||
tickTimer,
|
||||
updateDurations,
|
||||
type TimerPhase,
|
||||
} from './features/timer/timer'
|
||||
import {
|
||||
@@ -27,6 +40,7 @@ const PHASE_BUTTONS: Array<{ phase: TimerPhase; label: string }> = [
|
||||
]
|
||||
|
||||
interface AppProps {
|
||||
settingsStore?: SettingsStore
|
||||
sound?: CompletionSound
|
||||
}
|
||||
|
||||
@@ -45,10 +59,78 @@ function formatClock(date: Date): string {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function App({ sound = completionSound }: AppProps) {
|
||||
function App({
|
||||
settingsStore = indexedDbSettingsStore,
|
||||
sound = completionSound,
|
||||
}: AppProps) {
|
||||
const [timer, setTimer] = useState(createTimer)
|
||||
const [currentTime, setCurrentTime] = useState(() => new Date())
|
||||
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
|
||||
const [persistenceState, setPersistenceState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const completionPending = useRef(false)
|
||||
const customizeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const saveInFlight = useRef(false)
|
||||
const pendingSettingsSave = useRef<AppSettings | null>(null)
|
||||
|
||||
const updateSettings = useCallback((next: AppSettings) => {
|
||||
const normalized = normalizeSettings(next)
|
||||
const durations = settingsToDurations(normalized)
|
||||
setSettings(normalized)
|
||||
setTimer((current) => {
|
||||
const unchanged = Object.entries(durations).every(
|
||||
([phase, duration]) => current.durations[phase as TimerPhase] === duration,
|
||||
)
|
||||
return unchanged ? current : updateDurations(current, durations)
|
||||
})
|
||||
return normalized
|
||||
}, [])
|
||||
|
||||
const saveLatestSettings = useCallback((next: AppSettings) => {
|
||||
pendingSettingsSave.current = next
|
||||
if (saveInFlight.current) return
|
||||
|
||||
saveInFlight.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
while (pendingSettingsSave.current) {
|
||||
const settingsToSave = pendingSettingsSave.current
|
||||
pendingSettingsSave.current = null
|
||||
await settingsStore.save(settingsToSave)
|
||||
}
|
||||
} catch {
|
||||
pendingSettingsSave.current = null
|
||||
setPersistenceState('error')
|
||||
} finally {
|
||||
saveInFlight.current = false
|
||||
}
|
||||
})()
|
||||
}, [settingsStore])
|
||||
|
||||
const applySettings = useCallback((next: AppSettings) => {
|
||||
const normalized = updateSettings(next)
|
||||
if (persistenceState === 'ready') {
|
||||
saveLatestSettings(normalized)
|
||||
}
|
||||
}, [persistenceState, saveLatestSettings, updateSettings])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
settingsStore.load()
|
||||
.then((stored) => {
|
||||
if (!active) return
|
||||
if (stored) updateSettings(stored)
|
||||
setPersistenceState('ready')
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPersistenceState('error')
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [settingsStore, updateSettings])
|
||||
|
||||
|
||||
const updateTimer = useCallback(() => {
|
||||
setTimer((current) => {
|
||||
@@ -99,14 +181,32 @@ function App({ sound = completionSound }: AppProps) {
|
||||
setTimer((current) => startTimer(current, Date.now()))
|
||||
}
|
||||
|
||||
const closeSettings = () => {
|
||||
setSettingsOpen(false)
|
||||
customizeButtonRef.current?.focus()
|
||||
}
|
||||
|
||||
const settingsHydrated = persistenceState !== 'loading'
|
||||
|
||||
const cycleProgress = timer.completedFocusSessions % 4
|
||||
const visibleProgress =
|
||||
timer.phase === 'longBreak' && cycleProgress === 0 && timer.completedFocusSessions > 0
|
||||
? 4
|
||||
: cycleProgress
|
||||
|
||||
const selectedBackground = BACKGROUND_OPTIONS.find(
|
||||
(background) => background.id === settings.backgroundId,
|
||||
) ?? BACKGROUND_OPTIONS[0]
|
||||
const selectedFont = FONT_OPTIONS.find((font) => font.id === settings.fontId) ?? FONT_OPTIONS[0]
|
||||
const appStyle = {
|
||||
'--app-font': selectedFont.css,
|
||||
'--background-image': selectedBackground.css,
|
||||
'--overlay-opacity': String(settings.overlayOpacity),
|
||||
color: settings.textColor,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<main className={`app phase-${timer.phase}`}>
|
||||
<main className={`app phase-${timer.phase}`} data-testid="app" style={appStyle}>
|
||||
<div className="orb orb-one" aria-hidden="true" />
|
||||
<div className="orb orb-two" aria-hidden="true" />
|
||||
|
||||
@@ -115,9 +215,21 @@ function App({ sound = completionSound }: AppProps) {
|
||||
<span className="brand-mark" aria-hidden="true">P</span>
|
||||
<span>pomodoro</span>
|
||||
</a>
|
||||
<div className="topbar-actions">
|
||||
<button
|
||||
className="customize-button"
|
||||
disabled={!settingsHydrated}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
ref={customizeButtonRef}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">✦</span>
|
||||
Customize
|
||||
</button>
|
||||
<time className="clock" aria-label="Current time">
|
||||
{formatClock(currentTime)}
|
||||
</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="timer-card" aria-live="polite">
|
||||
@@ -133,6 +245,7 @@ function App({ sound = completionSound }: AppProps) {
|
||||
{PHASE_BUTTONS.map(({ phase, label }) => (
|
||||
<button
|
||||
className={timer.phase === phase ? 'active' : ''}
|
||||
disabled={!settingsHydrated}
|
||||
key={phase}
|
||||
onClick={() => setTimer((current) => selectPhase(current, phase))}
|
||||
type="button"
|
||||
@@ -156,7 +269,12 @@ function App({ sound = completionSound }: AppProps) {
|
||||
</output>
|
||||
|
||||
<div className="actions">
|
||||
<button className="primary-action" onClick={handlePrimaryAction} type="button">
|
||||
<button
|
||||
className="primary-action"
|
||||
disabled={!settingsHydrated}
|
||||
onClick={handlePrimaryAction}
|
||||
type="button"
|
||||
>
|
||||
{timer.status === 'running'
|
||||
? 'Pause'
|
||||
: timer.status === 'paused'
|
||||
@@ -165,6 +283,7 @@ function App({ sound = completionSound }: AppProps) {
|
||||
</button>
|
||||
<button
|
||||
className="secondary-action"
|
||||
disabled={!settingsHydrated}
|
||||
onClick={() => setTimer((current) => resetTimer(current))}
|
||||
type="button"
|
||||
>
|
||||
@@ -183,12 +302,22 @@ function App({ sound = completionSound }: AppProps) {
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>25 min focus</span>
|
||||
<span>{settings.focusMinutes} min focus</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>5 min short break</span>
|
||||
<span>{settings.shortBreakMinutes} min short break</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>15 min long break</span>
|
||||
<span>{settings.longBreakMinutes} min long break</span>
|
||||
</footer>
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsPanel
|
||||
onChange={applySettings}
|
||||
onClose={closeSettings}
|
||||
onReset={() => applySettings(DEFAULT_SETTINGS)}
|
||||
persistenceError={persistenceState === 'error'}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from 'react'
|
||||
import { BACKGROUND_OPTIONS, FONT_OPTIONS } from './options'
|
||||
import type { AppSettings } from './settings'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
settings: AppSettings
|
||||
persistenceError: boolean
|
||||
onChange: (settings: AppSettings) => void
|
||||
onClose: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[href]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
function SettingsPanel({ settings, persistenceError, onChange, onClose, onReset }: SettingsPanelProps) {
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
closeButtonRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') return
|
||||
|
||||
const focusable = Array.from(
|
||||
panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [],
|
||||
)
|
||||
const first = focusable[0]
|
||||
const last = focusable.at(-1)
|
||||
if (!first || !last) return
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
onChange({ ...settings, [key]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-backdrop" role="presentation">
|
||||
<aside
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
className="settings-panel"
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
>
|
||||
<header className="settings-header">
|
||||
<div>
|
||||
<p className="settings-kicker">Make it yours</p>
|
||||
<h2 id="settings-title">Customize</h2>
|
||||
</div>
|
||||
<button aria-label="Close customization" className="icon-button" onClick={onClose} ref={closeButtonRef} type="button">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="settings-scroll">
|
||||
<section className="settings-section">
|
||||
<div className="section-heading">
|
||||
<h3>Timer</h3>
|
||||
<span>minutes</span>
|
||||
</div>
|
||||
<div className="duration-grid">
|
||||
<label>
|
||||
<span>Focus minutes</span>
|
||||
<input
|
||||
max="120"
|
||||
min="1"
|
||||
onChange={(event) => update('focusMinutes', Number(event.target.value))}
|
||||
type="number"
|
||||
value={settings.focusMinutes}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Short break minutes</span>
|
||||
<input
|
||||
max="60"
|
||||
min="1"
|
||||
onChange={(event) => update('shortBreakMinutes', Number(event.target.value))}
|
||||
type="number"
|
||||
value={settings.shortBreakMinutes}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Long break minutes</span>
|
||||
<input
|
||||
max="90"
|
||||
min="1"
|
||||
onChange={(event) => update('longBreakMinutes', Number(event.target.value))}
|
||||
type="number"
|
||||
value={settings.longBreakMinutes}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="section-heading">
|
||||
<h3>Background</h3>
|
||||
<span>live preview</span>
|
||||
</div>
|
||||
<div className="background-grid">
|
||||
{BACKGROUND_OPTIONS.map((background) => (
|
||||
<label
|
||||
className={`background-option ${settings.backgroundId === background.id ? 'selected' : ''}`}
|
||||
key={background.id}
|
||||
style={{ '--preview': background.preview } as CSSProperties}
|
||||
>
|
||||
<input
|
||||
checked={settings.backgroundId === background.id}
|
||||
name="background"
|
||||
onChange={() => update('backgroundId', background.id)}
|
||||
type="radio"
|
||||
/>
|
||||
<span className="background-swatch" aria-hidden="true" />
|
||||
<span>{background.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section visual-controls">
|
||||
<label>
|
||||
<span>Clock font</span>
|
||||
<select onChange={(event) => update('fontId', event.target.value as AppSettings['fontId'])} value={settings.fontId}>
|
||||
{FONT_OPTIONS.map((font) => <option key={font.id} value={font.id}>{font.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Overlay darkness <strong aria-hidden="true">{Math.round(settings.overlayOpacity * 100)}%</strong></span>
|
||||
<input
|
||||
aria-label="Overlay darkness"
|
||||
max="0.8"
|
||||
min="0"
|
||||
onChange={(event) => update('overlayOpacity', Number(event.target.value))}
|
||||
step="0.05"
|
||||
type="range"
|
||||
value={settings.overlayOpacity}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="color-control">
|
||||
<span>Text color</span>
|
||||
<span className="color-input-wrap">
|
||||
<input
|
||||
aria-label="Text color"
|
||||
onChange={(event) => update('textColor', event.target.value)}
|
||||
type="color"
|
||||
value={settings.textColor}
|
||||
/>
|
||||
<code>{settings.textColor.toUpperCase()}</code>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="settings-footer">
|
||||
<button className="reset-settings" onClick={onReset} type="button">Reset customization</button>
|
||||
<span role="status">
|
||||
{persistenceError
|
||||
? 'Settings unavailable — changes won’t be saved'
|
||||
: 'Changes save automatically'}
|
||||
</span>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPanel
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BackgroundId, FontId } from './settings'
|
||||
|
||||
export const BACKGROUND_OPTIONS: Array<{
|
||||
id: BackgroundId
|
||||
label: string
|
||||
css: string
|
||||
preview: string
|
||||
}> = [
|
||||
{
|
||||
id: 'violet',
|
||||
label: 'Violet haze',
|
||||
css: 'radial-gradient(circle at 72% 18%, #8a416f 0%, transparent 38%), linear-gradient(135deg, #15142a 0%, #31265b 52%, #17162e 100%)',
|
||||
preview: 'linear-gradient(135deg, #19172f, #684167)',
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
label: 'Soft sunset',
|
||||
css: 'radial-gradient(circle at 72% 20%, #f09a78 0%, transparent 32%), linear-gradient(145deg, #242140 0%, #8f4b62 56%, #382743 100%)',
|
||||
preview: 'linear-gradient(135deg, #33294e, #ef8a70)',
|
||||
},
|
||||
{
|
||||
id: 'aurora',
|
||||
label: 'Quiet aurora',
|
||||
css: 'radial-gradient(circle at 28% 72%, #2e9f83 0%, transparent 38%), radial-gradient(circle at 78% 18%, #5755a8 0%, transparent 42%), #101e2d',
|
||||
preview: 'linear-gradient(135deg, #183244, #2e9f83)',
|
||||
},
|
||||
{
|
||||
id: 'dusk',
|
||||
label: 'Mountain dusk',
|
||||
css: 'url("/backgrounds/dusk.svg")',
|
||||
preview: 'linear-gradient(135deg, #37416f, #d68a79)',
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
label: 'Forest stillness',
|
||||
css: 'url("/backgrounds/forest.svg")',
|
||||
preview: 'linear-gradient(135deg, #153c3a, #6f9c79)',
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
label: 'Open ocean',
|
||||
css: 'url("/backgrounds/ocean.svg")',
|
||||
preview: 'linear-gradient(135deg, #173f5f, #53b6c3)',
|
||||
},
|
||||
]
|
||||
|
||||
export const FONT_OPTIONS: Array<{ id: FontId; label: string; css: string }> = [
|
||||
{ id: 'space', label: 'Space Grotesk', css: "'Space Grotesk', sans-serif" },
|
||||
{ id: 'sans', label: 'Clean sans', css: "'DM Sans', sans-serif" },
|
||||
{ id: 'serif', label: 'Editorial serif', css: "Georgia, 'Times New Roman', serif" },
|
||||
{ id: 'mono', label: 'Focus mono', css: "ui-monospace, 'SFMono-Regular', Consolas, monospace" },
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
import { normalizeSettings, type AppSettings } from './settings'
|
||||
|
||||
export interface SettingsStore {
|
||||
load: () => Promise<AppSettings | null>
|
||||
save: (settings: AppSettings) => Promise<void>
|
||||
}
|
||||
|
||||
const STORE_NAME = 'preferences'
|
||||
const SETTINGS_KEY = 'current'
|
||||
|
||||
export class IndexedDbSettingsStore implements SettingsStore {
|
||||
constructor(
|
||||
private readonly factory: IDBFactory = globalThis.indexedDB,
|
||||
private readonly databaseName = 'pomodoro',
|
||||
) {}
|
||||
|
||||
async load(): Promise<AppSettings | null> {
|
||||
const database = await this.open()
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readonly')
|
||||
const value = await requestResult(transaction.objectStore(STORE_NAME).get(SETTINGS_KEY))
|
||||
return value === undefined ? null : normalizeSettings(value)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
async save(settings: AppSettings): Promise<void> {
|
||||
const database = await this.open()
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, 'readwrite')
|
||||
transaction.objectStore(STORE_NAME).put(settings, SETTINGS_KEY)
|
||||
await transactionComplete(transaction)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
private open(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.factory.open(this.databaseName, 1)
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('Unable to open settings database'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
|
||||
})
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
|
||||
})
|
||||
}
|
||||
|
||||
export const indexedDbSettingsStore = new IndexedDbSettingsStore()
|
||||
@@ -0,0 +1,70 @@
|
||||
import { IDBFactory } from 'fake-indexeddb'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
normalizeSettings,
|
||||
settingsToDurations,
|
||||
type AppSettings,
|
||||
} from './settings'
|
||||
import { IndexedDbSettingsStore } from './settings-store'
|
||||
|
||||
describe('application settings', () => {
|
||||
it('uses an overlay default aligned with the slider step', () => {
|
||||
expect(DEFAULT_SETTINGS.overlayOpacity).toBe(0.2)
|
||||
})
|
||||
|
||||
it('converts duration settings from minutes to milliseconds', () => {
|
||||
expect(settingsToDurations({
|
||||
...DEFAULT_SETTINGS,
|
||||
focusMinutes: 40,
|
||||
shortBreakMinutes: 8,
|
||||
longBreakMinutes: 25,
|
||||
})).toEqual({
|
||||
focus: 40 * 60_000,
|
||||
shortBreak: 8 * 60_000,
|
||||
longBreak: 25 * 60_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes persisted values to safe supported settings', () => {
|
||||
expect(normalizeSettings({
|
||||
focusMinutes: 999,
|
||||
shortBreakMinutes: 0,
|
||||
longBreakMinutes: 20.8,
|
||||
fontId: 'unknown',
|
||||
backgroundId: 'missing',
|
||||
overlayOpacity: 4,
|
||||
textColor: '<script>',
|
||||
})).toEqual({
|
||||
...DEFAULT_SETTINGS,
|
||||
focusMinutes: 120,
|
||||
shortBreakMinutes: 1,
|
||||
longBreakMinutes: 21,
|
||||
overlayOpacity: 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IndexedDbSettingsStore', () => {
|
||||
it('returns null before any settings have been saved', async () => {
|
||||
const store = new IndexedDbSettingsStore(new IDBFactory(), 'empty-settings')
|
||||
|
||||
await expect(store.load()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('persists and restores settings', async () => {
|
||||
const store = new IndexedDbSettingsStore(new IDBFactory(), 'saved-settings')
|
||||
const settings: AppSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
focusMinutes: 45,
|
||||
fontId: 'serif',
|
||||
backgroundId: 'forest',
|
||||
overlayOpacity: 0.35,
|
||||
textColor: '#fff2d6',
|
||||
}
|
||||
|
||||
await store.save(settings)
|
||||
|
||||
await expect(store.load()).resolves.toEqual(settings)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TimerDurations } from '../timer/timer'
|
||||
|
||||
export const FONT_IDS = ['space', 'sans', 'serif', 'mono'] as const
|
||||
export type FontId = (typeof FONT_IDS)[number]
|
||||
|
||||
export const BACKGROUND_IDS = [
|
||||
'violet',
|
||||
'sunset',
|
||||
'aurora',
|
||||
'dusk',
|
||||
'forest',
|
||||
'ocean',
|
||||
] as const
|
||||
export type BackgroundId = (typeof BACKGROUND_IDS)[number]
|
||||
|
||||
export interface AppSettings {
|
||||
focusMinutes: number
|
||||
shortBreakMinutes: number
|
||||
longBreakMinutes: number
|
||||
fontId: FontId
|
||||
backgroundId: BackgroundId
|
||||
overlayOpacity: number
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
focusMinutes: 25,
|
||||
shortBreakMinutes: 5,
|
||||
longBreakMinutes: 15,
|
||||
fontId: 'space',
|
||||
backgroundId: 'violet',
|
||||
overlayOpacity: 0.2,
|
||||
textColor: '#f8f7ff',
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return fallback
|
||||
}
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(value)))
|
||||
}
|
||||
|
||||
function isOneOf<T extends readonly string[]>(value: unknown, values: T): value is T[number] {
|
||||
return typeof value === 'string' && values.includes(value)
|
||||
}
|
||||
|
||||
export function normalizeSettings(value: unknown): AppSettings {
|
||||
const candidate = value && typeof value === 'object'
|
||||
? value as Partial<Record<keyof AppSettings, unknown>>
|
||||
: {}
|
||||
|
||||
return {
|
||||
focusMinutes: clampInteger(candidate.focusMinutes, 1, 120, DEFAULT_SETTINGS.focusMinutes),
|
||||
shortBreakMinutes: clampInteger(candidate.shortBreakMinutes, 1, 60, DEFAULT_SETTINGS.shortBreakMinutes),
|
||||
longBreakMinutes: clampInteger(candidate.longBreakMinutes, 1, 90, DEFAULT_SETTINGS.longBreakMinutes),
|
||||
fontId: isOneOf(candidate.fontId, FONT_IDS) ? candidate.fontId : DEFAULT_SETTINGS.fontId,
|
||||
backgroundId: isOneOf(candidate.backgroundId, BACKGROUND_IDS)
|
||||
? candidate.backgroundId
|
||||
: DEFAULT_SETTINGS.backgroundId,
|
||||
overlayOpacity:
|
||||
typeof candidate.overlayOpacity === 'number' && Number.isFinite(candidate.overlayOpacity)
|
||||
? Math.min(0.8, Math.max(0, candidate.overlayOpacity))
|
||||
: DEFAULT_SETTINGS.overlayOpacity,
|
||||
textColor:
|
||||
typeof candidate.textColor === 'string' && /^#[0-9a-f]{6}$/i.test(candidate.textColor)
|
||||
? candidate.textColor
|
||||
: DEFAULT_SETTINGS.textColor,
|
||||
}
|
||||
}
|
||||
|
||||
export function settingsToDurations(settings: AppSettings): TimerDurations {
|
||||
return {
|
||||
focus: settings.focusMinutes * 60_000,
|
||||
shortBreak: settings.shortBreakMinutes * 60_000,
|
||||
longBreak: settings.longBreakMinutes * 60_000,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
selectPhase,
|
||||
startTimer,
|
||||
tickTimer,
|
||||
updateDurations,
|
||||
} from './timer'
|
||||
|
||||
describe('pomodoro timer model', () => {
|
||||
@@ -97,4 +98,63 @@ describe('pomodoro timer model', () => {
|
||||
status: 'idle',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom durations for the complete focus and break cycle', () => {
|
||||
const durations = {
|
||||
focus: 30 * 60_000,
|
||||
shortBreak: 7 * 60_000,
|
||||
longBreak: 20 * 60_000,
|
||||
}
|
||||
const timer = createTimer(durations)
|
||||
const completed = tickTimer(startTimer(timer, 0), durations.focus)
|
||||
|
||||
expect(timer.remainingMs).toBe(durations.focus)
|
||||
expect(completed.remainingMs).toBe(durations.shortBreak)
|
||||
expect(completed.durations).toEqual(durations)
|
||||
})
|
||||
|
||||
it('applies changed durations immediately when the timer is not running', () => {
|
||||
const durations = {
|
||||
focus: 40 * 60_000,
|
||||
shortBreak: 8 * 60_000,
|
||||
longBreak: 25 * 60_000,
|
||||
}
|
||||
const timer = selectPhase(createTimer(), 'shortBreak')
|
||||
|
||||
expect(updateDurations(timer, durations)).toMatchObject({
|
||||
remainingMs: durations.shortBreak,
|
||||
durations,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the current countdown when durations change while running', () => {
|
||||
const running = startTimer(createTimer(), 1_000)
|
||||
const durations = {
|
||||
focus: 40 * 60_000,
|
||||
shortBreak: 8 * 60_000,
|
||||
longBreak: 25 * 60_000,
|
||||
}
|
||||
|
||||
expect(updateDurations(running, durations)).toMatchObject({
|
||||
remainingMs: DURATIONS.focus,
|
||||
endAt: 1_000 + DURATIONS.focus,
|
||||
durations,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the current countdown when durations change while paused', () => {
|
||||
const paused = pauseTimer(startTimer(createTimer(), 1_000), 61_000)
|
||||
const durations = {
|
||||
focus: 40 * 60_000,
|
||||
shortBreak: 8 * 60_000,
|
||||
longBreak: 25 * 60_000,
|
||||
}
|
||||
|
||||
expect(updateDurations(paused, durations)).toMatchObject({
|
||||
status: 'paused',
|
||||
remainingMs: DURATIONS.focus - 60_000,
|
||||
endAt: null,
|
||||
durations,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type TimerPhase = 'focus' | 'shortBreak' | 'longBreak'
|
||||
export type TimerStatus = 'idle' | 'running' | 'paused'
|
||||
export type TimerDurations = Record<TimerPhase, number>
|
||||
|
||||
export const DURATIONS: Record<TimerPhase, number> = {
|
||||
export const DURATIONS: TimerDurations = {
|
||||
focus: 25 * 60 * 1_000,
|
||||
shortBreak: 5 * 60 * 1_000,
|
||||
longBreak: 15 * 60 * 1_000,
|
||||
@@ -13,15 +14,17 @@ export interface TimerState {
|
||||
remainingMs: number
|
||||
completedFocusSessions: number
|
||||
endAt: number | null
|
||||
durations: TimerDurations
|
||||
}
|
||||
|
||||
export function createTimer(): TimerState {
|
||||
export function createTimer(durations: TimerDurations = DURATIONS): TimerState {
|
||||
return {
|
||||
phase: 'focus',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.focus,
|
||||
remainingMs: durations.focus,
|
||||
completedFocusSessions: 0,
|
||||
endAt: null,
|
||||
durations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ export function resetTimer(timer: TimerState): TimerState {
|
||||
return {
|
||||
...timer,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[timer.phase],
|
||||
remainingMs: timer.durations[timer.phase],
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
@@ -67,11 +70,23 @@ export function selectPhase(
|
||||
...timer,
|
||||
phase,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[phase],
|
||||
remainingMs: timer.durations[phase],
|
||||
endAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDurations(
|
||||
timer: TimerState,
|
||||
durations: TimerDurations,
|
||||
): TimerState {
|
||||
return {
|
||||
...timer,
|
||||
durations,
|
||||
remainingMs:
|
||||
timer.status === 'idle' ? durations[timer.phase] : timer.remainingMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function tickTimer(timer: TimerState, now: number): TimerState {
|
||||
if (timer.status !== 'running' || timer.endAt === null) {
|
||||
return timer
|
||||
@@ -94,17 +109,19 @@ function completeSegment(timer: TimerState): TimerState {
|
||||
return {
|
||||
phase,
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS[phase],
|
||||
remainingMs: timer.durations[phase],
|
||||
completedFocusSessions,
|
||||
endAt: null,
|
||||
durations: timer.durations,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase: 'focus',
|
||||
status: 'idle',
|
||||
remainingMs: DURATIONS.focus,
|
||||
remainingMs: timer.durations.focus,
|
||||
completedFocusSessions: timer.completedFocusSessions,
|
||||
endAt: null,
|
||||
durations: timer.durations,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user