├── .gitignore ├── README.md ├── index.css ├── index.html ├── index.ts ├── package-lock.json ├── package.json └── utils.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pomodoro-timer 2 | 3 | > Pomodoro timer built using TypeScript and XState 4 | 5 | ## Todos 6 | 7 | - [x] Once the time has completed reset the timer\ 8 | Depending on the duration, automatically switch to a short break? 9 | - [x] Switch between time durations\ 10 | How do you update the context?\ 11 | Should the durations be a top level machine? -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gray-400: #CBD5E0; 3 | --gray-700: #4A5568; 4 | --green-400: #68D391; 5 | --green-700: #2F855A; 6 | --red-400: #FC8181; 7 | --red-700: #C53030; 8 | --orange-400: #F6AD55; 9 | --orange-700: #C05621; 10 | } 11 | 12 | * { 13 | box-sizing: border-box; 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | body { 19 | width: 100%; 20 | min-height: 100vh; 21 | display: grid; 22 | place-items: center; 23 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 24 | transition: all ease-in-out 0.2s; 25 | } 26 | 27 | body[data-state="idle"], 28 | body[data-state="completed"] { 29 | background-color: var(--gray-700); 30 | } 31 | 32 | body[data-state="running"] { 33 | background-color: var(--green-400); 34 | } 35 | 36 | body[data-state="paused"] { 37 | background-color: var(--orange-400); 38 | } 39 | 40 | fieldset { 41 | border: none; 42 | } 43 | 44 | .sr-only { 45 | position: absolute; 46 | width: 1px; 47 | height: 1px; 48 | padding: 0; 49 | margin: -1px; 50 | overflow: hidden; 51 | clip: rect(0, 0, 0, 0); 52 | white-space: nowrap; 53 | border-width: 0; 54 | } 55 | 56 | #app { 57 | display: flex; 58 | flex-direction: column; 59 | justify-content: center; 60 | align-items: center; 61 | } 62 | 63 | .time { 64 | margin-top: 2rem; 65 | margin-bottom: 2rem; 66 | color: white; 67 | font-size: 10rem; 68 | font-weight: 300; 69 | font-variant-numeric: tabular-nums; 70 | } 71 | 72 | .button-group { 73 | display: flex; 74 | } 75 | 76 | .button { 77 | appearance: none; 78 | min-width: 150px; 79 | padding: 1rem 1.5rem; 80 | background-color: white; 81 | border: transparent; 82 | color: var(--gray-700); 83 | font: inherit; 84 | transition: all ease-in-out 0.2s; 85 | cursor: pointer; 86 | } 87 | 88 | .button:hover, 89 | .button:focus { 90 | background-color: #1A202C; 91 | color: #fff; 92 | } 93 | 94 | .button:focus { 95 | outline: none; 96 | box-shadow: 0 0 0 .2rem rgba(0, 0, 0, 0.25); 97 | } 98 | 99 | body[data-state="running"] .button { 100 | color: var(--green-400); 101 | } 102 | 103 | body[data-state="running"] .button:hover, 104 | body[data-state="running"] .button:focus { 105 | background-color: var(--green-700); 106 | color: #fff; 107 | } 108 | 109 | body[data-state="paused"] .button { 110 | color: var(--orange-400); 111 | } 112 | 113 | body[data-state="paused"] .button:hover, 114 | body[data-state="paused"] .button:focus { 115 | background-color: var(--orange-700); 116 | color: #fff; 117 | } 118 | 119 | .button:first-of-type { 120 | border-top-left-radius: 4px; 121 | border-bottom-left-radius: 4px; 122 | } 123 | 124 | .button:last-of-type { 125 | border-top-right-radius: 4px; 126 | border-bottom-right-radius: 4px; 127 | } 128 | 129 | .button + .button { 130 | margin-left: 4px; 131 | } 132 | 133 | .control { 134 | position: relative; 135 | cursor: pointer; 136 | } 137 | 138 | .control-indicator { 139 | padding: .5rem .75rem; 140 | background-color: rgba(0, 0, 0, 0.2); 141 | color: #fff; 142 | } 143 | 144 | .control:first-of-type .control-indicator { 145 | border-top-left-radius: 4px; 146 | border-bottom-left-radius: 4px; 147 | } 148 | 149 | .control:last-of-type .control-indicator { 150 | border-top-right-radius: 4px; 151 | border-bottom-right-radius: 4px; 152 | } 153 | 154 | .control input:checked ~ .control-indicator, 155 | .control:hover > .control-indicator, 156 | .control input:focus ~ .control-indicator { 157 | background-color: rgba(0, 0, 0, 0.5); 158 | } 159 | 160 | .control input:focus ~ .control-indicator { 161 | box-shadow: 0 0 0 .2rem rgba(0, 0, 0, 0.25); 162 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Start a timer 7 | 8 | 9 | 10 |
11 |
12 | Durations 13 | 17 | 21 | 25 |
26 |

00:00

27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, assign } from 'xstate'; 2 | import { formatTime } from './utils'; 3 | 4 | interface PomodoroContext { 5 | duration: number; 6 | elapsed: number; 7 | interval: number; 8 | } 9 | 10 | type PomodoroEvent = 11 | | { 12 | type: 'TICK'; 13 | } 14 | | { 15 | type: 'DURATION.UPDATE'; 16 | value: number; 17 | } 18 | | { 19 | type: 'TOGGLE'; 20 | } 21 | | { 22 | type: 'RESET'; 23 | }; 24 | 25 | 26 | const pomodoroMachine = createMachine( 27 | { 28 | id: 'pomodoro', 29 | initial: 'idle', 30 | context: { 31 | duration: 1440, 32 | elapsed: 0, 33 | interval: 1 34 | }, 35 | states: { 36 | idle: { 37 | on: { 38 | TOGGLE: 'running' 39 | } 40 | }, 41 | running: { 42 | invoke: { 43 | src: context => cb => { 44 | cb('TICK'); 45 | const interval = setInterval(() => { 46 | cb('TICK'); 47 | }, 1000 * context.interval); 48 | 49 | return () => { 50 | clearInterval(interval); 51 | } 52 | } 53 | }, 54 | on: { 55 | '': { 56 | target: 'idle', 57 | cond: context => { 58 | return context.elapsed >= context.duration 59 | }, 60 | actions: assign({ 61 | elapsed: 0 62 | }) 63 | }, 64 | TOGGLE: 'paused', 65 | TICK: { 66 | actions: assign({ 67 | elapsed: context => +(context.elapsed + context.interval).toFixed(2) 68 | }) 69 | } 70 | } 71 | }, 72 | paused: { 73 | on: { 74 | TOGGLE: 'running' 75 | } 76 | }, 77 | completed: { 78 | type: 'final' 79 | } 80 | }, 81 | on: { 82 | 'DURATION.UPDATE': { 83 | target: 'idle', 84 | actions: assign({ 85 | duration: (_, event) => event.value, 86 | elapsed: 0 87 | }) 88 | }, 89 | RESET: { 90 | target: 'idle', 91 | actions: assign({ 92 | elapsed: 0 93 | }) 94 | } 95 | } 96 | } 97 | ); 98 | 99 | const timeEl = document.getElementById('time'); 100 | const toggleEl = document.getElementById('toggle'); 101 | 102 | const pomodoroService = interpret(pomodoroMachine) 103 | .onTransition((state) => { 104 | document.body.dataset.state = state.value.toString(); 105 | timeEl.innerHTML = formatTime(state.context.duration - state.context.elapsed) 106 | if (state.changed) { 107 | document.body.dataset.state = state.value.toString(); 108 | if (state.value === 'running') { 109 | document.title = `Running: ${formatTime(state.context.duration - state.context.elapsed)}`; 110 | toggleEl.innerHTML = 'Pause' 111 | } else if (state.value === 'paused') { 112 | document.title = `Paused: ${formatTime(state.context.duration - state.context.elapsed)}`; 113 | toggleEl.innerHTML = 'Resume' 114 | } else if (state.value === 'idle' || state.value === 'completed') { 115 | document.title = 'Start a timer'; 116 | toggleEl.innerHTML = 'Start' 117 | } 118 | } 119 | }) 120 | .start(); 121 | 122 | document.querySelectorAll("input[name='duration']").forEach((input) => { 123 | input.addEventListener('change', event => { 124 | pomodoroService.send("DURATION.UPDATE", { value: +event.target.value }); 125 | }); 126 | }); 127 | 128 | document.addEventListener('click', event => { 129 | if (event.target.matches('#toggle')) { 130 | pomodoroService.send('TOGGLE'); 131 | } else if (event.target.matches('#reset')) { 132 | pomodoroService.send('RESET'); 133 | } 134 | }); -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pomodoro-timer", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@xstate/fsm": { 8 | "version": "1.4.0", 9 | "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.4.0.tgz", 10 | "integrity": "sha512-uTHDeu2xI5E1IFwf37JFQM31RrH7mY7877RqPBS4ZqSNUwoLDuct8AhBWaXGnVizBAYyimVwgCyGa9z/NiRhXA==" 11 | }, 12 | "typescript": { 13 | "version": "3.9.7", 14 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", 15 | "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", 16 | "dev": true 17 | }, 18 | "xstate": { 19 | "version": "4.11.0", 20 | "resolved": "https://registry.npmjs.org/xstate/-/xstate-4.11.0.tgz", 21 | "integrity": "sha512-v+S3jF2YrM2tFOit8o7+4N3FuFd9IIGcIKHyfHeeNjMlmNmwuiv/IbY9uw7ECifx7H/A9aGLcxPSr0jdjTGDww==" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pomodoro-timer", 3 | "version": "1.0.0", 4 | "description": "Pomodoro timer built using TypeScript and XState", 5 | "main": "index.ts", 6 | "scripts": { 7 | "start": "parcel index.html" 8 | }, 9 | "author": "Alex Carpenter", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "typescript": "^3.9.7" 13 | }, 14 | "dependencies": { 15 | "@xstate/fsm": "^1.4.0", 16 | "xstate": "^4.11.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | export const formatTime = (time: number): string => { 2 | const minutes = Math.floor(time / 60); 3 | let seconds: number | string = time % 60; 4 | 5 | if (seconds < 10) { 6 | seconds = `0${seconds}`; 7 | } 8 | 9 | return `${minutes}:${seconds}`; 10 | } --------------------------------------------------------------------------------