Skip to content

Instantly share code, notes, and snippets.

@vasiliy-pdk
Created March 2, 2024 17:44
Show Gist options
  • Save vasiliy-pdk/bc8d7c8f621516d9e2c52ac6146b4002 to your computer and use it in GitHub Desktop.
Save vasiliy-pdk/bc8d7c8f621516d9e2c52ac6146b4002 to your computer and use it in GitHub Desktop.
Separate presentation from domain logic in SolidJS
// 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