Created
March 2, 2024 17:44
-
-
Save vasiliy-pdk/bc8d7c8f621516d9e2c52ac6146b4002 to your computer and use it in GitHub Desktop.
Separate presentation from domain logic in SolidJS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Shortened example | |
// in Exercise/index.jsx | |
import withState from 'components/withState'; | |
import { createExercise } from './createExercise'; | |
import { Exercise as ExerciseUi } from './Exercise'; | |
export const Exercise = withState(ExerciseUi, (props) => createExercise(props.skill)); | |
// in withState.jsx | |
import { mergeProps } from "solid-js"; | |
// Utility that allows to combine a ui component with its state factory passed as props into the ui component | |
export default function withState(UiComponent, stateFactory) { | |
return function(props) { | |
const mergedProps = mergeProps({ ...stateFactory(props) }, props); | |
return ( | |
<UiComponent { ...mergedProps } /> | |
); | |
} | |
} | |
// In Exercise/Exercise.jsx - UI component. No domain / business logic - presentation only | |
export function Exercise(props) { | |
let container; | |
onMount(() => { | |
container.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
}); | |
return ( | |
// . . . | |
<Match when={props.state() == 'initializing'}> | |
Give access to your microphone. | |
</Match> | |
<Match when={props.state() == 'running'}> | |
Play note {props.task().expectedNote()} | |
<Switch fallback=". . ."> | |
<Match when={props.task().state() == 'correct'}> | |
Well done👍 | |
// . . . Lots of JSX cut out . . . | |
<Match when={props.state() == 'stopped'}> | |
<div class="is-size-1"> | |
Good job! Here are your results | |
</div> | |
<table class="table is-size-4" style={{margin: 'auto'}}> | |
// . . . | |
<td>{props.score()}</td> | |
<td>{props.averageLatency()}</td> | |
<td>{props.successRate()}</td> | |
// . . . | |
<button class="button is-primary" onClick={props.onFinished}>Okay</button> | |
</Match> | |
// . . . | |
); | |
// In createExercise.js - domain logic and state, no presentation | |
import { createSignal, createEffect } from 'solid-js'; | |
import { getRandomInt } from '/src/lib/utils'; | |
import { getProgress } from '../progress.js'; | |
import { startNotesDetector, stopNotesDetector } from '/src/lib/audio/index.js'; | |
import { speak } from '/src/lib/audio/voice'; | |
import { screenWake } from '/src/lib/screenWake'; | |
import { trackEvent } from '/src/app/services/trackEvent'; | |
function createTask(trainable) { | |
const noteToTrain = trainable.getSkillToTrain(); | |
const [expectedNote, setExpectedNote] = createSignal(noteToTrain.note); | |
const [attemptsLeft, setAttemptsLeft] = createSignal(3); | |
const [timeLeft, setTimeLeft] = createSignal(30); // seconds | |
const [state, setState] = createSignal('new'); | |
let workStartedAt, timerId; | |
const beginWork = () => { | |
if(state() != 'new') throw 'Can begin only new task!'; | |
workStartedAt = Date.now(); | |
timerId = setTimeout(() => { | |
// run out of time | |
console.log('Timeout') | |
complete('fail'); | |
}, timeLeft() * 1000); | |
} | |
const stopWork = () => { | |
if (timerId) clearTimeout(timerId); | |
} | |
const check = (answerNote) => { | |
if(['correct', 'fail'].includes(state())) return; // Already checked | |
if (answerNote == expectedNote()) { | |
complete('correct'); | |
} else { | |
setAttemptsLeft(v => v - 1); | |
if(attemptsLeft() > 0) { | |
setState('retry'); | |
// update skill | |
noteToTrain.trackFailure(); | |
} else { | |
complete('fail'); | |
} | |
} | |
} | |
const complete = (state) => { | |
clearTimeout(timerId); | |
setState(state); | |
if (state == 'correct') { | |
noteToTrain.trackSuccess(Math.max(Date.now() - workStartedAt, 1)); | |
} else { | |
noteToTrain.trackFailure(); | |
} | |
} | |
return { | |
expectedNote, | |
attemptsLeft, | |
timeLeft, | |
state, | |
check, | |
beginWork, | |
stopWork | |
}; | |
} | |
export function createExercise(skill) { | |
const feedbackTtl = 1000; // Milliseconds for how long feedback is shown | |
const trainable = getProgress().getTrainableSkill(skill.id); | |
const durationSeconds = getExerciseDuration(trainable.skillsCount); | |
const [state, setState] = createSignal('initializing'); | |
const [task, setTask] = createSignal(createTask(trainable)); | |
const [score, setScore] = createSignal(0); | |
const [averageLatency, setAverageLatency] = createSignal(0); | |
const [successRate, setSuccessRate] = createSignal(0); | |
const [isListening, setIsListening] = createSignal(false); | |
const [currentNote, setCurrentNote] = createSignal(); | |
const [timeLeft, setTimeLeft] = createSignal(durationSeconds); | |
let isTalking = false; | |
const say = (message, pitch = 1) => { | |
// . . . | |
} | |
createEffect(() => { | |
if(state() != 'running') return; | |
const taskState = task().state(); | |
if (taskState == 'new') { | |
say(`Play ${task().expectedNote()}`).then(() => { | |
task().beginWork(); | |
setIsListening(true); | |
}); | |
} else if(taskState == 'retry') { | |
sayTaskState(taskState).then(() => setIsListening(true)); | |
} else if (['fail', 'correct'].includes(taskState)) { | |
setIsListening(false); | |
setScore(trainable.score); | |
setAverageLatency(trainable.averageLatency); | |
setSuccessRate(trainable.successRate); | |
sayTaskState(taskState).then(() => { | |
scheduleNextTask(); | |
}); | |
} | |
}); | |
let pastNote; | |
createEffect(() => { | |
const note = currentNote(); | |
if (note && isListening() && !isTalking && pastNote != currentNote) { | |
task()?.check(note) | |
} | |
}) | |
const scheduleNextTask = () => { | |
if (state() == 'running') { | |
setTimeout(() => setTask(createTask(trainable)), feedbackTtl); | |
} | |
} | |
trackEvent('ExerciseStarted', { name: skill.name }); | |
startNotesDetector( | |
(note, startedAt) => { | |
setCurrentNote(note); | |
}, | |
(note) => { | |
pastNote = note; | |
setCurrentNote(null); | |
} | |
).then(() => { | |
// Begin | |
screenWake.lock(); | |
setTimeout(() => { | |
const startedAt = Date.now(); | |
setState('running'); | |
const refreshInterval = setInterval(() => { | |
setTimeLeft(Math.round(durationSeconds - (Date.now() - startedAt) / 1000)); | |
}, 1000); | |
// schedule end | |
setTimeout(() => { | |
// end | |
clearInterval(refreshInterval); | |
stopNotesDetector(); | |
task().stopWork(); | |
getProgress().update(skill.id, trainable); | |
setState('stopped'); | |
screenWake.unlock(); | |
trackEvent('ExerciseCompleted', { name: skill.name, durationSeconds, score: trainable.score, successRate: trainable.successRate, averageLatency: trainable.averageLatency }) | |
}, durationSeconds * 1000); | |
}, 650); | |
}); | |
return { | |
task, | |
state, | |
isListening, | |
score, | |
averageLatency, | |
successRate, | |
currentNote, | |
timeLeft, | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment