├── .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 | ![Overview of the watch](src/assets/readme/intro.gif) 10 | ![Figure 31 from Harel’s paper](src/assets/figure_31.png) 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 | 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 | 10 | ) : ( 11 | 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 | Beep text 18 | Beep lines 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 Colon; 13 | }; 14 | 15 | const WeakBattery = function WeakBattery() { 16 | return ( 17 | Weak battery 22 | ); 23 | }; 24 | 25 | const Primes = function Primes() { 26 | return ( 27 | <> 28 | Prime 29 | Double prime 34 | 35 | ); 36 | }; 37 | 38 | const Period = function Period() { 39 | return Period; 40 | }; 41 | 42 | const AM = function AM() { 43 | return AM symbol; 44 | }; 45 | 46 | const PM = function PM() { 47 | return PM symbol; 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 | A happy piggy 14 | Pig 31 15 |
16 |
17 |

18 | A replica of the{' '} 19 | 20 | Citizen Quartz Multi Alarm III 21 | {' '} 22 | watch based on figure 31 in David Harel’s 1987{' '} 23 | 24 | paper 25 | {' '} 26 | introducing{' '} 27 | 28 | statecharts 29 | 30 |

31 |

Use with the on-screen buttons or your keyboard

32 |

33 | Built with XState &{' '} 34 | React by{' '} 35 | Andy 36 |

37 |

38 | Code on{' '} 39 | 40 | GitHub 41 | 42 |

43 |
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 | Figure 31 from Harel's statecharts paper 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 | 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 | --------------------------------------------------------------------------------