Skip to content

Commit 4ddd29a

Browse files
authored
Merge pull request #70 from flextremedev/extract-timer-machine
Extract timer machine
2 parents 476aac1 + dfa0184 commit 4ddd29a

File tree

15 files changed

+339
-277
lines changed

15 files changed

+339
-277
lines changed

packages/core/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module.exports = {
33
name: 'Core',
44
color: 'white',
55
},
6+
collectCoverageFrom: ['**/*.{ts,tsx}'],
67
coverageThreshold: {
78
global: {
89
branches: 100,

packages/core/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@
1010
},
1111
"devDependencies": {
1212
"@flextremedev/eslint-config-typescript": "^0.1.1"
13+
},
14+
"dependencies": {
15+
"xstate": "^4.17.1"
1316
}
1417
}

packages/core/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
export { hasOneSecondElapsed } from './hasOneSecondElapsed';
1+
export { hasOneSecondElapsed } from './utils/hasOneSecondElapsed';
2+
export { timerMachine } from './machine/timerMachine';
3+
export * from './machine/types';
4+
export { timerStates } from './model/timerStates';
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/* istanbul ignore file */
2+
/* covered by app integration test */
3+
import { hasOneSecondElapsed } from '@interval-timer/core';
4+
import { getMinutes, getSeconds, subSeconds } from 'date-fns';
5+
import { assign, createMachine, send } from 'xstate';
6+
7+
import { timerStates } from '../model/timerStates';
8+
9+
import {
10+
SetBreakIntervalEvent,
11+
SetRoundsEvent,
12+
SetWorkIntervalEvent,
13+
TimerContext,
14+
TimerEvent,
15+
TimerState,
16+
} from './types';
17+
18+
export const timerEvents = {
19+
START: 'START',
20+
WORK: 'WORK',
21+
BREAK: 'BREAK',
22+
STOP: 'STOP',
23+
TICK: 'TICK',
24+
SET_ROUNDS: 'SET_ROUNDS',
25+
SET_WORK_INTERVAL: 'SET_WORK_INTERVAL',
26+
SET_BREAK_INTERVAL: 'SET_BREAK_INTERVAL',
27+
};
28+
29+
const SECONDS_PER_MINUTE = 60;
30+
const countDown = (ctx: TimerContext): Partial<TimerContext> => {
31+
return {
32+
timestamp: Date.now(),
33+
timeLeft: subSeconds(ctx.timeLeft, 1),
34+
};
35+
};
36+
const shouldCountDown = (ctx: TimerContext): boolean => {
37+
return (
38+
(getSeconds(ctx.timeLeft) > 0 || getMinutes(ctx.timeLeft) > 0) &&
39+
hasOneSecondElapsed(ctx.timestamp)
40+
);
41+
};
42+
43+
export const timerMachine = createMachine<TimerContext, TimerEvent, TimerState>(
44+
{
45+
context: {
46+
prepareTime: new Date(5000),
47+
timeLeft: new Date(5000),
48+
rounds: 2,
49+
roundsLeft: 0,
50+
workInterval: new Date(0),
51+
breakInterval: new Date(0),
52+
timestamp: Date.now(),
53+
},
54+
initial: timerStates.STOPPED,
55+
states: {
56+
[timerStates.STOPPED]: {
57+
on: {
58+
[timerEvents.START]: {
59+
target: timerStates.PREWORK,
60+
},
61+
[timerEvents.SET_ROUNDS]: {
62+
actions: 'assignRounds',
63+
},
64+
[timerEvents.SET_BREAK_INTERVAL]: {
65+
actions: 'assignBreakInterval',
66+
},
67+
[timerEvents.SET_WORK_INTERVAL]: {
68+
actions: 'assignWorkInterval',
69+
},
70+
},
71+
entry: send(timerEvents.STOP),
72+
},
73+
[timerStates.PREWORK]: {
74+
on: {
75+
[timerEvents.STOP]: timerStates.STOPPED,
76+
[timerEvents.TICK]: [
77+
{
78+
actions: ['countDown', 'countDownLastBreakEffect'],
79+
cond: 'shouldCountDownLast',
80+
},
81+
{
82+
actions: 'countDown',
83+
cond: 'shouldCountDown',
84+
},
85+
],
86+
'': [
87+
{
88+
target: timerStates.STOPPED,
89+
cond: 'isDone',
90+
},
91+
{
92+
target: timerStates.WORK,
93+
cond: 'shouldTransition',
94+
},
95+
],
96+
},
97+
entry: 'initPrepare',
98+
},
99+
[timerStates.WORK]: {
100+
on: {
101+
[timerEvents.STOP]: timerStates.STOPPED,
102+
[timerEvents.TICK]: [
103+
{
104+
actions: ['countDown', 'countDownLastWorkEffect'],
105+
cond: 'shouldCountDownLast',
106+
},
107+
{
108+
actions: 'countDown',
109+
cond: 'shouldCountDown',
110+
},
111+
],
112+
'': [
113+
{
114+
target: timerStates.STOPPED,
115+
cond: 'isDone',
116+
},
117+
{
118+
target: timerStates.BREAK,
119+
cond: 'shouldTransition',
120+
},
121+
],
122+
},
123+
entry: ['initWork', 'initWorkEffect'],
124+
},
125+
[timerStates.BREAK]: {
126+
on: {
127+
[timerEvents.STOP]: timerStates.STOPPED,
128+
[timerEvents.TICK]: [
129+
{
130+
actions: ['countDown', 'countDownLastBreakEffect'],
131+
cond: 'shouldCountDownLast',
132+
},
133+
{
134+
actions: 'countDown',
135+
cond: 'shouldCountDown',
136+
},
137+
],
138+
'': {
139+
target: timerStates.WORK,
140+
cond: 'shouldTransition',
141+
},
142+
},
143+
entry: ['initBreak', 'initBreakEffect'],
144+
},
145+
},
146+
},
147+
{
148+
actions: {
149+
assignBreakInterval: assign({
150+
breakInterval: (_context, event) => {
151+
return (event as SetBreakIntervalEvent).breakInterval;
152+
},
153+
}),
154+
assignRounds: assign({
155+
rounds: (_context, event) => {
156+
return (event as SetRoundsEvent).rounds;
157+
},
158+
}),
159+
assignWorkInterval: assign({
160+
workInterval: (_context, event) => {
161+
return (event as SetWorkIntervalEvent).workInterval;
162+
},
163+
}),
164+
initWorkEffect: () => {
165+
// test stub
166+
},
167+
initBreakEffeet: () => {
168+
// test stub
169+
},
170+
countDown: assign(countDown),
171+
countDownLastWorkEffect: () => {
172+
// test stub
173+
},
174+
countDownLastBreakEffect: () => {
175+
// test stub
176+
},
177+
initBreak: assign({
178+
timeLeft: (ctx) => {
179+
return ctx.breakInterval;
180+
},
181+
}),
182+
initPrepare: assign<TimerContext, TimerEvent>({
183+
timestamp: () => {
184+
return Date.now();
185+
},
186+
roundsLeft: (ctx) => {
187+
return ctx.rounds;
188+
},
189+
timeLeft: (ctx) => {
190+
return ctx.prepareTime;
191+
},
192+
}),
193+
initWork: assign({
194+
timeLeft: (ctx) => {
195+
return ctx.workInterval;
196+
},
197+
roundsLeft: (ctx) => {
198+
return ctx.roundsLeft - 1;
199+
},
200+
}),
201+
},
202+
guards: {
203+
shouldCountDownLast: (ctx) => {
204+
const secondsLeft =
205+
getMinutes(ctx.timeLeft) * SECONDS_PER_MINUTE +
206+
getSeconds(ctx.timeLeft);
207+
const actualSecondsLeft = secondsLeft - 1;
208+
return (
209+
shouldCountDown(ctx) &&
210+
actualSecondsLeft <= 3 &&
211+
actualSecondsLeft > 0
212+
);
213+
},
214+
shouldTransition: (ctx) => {
215+
return (
216+
getMinutes(ctx.timeLeft) <= 0 &&
217+
getSeconds(ctx.timeLeft) <= 0 &&
218+
ctx.roundsLeft > 0
219+
);
220+
},
221+
shouldCountDown,
222+
isDone: (ctx) => {
223+
return (
224+
getMinutes(ctx.timeLeft) <= 0 &&
225+
getSeconds(ctx.timeLeft) <= 0 &&
226+
(ctx.roundsLeft <= 0 ||
227+
(getMinutes(ctx.workInterval) === 0 &&
228+
getSeconds(ctx.workInterval) === 0) ||
229+
(getMinutes(ctx.breakInterval) === 0 &&
230+
getSeconds(ctx.breakInterval) === 0))
231+
);
232+
},
233+
},
234+
}
235+
);

