├── .gitignore
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── favicon.png
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
├── opengraph.jpg
└── robots.txt
└── src
├── App.js
├── BatteryButton.js
├── BeepLabel.js
├── Display.js
├── ProjectInfo.js
├── StateInfo.js
├── StatusIcons.js
├── Watch.js
├── Watch.scss
├── WatchButton.js
├── assets
├── DigitalDismay-VAKw.ttf
├── alarm_1.svg
├── alarm_2.svg
├── am.svg
├── beep_lines.svg
├── beep_text.svg
├── chime.svg
├── colon.svg
├── double_prime.svg
├── face.svg
├── figure_31.png
├── happy_piggy.png
├── period.svg
├── pig_31_text.svg
├── pm.svg
├── prime.svg
├── readme
│ └── intro.gif
├── stopwatch.svg
└── weak_battery.svg
├── classNames.js
├── extras.js
├── index.css
├── index.js
├── reportWebVitals.js
├── setupTests.js
└── watchCaseMachine.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .eslintcache
26 | *.code-workspace
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Andy Jakubowski
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Citizen Quartz Multi Alarm III
2 |
3 | Make sure to play with the [**live demo**](https://andyjakubowski.github.io/statechart-watch/) of the watch! 💫
4 |
5 | A pretty exact replica of the Citizen Quartz Multi Alarm III watch based on figure 31 in David Harel’s 1987 [paper](https://www.sciencedirect.com/science/article/pii/0167642387900359) introducing statecharts.
6 |
7 | Built with [Create React App](https://github.com/facebook/create-react-app) and [XState](https://xstate.js.org/docs/). The live demo is deployed with GitHub Pages.
8 |
9 | 
10 | 
11 |
12 | ## A few things to try in the [live demo](https://andyjakubowski.github.io/statechart-watch/):
13 |
14 | The live demo includes an image of the statechart. The arrows labeled `a`, `b`, `c`, and `d` represent the watch buttons, and show you which ones will navigate you to the different features of the watch.
15 |
16 | Try playing with the watch yourself, or try out some of the ideas below.
17 |
18 | You can press the `a`, `b`, `c`, and `d` buttons you see on the screen, or use the keyboard. You’ll be faster with the keyboard, so I recommend that if you’re device has one.
19 |
20 | - Press `d` to toggle between the current time and date.
21 | - Starting with the current time, press `a` 4 times to go to the stopwatch. Then, press `b` to start the stopwatch. With the stopwatch running, press `a` again to loop back to the current time. Note how the stopwatch icon is still visible on the watch display.
22 | - Hold down `b`. The display will light up 💡.
23 | - Hold down `b` and `d` at the same time. The watch should beep 🔊!
24 | - Press _Remove battery_ to transition the watch to the _dead_ state. Then press _Insert battery_ to start over.
25 |
26 | Have fun! 👩🔬
27 |
28 | ## But what is it, _exactly_?
29 |
30 | Statecharts are a visual way of expressing complex behavior, like a watch or some really complicated piece of UI. XState is a library that lets you implement statecharts with JavaScript. This project uses XState to implement a statechart describing the behavior of a watch. And it uses React to let you see and interact with the watch.
31 |
32 | ## Why should I care?
33 |
34 | Statecharts are the key to implement really complex behavior in a way that’s easy to understand and really hard to break. They let you express what should be possible, and when. It’s easy to understand _and_ precise, making it a great way to express how something should work in your project.
35 |
36 | Interactive devices and UIs have a surprising amount of complexity to them. This watch, for example, has a whole bunch of features:
37 |
38 | - The watch face lights up when you press and hold the `b` button.
39 | - The watch starts beeping if you press and hold `b` and `d` at the same time.
40 | - There are two alarms that can be enabled or disabled.
41 | - There’s a stopwatch.
42 | - If you start updating the date on the watch and don’t do anything for 2 minutes, the watch should go back to showing the current time.
43 |
44 | This is a lot of stuff to keep track of, especially when some of these features depend on _other features_ being on or off. If you’re not careful, you can easily end up with a complicated mess of conditional checks. The more complex the behavior you’re trying to represent, the harder it will get to maintain.
45 |
46 | Statecharts are an answer to this problem. They let you express those behaviors visually in a way that’s understandable for humans.
47 |
48 | ## What do I do with it?
49 |
50 | A few ideas:
51 |
52 | - Play with the [demo](https://andyjakubowski.github.io/statechart-watch/) to test out the concepts defined in the [statecharts paper](https://www.sciencedirect.com/science/article/pii/0167642387900359).
53 | - Read this [introductory _What is a statechart?_ article](https://statecharts.dev/what-is-a-statechart.html) instead if the paper feels too dense at first!
54 | - Look at the statechart at the bottom of the demo as you play with the watch to figure out “where” in the statechart you can go. For example, hold down `b` and `d` at the same time to make the watch _Beep_.
55 | - Clone this repo, and see how the statechart is implemented with [XState](https://xstate.js.org/docs/).
56 |
57 | ## Installation
58 |
59 | 1. Clone the repo
60 | 2. Install the project’s dependencies with `npm install`, or another package manager of your choice.
61 |
62 | ### Troubleshooting
63 |
64 | `npm audit` might alert you of vulnerabilities. Some of the audited dependencies are developiment-only build-time dependencies like Create React App’s [`react-scripts`](https://www.npmjs.com/package/react-scripts). Run `npm audit --omit=dev` and continue if you get 0 vulnerabilities. For details, read [Help, npm audit says I have a vulnerability in react-scripts!](https://github.com/facebook/create-react-app/issues/11174)
65 |
66 | ## Running the project
67 |
68 | `npm start` runs the app in the development mode at `https://localhost:3000` by default.
69 |
70 | ### Troubleshooting
71 |
72 | When the server starts, you might see a deprecation warning message from the Webpack dev server. You can safely ignore it. There’s currently an open pull request that will fix it. For details, see [Use of deprecated webpack DevServer onBeforeSetupMiddleware and onAfterSetupMiddleware options](https://github.com/facebook/create-react-app/issues/11860) and [fix(webpackDevServer): fix deprecation warning](https://github.com/facebook/create-react-app/pull/11862).
73 |
74 | ## Learn more about statecharts and finite state machines
75 |
76 | [Statecharts: a visual formalism for complex systems](https://www.sciencedirect.com/science/article/pii/0167642387900359)
77 |
78 | Awesome overall intro to statecharts: [https://statecharts.github.io/](https://statecharts.github.io/)
79 |
80 | [XState](https://xstate.js.org), a JavaScript framework that implements statecharts
81 |
82 | ## Learn more about the watch
83 |
84 | [Citizen Quartz Multi Alarm III 41-3534](https://whichwatchtoday.blogspot.com/2013/02/citizen-quartz-multi-alarm-iii-41-3534.html)
85 |
86 | ## Deploying to GitHub Pages
87 |
88 | 1. Tweak the `homepage` field in `package.json` if you’re going to deploy to GitHub Pages.
89 | 2. Run `npm run deploy`.
90 | 3. Go to the Pages tab in your GitHub project’s settings, and make sure that the GitHub Pages site is being built from the `gh-pages` branch.
91 |
92 | [Read more about deploying Create React App apps to GitHub Pages](https://create-react-app.dev/docs/deployment/#github-pages).
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "statechart-watch",
3 | "version": "0.2.0",
4 | "homepage": "https://andyjakubowski.github.io/statechart-watch",
5 | "private": true,
6 | "dependencies": {
7 | "react": "^18.2.0",
8 | "react-dom": "^18.2.0",
9 | "web-vitals": "^2.1.4",
10 | "xstate": "^4.34.0",
11 | "@xstate/react": "^3.0.1"
12 | },
13 | "scripts": {
14 | "start": "react-scripts start",
15 | "build": "react-scripts build",
16 | "test": "react-scripts test",
17 | "eject": "react-scripts eject",
18 | "predeploy": "npm run build",
19 | "deploy": "gh-pages -d build"
20 | },
21 | "eslintConfig": {
22 | "extends": [
23 | "react-app",
24 | "react-app/jest"
25 | ]
26 | },
27 | "browserslist": {
28 | "production": [
29 | ">0.2%",
30 | "not dead",
31 | "not op_mini all"
32 | ],
33 | "development": [
34 | "last 1 chrome version",
35 | "last 1 firefox version",
36 | "last 1 safari version"
37 | ]
38 | },
39 | "devDependencies": {
40 | "@testing-library/jest-dom": "^5.16.5",
41 | "@testing-library/react": "^13.4.0",
42 | "@testing-library/user-event": "^13.5.0",
43 | "react-scripts": "5.0.1",
44 | "gh-pages": "^4.0.0",
45 | "sass": "^1.56.1"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/public/favicon.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
18 |
19 |
28 |
29 |
33 |
37 |
38 |
39 |
40 |
41 |
42 | Citizen Watch
43 |
44 |
45 | You need to enable JavaScript to run this app.
46 |
47 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/opengraph.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/public/opengraph.jpg
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import WatchCase from './Watch';
2 |
3 | function App() {
4 | return ;
5 | }
6 |
7 | export default App;
8 |
--------------------------------------------------------------------------------
/src/BatteryButton.js:
--------------------------------------------------------------------------------
1 | import cn from './classNames';
2 |
3 | const BatteryButton = function BatteryButton({ state, send }) {
4 | const isAlive = state.matches('alive');
5 | const buttonCn = cn('battery-button');
6 | const elButton = isAlive ? (
7 | send('REMOVE_BATTERY')}>
8 | Remove battery
9 |
10 | ) : (
11 | send('INSERT_BATTERY')}>
12 | Insert battery
13 |
14 | );
15 | return {elButton}
;
16 | };
17 |
18 | export default BatteryButton;
19 |
--------------------------------------------------------------------------------
/src/BeepLabel.js:
--------------------------------------------------------------------------------
1 | import beepText from './assets/beep_text.svg';
2 | import beepLines from './assets/beep_lines.svg';
3 | import cn from './classNames';
4 |
5 | const BeepLabel = function BeepLabel({ state }) {
6 | const beepStates = [
7 | 'alive.main.displays.regularAndBeep.beep-test.beep',
8 | 'alive.main.alarms-beep',
9 | 'alive.chime-status.enabled.beep',
10 | ];
11 |
12 | const isBeeping = beepStates.some(state.matches);
13 | const dataStateBeep = isBeeping ? 'beeping' : undefined;
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | );
21 | };
22 |
23 | export default BeepLabel;
24 |
--------------------------------------------------------------------------------
/src/Display.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import cn from './classNames';
3 | import colon from './assets/colon.svg';
4 | import period from './assets/period.svg';
5 | import prime from './assets/prime.svg';
6 | import doublePrime from './assets/double_prime.svg';
7 | import weakBattery from './assets/weak_battery.svg';
8 | import am from './assets/am.svg';
9 | import pm from './assets/pm.svg';
10 |
11 | const Colon = function Colon() {
12 | return ;
13 | };
14 |
15 | const WeakBattery = function WeakBattery() {
16 | return (
17 |
22 | );
23 | };
24 |
25 | const Primes = function Primes() {
26 | return (
27 | <>
28 |
29 |
34 | >
35 | );
36 | };
37 |
38 | const Period = function Period() {
39 | return ;
40 | };
41 |
42 | const AM = function AM() {
43 | return ;
44 | };
45 |
46 | const PM = function PM() {
47 | return ;
48 | };
49 |
50 | const Digits1 = function Digits1({ children }) {
51 | return {children}
;
52 | };
53 |
54 | const Digits2 = function Digits2({ children }) {
55 | return {children}
;
56 | };
57 |
58 | const Digits3 = function Digits3({ children }) {
59 | return {children}
;
60 | };
61 |
62 | const LCD = function LCD({ state, children }) {
63 | const weakBatteryState = 'alive.power.blink';
64 | const isWeakBattery = state.matches(weakBatteryState);
65 | const weakBatteryIndicator = isWeakBattery ? : undefined;
66 |
67 | return (
68 |
69 | {weakBatteryIndicator}
70 | {children}
71 |
72 | );
73 | };
74 |
75 | const formatHr = function formatHr(mode, hr) {
76 | if (mode === '24h') {
77 | return hr;
78 | }
79 |
80 | const num = hr % 12;
81 | if (num === 0) {
82 | return 12;
83 | } else {
84 | return num;
85 | }
86 | };
87 |
88 | const getPeriodIndicator = (function makeGetPeriodIndicator() {
89 | const isPM = function isPM(hr) {
90 | return Math.floor(hr / 12) >= 1;
91 | };
92 |
93 | return function getPeriodIndicator(mode, hr) {
94 | if (mode === '24h') {
95 | return undefined;
96 | }
97 |
98 | return isPM(hr) ? : ;
99 | };
100 | })();
101 |
102 | const TimeDisplay = function TimeDisplay({ state }) {
103 | const { sec, oneMin, tenMin, hr, mode } = state.context.T;
104 | const formattedSec = String(sec).padStart(2, '0');
105 | const formattedHr = formatHr(mode, hr);
106 | const PeriodIndicator = getPeriodIndicator(mode, hr);
107 |
108 | return (
109 |
110 | {formattedHr}
111 |
112 |
113 | {tenMin}
114 | {oneMin}
115 |
116 | {PeriodIndicator}
117 | {formattedSec}
118 |
119 | );
120 | };
121 |
122 | const DateDisplay = function DateDisplay({ state }) {
123 | const { mon, date, day } = state.context.T;
124 | const days = ['Mo', 'Tu', 'We', 'Th', 'fr', 'sa', 'su'];
125 |
126 | return (
127 |
128 | {mon + 1}
129 |
130 | {date + 1}
131 | {days[day]}
132 |
133 | );
134 | };
135 |
136 | const TimeUpdateDisplay = function TimeUpdateDisplay({ state, updateState }) {
137 | const { sec, oneMin, tenMin, hr, mode } = state.context.T;
138 | const classNames = ['sec', '1min', '10min', 'hr'].reduce((result, el) => {
139 | result[el] = el === updateState ? cn(null, 'blinking') : undefined;
140 | return result;
141 | }, {});
142 | const formattedHr = formatHr(mode, hr);
143 | const PeriodIndicator = getPeriodIndicator(mode, hr);
144 | const formattedSec = String(sec).padStart(2, '0');
145 | return (
146 |
147 |
148 | {formattedHr}
149 |
150 |
151 |
152 | {tenMin}
153 | {oneMin}
154 |
155 | {PeriodIndicator}
156 |
157 | {formattedSec}
158 |
159 |
160 | );
161 | };
162 |
163 | const DateUpdateDisplay = function DateUpdateDisplay({ state, updateState }) {
164 | const { mon, date, day } = state.context.T;
165 | const days = ['Mo', 'Tu', 'We', 'Th', 'fr', 'sa', 'su'];
166 | const classNames = ['mon', 'date', 'day'].reduce((result, el) => {
167 | result[el] = el === updateState ? cn(null, 'blinking') : undefined;
168 | return result;
169 | }, {});
170 | return (
171 |
172 |
173 | {mon + 1}
174 |
175 |
176 |
177 | {date + 1}
178 |
179 |
180 | {days[day]}
181 |
182 |
183 | );
184 | };
185 | const YearUpdateDisplay = function YearUpdateDisplay({ state, updateState }) {
186 | const yearString = String(state.context.T.year);
187 | const firstTwoDigits = yearString.slice(0, 2);
188 | const lastTwoDigits = yearString.slice(2);
189 | return (
190 |
191 |
192 | {firstTwoDigits}
193 |
194 |
195 | {lastTwoDigits}
196 |
197 |
198 | );
199 | };
200 | const ModeUpdateDisplay = function ModeUpdateDisplay({ state, updateState }) {
201 | const { mode } = state.context.T;
202 | const modeDigits = mode === '12h' ? '12' : '24';
203 |
204 | return (
205 |
206 |
207 | {modeDigits}
208 |
209 | h
210 |
211 | );
212 | };
213 |
214 | const UpdateDisplay = function UpdateDisplay({ state }) {
215 | const states = [
216 | 'sec',
217 | '1min',
218 | '10min',
219 | 'hr',
220 | 'mon',
221 | 'date',
222 | 'day',
223 | 'year',
224 | 'mode',
225 | ].reduce((result, key) => {
226 | result[key] = `alive.main.displays.regularAndBeep.regular.update.${key}`;
227 | return result;
228 | }, {});
229 | const updateTypes = {
230 | time: ['sec', '1min', '10min', 'hr'],
231 | date: ['mon', 'date', 'day'],
232 | year: ['year'],
233 | mode: ['mode'],
234 | };
235 | const currentState = Object.keys(states).find((key) =>
236 | state.matches(states[key])
237 | );
238 | const currentUpdateType = Object.keys(updateTypes).find((key) =>
239 | updateTypes[key].includes(currentState)
240 | );
241 | const displays = {
242 | time: ,
243 | date: ,
244 | year: ,
245 | mode: ,
246 | };
247 |
248 | return displays[currentUpdateType];
249 | };
250 |
251 | const AlarmDisplay = function AlarmDisplay({ state, alarmNumber }) {
252 | const { mode } = state.context.T;
253 | const { oneMin, tenMin, hr } = state.context[`T${alarmNumber}`];
254 | const states = {
255 | '1min': `alive.main.displays.out.update-${alarmNumber}.1min`,
256 | '10min': `alive.main.displays.out.update-${alarmNumber}.10min`,
257 | hr: `alive.main.displays.out.update-${alarmNumber}.hr`,
258 | on: `alive.main.displays.out.alarm-${alarmNumber}.on`,
259 | off: `alive.main.displays.out.alarm-${alarmNumber}.off`,
260 | };
261 | const currentState = Object.keys(states).find((key) =>
262 | state.matches(states[key])
263 | );
264 | const isEnabled = state.matches(`alive.alarm-${alarmNumber}-status.enabled`);
265 | const statusLabel = isEnabled ? 'on' : 'of';
266 | const classNames = Object.keys(states).reduce((result, el) => {
267 | result[el] = el === currentState ? cn(null, 'blinking') : undefined;
268 | return result;
269 | }, {});
270 | const formattedHr = formatHr(mode, hr);
271 | const PeriodIndicator = getPeriodIndicator(mode, hr);
272 |
273 | return (
274 |
275 |
276 | {formattedHr}
277 |
278 |
279 |
280 | {tenMin}
281 | {oneMin}
282 |
283 | {PeriodIndicator}
284 |
285 |
286 | {statusLabel}
287 |
288 |
289 |
290 | );
291 | };
292 |
293 | const Alarm1Display = function Alarm1Display({ alarmNumber, ...props }) {
294 | return ;
295 | };
296 |
297 | const Alarm2Display = function Alarm2Display({ alarmNumber, ...props }) {
298 | return ;
299 | };
300 |
301 | const ChimeDisplay = function ChimeDisplay({ state }) {
302 | const states = {
303 | off: 'alive.main.displays.out.chime.off',
304 | on: 'alive.main.displays.out.chime.on',
305 | };
306 | const currentState = Object.keys(states).find((key) =>
307 | state.matches(states[key])
308 | );
309 | const statusLabel = currentState === 'on' ? 'on' : 'of';
310 |
311 | return (
312 |
313 |
314 | 00
315 |
316 | {statusLabel}
317 |
318 |
319 | );
320 | };
321 |
322 | const Regular = function Regular({ state }) {
323 | const states = {
324 | time: 'alive.main.displays.regularAndBeep.regular.time',
325 | date: 'alive.main.displays.regularAndBeep.regular.date',
326 | update: 'alive.main.displays.regularAndBeep.regular.update',
327 | };
328 |
329 | const currentState = Object.keys(states).find((key) =>
330 | state.matches(states[key])
331 | );
332 |
333 | const displays = {
334 | time: ,
335 | date: ,
336 | update: ,
337 | };
338 |
339 | return displays[currentState] || displays.time;
340 | };
341 |
342 | const Out = function Out({ state }) {
343 | const states = {
344 | alarm1: 'alive.main.displays.out.alarm-1',
345 | update1: 'alive.main.displays.out.update-1',
346 | alarm2: 'alive.main.displays.out.alarm-2',
347 | update2: 'alive.main.displays.out.update-2',
348 | chime: 'alive.main.displays.out.chime',
349 | };
350 |
351 | const currentState = Object.keys(states).find((key) =>
352 | state.matches(states[key])
353 | );
354 |
355 | const displays = {
356 | alarm1: ,
357 | update1: ,
358 | alarm2: ,
359 | update2: ,
360 | chime: ,
361 | };
362 |
363 | return displays[currentState] || displays.time;
364 | };
365 |
366 | const getTimesFromMs = (function makeGetTimesFromMs() {
367 | const MS_PER_SECOND = 1000;
368 | const SECONDS_PER_MINUTE = 60;
369 | const MS_PER_MINUTE = MS_PER_SECOND * SECONDS_PER_MINUTE;
370 | const MS_PER_ONE_HUNDRETH_OF_SEC = 10;
371 | return function getTimesFromMs(ms) {
372 | const min = Math.floor(ms / MS_PER_MINUTE);
373 | let remainingMs = ms % MS_PER_MINUTE;
374 | const sec = Math.floor(remainingMs / MS_PER_SECOND);
375 | remainingMs = remainingMs % MS_PER_SECOND;
376 | const hundrethsOfSec = Math.floor(remainingMs / MS_PER_ONE_HUNDRETH_OF_SEC);
377 | return { min, sec, hundrethsOfSec };
378 | };
379 | })();
380 |
381 | const Stopwatch = function Stopwatch({ state }) {
382 | const { elapsedTotal, lap } = state.context.stopwatch;
383 | const states = {
384 | regular: 'alive.main.displays.stopwatch.displayAndRun.display.regular',
385 | lap: 'alive.main.displays.stopwatch.displayAndRun.display.lap',
386 | };
387 | const currentState = Object.keys(states).find((key) =>
388 | state.matches(states[key])
389 | );
390 | const shownTime = currentState === 'regular' ? elapsedTotal : lap;
391 | const { min, sec, hundrethsOfSec } = getTimesFromMs(shownTime);
392 | const minString = String(min).padStart(2, '0');
393 | const formattedSec = String(sec).padStart(2, '0');
394 | const hundrethsOfSecString = String(hundrethsOfSec).padStart(2, '0');
395 |
396 | return (
397 |
398 | {minString}
399 |
400 |
401 | {formattedSec}
402 | {hundrethsOfSecString}
403 |
404 | );
405 | };
406 |
407 | const Displays = {
408 | Regular,
409 | Out,
410 | Stopwatch,
411 | };
412 |
413 | const Display = function Display({ state }) {
414 | const states = {
415 | regular: 'alive.main.displays.regularAndBeep.regular',
416 | wait: 'alive.main.displays.wait',
417 | out: 'alive.main.displays.out',
418 | stopwatch: 'alive.main.displays.stopwatch',
419 | };
420 |
421 | const currentState = Object.keys(states).find((key) =>
422 | state.matches(states[key])
423 | );
424 |
425 | const displays = {
426 | regular: ,
427 | out: ,
428 | stopwatch: ,
429 | };
430 |
431 | return displays[currentState] || displays.regular;
432 | };
433 |
434 | export default Display;
435 |
--------------------------------------------------------------------------------
/src/ProjectInfo.js:
--------------------------------------------------------------------------------
1 | import happyPiggy from './assets/happy_piggy.png';
2 | import pig31Text from './assets/pig_31_text.svg';
3 | import cn from './classNames';
4 |
5 | const ProjectInfo = function ProjectInfo() {
6 | return (
7 |
8 |
9 |
14 |
15 |
16 |
44 |
45 | );
46 | };
47 |
48 | export default ProjectInfo;
49 |
--------------------------------------------------------------------------------
/src/StateInfo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import cn from './classNames';
3 |
4 | const formatContextObject = (function makeFormatContextObject() {
5 | const tabSeparatedObjects = ['T', 'T1', 'T2'];
6 |
7 | return function formatContextObject(obj, objKey) {
8 | const separator = tabSeparatedObjects.includes(objKey) ? '\t' : ' ';
9 | return Object.entries(obj)
10 | .map(([name, value]) => `${name}:${separator}${value}`)
11 | .join('\n');
12 | };
13 | })();
14 |
15 | const removeAlivePrefix = (function makeRemoveAlivePrefix() {
16 | const alivePrefixLength = 'alive.'.length;
17 | return function removeAlivePrefix(string) {
18 | if (string === 'alive') {
19 | return undefined;
20 | }
21 |
22 | return string.slice(alivePrefixLength);
23 | };
24 | })();
25 |
26 | const StateInfo = function StateInfo({ state }) {
27 | const { context } = state;
28 | const { T, T1, T2, stopwatch, ...rest } = context;
29 | const contextObjects = { T, T1, T2, stopwatch, rest };
30 | const contextInfoElements = Object.keys(contextObjects).map((key, i) => {
31 | const formattedObjectValues = formatContextObject(contextObjects[key], key);
32 | const text = [key, formattedObjectValues].join('\n');
33 | return (
34 |
35 | {text}
36 |
37 | );
38 | });
39 |
40 | return (
41 |
42 |
43 |
Active states
44 |
45 | {state.toStrings().map(removeAlivePrefix).sort().join('\n')}
46 |
47 |
48 |
49 |
Extended state
50 |
{contextInfoElements}
51 |
52 |
53 | );
54 | };
55 |
56 | export default StateInfo;
57 |
--------------------------------------------------------------------------------
/src/StatusIcons.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import cn from './classNames';
3 | import { ReactComponent as IconAlarm1 } from './assets/alarm_1.svg';
4 | import { ReactComponent as IconAlarm2 } from './assets/alarm_2.svg';
5 | import { ReactComponent as IconChime } from './assets/chime.svg';
6 | import { ReactComponent as IconStopwatch } from './assets/stopwatch.svg';
7 |
8 | const timeDisplayStates = [
9 | 'alive.main.displays.regularAndBeep.regular.time',
10 | 'alive.main.displays.wait',
11 | ];
12 | const alarmsBeepState = 'alive.main.alarms-beep';
13 |
14 | const AlarmStatus = function AlarmStatus({ state, alarmNumber, ...props }) {
15 | const iconStates = {
16 | enabled: `alive.alarm-${alarmNumber}-status.enabled`,
17 | blinking: [
18 | 'alive.main.alarms-beep.both-beep',
19 | `alive.main.alarms-beep.alarm-${alarmNumber}-beeps`,
20 | `alive.main.displays.out.alarm-${alarmNumber}`,
21 | `alive.main.displays.out.update-${alarmNumber}`,
22 | ],
23 | };
24 | const shouldBlink = iconStates.blinking.some(state.matches);
25 | const shouldShow =
26 | state.matches(iconStates.enabled) &&
27 | (timeDisplayStates.some(state.matches) || state.matches(alarmsBeepState));
28 | const Icon = alarmNumber === 1 ? IconAlarm1 : IconAlarm2;
29 |
30 | if (shouldBlink) {
31 | return ;
32 | } else if (shouldShow) {
33 | return ;
34 | } else {
35 | return '';
36 | }
37 | };
38 |
39 | const Alarm1Status = function Alarm1Status({ state, ...props }) {
40 | return ;
41 | };
42 |
43 | const Alarm2Status = function Alarm2Status({ state, ...props }) {
44 | return ;
45 | };
46 |
47 | const ChimeStatus = function ChimeStatus({ state, ...props }) {
48 | const iconStates = {
49 | enabled: 'alive.chime-status.enabled.quiet',
50 | blinking: [
51 | 'alive.chime-status.enabled.beep',
52 | 'alive.main.displays.out.chime',
53 | ],
54 | };
55 | const shouldBlink = iconStates.blinking.some(state.matches);
56 | const shouldShow =
57 | state.matches(iconStates.enabled) &&
58 | (timeDisplayStates.some(state.matches) || state.matches(alarmsBeepState));
59 |
60 | if (shouldBlink) {
61 | return ;
62 | } else if (shouldShow) {
63 | return ;
64 | } else {
65 | return '';
66 | }
67 | };
68 |
69 | const StopwatchStatus = function StopwatchStatus({ state, ...props }) {
70 | const { start, elapsedBeforeStart, elapsedTotal } = state.context.stopwatch;
71 | const isPaused = elapsedBeforeStart === elapsedTotal;
72 | const isRunning = !!start && !isPaused;
73 | const iconStates = {
74 | blinking: 'alive.main.displays.stopwatch',
75 | };
76 | const shouldBlink = state.matches(iconStates.blinking);
77 | const shouldShow =
78 | isRunning &&
79 | (timeDisplayStates.some(state.matches) || state.matches(alarmsBeepState));
80 |
81 | if (shouldBlink) {
82 | return ;
83 | } else if (shouldShow) {
84 | return ;
85 | } else {
86 | return '';
87 | }
88 | };
89 |
90 | const StatusIcons = function StatusIcons({ state }) {
91 | return (
92 | <>
93 |
94 |
95 |
96 |
97 | >
98 | );
99 | };
100 |
101 | export default StatusIcons;
102 |
--------------------------------------------------------------------------------
/src/Watch.js:
--------------------------------------------------------------------------------
1 | import './Watch.scss';
2 | import React from 'react';
3 | import { useMachine, useActor } from '@xstate/react';
4 | import { useKeyDown, useKeyUp } from './extras';
5 | import { watchCaseMachine } from './watchCaseMachine';
6 | import StatusIcons from './StatusIcons';
7 | import cn from './classNames';
8 | import StateInfo from './StateInfo';
9 | import ProjectInfo from './ProjectInfo';
10 | import BeepLabel from './BeepLabel';
11 | import BatteryButton from './BatteryButton';
12 | import WatchButton from './WatchButton';
13 | import Display from './Display';
14 | import figure31 from './assets/figure_31.png';
15 | import { ReactComponent as FaceBackground } from './assets/face.svg';
16 |
17 | const WatchCase = function WatchCase() {
18 | const [state, send] = useMachine(watchCaseMachine);
19 | const watchRef = state?.children?.watch;
20 | const watchEl = watchRef ? : ;
21 |
22 | return (
23 |
24 |
25 | {watchEl}
26 |
27 |
32 |
33 | );
34 | };
35 |
36 | const DeadWatch = function DeadWatch() {
37 | return (
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | const Watch = function Watch({ watchRef }) {
45 | const [state, send] = useActor(watchRef);
46 | useKeyDown(send);
47 | useKeyUp(send);
48 |
49 | return (
50 | <>
51 |
52 |
53 |
54 |
55 |
56 | a
57 |
58 |
59 | b
60 |
61 |
62 | c
63 |
64 |
65 | d
66 |
67 |
68 | >
69 | );
70 | };
71 |
72 | const Face = function Face({ state }) {
73 | const isAlive = !!state && state.matches('alive');
74 | let lightState;
75 | let elStatusIcons;
76 | let elDisplay;
77 | if (isAlive) {
78 | lightState = state.value.alive.light;
79 | elStatusIcons = ;
80 | elDisplay = ;
81 | }
82 |
83 | return (
84 |
85 |
89 |
90 | {elStatusIcons}
91 | {elDisplay}
92 |
93 |
94 | );
95 | };
96 |
97 | export default WatchCase;
98 |
--------------------------------------------------------------------------------
/src/Watch.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Digital Dismay';
3 | src: url('assets/DigitalDismay-VAKw.ttf');
4 | }
5 |
6 | :root {
7 | --animation-blink: blink 0.6s infinite alternate;
8 | --font-family: ff-providence-sans-web-pro, -apple-system, BlinkMacSystemFont,
9 | 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
10 | 'Droid Sans', 'Helvetica Neue', sans-serif;
11 | }
12 |
13 | body,
14 | button {
15 | font-family: var(--font-family);
16 | }
17 |
18 | p {
19 | margin: 0;
20 | }
21 |
22 | a {
23 | color: currentcolor;
24 | }
25 |
26 | .Watch {
27 | &__container {
28 | --rows_x-small: auto 132px 275px auto auto;
29 | --min-height_x-small: initial;
30 | --row-gap_x-small: 24px;
31 |
32 | --columns_small: 100%;
33 | --rows_small: auto 132px 419px auto auto;
34 | --areas_small: 'project-info' 'beep' 'face-and-buttons' 'battery-button'
35 | 'state-info' 'fig-31';
36 |
37 | --min-height_default: 100vh;
38 | --columns_default: 430px min(72px, 3vw) 1fr;
39 | --rows_default: auto 132px auto auto 1fr auto auto;
40 | --areas_default: 'project-info project-info project-info project-info'
41 | 'state-info . beep .' 'state-info . face-and-buttons .'
42 | 'state-info . battery-button .' 'state-info . . . '
43 | 'fig-31 fig-31 fig-31 fig-31';
44 | --row-gap_default: 24px;
45 |
46 | --rows_large: 132px auto auto 1fr auto auto;
47 | --columns_large: 0.7fr 430px min(72px, 3vw) max-content 1fr;
48 | --areas_large: '. state-info . beep .' '. state-info . face-and-buttons .'
49 | '. state-info . battery-button .' '. state-info . . . '
50 | 'fig-31 fig-31 fig-31 fig-31 fig-31';
51 |
52 | box-sizing: border-box;
53 | min-height: var(--min-height_default);
54 | padding: 32px;
55 | display: grid;
56 |
57 | grid-template-columns: var(--columns_default);
58 | grid-template-rows: var(--rows_default);
59 | grid-template-areas: var(--areas_default);
60 | row-gap: var(--row-gap_default);
61 | place-content: center;
62 | }
63 |
64 | &__project-info {
65 | grid-area: project-info;
66 |
67 | display: grid;
68 | gap: 24px;
69 | justify-items: center;
70 | }
71 |
72 | &__piggy-and-text {
73 | display: grid;
74 | justify-items: center;
75 | }
76 |
77 | &__description {
78 | width: unquote('min(90vw, 400px)');
79 | text-align: center;
80 | line-height: 1.4375;
81 | display: grid;
82 | gap: 16px;
83 | }
84 |
85 | &__state-info {
86 | grid-area: state-info;
87 | align-self: center;
88 |
89 | display: grid;
90 | grid-auto-rows: max-content;
91 | gap: 24px;
92 |
93 | &-section {
94 | box-sizing: border-box;
95 | height: 390px;
96 | display: grid;
97 | grid-auto-rows: max-content;
98 | gap: 16px;
99 | padding: unquote('min(5vw, 32px)');
100 | border-radius: 24px;
101 | background-color: hsla(0, 0%, 0%, 0.03);
102 | }
103 |
104 | &-heading {
105 | margin: 0;
106 | font-size: 18px;
107 | font-family: var(--font-family);
108 | color: black;
109 | }
110 |
111 | &-context,
112 | &-states {
113 | white-space: pre-wrap;
114 | font-family: 'Courier New', Courier, monospace;
115 | font-size: 12px;
116 | font-weight: 700;
117 | color: hsl(0, 0%, 40%);
118 | }
119 | &-context {
120 | display: grid;
121 | grid:
122 | 'T T1 T2' max-content
123 | 'stopwatch stopwatch stopwatch' max-content
124 | 'rest rest rest' max-content
125 | / 1fr 1fr 1fr;
126 | gap: 16px;
127 |
128 | &-T,
129 | &-T1,
130 | &-T2,
131 | &-stopwatch,
132 | &-rest {
133 | &::first-line {
134 | color: black;
135 | }
136 | }
137 |
138 | &-T {
139 | grid-area: T;
140 | }
141 |
142 | &-T1 {
143 | grid-area: T1;
144 | }
145 |
146 | &-T2 {
147 | grid-area: T2;
148 | }
149 |
150 | &-stopwatch {
151 | grid-area: stopwatch;
152 | }
153 |
154 | &-rest {
155 | grid-area: rest;
156 | }
157 | }
158 | }
159 |
160 | &__beep-container {
161 | grid-area: beep;
162 | display: grid;
163 | grid-template-areas: 'the-one-area';
164 | place-content: center;
165 | place-items: center;
166 | opacity: 0;
167 |
168 | &[data-state-beep='beeping'] {
169 | opacity: 1;
170 | --animation-details: 0.5s infinite alternate;
171 | --beep-text-animation: beep-text-zoom var(--animation-details);
172 | --beep-lines-animation: beep-lines-fade var(--animation-details);
173 | }
174 | }
175 |
176 | &__beep-text,
177 | &__beep-lines {
178 | grid-area: the-one-area;
179 | }
180 |
181 | &__beep-text {
182 | animation: var(--beep-text-animation);
183 | }
184 |
185 | &__beep-lines {
186 | animation: var(--beep-lines-animation);
187 | }
188 |
189 | &__face-and-buttons {
190 | grid-area: face-and-buttons;
191 | display: grid;
192 | place-content: center;
193 | grid-template-columns:
194 | [a-button-start]
195 | 73px
196 | [face-start]
197 | 361px
198 | [a-button-end
199 | b-button-start d-button-start]
200 | 15px
201 | [c-button-start]
202 | 85px
203 | [face-end b-button-end c-button-end d-button-end];
204 | grid-template-rows:
205 | [face-start]
206 | 54px
207 | [a-button-start b-button-start]
208 | 110px
209 | [c-button-start
210 | a-button-end b-button-end]
211 | 123px
212 | [c-button-end
213 | d-button-start]
214 | 132px
215 | [face-end d-button-end];
216 | }
217 |
218 | &__button-a,
219 | &__button-b,
220 | &__button-c,
221 | &__button-d {
222 | width: 85px;
223 | height: 85px;
224 | border: none;
225 | border-radius: 42.5px;
226 | background-color: transparent;
227 |
228 | display: flex;
229 | justify-content: center;
230 |
231 | font-size: 51px;
232 | line-height: 92px;
233 |
234 | cursor: pointer;
235 | --user-select: none;
236 | -webkit-user-select: var(--user-select);
237 | -ms-user-select: var(--user-select);
238 | user-select: var(--user-select);
239 |
240 | &:hover {
241 | background-color: hsla(240, 3%, 70%, 0.1);
242 | }
243 |
244 | &:active {
245 | background-color: hsla(240, 3%, 70%, 0.12);
246 | }
247 | }
248 |
249 | &__button-a {
250 | grid-column: a-button-start / a-button-end;
251 | grid-row: a-button-start / a-button-end;
252 | }
253 |
254 | &__button-b {
255 | grid-column: b-button-start / b-button-end;
256 | grid-row: b-button-start / b-button-end;
257 | }
258 |
259 | &__button-c {
260 | grid-column: c-button-start / c-button-end;
261 | grid-row: c-button-start / c-button-end;
262 | }
263 |
264 | &__button-d {
265 | grid-column: d-button-start / d-button-end;
266 | grid-row: d-button-start / d-button-end;
267 | }
268 |
269 | &__face {
270 | grid-column: face-start / face-end;
271 | grid-row: face-start / face-end;
272 | display: grid;
273 | grid-template-columns: [start] 83.01px [display-start] 206px [display-end] 96.99px [end];
274 | grid-template-rows: [start] 170.61px [display-start] 96px [display-end] 152.39px [end];
275 | }
276 |
277 | &__face-background {
278 | &[data-state-light='on'] {
279 | & #status-icons {
280 | fill: #c0bdb5;
281 | }
282 |
283 | & #lcd {
284 | fill: #dadad2;
285 | }
286 | }
287 | }
288 |
289 | &__displays {
290 | grid-column: display-start / display-end;
291 | grid-row: display-start / display-end;
292 |
293 | display: grid;
294 | grid-template-columns:
295 | [lcd-start]
296 | 16px
297 | [alarm1-start] 41px [alarm1-end]
298 | 11px
299 | [alarm2-start] 40px [alarm2-end]
300 | 12px
301 | [chime-start] 28px [chime-end]
302 | 11px
303 | [stopwatch-start] 41px [stopwatch-end]
304 | 6px
305 | [lcd-end];
306 | grid-template-rows: 3px [icons-start] 30px [icons-end] 3px [lcd-start] 60px [lcd-end];
307 | align-items: center;
308 | }
309 |
310 | &__alarm1-icon,
311 | &__alarm2-icon,
312 | &__chime-icon,
313 | &__stopwatch-icon {
314 | grid-row: icons-start / icons-end;
315 |
316 | &[data-state='blinking'] {
317 | animation: var(--animation-blink);
318 | }
319 | }
320 |
321 | &__alarm1-icon {
322 | grid-column: alarm1-start / alarm1-end;
323 | }
324 |
325 | &__alarm2-icon {
326 | grid-column: alarm2-start / alarm2-end;
327 | }
328 |
329 | &__chime-icon {
330 | grid-column: chime-start / chime-end;
331 | }
332 |
333 | &__stopwatch-icon {
334 | grid-column: stopwatch-start / stopwatch-end;
335 | }
336 |
337 | &__display {
338 | grid-column: lcd-start / lcd-end;
339 | grid-row: lcd-start / lcd-end;
340 |
341 | display: grid;
342 | grid-template-columns:
343 | 3px
344 | [weak-battery-start] 6px [weak-battery-end
345 | digits1-start] 63px [digits1-end
346 | prime-start colon-start period-start] 13px [prime-end colon-end period-end
347 | digits2-start] 63px [digits2-end
348 | double-prime-start] 5px [double-prime-end
349 | am-start pm-start digits3-start] 46px [am-end pm-end]
350 | 7px [digits3-end];
351 | grid-template-rows:
352 | 5px
353 | [primes-start] 4px
354 | [digits1-start digits2-start am-start pm-start weak-battery-start] 13px [primes-end am-end pm-end weak-battery-end
355 | colon-start period-start digits3-start] 32px [colon-end period-end digits1-end digits2-end digits3-end]
356 | 6px;
357 | }
358 |
359 | &__weak-battery-icon {
360 | grid-column: weak-battery-start / weak-battery-end;
361 | grid-row: weak-battery-start / weak-battery-end;
362 | animation: var(--animation-blink);
363 | }
364 |
365 | &__colon-icon {
366 | grid-column: colon-start / colon-end;
367 | grid-row: colon-start / colon-end;
368 | }
369 |
370 | &__period-icon {
371 | grid-column: period-start / period-end;
372 | grid-row: period-start / period-end;
373 | align-self: end;
374 | }
375 |
376 | &__prime-icon {
377 | grid-column: prime-start / prime-end;
378 | grid-row: primes-start / primes-end;
379 | }
380 |
381 | &__double-prime-icon {
382 | grid-column: double-prime-start / double-prime-end;
383 | grid-row: primes-start / primes-end;
384 | }
385 |
386 | &__am-icon {
387 | grid-column: am-start / am-end;
388 | grid-row: am-start / am-end;
389 | }
390 |
391 | &__pm-icon {
392 | grid-column: pm-start / pm-end;
393 | grid-row: pm-start / pm-end;
394 | justify-self: end;
395 | }
396 |
397 | &__digits1,
398 | &__digits2,
399 | &__digits3 {
400 | color: #2c2e30;
401 | font-family: 'Digital Dismay', sans-serif;
402 | line-height: 0.64;
403 | letter-spacing: 2.94px;
404 | align-self: end;
405 | text-align: right;
406 | }
407 |
408 | &__digits1,
409 | &__digits2 {
410 | font-size: 66px;
411 | }
412 |
413 | &__digits1 {
414 | grid-column: digits1-start / digits1-end;
415 | grid-row: digits1-start / digits1-end;
416 | }
417 |
418 | &__digits2 {
419 | grid-column: digits2-start / digits2-end;
420 | grid-row: digits2-start / digits2-end;
421 | }
422 |
423 | &__digits3 {
424 | padding-right: 3px;
425 | font-size: 42px;
426 | grid-column: digits3-start / digits3-end;
427 | grid-row: digits3-start / digits3-end;
428 | justify-self: end;
429 | }
430 |
431 | &_blinking {
432 | animation: var(--animation-blink);
433 | }
434 |
435 | &__battery-button-container {
436 | grid-area: battery-button;
437 |
438 | display: flex;
439 | justify-content: center;
440 |
441 | --user-select: none;
442 | -webkit-user-select: var(--user-select);
443 | -ms-user-select: var(--user-select);
444 | user-select: var(--user-select);
445 | }
446 |
447 | &__battery-button {
448 | height: 48px;
449 | border: none;
450 | background-color: transparent;
451 | font-size: 24px;
452 | cursor: pointer;
453 | }
454 |
455 | &__figure-31 {
456 | width: 100%;
457 | grid-area: fig-31;
458 | }
459 |
460 | &__happy-piggy {
461 | width: 128px;
462 | }
463 | }
464 |
465 | @keyframes blink {
466 | 0%,
467 | 45.8333333% {
468 | opacity: 0;
469 | }
470 |
471 | 54.1666666% {
472 | opacity: 1;
473 | }
474 |
475 | 100% {
476 | opacity: 1;
477 | }
478 | }
479 |
480 | @keyframes beep-text-zoom {
481 | 0% {
482 | transform: scale(0.8) rotate(-1deg);
483 | }
484 |
485 | 60%,
486 | 100% {
487 | transform: scale(1) rotate(1deg);
488 | }
489 | }
490 |
491 | @keyframes beep-lines-fade {
492 | 0%,
493 | 20% {
494 | opacity: 0;
495 | transform: scale(0.95);
496 | }
497 |
498 | 29%,
499 | 100% {
500 | opacity: 1;
501 | transform: scale(1);
502 | }
503 | }
504 |
505 | @media screen and (min-width: 90em) {
506 | .Watch {
507 | &__container {
508 | grid-template-rows: var(--rows_large);
509 | grid-template-columns: var(--columns_large);
510 | grid-template-areas: var(--areas_large);
511 | }
512 |
513 | &__project-info {
514 | width: 160px;
515 | align-self: start;
516 | justify-self: end;
517 | grid-column: -3 / -1;
518 | grid-row: 1 / span 3;
519 | justify-items: end;
520 | }
521 |
522 | &__description {
523 | width: unset;
524 | text-align: right;
525 | }
526 | }
527 | }
528 |
529 | @media screen and (max-width: 63em) {
530 | .Watch {
531 | &__container {
532 | grid-template-columns: var(--columns_small);
533 | grid-template-rows: var(--rows_small);
534 | grid-template-areas: var(--areas_small);
535 | }
536 | }
537 | }
538 |
539 | @media screen and (max-width: 32em) {
540 | .Watch {
541 | &__container {
542 | grid-template-rows: var(--rows_x-small);
543 | min-height: var(--min-height_x-small);
544 | row-gap: var(--row-gap_x-small);
545 | padding: 16px;
546 | }
547 |
548 | &__face-and-buttons {
549 | transform: scale(0.65);
550 | }
551 | }
552 | }
553 |
--------------------------------------------------------------------------------
/src/WatchButton.js:
--------------------------------------------------------------------------------
1 | import cn from './classNames';
2 |
3 | const WatchButton = (function makeWatchButton() {
4 | const types = ['a', 'b', 'c', 'd'];
5 | const events = types.reduce((result, type) => {
6 | result[type] = {
7 | pointerDown: `${type.toUpperCase()}_PRESSED`,
8 | pointerUp: `${type.toUpperCase()}_RELEASED`,
9 | pointerCancel: `${type.toUpperCase()}_RELEASED`,
10 | };
11 | return result;
12 | }, {});
13 |
14 | return function WatchButton({ type, send, children: label }) {
15 | return (
16 | send(events[type].pointerDown)}
18 | onPointerUp={() => send(events[type].pointerUp)}
19 | onPointerCancel={() => send(events[type].pointerCancel)}
20 | className={cn(`button-${type}`)}
21 | >
22 | {label}
23 |
24 | );
25 | };
26 | })();
27 |
28 | export default WatchButton;
29 |
--------------------------------------------------------------------------------
/src/assets/DigitalDismay-VAKw.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/src/assets/DigitalDismay-VAKw.ttf
--------------------------------------------------------------------------------
/src/assets/alarm_1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | alarm1
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/assets/alarm_2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | alarm2
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/assets/am.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | am
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/beep_lines.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | beep_lines
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/beep_text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | beep_text
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/chime.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | chime
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/assets/colon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | colon
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/double_prime.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | double_prime
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/figure_31.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/src/assets/figure_31.png
--------------------------------------------------------------------------------
/src/assets/happy_piggy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/src/assets/happy_piggy.png
--------------------------------------------------------------------------------
/src/assets/period.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | period
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/pig_31_text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Group
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/assets/pm.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | pm
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/prime.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | prime
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/assets/readme/intro.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andyjakubowski/statechart-watch/982aefba45e88eea6fa99f11f6d1a1b6f19f41ff/src/assets/readme/intro.gif
--------------------------------------------------------------------------------
/src/assets/stopwatch.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | stopwatch
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/assets/weak_battery.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | weak_battery
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/classNames.js:
--------------------------------------------------------------------------------
1 | const makeBemClassNamer = function makeBemClassNamer(blockName) {
2 | return function bemClassNamer(element, modifier = null) {
3 | const elPart = !element ? '' : `__${element}`;
4 | const modPart = !modifier ? '' : `_${modifier}`;
5 |
6 | return `${blockName}${elPart}${modPart}`;
7 | };
8 | };
9 |
10 | const cn = makeBemClassNamer('Watch');
11 |
12 | export default cn;
13 |
--------------------------------------------------------------------------------
/src/extras.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | let keysDown = {
4 | a: false,
5 | b: false,
6 | c: false,
7 | d: false,
8 | };
9 |
10 | export const useKeyDown = function useKeyDown(send) {
11 | useEffect(() => {
12 | const handleKeyDown = function handleKeyDown(e) {
13 | if (keysDown?.[e.key] === true) {
14 | return;
15 | }
16 |
17 | switch (e.key) {
18 | case 'a':
19 | send('A_PRESSED');
20 | keysDown.a = true;
21 | break;
22 | case 'b':
23 | send('B_PRESSED');
24 | keysDown.b = true;
25 | break;
26 | case 'c':
27 | send('C_PRESSED');
28 | keysDown.c = true;
29 | break;
30 | case 'd':
31 | send('D_PRESSED');
32 | keysDown.d = true;
33 | break;
34 | default:
35 | }
36 | };
37 | window.addEventListener('keydown', handleKeyDown);
38 | return () => window.removeEventListener('keydown', handleKeyDown);
39 | }, [send]);
40 | };
41 |
42 | export const useKeyUp = function useKeyUp(send) {
43 | useEffect(() => {
44 | const handleKeyUp = (e) => {
45 | switch (e.key) {
46 | case 'a':
47 | send('A_RELEASED');
48 | keysDown.a = false;
49 | break;
50 | case 'b':
51 | send('B_RELEASED');
52 | keysDown.b = false;
53 | break;
54 | case 'c':
55 | send('C_RELEASED');
56 | keysDown.c = false;
57 | break;
58 | case 'd':
59 | send('D_RELEASED');
60 | keysDown.d = false;
61 | break;
62 | default:
63 | }
64 | };
65 | window.addEventListener('keyup', handleKeyUp);
66 | return () => window.removeEventListener('keyup', handleKeyUp);
67 | }, [send]);
68 | };
69 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root'));
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/watchCaseMachine.js:
--------------------------------------------------------------------------------
1 | import { createMachine, actions } from 'xstate';
2 | const { assign, send, pure } = actions;
3 |
4 | const seconds = function seconds(num) {
5 | return num * 1000;
6 | };
7 | const IDLENESS_DELAY = seconds(120);
8 | const STOPWATCH_INTERVAL = 10;
9 | const INITIAL_STOPWATCH_CONTEXT = {
10 | start: null,
11 | elapsedBeforeStart: 0,
12 | elapsedTotal: 0,
13 | lap: 0,
14 | };
15 | const MIN_YEAR = 1979;
16 | const MAX_YEAR = 2009;
17 | const INITIAL_YEAR = MIN_YEAR;
18 | const incrementByOneSec = function incrementByOneSec(sec) {
19 | return (sec + 1) % 60;
20 | };
21 | const incrementByOneMin = function incrementByOneMin(min) {
22 | return (min + 1) % 10;
23 | };
24 | const incrementByTenMin = function incrementByTenMin(min) {
25 | return (min + 1) % 6;
26 | };
27 | const incrementByOneHour = function incrementByOneHour(hr, hourMode24 = true) {
28 | if (hourMode24) {
29 | return (hr + 1) % 24;
30 | }
31 | };
32 |
33 | const incrementMonth = function incrementMonth(month) {
34 | return (month + 1) % 12;
35 | };
36 | const incrementDate = function incrementDate(date, month) {
37 | return (date + 1) % daysInMonth(month);
38 | };
39 | const incrementDay = function incrementDay(day) {
40 | return (day + 1) % 7;
41 | };
42 | const incrementYear = function incrementYear(year) {
43 | const incremented = year + 1;
44 | return (
45 | MIN_YEAR + (Math.max(0, incremented - MIN_YEAR) % (MAX_YEAR - MIN_YEAR + 1))
46 | );
47 | };
48 |
49 | const daysInMonth = function daysInMonth(monthIndex) {
50 | const months = [
51 | 'Jan',
52 | 'Feb',
53 | 'Mar',
54 | 'Apr',
55 | 'May',
56 | 'Jun',
57 | 'Jul',
58 | 'Aug',
59 | 'Sep',
60 | 'Oct',
61 | 'Nov',
62 | 'Dec',
63 | ];
64 | const monthKey = months[monthIndex].toLowerCase();
65 | const daysPerMonth = {
66 | jan: 31,
67 | feb: 29,
68 | mar: 31,
69 | apr: 30,
70 | may: 31,
71 | jun: 30,
72 | jul: 31,
73 | aug: 31,
74 | sep: 30,
75 | oct: 31,
76 | nov: 30,
77 | dec: 31,
78 | };
79 | return daysPerMonth[monthKey];
80 | };
81 |
82 | const getTimeAfterTick = function getTimeAfterTick({
83 | sec,
84 | oneMin,
85 | tenMin,
86 | hr,
87 | mon,
88 | date,
89 | day,
90 | year,
91 | ...rest
92 | }) {
93 | const crossedBorderline = function crossedBorderline(time, newTime) {
94 | return time !== newTime && newTime === 0;
95 | };
96 |
97 | const newSec = incrementByOneSec(sec);
98 | const newOneMin = crossedBorderline(sec, newSec)
99 | ? incrementByOneMin(oneMin)
100 | : oneMin;
101 | const newTenMin = crossedBorderline(oneMin, newOneMin)
102 | ? incrementByTenMin(tenMin)
103 | : tenMin;
104 | const newHr = crossedBorderline(tenMin, newTenMin)
105 | ? incrementByOneHour(hr)
106 | : hr;
107 | const newDate = crossedBorderline(hr, newHr)
108 | ? incrementDate(date, mon)
109 | : date;
110 | const newDay = crossedBorderline(hr, newHr) ? incrementDay(day) : day;
111 | const newMon = crossedBorderline(date, newDate) ? incrementMonth(mon) : mon;
112 | const newYear = crossedBorderline(mon, newMon) ? incrementYear(year) : year;
113 |
114 | return {
115 | sec: newSec,
116 | oneMin: newOneMin,
117 | tenMin: newTenMin,
118 | hr: newHr,
119 | mon: newMon,
120 | date: newDate,
121 | day: newDay,
122 | year: newYear,
123 | ...rest,
124 | };
125 | };
126 |
127 | const areTimesEqual = function areTimesEqual(a, b) {
128 | return (
129 | a.sec === b.sec &&
130 | a.oneMin === b.oneMin &&
131 | a.tenMin === b.tenMin &&
132 | a.hr === b.hr
133 | );
134 | };
135 |
136 | const isWholeHour = function isWholeHour(time) {
137 | return time.sec === 0 && time.oneMin === 0 && time.tenMin === 0;
138 | };
139 |
140 | const createTimeIncrementActions = function createTimeIncrementActions(name) {
141 | return {
142 | [`increment${name}ByOneSec`]: assign({
143 | [name]: (ctx) => ({
144 | ...ctx[name],
145 | sec: incrementByOneSec(ctx[name].sec),
146 | }),
147 | }),
148 | [`increment${name}ByOneMin`]: assign({
149 | [name]: (ctx) => ({
150 | ...ctx[name],
151 | oneMin: incrementByOneMin(ctx[name].oneMin),
152 | }),
153 | }),
154 | [`increment${name}ByTenMin`]: assign({
155 | [name]: (ctx) => ({
156 | ...ctx[name],
157 | tenMin: incrementByTenMin(ctx[name].tenMin),
158 | }),
159 | }),
160 | [`increment${name}ByOneHour`]: assign({
161 | [name]: (ctx) => ({
162 | ...ctx[name],
163 | hr: incrementByOneHour(ctx[name].hr),
164 | }),
165 | }),
166 | };
167 | };
168 |
169 | const dateIncrementActions = {
170 | incrementTMonth: assign({
171 | T: (ctx) => ({
172 | ...ctx.T,
173 | mon: incrementMonth(ctx.T.mon),
174 | }),
175 | }),
176 | incrementTDate: assign({
177 | T: (ctx) => ({
178 | ...ctx.T,
179 | date: incrementDate(ctx.T.date, ctx.T.mon),
180 | }),
181 | }),
182 | incrementTDay: assign({
183 | T: (ctx) => ({
184 | ...ctx.T,
185 | day: incrementDay(ctx.T.day),
186 | }),
187 | }),
188 | incrementTYear: assign({
189 | T: (ctx) => ({
190 | ...ctx.T,
191 | year: incrementYear(ctx.T.year),
192 | }),
193 | }),
194 | toggleClockMode: assign({
195 | T: (ctx) => ({
196 | ...ctx.T,
197 | mode: ctx.T.mode === '24h' ? '12h' : '24h',
198 | }),
199 | }),
200 | };
201 |
202 | const timeIncrementActions = {
203 | ...createTimeIncrementActions('T'),
204 | ...createTimeIncrementActions('T1'),
205 | ...createTimeIncrementActions('T2'),
206 | };
207 |
208 | const watchMachine = createMachine(
209 | {
210 | id: 'watch',
211 | initial: 'alive',
212 | predictableActionArguments: true,
213 | context: {
214 | T: {
215 | sec: 50,
216 | oneMin: 9,
217 | tenMin: 5,
218 | hr: 11,
219 | mon: 11,
220 | date: 30,
221 | day: 0,
222 | year: INITIAL_YEAR,
223 | mode: '24h',
224 | },
225 | T1: {
226 | sec: 0,
227 | oneMin: 0,
228 | tenMin: 0,
229 | hr: 12,
230 | },
231 | T2: {
232 | sec: 0,
233 | oneMin: 0,
234 | tenMin: 0,
235 | hr: 12,
236 | },
237 | stopwatch: INITIAL_STOPWATCH_CONTEXT,
238 | TICK_INTERVAL: 1000,
239 | batteryPercentage: 100,
240 | },
241 | states: {
242 | dead: {
243 | id: 'dead',
244 | type: 'final',
245 | },
246 | alive: {
247 | type: 'parallel',
248 | invoke: [
249 | {
250 | src: 'ticker',
251 | },
252 | ],
253 | states: {
254 | 'alarm-1-status': {
255 | initial: 'disabled',
256 | states: {
257 | disabled: {
258 | on: {
259 | D_PRESSED: {
260 | target: 'enabled',
261 | in: '#watch.alive.main.displays.out.alarm-1.off',
262 | },
263 | },
264 | },
265 | enabled: {
266 | on: {
267 | D_PRESSED: {
268 | target: 'disabled',
269 | in: '#watch.alive.main.displays.out.alarm-1.on',
270 | },
271 | },
272 | },
273 | },
274 | },
275 | 'alarm-2-status': {
276 | initial: 'disabled',
277 | states: {
278 | disabled: {
279 | on: {
280 | D_PRESSED: {
281 | target: 'enabled',
282 | in: '#watch.alive.main.displays.out.alarm-2.off',
283 | },
284 | },
285 | },
286 | enabled: {
287 | on: {
288 | D_PRESSED: {
289 | target: 'disabled',
290 | in: '#watch.alive.main.displays.out.alarm-2.on',
291 | },
292 | },
293 | },
294 | },
295 | },
296 | 'chime-status': {
297 | initial: 'disabled',
298 | states: {
299 | disabled: {
300 | on: {
301 | D_PRESSED: {
302 | target: 'enabled.quiet',
303 | in: '#watch.alive.main.displays.out.chime.off',
304 | },
305 | },
306 | },
307 | enabled: {
308 | states: {
309 | quiet: {
310 | on: {
311 | T_IS_WHOLE_HOUR: {
312 | target: 'beep',
313 | },
314 | },
315 | },
316 | beep: {
317 | after: {
318 | CHIME_BEEP_DURATION: {
319 | target: 'quiet',
320 | },
321 | },
322 | },
323 | },
324 | on: {
325 | D_PRESSED: {
326 | target: 'disabled',
327 | in: '#watch.alive.main.displays.out.chime.on',
328 | },
329 | },
330 | },
331 | },
332 | },
333 | light: {
334 | initial: 'off',
335 | states: {
336 | off: {
337 | on: {
338 | B_PRESSED: {
339 | target: 'on',
340 | },
341 | },
342 | },
343 | on: {
344 | on: {
345 | B_RELEASED: {
346 | target: 'off',
347 | },
348 | },
349 | },
350 | },
351 | },
352 | power: {
353 | initial: 'ok',
354 | states: {
355 | ok: {
356 | on: {
357 | BATTERY_WEAKENS: {
358 | target: 'blink',
359 | },
360 | },
361 | },
362 | blink: {
363 | on: {
364 | WEAK_BATTERY_DIES: {
365 | target: '#dead',
366 | },
367 | },
368 | },
369 | },
370 | },
371 | main: {
372 | initial: 'displays',
373 | states: {
374 | displays: {
375 | initial: 'regularAndBeep',
376 | states: {
377 | hist: {
378 | type: 'history',
379 | history: 'deep',
380 | },
381 | regularAndBeep: {
382 | type: 'parallel',
383 | states: {
384 | regular: {
385 | initial: 'time',
386 | states: {
387 | time: {
388 | id: 'time',
389 | on: {
390 | A_PRESSED: {
391 | target: '#alarm-1.hist',
392 | },
393 | C_PRESSED: {
394 | target: '#wait',
395 | },
396 | D_PRESSED: {
397 | target: 'date',
398 | },
399 | },
400 | },
401 | date: {
402 | after: {
403 | IDLENESS_DELAY: {
404 | target: 'time',
405 | },
406 | },
407 | on: {
408 | D_PRESSED: {
409 | target: 'time',
410 | },
411 | },
412 | },
413 | update: {
414 | initial: 'sec',
415 | invoke: {
416 | id: 'idlenessTimer',
417 | src: 'idlenessTimer',
418 | },
419 | states: {
420 | sec: {
421 | id: 'sec',
422 | on: {
423 | C_PRESSED: {
424 | target: '1min',
425 | actions: ['resetIdlenessTimer'],
426 | },
427 | D_PRESSED: {
428 | actions: [
429 | 'incrementTByOneSec',
430 | 'resetIdlenessTimer',
431 | ],
432 | },
433 | },
434 | },
435 | '1min': {
436 | on: {
437 | C_PRESSED: {
438 | target: '10min',
439 | actions: ['resetIdlenessTimer'],
440 | },
441 | D_PRESSED: {
442 | actions: [
443 | 'incrementTByOneMin',
444 | 'resetIdlenessTimer',
445 | ],
446 | },
447 | },
448 | },
449 | '10min': {
450 | on: {
451 | C_PRESSED: {
452 | target: 'hr',
453 | actions: ['resetIdlenessTimer'],
454 | },
455 | D_PRESSED: {
456 | actions: [
457 | 'incrementTByTenMin',
458 | 'resetIdlenessTimer',
459 | ],
460 | },
461 | },
462 | },
463 | hr: {
464 | on: {
465 | C_PRESSED: {
466 | target: 'mon',
467 | actions: ['resetIdlenessTimer'],
468 | },
469 | D_PRESSED: {
470 | actions: [
471 | 'incrementTByOneHour',
472 | 'resetIdlenessTimer',
473 | ],
474 | },
475 | },
476 | },
477 | mon: {
478 | on: {
479 | C_PRESSED: {
480 | target: 'date',
481 | actions: ['resetIdlenessTimer'],
482 | },
483 | D_PRESSED: {
484 | actions: [
485 | 'incrementTMonth',
486 | 'resetIdlenessTimer',
487 | ],
488 | },
489 | },
490 | },
491 | date: {
492 | on: {
493 | C_PRESSED: {
494 | target: 'day',
495 | actions: ['resetIdlenessTimer'],
496 | },
497 | D_PRESSED: {
498 | actions: [
499 | 'incrementTDate',
500 | 'resetIdlenessTimer',
501 | ],
502 | },
503 | },
504 | },
505 | day: {
506 | on: {
507 | C_PRESSED: {
508 | target: 'year',
509 | actions: ['resetIdlenessTimer'],
510 | },
511 | D_PRESSED: {
512 | actions: [
513 | 'incrementTDay',
514 | 'resetIdlenessTimer',
515 | ],
516 | },
517 | },
518 | },
519 | year: {
520 | on: {
521 | C_PRESSED: {
522 | target: 'mode',
523 | actions: ['resetIdlenessTimer'],
524 | },
525 | D_PRESSED: {
526 | actions: [
527 | 'incrementTYear',
528 | 'resetIdlenessTimer',
529 | ],
530 | },
531 | },
532 | },
533 | mode: {
534 | on: {
535 | C_PRESSED: {
536 | target: '#time',
537 | },
538 | D_PRESSED: {
539 | actions: [
540 | 'toggleClockMode',
541 | 'resetIdlenessTimer',
542 | ],
543 | },
544 | },
545 | },
546 | },
547 | on: {
548 | B_PRESSED: {
549 | target: 'time',
550 | },
551 | IDLENESS_TIMER_EXPIRED: {
552 | target: 'time',
553 | },
554 | },
555 | },
556 | },
557 | },
558 | 'beep-test': {
559 | initial: '00',
560 | states: {
561 | '00': {
562 | on: {
563 | B_PRESSED: {
564 | target: '10',
565 | },
566 | D_PRESSED: {
567 | target: '01',
568 | },
569 | },
570 | },
571 | 10: {
572 | on: {
573 | B_RELEASED: {
574 | target: '00',
575 | },
576 | D_PRESSED: {
577 | target: 'beep',
578 | },
579 | },
580 | },
581 | '01': {
582 | on: {
583 | D_RELEASED: {
584 | target: '00',
585 | },
586 | B_PRESSED: {
587 | target: 'beep',
588 | },
589 | },
590 | },
591 | beep: {
592 | on: {
593 | B_RELEASED: {
594 | target: '01',
595 | },
596 | D_RELEASED: {
597 | target: '10',
598 | },
599 | },
600 | },
601 | },
602 | },
603 | },
604 | },
605 | wait: {
606 | id: 'wait',
607 | after: {
608 | WAIT_DELAY: {
609 | target: '#sec',
610 | },
611 | },
612 | on: {
613 | C_RELEASED: {
614 | target: '#time',
615 | },
616 | },
617 | },
618 | out: {
619 | initial: 'alarm-1',
620 | invoke: {
621 | id: 'idlenessTimer',
622 | src: 'idlenessTimer',
623 | },
624 | states: {
625 | 'alarm-1': {
626 | id: 'alarm-1',
627 | initial: 'off',
628 | states: {
629 | hist: {
630 | type: 'history',
631 | },
632 | off: {
633 | on: {
634 | D_PRESSED: {
635 | target: 'on',
636 | actions: ['resetIdlenessTimer'],
637 | },
638 | },
639 | },
640 | on: {
641 | on: {
642 | D_PRESSED: {
643 | target: 'off',
644 | actions: ['resetIdlenessTimer'],
645 | },
646 | },
647 | },
648 | },
649 | on: {
650 | A_PRESSED: {
651 | target: 'alarm-2.hist',
652 | actions: ['resetIdlenessTimer'],
653 | },
654 | C_PRESSED: {
655 | target: 'update-1.hr',
656 | actions: ['resetIdlenessTimer'],
657 | },
658 | },
659 | },
660 | 'update-1': {
661 | states: {
662 | hr: {
663 | on: {
664 | C_PRESSED: {
665 | target: '10min',
666 | actions: ['resetIdlenessTimer'],
667 | },
668 | D_PRESSED: {
669 | actions: [
670 | 'incrementT1ByOneHour',
671 | 'resetIdlenessTimer',
672 | ],
673 | },
674 | },
675 | },
676 | '10min': {
677 | on: {
678 | C_PRESSED: {
679 | target: '1min',
680 | actions: ['resetIdlenessTimer'],
681 | },
682 | D_PRESSED: {
683 | actions: [
684 | 'incrementT1ByTenMin',
685 | 'resetIdlenessTimer',
686 | ],
687 | },
688 | },
689 | },
690 | '1min': {
691 | on: {
692 | C_PRESSED: {
693 | target: '#alarm-1',
694 | actions: ['resetIdlenessTimer'],
695 | },
696 | D_PRESSED: {
697 | actions: [
698 | 'incrementT1ByOneMin',
699 | 'resetIdlenessTimer',
700 | ],
701 | },
702 | },
703 | },
704 | },
705 | on: {
706 | B_PRESSED: {
707 | target: 'alarm-1',
708 | actions: ['resetIdlenessTimer'],
709 | },
710 | },
711 | },
712 | 'update-2': {
713 | states: {
714 | hr: {
715 | on: {
716 | C_PRESSED: {
717 | target: '10min',
718 | actions: ['resetIdlenessTimer'],
719 | },
720 | D_PRESSED: {
721 | actions: [
722 | 'incrementT2ByOneHour',
723 | 'resetIdlenessTimer',
724 | ],
725 | },
726 | },
727 | },
728 | '10min': {
729 | on: {
730 | C_PRESSED: {
731 | target: '1min',
732 | actions: ['resetIdlenessTimer'],
733 | },
734 | D_PRESSED: {
735 | actions: [
736 | 'incrementT2ByTenMin',
737 | 'resetIdlenessTimer',
738 | ],
739 | },
740 | },
741 | },
742 | '1min': {
743 | on: {
744 | C_PRESSED: {
745 | target: '#alarm-2',
746 | actions: ['resetIdlenessTimer'],
747 | },
748 | D_PRESSED: {
749 | actions: [
750 | 'incrementT2ByOneMin',
751 | 'resetIdlenessTimer',
752 | ],
753 | },
754 | },
755 | },
756 | },
757 | on: {
758 | B_PRESSED: {
759 | target: 'alarm-2',
760 | actions: ['resetIdlenessTimer'],
761 | },
762 | },
763 | },
764 | 'alarm-2': {
765 | id: 'alarm-2',
766 | initial: 'off',
767 | states: {
768 | hist: {
769 | type: 'history',
770 | },
771 | off: {
772 | on: {
773 | D_PRESSED: {
774 | target: 'on',
775 | actions: ['resetIdlenessTimer'],
776 | },
777 | },
778 | },
779 | on: {
780 | on: {
781 | D_PRESSED: {
782 | target: 'off',
783 | actions: ['resetIdlenessTimer'],
784 | },
785 | },
786 | },
787 | },
788 | on: {
789 | A_PRESSED: {
790 | target: 'chime.hist',
791 | actions: ['resetIdlenessTimer'],
792 | },
793 | C_PRESSED: {
794 | target: 'update-2.hr',
795 | actions: ['resetIdlenessTimer'],
796 | },
797 | },
798 | },
799 | chime: {
800 | initial: 'off',
801 | states: {
802 | hist: {
803 | type: 'history',
804 | },
805 | off: {
806 | on: {
807 | D_PRESSED: {
808 | target: 'on',
809 | actions: ['resetIdlenessTimer'],
810 | },
811 | },
812 | },
813 | on: {
814 | on: {
815 | D_PRESSED: {
816 | target: 'off',
817 | actions: ['resetIdlenessTimer'],
818 | },
819 | },
820 | },
821 | },
822 | on: {
823 | A_PRESSED: {
824 | target: '#stopwatch.hist',
825 | },
826 | },
827 | },
828 | },
829 | on: {
830 | IDLENESS_TIMER_EXPIRED: {
831 | target: 'regularAndBeep',
832 | },
833 | },
834 | },
835 | stopwatch: {
836 | id: 'stopwatch',
837 | initial: 'zero',
838 | states: {
839 | hist: {
840 | type: 'history',
841 | history: 'deep',
842 | },
843 | zero: {
844 | id: 'zero',
845 | entry: ['resetStopwatch'],
846 | on: {
847 | B_PRESSED: {
848 | target: [
849 | 'displayAndRun.display.regular',
850 | 'displayAndRun.run.on',
851 | ],
852 | actions: ['startStopwatch'],
853 | },
854 | },
855 | },
856 | displayAndRun: {
857 | type: 'parallel',
858 | states: {
859 | display: {
860 | states: {
861 | regular: {
862 | on: {
863 | D_PRESSED: [
864 | {
865 | target: 'lap',
866 | in: '#watch.alive.main.displays.stopwatch.displayAndRun.run.on',
867 | actions: ['saveStopwatchLap'],
868 | },
869 | {
870 | target: '#zero',
871 | in: '#watch.alive.main.displays.stopwatch.displayAndRun.run.off',
872 | },
873 | ],
874 | },
875 | },
876 | lap: {
877 | on: {
878 | D_PRESSED: {
879 | target: 'regular',
880 | actions: ['clearStopwatchLap'],
881 | },
882 | },
883 | },
884 | },
885 | },
886 | run: {
887 | id: 'run',
888 | states: {
889 | on: {
890 | invoke: {
891 | src: 'stopwatch',
892 | },
893 | on: {
894 | B_PRESSED: {
895 | target: 'off',
896 | actions: ['pauseStopwatch'],
897 | },
898 | STOPWATCH_TICK: {
899 | actions: [
900 | assign({
901 | stopwatch: ({ stopwatch }) => ({
902 | ...stopwatch,
903 | elapsedTotal:
904 | stopwatch.elapsedBeforeStart +
905 | Date.now() -
906 | stopwatch.start,
907 | }),
908 | }),
909 | ],
910 | },
911 | },
912 | },
913 | off: {
914 | on: {
915 | B_PRESSED: {
916 | target: 'on',
917 | actions: ['startStopwatch'],
918 | },
919 | },
920 | },
921 | },
922 | },
923 | },
924 | },
925 | },
926 | on: {
927 | A_PRESSED: {
928 | target: 'regularAndBeep',
929 | },
930 | },
931 | },
932 | },
933 | on: {
934 | T_HITS_T1: [
935 | {
936 | target: 'alarms-beep.both-beep',
937 | cond: 'P',
938 | },
939 | {
940 | target: 'alarms-beep.alarm-1-beeps',
941 | cond: 'P1',
942 | },
943 | ],
944 | T_HITS_T2: {
945 | target: 'alarms-beep.alarm-2-beeps',
946 | cond: 'P2',
947 | },
948 | },
949 | },
950 | 'alarms-beep': {
951 | states: {
952 | 'alarm-1-beeps': {},
953 | 'alarm-2-beeps': {},
954 | 'both-beep': {},
955 | },
956 | on: {
957 | A_PRESSED: 'displays.hist',
958 | B_PRESSED: 'displays.hist',
959 | C_PRESSED: 'displays.hist',
960 | D_PRESSED: 'displays.hist',
961 | },
962 | after: {
963 | ALARM_BEEPS_DELAY: 'displays.hist',
964 | },
965 | },
966 | },
967 | },
968 | },
969 | on: {
970 | TICK: {
971 | actions: [
972 | pure((ctx) => {
973 | const newBatteryPercentage = ctx.batteryPercentage - 0.1;
974 | const isWeakBattery = newBatteryPercentage < 10;
975 | const isDeadBattery = newBatteryPercentage <= 0;
976 | let actions = [];
977 |
978 | if (isWeakBattery) {
979 | actions.push(send('BATTERY_WEAKENS'));
980 | }
981 |
982 | if (isDeadBattery) {
983 | actions.push(send('WEAK_BATTERY_DIES'));
984 | } else {
985 | actions.push(
986 | assign({ batteryPercentage: newBatteryPercentage })
987 | );
988 | }
989 |
990 | return actions;
991 | }),
992 | pure((ctx) => {
993 | let actions = [];
994 |
995 | const newTime = getTimeAfterTick(ctx.T);
996 | actions.push(
997 | assign({
998 | T: newTime,
999 | })
1000 | );
1001 |
1002 | if (areTimesEqual(newTime, ctx.T1)) {
1003 | actions.push(send('T_HITS_T1'));
1004 | }
1005 |
1006 | if (areTimesEqual(newTime, ctx.T2)) {
1007 | actions.push(send('T_HITS_T2'));
1008 | }
1009 |
1010 | if (isWholeHour(newTime)) {
1011 | actions.push(send('T_IS_WHOLE_HOUR'));
1012 | }
1013 |
1014 | return actions;
1015 | }),
1016 | ],
1017 | },
1018 | },
1019 | },
1020 | },
1021 | },
1022 | {
1023 | delays: {
1024 | IDLENESS_DELAY,
1025 | WAIT_DELAY: seconds(0.5),
1026 | ALARM_BEEPS_DELAY: seconds(30),
1027 | CHIME_BEEP_DURATION: seconds(2),
1028 | },
1029 | actions: {
1030 | resetIdlenessTimer: send('RESET_IDLENESS_TIMER', { to: 'idlenessTimer' }),
1031 | resetStopwatch: assign({
1032 | stopwatch: INITIAL_STOPWATCH_CONTEXT,
1033 | }),
1034 | startStopwatch: assign({
1035 | stopwatch: ({ stopwatch }) => ({
1036 | ...stopwatch,
1037 | start: Date.now(),
1038 | }),
1039 | }),
1040 | pauseStopwatch: assign({
1041 | stopwatch: ({ stopwatch }) => {
1042 | const elapsed =
1043 | stopwatch.elapsedBeforeStart + Date.now() - stopwatch.start;
1044 | return {
1045 | ...stopwatch,
1046 | elapsedBeforeStart: elapsed,
1047 | elapsedTotal: elapsed,
1048 | };
1049 | },
1050 | }),
1051 | saveStopwatchLap: assign({
1052 | stopwatch: ({ stopwatch }) => ({
1053 | ...stopwatch,
1054 | lap: stopwatch.elapsedBeforeStart + Date.now() - stopwatch.start,
1055 | }),
1056 | }),
1057 | clearStopwatchLap: assign({
1058 | stopwatch: ({ stopwatch }) => ({
1059 | ...stopwatch,
1060 | lap: 0,
1061 | }),
1062 | }),
1063 | ...timeIncrementActions,
1064 | ...dateIncrementActions,
1065 | },
1066 | guards: {
1067 | P: (ctx, _, condMeta) => {
1068 | return (
1069 | areTimesEqual(ctx.T1, ctx.T2) &&
1070 | condMeta.state.matches('alive.alarm-1-status.enabled') &&
1071 | condMeta.state.matches('alive.alarm-2-status.enabled')
1072 | );
1073 | },
1074 | P1: (ctx, _, condMeta) => {
1075 | return (
1076 | condMeta.state.matches('alive.alarm-1-status.enabled') &&
1077 | (condMeta.state.matches('alive.alarm-2-status.disabled') ||
1078 | !areTimesEqual(ctx.T1, ctx.T2))
1079 | );
1080 | },
1081 | P2: (ctx, _, condMeta) => {
1082 | return (
1083 | condMeta.state.matches('alive.alarm-2-status.enabled') &&
1084 | (condMeta.state.matches('alive.alarm-1-status.disabled') ||
1085 | !areTimesEqual(ctx.T1, ctx.T2))
1086 | );
1087 | },
1088 | },
1089 | services: {
1090 | ticker: (context) => (callback) => {
1091 | const id = setInterval(() => callback('TICK'), context.TICK_INTERVAL);
1092 |
1093 | return () => clearInterval(id);
1094 | },
1095 | stopwatch: () => (callback) => {
1096 | const id = setInterval(
1097 | () => callback('STOPWATCH_TICK'),
1098 | STOPWATCH_INTERVAL
1099 | );
1100 |
1101 | return () => clearInterval(id);
1102 | },
1103 | idlenessTimer: () => (callback, onReceive) => {
1104 | const start = function start() {
1105 | return setInterval(
1106 | () => callback('IDLENESS_TIMER_EXPIRED'),
1107 | IDLENESS_DELAY
1108 | );
1109 | };
1110 | let id = start();
1111 |
1112 | onReceive((e) => {
1113 | if (e.type === 'RESET_IDLENESS_TIMER') {
1114 | clearInterval(id);
1115 | id = start();
1116 | }
1117 | });
1118 |
1119 | return () => clearInterval(id);
1120 | },
1121 | },
1122 | }
1123 | );
1124 |
1125 | export const watchCaseMachine = createMachine(
1126 | {
1127 | id: 'WatchCase',
1128 | initial: 'alive',
1129 | predictableActionArguments: true,
1130 | states: {
1131 | dead: {
1132 | on: {
1133 | INSERT_BATTERY: 'alive',
1134 | },
1135 | },
1136 | alive: {
1137 | invoke: {
1138 | id: 'watch',
1139 | src: 'watchMachine',
1140 | onDone: {
1141 | target: 'dead',
1142 | },
1143 | },
1144 | on: {
1145 | REMOVE_BATTERY: 'dead',
1146 | },
1147 | },
1148 | },
1149 | },
1150 | {
1151 | services: {
1152 | watchMachine,
1153 | },
1154 | }
1155 | );
1156 |
--------------------------------------------------------------------------------