├── .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 |
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 | }
--------------------------------------------------------------------------------