packages/core/src/machine/types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { timerStates } from '../model/timerStates';
2+
3+
export type TimerContext = {
4+
prepareTime: Date;
5+
timeLeft: Date;
6+
rounds: number;
7+
roundsLeft: number;
8+
workInterval: Date;
9+
breakInterval: Date;
10+
timestamp: number;
11+
};
12+
13+
export type SetRoundsEvent = {
14+
type: 'SET_ROUNDS';
15+
rounds: number;
16+
};
17+
18+
export type SetBreakIntervalEvent = {
19+
type: 'SET_BREAK_INTERVAL';
20+
breakInterval: Date;
21+
};
22+
23+
export type SetWorkIntervalEvent = {
24+
type: 'SET_WORK_INTERVAL';
25+
workInterval: Date;
26+
};
27+
28+
export type BaseEvent = {
29+
type: 'START' | 'STOP' | 'BREAK' | 'WORK' | 'TICK';
30+
};
31+
export type TimerEvent =
32+
| SetRoundsEvent
33+
| SetBreakIntervalEvent
34+
| SetWorkIntervalEvent
35+
| BaseEvent;
36+
37+
export type TimerStateSchema = {
38+
states: Record<keyof typeof timerStates, Record<string, unknown>>;
39+
};
40+
41+
export type TimerState = {
42+
value: keyof TimerStateSchema['states'];
43+
context: TimerContext;
44+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* istanbul ignore file */
2+
/* covered by app integration test */
3+
type TTimerStates = {
4+
STOPPED: 'STOPPED';
5+
PREWORK: 'PREWORK';
6+
WORK: 'WORK';
7+
BREAK: 'BREAK';
8+
};
9+
10+
export const timerStates: TTimerStates = {
11+
STOPPED: 'STOPPED',
12+
PREWORK: 'PREWORK',
13+
WORK: 'WORK',
14+
BREAK: 'BREAK',
15+
};

packages/core/src/hasOneSecondElapsed.test.tsx renamed to packages/core/src/utils/__tests__/hasOneSecondElapsed.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hasOneSecondElapsed } from './hasOneSecondElapsed';
1+
import { hasOneSecondElapsed } from '../hasOneSecondElapsed';
22

33
describe('hasOneSecondElapsed()', () => {
44
it('should return true if one second has elapsed', () => {
File renamed without changes.

packages/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"react-dom": "^17.0.2",
1414
"react-helmet": "^6.1.0",
1515
"react-scripts": "^4.0.3",
16-
"xstate": "^4.11.0"
16+
"xstate": "^4.17.1"
1717
},
1818
"scripts": {
1919
"start": "craco start",

0 commit comments

Comments
 (0)