Browse Source

wip

main
Stian Fredrik Aune 2 years ago
parent
commit
5bec55397f
  1. 1
      docker/bakend/Dockerfile
  2. 1
      pom.xml
  3. 3
      webui-react/package.json
  4. 9
      webui-react/src/App.tsx
  5. 31
      webui-react/src/actions/programs.ts
  6. 2
      webui-react/src/actions/workouts.ts
  7. 9
      webui-react/src/contexts/RuntimeContext.tsx
  8. 12
      webui-react/src/contexts/WorkoutContext.tsx
  9. 11
      webui-react/src/models/Programs.ts
  10. 53
      webui-react/src/models/Shared.ts
  11. 2
      webui-react/src/models/Workouts.ts
  12. 27
      webui-react/src/pages/DevicePage.tsx
  13. 147
      webui-react/src/pages/EditProgramPage.tsx
  14. 28
      webui-react/src/pages/IndexPage.tsx
  15. 23
      webui-react/src/pages/LoadingPage.tsx
  16. 31
      webui-react/src/pages/PlayPage.tsx
  17. 97
      webui-react/src/pages/ProgramPage.tsx
  18. 34
      webui-react/src/pages/WorkoutPage.tsx
  19. 70
      webui-react/src/pages/runtime/ControlsBoi.tsx
  20. 13
      webui-react/src/pages/runtime/ProgramBoi.sass
  21. 83
      webui-react/src/pages/runtime/ProgramBoi.tsx
  22. 2
      webui-react/src/primitives/blob/Blob.sass
  23. 8
      webui-react/src/primitives/blob/Blob.tsx
  24. 4
      webui-react/src/primitives/boi/Boi.sass
  25. 7
      webui-react/src/primitives/boi/Boi.tsx
  26. 36
      webui-react/src/primitives/misc/Misc.tsx
  27. 5
      ykonsole-core/pom.xml
  28. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt
  29. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
  30. 26
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt
  31. 24
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt
  32. 30
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt
  33. 28
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt
  34. 52
      ykonsole-exporter/pom.xml
  35. 23
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
  36. 57
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
  37. 177
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
  38. 5
      ykonsole-server/pom.xml
  39. 66
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

1
docker/bakend/Dockerfile

@ -0,0 +1 @@

1
pom.xml

@ -11,6 +11,7 @@
<module>ykonsole-mysql</module>
<module>ykonsole-server</module>
<module>ykonsole-ktor</module>
<module>ykonsole-exporter</module>
</modules>
<groupId>net.aiterp.git.trimlog</groupId>
<version>2.0.0</version>

3
webui-react/package.json

@ -4,7 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_MODE=webapp vite --host",
"dev": "vite --host",
"dev-webapp": "VITE_MODE=webapp vite --host",
"build-webapp": "tsc && VITE_MODE=webapp vite build",
"build-chrome-plugin": "tsc && VITE_MODE=chrome-plugin vite build",
"preview": "vite preview"

9
webui-react/src/App.tsx

@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'
import IndexPage from "./pages/IndexPage";
import "./App.sass";
import {ProgramContextProvider} from "./contexts/ProgramContext";
import ProgramContext, {ProgramContextProvider} from "./contexts/ProgramContext";
import {BrowserRouter, Route, Routes} from "react-router-dom";
import {DeviceContextProvider} from "./contexts/DeviceContext";
import DevicePage from "./pages/DevicePage";
@ -9,6 +9,8 @@ import {RuntimeContextProvider} from "./contexts/RuntimeContext";
import {WorkoutContextProvider} from "./contexts/WorkoutContext";
import WorkoutPage from "./pages/WorkoutPage";
import PlayPage from "./pages/PlayPage";
import EditProgramPage from "./pages/EditProgramPage";
import ProgramPage from "./pages/ProgramPage";
function App() {
return (
@ -20,6 +22,11 @@ function App() {
<Routes>
<Route path="/" element={<IndexPage/>}/>
<Route path="/devices/:id" element={<DevicePage/>}/>
<Route path="/devices/:id/edit" element={<DevicePage edit/>}/>
<Route path="/devices/new" element={<DevicePage edit/>}/>
<Route path="/programs/:id" element={<ProgramPage/>}/>
<Route path="/programs/:id/edit" element={<EditProgramPage/>}/>
<Route path="/programs/new" element={<EditProgramPage/>}/>
<Route path="/workouts/:id" element={<WorkoutPage/>}/>
<Route path="/play" element={<PlayPage/>}/>
</Routes>

31
webui-react/src/actions/programs.ts

@ -1,8 +1,10 @@
import {Program} from "../models/Programs";
import {getRequest} from "./shared";
import {deleteRequest, getRequest, postRequest, putRequest} from "./shared";
interface ProgramRepository {
fetchAll(): Promise<Program[]>
save(program: Partial<Program>): Promise<boolean>
delete(program: Pick<Program, "id">): Promise<boolean>
}
export default function programRepo(): ProgramRepository {
@ -16,5 +18,30 @@ export default function programRepo(): ProgramRepository {
}
const defaultImpl: ProgramRepository = {
fetchAll: () => getRequest("/programs") || null,
fetchAll() {
return getRequest("/programs") || null;
},
async save({id, name, steps}: Partial<Program>): Promise<boolean> {
try {
if (id) {
await putRequest(`/programs/${id}`, {name, steps});
return true;
} else if (name && steps) {
await postRequest("/programs", {name, steps});
return true;
}
return false;
} catch (e) {
return false;
}
},
async delete({id}: Pick<Program, "id">): Promise<boolean> {
try {
await deleteRequest(`/programs/${id}`);
return true;
} catch (e) {
return false;
}
},
};

2
webui-react/src/actions/workouts.ts

@ -2,7 +2,7 @@ import {getRequest, postRequest} from "./shared";
import {PastWorkout, WorkoutState} from "../models/Workouts";
interface WorkoutFilter {
daysBack: number
daysBack?: number
includeTest: boolean
}

9
webui-react/src/contexts/RuntimeContext.tsx

@ -22,6 +22,7 @@ interface RuntimeContextValue {
start(): void
stop(): void
setLevel(level: number): void
skip(): void
reset(): void
resume(): void
@ -41,6 +42,7 @@ const RuntimeContext = createContext<RuntimeContextValue>({
start: unimplemented,
stop: unimplemented,
setLevel: unimplemented,
skip: unimplemented,
reset: unimplemented,
resume: unimplemented,
create: unimplemented,
@ -51,6 +53,7 @@ interface SocketInput {
stop?: true
connect?: true
disconnect?: true
skip?: true
setValue?: Values
}
@ -109,6 +112,10 @@ export function RuntimeContextProvider({children}: WithChildren): JSX.Element {
});
}, [sendCommand]);
const skip = useCallback(() => {
sendCommand({skip: true});
}, [sendCommand]);
const reset = useCallback(() => {
setActive(false);
setEnded(false);
@ -202,7 +209,7 @@ export function RuntimeContextProvider({children}: WithChildren): JSX.Element {
workout, states, error,
lastEvent,
active, ready, ended,
connect, disconnect, start, stop, setLevel,
connect, disconnect, start, stop, setLevel, skip,
reset, resume, create,
}}>
{children}

12
webui-react/src/contexts/WorkoutContext.tsx

@ -7,6 +7,7 @@ import workoutRepo from "../actions/workouts";
interface WorkoutContextValue {
workouts: PastWorkout[]
loadingWorkouts: boolean
expanded: boolean
getWorkout(workoutId: string): PastWorkout | null
fetchWorkout(workoutId: string): void
@ -21,6 +22,7 @@ interface WorkoutContextValue {
const WorkoutContext = createContext<WorkoutContextValue>({
workouts: [],
loadingWorkouts: false,
expanded: false,
getWorkout: unimplemented,
fetchWorkout: unimplemented,
getStates: unimplemented,
@ -33,7 +35,7 @@ export function WorkoutContextProvider({children}: WithChildren) {
const [workouts, setWorkouts] = useState<PastWorkout[]>([]);
const [cache, setCache] = useState<Record<string, PastWorkout>>({});
const [loadingWorkouts, setLoadingWorkouts] = useState<boolean>(false);
const [days, setDays] = useState(6);
const [expanded, setExpanded] = useState<boolean>(false);
const [ver, setVer] = useState(0);
const [stateMap, setStateMap] = useState<Record<string, WorkoutState[]>>({});
@ -64,7 +66,7 @@ export function WorkoutContextProvider({children}: WithChildren) {
}, []);
const showMoreWorkouts = useCallback(() => {
setDays(prev => prev + (prev > 30 ? 30 : 7));
setExpanded(true);
}, []);
useEffect(() => {
@ -73,14 +75,14 @@ export function WorkoutContextProvider({children}: WithChildren) {
}
setLoadingWorkouts(true);
workoutRepo().fetchByFilter({daysBack: days, includeTest: false})
workoutRepo().fetchByFilter({daysBack: expanded ? undefined : 6, includeTest: false})
.then(setWorkouts)
.finally(() => setLoadingWorkouts(false));
}, [days, ver]);
}, [expanded, ver]);
return (
<WorkoutContext.Provider value={{
workouts, loadingWorkouts,
workouts, loadingWorkouts, expanded,
getWorkout, fetchWorkout,
getStates, fetchStates,
refreshWorkouts, showMoreWorkouts,

11
webui-react/src/models/Programs.ts

@ -7,7 +7,7 @@ export interface Program {
}
export interface ProgramStep {
index: number
index?: number
values: Values,
duration?: Values,
}
@ -27,6 +27,7 @@ export function weighting(step: ProgramStep) {
}
export function subTitleOfProgram(program: Program): string {
let minSum = 0;
let secSum = 0;
let kcalSum = 0;
let mSum = 0;
@ -40,10 +41,14 @@ export function subTitleOfProgram(program: Program): string {
hasCustom = hasCustom || (!(step.duration?.time) && !(step.duration?.calories) && !(step.duration?.distance));
}
minSum = Math.floor(secSum / 60);
secSum = secSum % 60;
const parts = [];
if (secSum > 0) parts.push(`${secSum} kcal`);
if (minSum > 0) parts.push(`${minSum} min`);
if (secSum > 0) parts.push(`${secSum} sek`);
if (kcalSum > 0) parts.push(`${kcalSum} kcal`);
if (mSum > 0) parts.push(`${mSum} kcal`);
if (mSum > 0) parts.push(`${mSum / 1000} km`);
if (hasCustom) parts.push("Custom");
return parts.join(" + ");

53
webui-react/src/models/Shared.ts

@ -1,3 +1,5 @@
import {Value} from "sass";
export enum Size {
Mobile = "mobile",
Tablet = "tablet",
@ -51,3 +53,54 @@ export function formatValue(raw: number, type: keyof Values): string {
return "";
}
export function valuesToString(v: Values): string {
let str: string[] = [];
if (v.time) {
const min = Math.floor(v.time / 60);
const sec = v.time % 60;
if (min > 0) str.push(`${min}min`);
if (sec > 0) str.push(`${sec}sek`);
}
if (v.distance) {
str.push(`${v.distance / 1000}km`);
}
if (v.calories) {
str.push(`${v.calories}kcal`);
}
return str.join(" ");
}
const parseRegex = new RegExp("([0-9.]+)\\s?(min|sek|km|kcal|cal)", "g");
export function stringToValues(str: string): Values {
const v: Values = {};
const matches = str.matchAll(parseRegex);
for (const m of matches) {
if (m.length === 2) {
continue;
}
switch (m[2]) {
case "sek":
v.time = (v.time || 0) + parseInt(m[1]);
break;
case "min":
v.time = (v.time || 0) + (parseInt(m[1]) * 60);
break;
case "km":
v.distance = (v.distance || 0) + Math.round(parseFloat(m[1]) * 1000);
break;
case "kcal":
case "cal":
v.calories = (v.calories || 0) + parseInt(m[1]);
break;
}
}
return v;
}

2
webui-react/src/models/Workouts.ts

@ -49,7 +49,7 @@ export function firstKey(state: WorkoutState): (keyof WorkoutState) | null {
}
export function stateString(state: Values, type: keyof WorkoutState): string | null {
if (type === "time" && state.time) {
if (type === "time" && state.time !== undefined) {
return formatValue(state.time, type);
}
if (type === "calories" && state.calories !== undefined) {

27
webui-react/src/pages/DevicePage.tsx

@ -8,7 +8,7 @@ import {
faChain,
faCheck, faChevronDown,
faChevronLeft,
faChevronUp, faCircleInfo, faInfoCircle, faMessage,
faChevronUp, faMessage,
faPencilAlt,
faTag,
faTrashCan
@ -22,16 +22,19 @@ import {Device} from "../models/Devices";
import deviceRepo from "../actions/devices";
import RuntimeContext from "../contexts/RuntimeContext";
export default function DevicePage(): JSX.Element {
interface DevicePageProps {
edit?: boolean
}
export default function DevicePage({edit}: DevicePageProps): JSX.Element {
const {devices} = useContext(DeviceContext);
const navigate = useNavigate();
const {id} = useParams();
const [search] = useSearchParams();
const device = useMemo(() => devices?.find(d => d.id === id) || null, [devices]);
const edit = useMemo(() => search.has("edit") && search.get("edit") === "true", [search, device]);
if (edit && id === "new") {
if (edit && id === undefined) {
return <NewDevicePage/>;
} else if (edit && device) {
return <EditDevicePage device={device}/>;
@ -161,11 +164,11 @@ function EditDevicePage({device}: EditDevicePageProps): JSX.Element {
);
}
interface DevicePageProps {
interface ViewDevicePageProps {
device: Device
}
function ViewDevicePage({device}: DevicePageProps): JSX.Element {
function ViewDevicePage({device}: ViewDevicePageProps): JSX.Element {
const navigate = useNavigate();
const {refreshDevices} = useContext(DeviceContext);
const {active, ended, error, create} = useContext(RuntimeContext);
@ -201,7 +204,7 @@ function ViewDevicePage({device}: DevicePageProps): JSX.Element {
<Icon value={faChain}/> {device.connectionString}
</BlobText>
</Blob>
<Blob color="indigo" onClick={() => navigate(`/devices/${device.id}?edit=true`)}>
<Blob color="indigo" onClick={() => navigate(`/devices/${device.id}/edit`)}>
<BlobText>
<Icon value={faPencilAlt}/>
</BlobText>
@ -255,16 +258,8 @@ function TestSection(): JSX.Element {
setEvents(prev => [
...prev,
{createdAt, type: "down", message: `Opprettet økt ${lastEvent.workout!.id}`},
{createdAt, type: "log", message: "Vil koble til om 5 sekunder"},
{createdAt, type: "log", message: "Vil koble til umiddelbart"},
]);
timeouts.push(setTimeout(() => {
setEvents(prev => [
...prev,
{createdAt: currentTime(), type: "up", message: `Kobler til`},
]);
connect();
}, 5000));
}
if (lastEvent.workoutStates && lastEvent.workoutStates.length > 0) {
const last = lastEvent.workoutStates[lastEvent.workoutStates.length - 1];

147
webui-react/src/pages/EditProgramPage.tsx

@ -0,0 +1,147 @@
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import {useNavigate, useParams} from "react-router";
import React, {useCallback, useContext, useEffect, useMemo, useState} from "react";
import ProgramContext from "../contexts/ProgramContext";
import LoadingPage from "./LoadingPage";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import {Icon} from "../primitives/Shared";
import {
faArrowUpRightDots, faCheck,
faChevronLeft,
faClose,
faLevelUpAlt,
faPlus,
faStopwatch
} from "@fortawesome/free-solid-svg-icons";
import {Size, stringToValues, valuesToString} from "../models/Shared";
import {TitleLine} from "../primitives/misc/Misc";
import Blob, {BlobInput, BlobText} from "../primitives/blob/Blob";
import {ProgramStep} from "../models/Programs";
import programRepo from "../actions/programs";
interface StepOption {
level: number
duration: string
}
export default function EditProgramPage() {
const {programs, refreshPrograms} = useContext(ProgramContext);
const navigate = useNavigate();
const {id} = useParams();
const program = useMemo(() => programs?.find(p => p.id === id), [programs, id]);
const [name, setName] = useState(program?.name || "");
const [steps, setSteps] = useState<StepOption[]>([]);
const [wait, setWait] = useState<boolean>(false);
useEffect(() => {
if (program) {
setName(program.name)
setSteps(program.steps.map(s => ({
level: s.values.level || 0,
duration: valuesToString(s.duration || {}),
})));
}
}, [program]);
const onSave = useCallback(() => {
const id = program?.id || undefined;
const newSteps: ProgramStep[] = steps.map(s => ({
values: {level: s.level},
duration: stringToValues(s.duration),
}));
setWait(true);
programRepo().save({id, name, steps: newSteps})
.then(res => {
if (res) {
navigate(program ? `/programs/${program.id}` : "/");
refreshPrograms();
} else {
setWait(false);
}
});
}, [program, name, steps, navigate, refreshPrograms]);
if (programs === null) {
return <LoadingPage text="Henter programmer"/>;
} else if (wait) {
return <LoadingPage text="Lagrer programm"/>;
}
const title = program ? `Endre "${program.name}"` : "Nytt programm";
const canSave = name.trim() !== "" && steps.length > 0 && !steps.find(p => p.level === 0);
return (
<Page title={title}>
<Header>
<HeaderButton onClick={() => navigate(program ? `/programs/${program.id}` : "/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>{title}</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Tablet}>
<PageFlexColumn flex={1}>
<TitleLine>Programm</TitleLine>
<Blob fillOn={Size.Any}>
<BlobText>Navn</BlobText>
<BlobInput type="text" value={name} onChange={setName} flex={1}/>
</Blob>
<Blob color={canSave ? "indigo" : "gray"} onClick={onSave} disabled={!canSave}>
<BlobText>
<Icon value={faCheck}/> Lagre
</BlobText>
</Blob>
</PageFlexColumn>
<PageFlexColumn flex={1}>
<TitleLine>Steg</TitleLine>
{steps.map((s, i) => {
const onChange = (arg: Partial<StepOption>) => setSteps(prev => {
return prev.map((ps, pi) => (pi === i ? {...ps, ...arg} : ps));
});
const onRemove = () => setSteps(prev => {
return prev.filter((ignored, pi) => pi !== i);
})
return (
<PageFlexRow key={i}>
<Blob>
<BlobText>
<Icon value={faArrowUpRightDots}/>
</BlobText>
<BlobInput
type="number" value={s.level}
onChange={level => onChange({level})}
/>
</Blob>
<Blob flex={2}>
<BlobText>
<Icon value={faStopwatch}/>
</BlobText>
<BlobInput
flex={1} type="text" value={s.duration} placeholder="Manuell"
onChange={duration => onChange({duration})}
/>
</Blob>
<Blob color="red" onClick={onRemove}>
<BlobText>
<Icon value={faClose}/>
</BlobText>
</Blob>
</PageFlexRow>
);
})}
<Blob color="green" onClick={() => setSteps(prev => [...prev, {duration: "", level: 1}])}>
<BlobText>
<Icon value={faPlus}/> Legg til
</BlobText>
</Blob>
</PageFlexColumn>
</PageFlexRow>
</PageBody>
</Page>
);
}

28
webui-react/src/pages/IndexPage.tsx

@ -21,7 +21,7 @@ import {Boi} from "../primitives/boi/Boi";
export default function IndexPage(): JSX.Element {
const {devices} = useContext(DeviceContext);
const {programs} = useContext(ProgramContext);
const {workouts, loadingWorkouts, showMoreWorkouts, refreshWorkouts} = useContext(WorkoutContext);
const {workouts, loadingWorkouts, expanded, showMoreWorkouts, refreshWorkouts} = useContext(WorkoutContext);
const navigate = useNavigate();
const isRunning = useMemo(() => workouts.some(w => w.status !== WorkoutStatus.Disconnected), [workouts]);
@ -43,7 +43,7 @@ export default function IndexPage(): JSX.Element {
<Header>
<HeaderTitle>YKonsole</HeaderTitle>
</Header>
<Boi vertical="bottom" horizontal="left">
<Boi vertical="bottom" horizontal="center">
<Blob onClick={() => navigate("/play")} color={isRunning ? "yellow" : "green"}>
<BlobText>
<Icon value={faPlay}/> {isRunning ? "Fortsett" : "Start"}
@ -53,7 +53,7 @@ export default function IndexPage(): JSX.Element {
<PageBody>
<PageFlexRow collapseOn={Size.Tablet}>
<PageFlexColumn flex={1}>
<TitleLine>Siste økter ({workouts.length})</TitleLine>
<TitleLine>Siste økter ({loadingWorkouts ? <Icon value={faSpinner} spin/> : workouts.length})</TitleLine>
{workouts.map(w => (
<Blob key={w.id} color={colorOf(w)} onClick={() => navigate(`/workouts/${w.id}`)}>
<BlobText>
@ -64,14 +64,16 @@ export default function IndexPage(): JSX.Element {
</BlobText>
</Blob>
))}
<PageFlexRow>
<Blob onClick={loadingWorkouts ? undefined : () => showMoreWorkouts()}>
<BlobText>
{loadingWorkouts && <Icon value={faSpinner} spin/> }
{!loadingWorkouts && <><Icon value={faChevronDown}/> Vis flere</> }
</BlobText>
</Blob>
</PageFlexRow>
{!expanded && (
<PageFlexRow>
<Blob onClick={loadingWorkouts ? undefined : () => showMoreWorkouts()}>
<BlobText>
{loadingWorkouts && <Icon value={faSpinner} spin/> }
{!loadingWorkouts && <><Icon value={faChevronDown}/> Vis flere</> }
</BlobText>
</Blob>
</PageFlexRow>
)}
</PageFlexColumn>
<PageFlexColumn flex={1}>
@ -84,7 +86,7 @@ export default function IndexPage(): JSX.Element {
</BlobText>
</Blob>
))}
<Blob color="green" onClick={() => navigate(`/programs/new?edit=true`)}>
<Blob color="green" onClick={() => navigate(`/programs/new`)}>
<BlobText>
<BlobTextLine>
<Icon value={faPlus}/>
@ -102,7 +104,7 @@ export default function IndexPage(): JSX.Element {
</BlobText>
</Blob>
))}
<Blob color="green" onClick={() => navigate(`/devices/new?edit=true`)}>
<Blob color="green" onClick={() => navigate("/devices/new")}>
<BlobText>
<BlobTextLine>
<Icon value={faPlus}/>

23
webui-react/src/pages/LoadingPage.tsx

@ -6,14 +6,17 @@ import {Boi} from "../primitives/boi/Boi";
interface LoadingPageProps {
text?: string
minimal?: boolean
}
function LoadingPage({text}: LoadingPageProps) {
function LoadingPage({text, minimal}: LoadingPageProps) {
return (
<Page>
<Header>
<HeaderTitle>YKonsole</HeaderTitle>
</Header>
<Page background={minimal ? "2046" : undefined}>
{!minimal && (
<Header>
<HeaderTitle>YKonsole</HeaderTitle>
</Header>
)}
<PageBody>
<PageFlexRow vertical>
<PageFlexRow flex={1}/>
@ -31,8 +34,14 @@ interface LoadingSectionProps {
export function LoadingSection({text}: LoadingSectionProps) {
return (
<Boi vertical="center" horizontal="center">
<Icon value={faSpinner} spin/> {text ? `${text}` : ""}
<Boi vertical="center" horizontal="center" style={{
textAlign: "center",
marginTop: "1vmax", marginLeft: "1vmax", marginRight: "1vmax",
}}>
<div style={{fontSize: "200%"}}>
<Icon value={faSpinner} spin/>
</div>
{text && <div>{text}</div>}
</Boi>
);
}

31
webui-react/src/pages/PlayPage.tsx

@ -2,17 +2,16 @@ import Page, {PageFlexRow} from "../primitives/page/Page";
import {useCallback, useContext, useEffect, useMemo, useState} from "react";
import RuntimeContext from "../contexts/RuntimeContext";
import {useNavigate} from "react-router";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import LoadingPage from "./LoadingPage";
import DeviceContext from "../contexts/DeviceContext";
import ProgramContext from "../contexts/ProgramContext";
import {Device} from "../models/Devices";
import {Program, subTitleOfProgram} from "../models/Programs";
import {useKey, usePlusMinus} from "../hooks/keyboard";
import {TitleLine} from "../primitives/misc/Misc";
import {TitleLine, Value} from "../primitives/misc/Misc";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {Icon} from "../primitives/Shared";
import {faChevronLeft, faClose, faPlay} from "@fortawesome/free-solid-svg-icons";
import {faClose, faPlay} from "@fortawesome/free-solid-svg-icons";
import {stateString, WorkoutStatus} from "../models/Workouts";
import {Boi} from "../primitives/boi/Boi";
import {useLastState} from "./runtime/hooks";
@ -43,7 +42,7 @@ function PlayPage(): JSX.Element {
return <RunPlayPage/>;
}
return <LoadingPage text="Starter økt"/>;
return <LoadingPage minimal text="Starter økt"/>;
}
const noProgram: Program = {
@ -94,18 +93,14 @@ function CreatePlayPage(): JSX.Element {
}
}, [devices]);
if (devices === null || programWithFake === null) {
return <LoadingPage text="Laster inn"/>
if (devices === null) {
return <LoadingPage minimal text="Henter enheter"/>
} else if (programWithFake === null) {
return <LoadingPage minimal text="Henter programmer"/>
}
return (
<Page background={"2046"}>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Ny økt</HeaderTitle>
</Header>
{device === null && (
<Boi vertical="center" horizontal="center" style={{fontSize: undefined}}>
<TitleLine>Velg enhet</TitleLine>
@ -180,21 +175,21 @@ function RunPlayPage(): JSX.Element {
const lastState = useLastState();
if (!workout || workout.status === WorkoutStatus.Created) {
return <LoadingPage/>;
return <LoadingPage minimal/>;
}
return (
<Page title="YKonsole" background={"2046"}>
<ControlsBoi/>
{lastState && (
<Boi vertical="center" horizontal="left">
{stateString(lastState, "time")}
<Boi vertical="center" horizontal="left" style={{padding: "0.5vmax", paddingBottom: "0"}}>
<Value raw={lastState} valueKey="time"/>
<br/>
{stateString(lastState, "calories")}
<Value raw={lastState} valueKey="calories"/>
<br/>
{stateString(lastState, "distance")}
<Value raw={lastState} valueKey="distance"/>
<br/>
{stateString(lastState, "level")}
<Value raw={lastState} valueKey="level"/>
</Boi>
)}
{workout?.status === WorkoutStatus.Connected && <MessageBoi text="Trykk Enter for å begynne"/>}

97
webui-react/src/pages/ProgramPage.tsx

@ -0,0 +1,97 @@
import React, {useCallback, useContext, useEffect, useMemo} from "react";
import ProgramContext from "../contexts/ProgramContext";
import {useNavigate, useParams} from "react-router";
import LoadingPage from "./LoadingPage";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import {Icon} from "../primitives/Shared";
import {
faArrowUpRightDots,
faCheck,
faChevronLeft,
faPencilAlt, faStopwatch,
faTag,
faTrashCan
} from "@fortawesome/free-solid-svg-icons";
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import {Size, valuesToString} from "../models/Shared";
import {TitleLine} from "../primitives/misc/Misc";
import Blob, {BlobInput, BlobText} from "../primitives/blob/Blob";
import deviceRepo from "../actions/devices";
import programRepo from "../actions/programs";
export default function ProgramPage() {
const {programs, refreshPrograms} = useContext(ProgramContext);
const navigate = useNavigate();
const {id} = useParams();
const program = useMemo(() => programs?.find(p => p.id === id), [programs, id]);
useEffect(() => {
if (programs && !program) {
navigate("/");
}
}, [programs, program]);
const onDelete = useCallback(() => {
if (!program || !window.confirm("Vil du fjerne denne enheten?")) return;
programRepo().delete(program).then(() => {
refreshPrograms();
navigate("/");
})
}, [program, navigate, refreshPrograms]);
if (!program) {
return <LoadingPage text="Henter programmer"/>;
}
return (
<Page title={program.name}>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>{program.name}</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Mobile}>
<PageFlexColumn flex={1}>
<TitleLine>Programm</TitleLine>
<Blob>
<BlobText>
<Icon value={faTag}/> {program.name}
</BlobText>
</Blob>
<Blob color="indigo" onClick={() => navigate(`/programs/${program.id}/edit`)}>
<BlobText>
<Icon value={faPencilAlt}/>
</BlobText>
</Blob>
<Blob color="red" onClick={onDelete}>
<BlobText>
<Icon value={faTrashCan}/>
</BlobText>
</Blob>
</PageFlexColumn>
<PageFlexColumn flex={1}>
<TitleLine>Steg</TitleLine>
{program.steps.map((s, i) => (
<PageFlexRow key={i}>
<Blob>
<BlobText>
<Icon value={faArrowUpRightDots}/> {s.values.level}
</BlobText>
</Blob>
<Blob>
<BlobText>
<Icon value={faStopwatch}/> {valuesToString(s.duration || {}) || "Manuell"}
</BlobText>
</Blob>
</PageFlexRow>
))}
</PageFlexColumn>
</PageFlexRow>
</PageBody>
</Page>
);
}

34
webui-react/src/pages/WorkoutPage.tsx

@ -1,19 +1,27 @@
import {useContext, useEffect, useMemo} from "react";
import {useCallback, useContext, useEffect, useMemo, useState} from "react";
import DeviceContext from "../contexts/DeviceContext";
import {useNavigate, useParams} from "react-router";
import {useSearchParams} from "react-router-dom";
import WorkoutContext from "../contexts/WorkoutContext";
import {PastWorkout, stateString} from "../models/Workouts";
import {PastWorkout, stateString, WorkoutState} from "../models/Workouts";
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import LoadingPage, {LoadingSection} from "./LoadingPage";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import {Icon} from "../primitives/Shared";
import {faChevronLeft, faClock, faClockFour, faClockRotateLeft, faTableList} from "@fortawesome/free-solid-svg-icons";
import {
faChevronDown,
faChevronLeft,
faClock,
faClockFour,
faClockRotateLeft,
faTableList
} from "@fortawesome/free-solid-svg-icons";
import {Size} from "../models/Shared";
import {TitleLine} from "../primitives/misc/Misc";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {subTitleOfProgram} from "../models/Programs";
import {formatDate, formatTime} from "../helpers/dates";
import {faSpinner} from "@fortawesome/free-solid-svg-icons/faSpinner";
export default function WorkoutPage(): JSX.Element {
const {getWorkout, fetchWorkout, getStates, fetchStates} = useContext(WorkoutContext);
@ -22,6 +30,11 @@ export default function WorkoutPage(): JSX.Element {
const workout = useMemo(() => getWorkout(id || "random"), [getWorkout, id]);
const states = useMemo(() => getStates(id || "random"), [getStates, id]);
const [expanded, setExpanded] = useState(false);
const wsFilter = useCallback((ws: WorkoutState, index: number, arr: WorkoutState[]) => {
return expanded || (ws.time % 15 === 0) || index === arr.length - 1;
}, [expanded]);
useEffect(() => {
fetchWorkout(id || "random");
@ -31,7 +44,7 @@ export default function WorkoutPage(): JSX.Element {
return (
<Page title={`Økt ${id}`}>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<HeaderButton onClick={() => navigate("/")} shortcut="/">
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Øktdetaljer</HeaderTitle>
@ -80,8 +93,8 @@ export default function WorkoutPage(): JSX.Element {
<PageFlexColumn flex={1}>
<TitleLine>Målinger</TitleLine>
{states ? (
states.map(s => (
<PageFlexRow>
states.filter(wsFilter).map(s => (
<PageFlexRow key={s.time}>
<Blob>
<BlobText>{stateString(s, "time")}</BlobText>
</Blob>
@ -97,6 +110,15 @@ export default function WorkoutPage(): JSX.Element {
</PageFlexRow>
))
) : <LoadingSection/>}
{!expanded && (
<PageFlexRow>
<Blob onClick={() => setExpanded(true)}>
<BlobText>
<Icon value={faChevronDown}/> Vis alle
</BlobText>
</Blob>
</PageFlexRow>
)}
</PageFlexColumn>
</PageFlexRow>
</PageBody>

70
webui-react/src/pages/runtime/ControlsBoi.tsx

@ -14,15 +14,19 @@ import MessageBoi from "./MessageBoi";
interface Option {
icon?: IconDefinition
text?: string
warning?: boolean
onClick(): void
}
const skipMessages = ["Ikke hopp over", "Hopp over straks", "Hopp over nå"];
export function ControlsBoi() {
const {workout, disconnect, start, stop, setLevel} = useContext(RuntimeContext);
const {workout, disconnect, start, stop, setLevel, skip} = useContext(RuntimeContext);
const lastState = useLastState();
const [mode, setMode] = useState<"default" | "level">("default");
const [mode, setMode] = useState<"default" | "level" | "skip">("default");
const [nextSkip, setNextSkip] = useState(-1);
const [lastTime, setLastTime] = useState(0);
const options: Option[] = useMemo(() => {
if (!workout) return [];
@ -37,12 +41,12 @@ export function ControlsBoi() {
if (workout.status === WorkoutStatus.Started) {
btnList.push({icon: faPause, onClick: stop});
if (!workout.program) {
btnList.push({
text: "Motstand", onClick: () => {
setMode("level");
}
});
if (workout.program) {
const text = nextSkip > 0 ? `Hopper om ${nextSkip - lastTime} sek.` : "Hopp over steg";
btnList.push({text, warning: nextSkip >= 0, onClick: () => setMode("skip")})
} else {
btnList.push({text: "Motstand", onClick: () => setMode("level")});
}
}
@ -51,13 +55,28 @@ export function ControlsBoi() {
}
return btnList;
}, [workout]);
}, [workout, start, stop, disconnect, skip, nextSkip, lastTime]);
let length = options.length;
if (mode === "level") length = 32;
if (mode === "skip") length = 3;
const [sel, setSel] = usePlusMinus(mode === "level" ? 32 : options.length);
const [sel, setSel] = usePlusMinus(length);
useKey("Enter", () => {
if (mode === "level") {
setLevel(sel + 1);
if (mode !== "default") {
if (mode === "level") {
setLevel(sel + 1);
} else {
if (sel === 1) {
setNextSkip(lastTime + 60 - (lastTime % 60));
} else if (sel === 2) {
setNextSkip(0);
} else {
setNextSkip(-1);
}
}
setSel(0);
setMode("default");
} else {
@ -67,7 +86,7 @@ export function ControlsBoi() {
}
setSel(0);
}, [options, sel]);
}, [options, lastTime, sel]);
useKey("Escape", () => {
if (!workout) return;
@ -86,16 +105,31 @@ export function ControlsBoi() {
}
}, [mode]);
useEffect(() => {
if (lastState?.time) {
setLastTime(lastState.time!);
}
}, [lastState]);
useEffect(() => {
if (nextSkip !== -1 && (nextSkip === 0 || nextSkip <= lastTime)) {
setNextSkip(-1);
skip();
}
}, [lastTime, nextSkip]);
if (mode === "level") {
return (
<MessageBoi text={`${sel + 1} +/-`}/>
);
return <MessageBoi text={`${sel + 1} +/-`}/>;
}
if (mode === "skip") {
return <MessageBoi text={`${skipMessages[sel]} +/-`}/>;
}
return (
<Boi vertical="top" horizontal="right" style={{fontSize: "2vmax"}}>
{options.map((o, i) => (
<Blob key={i} color={sel === i ? "indigo" : "gray"} onClick={o.onClick}>
<Blob key={i} color={(o.warning && lastTime % 2 === 0) ? "yellow" : (sel === i ? "indigo" : "gray")} onClick={o.onClick}>
<BlobText>
{o.icon && <Icon value={o.icon}/>} {o.text}
</BlobText>

13
webui-react/src/pages/runtime/ProgramBoi.sass

@ -19,24 +19,21 @@
.ProgressBar
padding: 2px
height: 16px
padding: 0.1vmax
height: 1vmax
opacity: 0.5
box-sizing: border-box
display: flex
&:first-child
padding-left: 4px
&:last-child
padding-right: 4px
div
height: 100%
.ProgressBar-bg
background-color: black
.ProgressBar-inactive
background-color: $blob-background
.ProgressBar-level-5
background-color: $red-3

83
webui-react/src/pages/runtime/ProgramBoi.tsx

@ -1,12 +1,11 @@
import "./ProgramBoi.sass";
import {useContext, useEffect, useReducer} from "react";
import {useContext, useEffect, useMemo, useReducer} from "react";
import RuntimeContext from "../../contexts/RuntimeContext";
import {ProgramStep, weighting} from "../../models/Programs";
import {firstKey, stateString, WorkoutState} from "../../models/Workouts";
import {firstKey, WorkoutState} from "../../models/Workouts";
import {diffLinearValues, formatValue} from "../../models/Shared";
import {Simulate} from "react-dom/test-utils";
import touchMove = Simulate.touchMove;
import {Boi} from "../../primitives/boi/Boi";
interface StepMeta {
actualDuration?: WorkoutState
@ -17,7 +16,7 @@ interface ProgressState {
currentIndex: number
lastTransition: WorkoutState
lastValue: WorkoutState
toNext: {current: number, max: number}
toNext: { current: number, max: number }
stopped: boolean
}
@ -101,8 +100,7 @@ export default function ProgramBoi() {
}, [lastEvent]);
if (progress.steps.some(s => weighting(s) === 0)) {
// TODO: Non-finite mode
return null;
return <InfiniteProgress progress={progress}/>;
}
return <HealthBarProgress progress={progress}/>;
@ -112,10 +110,75 @@ interface ProgressProps {
progress: ProgressState
}
type FromTo = {
count: false
} | {
count: true
rawCurr: number
rawTo: number
strCurr: string
strTo: string
}
function InfiniteProgress({progress}: ProgressProps) {
const offset = 6 - progress.steps.length + progress.currentIndex;
const ft: FromTo = useMemo(() => {
const currentStep = progress.steps[progress.currentIndex];
if (currentStep) {
const key = firstKey(currentStep.duration as WorkoutState);
if (key !== null) {
const duration = progress.steps[progress.currentIndex].duration![key]!;
const numStr = formatValue(progress.toNext.current, key);
const toStr = formatValue(duration, key);
return {
count: true,
rawCurr: progress.toNext.current,
rawTo: duration,
strCurr: numStr,
strTo: toStr,
} as FromTo
}
}
return {count: false};
}, [progress]);
if (progress.currentIndex >= progress.steps.length) {
return null;
}
return (
<Boi
vertical="bottom" horizontal="center" unchunky
style={{fontSize: "2vmax", minWidth: "24vmax"}}
>
<div style={{padding: "0.5vmax 0.5vmax 0", fontWeight: 400}}>
Steg {progress.currentIndex + 1} / {progress.steps.length}
</div>
{ft.count && (
<div>
<div style={{margin: "0.5vmax"}}>
{ft.strCurr} / {ft.strTo}
</div>
<div className="ProgressBar">
<div className={`ProgressBar-level-${offset}`} style={{flex: ft.rawCurr}}/>
<div className={`ProgressBar-bg`} style={{flex: ft.rawTo - ft.rawCurr}}/>
</div>
</div>
)}
{!ft.count && (
<div style={{margin: "0.5vmax"}}>
(Custom)
</div>
)}
</Boi>
);
}
function HealthBarProgress({progress}: ProgressProps) {
const offset = 6 - progress.steps.length;
const steps = [...progress.steps];
steps.reverse();
const steps = progress.steps.reduce((acc, b) => ([b, ...acc]), [] as typeof progress.steps);
return (
<div className="HealthBar">
@ -139,7 +202,7 @@ function HealthBarProgress({progress}: ProgressProps) {
)}
<div className="ProgressBar">
{stepIndex > progress.currentIndex && (
<div className={`ProgressBar-level-${level}`} style={{flex: 1}}/>
<div className={`ProgressBar-inactive`} style={{flex: 1}}/>
)}
{stepIndex === progress.currentIndex && (
<>

2
webui-react/src/primitives/blob/Blob.sass

@ -4,6 +4,7 @@
margin-top: 0.5em
border-radius: 0.5em
display: inline-block
box-sizing: border-box
cursor: default
margin-left: 0.25em
margin-right: 0.25em
@ -86,6 +87,7 @@
.BlobText
padding: 0.5em
min-width: 1ch
&.BlobText-centered
text-align: center

8
webui-react/src/primitives/blob/Blob.tsx

@ -93,9 +93,10 @@ interface BaseBlobInputProps<T, V> {
value: V
disabled?: boolean
onChange?: (newValue: V) => void
placeholder?: string
}
export function BlobInput({type, name, flex, disabled, value, onChange}: BlobInputProps) {
export function BlobInput({type, name, flex, disabled, value, onChange, placeholder}: BlobInputProps) {
const actualOnChange = useCallback((input: string) => {
if (onChange === undefined) return;
@ -108,12 +109,13 @@ export function BlobInput({type, name, flex, disabled, value, onChange}: BlobInp
return (
<input
placeholder={placeholder}
disabled={disabled}
className="BlobInput"
name={name}
type={type || "text"}
value={`${value}`}
style={{flex}}
value={`${value || ""}`}
style={flex ? {flex} : {width: 50}}
onChange={e => actualOnChange(e.target.value || "")}
/>
);

4
webui-react/src/primitives/boi/Boi.sass

@ -3,9 +3,11 @@
.Boi
background-color: rgba(0, 0, 0, 0.33)
z-index: 9999
padding: 0.25vmax 0.75vmax 1.5vmax
box-sizing: border-box
&.Boi-chunky
padding: 0.25vmax 0.75vmax 1.5vmax
.TitleLine
margin-bottom: 0.25em

7
webui-react/src/primitives/boi/Boi.tsx

@ -5,11 +5,12 @@ import {useMemo} from "react";
interface BoiProps extends WithChildren, WithStyle {
vertical: "top" | "center" | "bottom"
horizontal: "left" | "center" | "right"
unchunky?: boolean
}
const defaultStyle = {fontSize: "3vmax"};
export function Boi({horizontal, vertical, children, style}: BoiProps) {
export function Boi({horizontal, vertical, unchunky, children, style}: BoiProps) {
const className = useMemo(() => {
const list = [
"Boi",
@ -17,6 +18,10 @@ export function Boi({horizontal, vertical, children, style}: BoiProps) {
`Boi-v-${vertical}`,
];
if (!unchunky) {
list.push("Boi-chunky");
}
return list.join(" ");
}, [horizontal, vertical]);

36
webui-react/src/primitives/misc/Misc.tsx

@ -1,8 +1,44 @@
import "./Misc.sass";
import {WithChildren} from "../Shared";
import {Values} from "../../models/Shared";
export function TitleLine({children}: WithChildren) {
return (
<div className="TitleLine">{children}</div>
);
}
interface ValueProps {
raw: Values | number
valueKey: keyof Values
}
export function Value({raw, valueKey}: ValueProps): JSX.Element | null {
const actual = typeof raw === "number" ? raw : (raw[valueKey]);
if (actual !== null && actual !== undefined) {
if (valueKey === "time") {
const minutes = Math.floor(actual / 60).toString(10).padStart(2, "0");
const seconds = (actual % 60).toString(10).padStart(2, "0");
return <><strong>{minutes}</strong>'<strong>{seconds}</strong>"</>;
}
if (valueKey === "calories") {
return <><strong>{actual}</strong> kcal</>;
}
if (valueKey === "distance") {
const km = actual / 1000;
const kmStr = km > 9.95 ? km.toFixed(1) : km.toFixed(2);
return <><strong>{kmStr}</strong> km</>;
}
if (valueKey === "level") {
return <><strong>{actual}</strong> lvl</>;
}
}
return null;
}

5
ykonsole-core/pom.xml

@ -23,6 +23,11 @@
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
<version>1.6.4</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-jdk8</artifactId>
<version>1.6.4</version>
</dependency>
</dependencies>
<build>

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt

@ -10,4 +10,4 @@ class InfrastructureException(cause: Throwable) : YKonsoleException(cause)
class BadInputException(message: String) : YKonsoleException(message)
object BusyDriverException : YKonsoleException("Driver is busy")
class StorageException(message: String) : YKonsoleException(message)

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt

@ -5,7 +5,7 @@ import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
import kotlin.time.Duration.Companion.seconds
object Skipper : ActiveDriver() {
class Skipper : ActiveDriver() {
private var enabled: Boolean = false
override suspend fun onCommand(command: Command, output: FlowBus<Event>) {

26
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt

@ -0,0 +1,26 @@
package net.aiterp.git.ykonsole2.infrastructure.testing
import net.aiterp.git.ykonsole2.domain.models.Device
import net.aiterp.git.ykonsole2.domain.models.DeviceRepository
class InMemoryDeviceRepository : DeviceRepository {
private val devices = mutableListOf<Device>()
override fun findById(id: String): Device? = devices.firstOrNull { it.id == id }
override fun fetchAll() = devices.map(Device::copy)
override fun save(device: Device) {
val index = devices.indexOfFirst { it.id == device.id }
if (index >= 0) {
devices[index] = device
} else {
devices += device
}
}
override fun delete(device: Device) {
devices.removeIf { it.id == device.id }
}
}

24
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt

@ -0,0 +1,24 @@
package net.aiterp.git.ykonsole2.infrastructure.testing
import net.aiterp.git.ykonsole2.domain.models.Program
import net.aiterp.git.ykonsole2.domain.models.ProgramRepository
class InMemoryProgramRepository : ProgramRepository {
private val programs = mutableListOf<Program>()
override fun findById(id: String): Program? = programs.firstOrNull { it.id == id }
override fun fetchAll(): List<Program> = programs.asSequence()
.map(Program::copy)
.sortedBy { it.name }
.toList()
override fun save(program: Program) {
programs.removeIf { it.id == program.id }
programs += program
}
override fun delete(program: Program) {
programs.removeIf { it.id == program.id }
}
}

30
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt

@ -0,0 +1,30 @@
package net.aiterp.git.ykonsole2.infrastructure.testing
import net.aiterp.git.ykonsole2.domain.models.Workout
import net.aiterp.git.ykonsole2.domain.models.WorkoutRepository
import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus
class InMemoryWorkoutRepository : WorkoutRepository {
private val workouts = mutableListOf<Workout>()
override fun findById(id: String): Workout? = workouts.firstOrNull { it.id == id }
override fun fetchAll(): List<Workout> = workouts.asSequence()
.map { it.copy() }
.sortedByDescending { it.createdAt }
.toList()
override fun findActive(): Workout? = workouts.asSequence()
.filter { it.status != WorkoutStatus.Disconnected }
.sortedByDescending { it.createdAt }
.firstOrNull()
override fun save(workout: Workout) {
workouts.removeIf { it.id == workout.id }
workouts += workout
}
override fun delete(workout: Workout) {
workouts.removeIf { it.id == workout.id }
}
}

28
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt

@ -0,0 +1,28 @@
package net.aiterp.git.ykonsole2.infrastructure.testing
import net.aiterp.git.ykonsole2.domain.models.WorkoutState
import net.aiterp.git.ykonsole2.domain.models.WorkoutStateRepository
class InMemoryWorkoutStateRepository : WorkoutStateRepository {
private val map = mutableMapOf<String, MutableList<WorkoutState>>()
override fun fetchByWorkoutId(workoutId: String): List<WorkoutState> {
return map[workoutId]?.sortedBy { it.time.toInt() } ?: emptyList()
}
override fun save(state: WorkoutState) {
if (map[state.workoutId] != null) {
val list = map[state.workoutId]!!
list.removeIf { it.time == state.time }
list += state
} else {
map[state.workoutId] = mutableListOf(state)
}
}
override fun deleteAll(states: Collection<WorkoutState>) {
for (state in states) {
map[state.workoutId]?.removeIf { it.time == state.time }
}
}
}

52
ykonsole-exporter/pom.xml

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>ykonsole</artifactId>
<groupId>net.aiterp.git.trimlog</groupId>
<version>2.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ykonsole-exporter</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<repositories>
<repository>
<snapshots>
<enabled>false</enabled>
</snapshots>
<id>central</id>
<name>bintray</name>
<url>https://jcenter.bintray.com</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>2.13.3</version>
</dependency>
<dependency>
<groupId>net.aiterp.git.trimlog</groupId>
<artifactId>ykonsole-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>me.lazmaid.kraph</groupId>
<artifactId>kraph</artifactId>
<version>0.6.1</version>
</dependency>
</dependencies>
</project>

23
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt

@ -0,0 +1,23 @@
package net.aiterp.git.ykonsole2.infrastructure
import net.aiterp.git.ykonsole2.domain.models.Device
import net.aiterp.git.ykonsole2.domain.models.Program
import net.aiterp.git.ykonsole2.domain.models.Workout
import net.aiterp.git.ykonsole2.domain.models.WorkoutState
interface ExportTarget {
/**
* Check if [workout] is already exported.
*/
suspend fun isExported(workout: Workout): Boolean
/**
* Export a [Workout] with its pertaining [WorkoutState]s, [Device] and [Program].
*/
suspend fun export(
workout: Workout,
workoutStates: List<WorkoutState>,
device: Device?,
program: Program?,
)
}

57
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt

@ -0,0 +1,57 @@
package net.aiterp.git.ykonsole2.infrastructure
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ReactiveDriver
import java.time.Instant
import java.time.temporal.ChronoUnit
class WorkoutExporter(
private val workoutRepo: WorkoutRepository,
private val stateRepo: WorkoutStateRepository,
private val deviceRepo: DeviceRepository,
private val programRepo: ProgramRepository,
private val exportTarget: ExportTarget,
) : ReactiveDriver() {
private var workoutId: String = ""
override suspend fun onEvent(event: Event, input: FlowBus<Command>) {
if (event is Connected) {
workoutId = workoutRepo.findActive()?.id ?: ""
}
if (event is Disconnected && workoutId != "") {
export(workoutId)
workoutId = ""
}
}
override suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) {
log.info("Checking recent workouts...")
val yesterday = Instant.now().minus(24, ChronoUnit.HOURS)
for (workout in workoutRepo.fetchAll().filter { it.createdAt > yesterday }) {
export(workout)
}
log.info("Recent workouts verified and exported if needed")
super.start(input, output)
}
private suspend inline fun export(workoutId: String) {
val workout = workoutRepo.findById(workoutId) ?: return
export(workout)
}
private suspend inline fun export(workout: Workout) {
if (workout.test) return
val states = stateRepo.fetchByWorkoutId(workout.id)
if (states.isEmpty() || exportTarget.isExported(workout)) return
val device = deviceRepo.findById(workout.deviceId)
val program = workout.programId?.let { programRepo.findById(it) }
exportTarget.export(workout, states, device, program)
}
}

177
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt

@ -0,0 +1,177 @@
package net.aiterp.git.ykonsole2.infrastructure.indigo1
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.future.await
import me.lazmaid.kraph.Kraph
import net.aiterp.git.ykonsole2.StorageException
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.domain.models.Device
import net.aiterp.git.ykonsole2.domain.models.Program
import net.aiterp.git.ykonsole2.domain.models.Workout
import net.aiterp.git.ykonsole2.domain.models.WorkoutState
import net.aiterp.git.ykonsole2.infrastructure.ExportTarget
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse.BodyHandlers
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import java.util.*
class Indigo1(
private val endpoint: String,
private val clientId: String,
private val clientSecret: String,
) : ExportTarget {
private val logger = log
override suspend fun isExported(workout: Workout): Boolean {
val ids = run {
query {
fieldObject(
"exercises", args = mapOf(
"filter" to mapOf(
"fromDate" to workout.createdAt.minus(7, ChronoUnit.DAYS).atZone(ZoneOffset.UTC).toLocalDate().toString(),
"kindId" to 3,
"tags" to listOf(tag("ykonsole:Version", "2"), tag("ykonsole:WorkoutID", workout.id)),
)
)
) {
field("id")
}
}
}.data.exercises ?: return false
return ids.isNotEmpty()
}
override suspend fun export(
workout: Workout,
workoutStates: List<WorkoutState>,
device: Device?,
program: Program?,
) {
logger.info("Creating exercise...")
val exerciseId = run {
mutation {
fieldObject(
"addExercise", args = mapOf(
"options" to mapOf(
"kindId" to 3,
"partOfDayId" to workout.partOfDayId,
"date" to workout.date.toString(),
),
),
) { field("id") }
}
}.data.addExercise?.id ?: throw StorageException("Failed to create exercise")
logger.info("Created exercise with ID $exerciseId")
logger.info("Exporting states for exercise with ID $exerciseId...")
for (chunk in workoutStates.chunked(100)) {
val calories = chunk.mapNotNull { ws ->
if (ws.calories != null) (ws.time.toInt() to ws.calories!!.toInt()) else null
}
val distance = chunk.mapNotNull { ws ->
if (ws.distance != null) (ws.time.toInt() to ws.distance!!.toInt().toDouble() / 1000) else null
}
if (calories.isNotEmpty()) {
run {
mutation {
fieldObject(
"addMeasurementBatch", args = mapOf(
"exerciseId" to exerciseId,
"options" to calories.map { mapOf("point" to it.first, "value" to it.second) },
)
) { field("id") }
}
}
}
if (distance.isNotEmpty()) {
run {
mutation {
fieldObject(
"addMetadataBatch", args = mapOf(
"exerciseId" to exerciseId,
"options" to distance.map { mapOf("point" to it.first, "kindId" to 5, "value" to it.second) },
)
) { field("id") }
}
}
}
}
logger.info("Exporting tags for exercise with ID $exerciseId...")
run {
mutation {
fieldObject(
"addTagBatch", args = mapOf(
"exerciseId" to exerciseId,
"options" to listOfNotNull(
tag("ykonsole:Version", "2"),
tag("ykonsole:WorkoutID", workout.id),
workout.message.takeIf(String::isNotBlank)?.let { tag("ykonsole:ErrorMessage", it) },
tag("ykonsole:DeviceID", workout.deviceId),
device?.let { tag("ykonsole:DeviceName", it.name) },
program?.let { tag("ykonsole:ProgramID", it.id) },
program?.let { tag("ykonsole:ProgramName", it.name) },
tag("ykonsole:CreatedAt", "${workout.createdAt}"),
)
)
) {
field("id")
}
}
}
}
private suspend fun run(func: Kraph.() -> Unit): Output {
val query = Kraph { func() }
val request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.POST(BodyPublishers.ofString(query.toRequestString()))
.header("Content-Type", "application/json")
.header("Authorization", "Basic ${Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray())}")
.build()
val response = HttpClient.newHttpClient()
.sendAsync(request, BodyHandlers.ofString())
.await()
return jackson.readValue(response.body(), Output::class.java)
}
private val jackson = jacksonObjectMapper()
private data class Output(val data: Data)
private data class Data(
val addExercise: ExerciseOutput? = null,
val addMeasurementBatch: List<ExerciseOutput>? = null,
val addMetadataBatch: List<ExerciseOutput>? = null,
val addTagBatch: List<ExerciseOutput>? = null,
val exercises: List<ExerciseOutput>? = null,
)
private data class ExerciseOutput(val id: Int)
private val Workout.partOfDayId
get() = when (LocalTime.ofInstant(createdAt, zone).hour) {
in 5..11 -> "M"
in 12..17 -> "A"
in 18..22 -> "E"
else -> "N"
}
private val Workout.date get() = LocalDate.ofInstant(createdAt, zone)
private val zone = ZoneId.of("Europe/Oslo")
private fun tag(key: String, value: String) = mapOf("key" to key, "value" to value)
}

5
ykonsole-server/pom.xml

@ -23,6 +23,11 @@
<artifactId>ykonsole-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.aiterp.git.trimlog</groupId>
<artifactId>ykonsole-exporter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.aiterp.git.trimlog</groupId>
<artifactId>ykonsole-iconsole</artifactId>

66
ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

@ -2,28 +2,29 @@ package net.aiterp.git.ykonsole2
import kotlinx.coroutines.runBlocking
import net.aiterp.git.ykonsole2.application.createServer
import net.aiterp.git.ykonsole2.application.env.optStrEnv
import net.aiterp.git.ykonsole2.application.env.strEnv
import net.aiterp.git.ykonsole2.application.services.DriverStarter
import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus
import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus
import net.aiterp.git.ykonsole2.domain.runtime.EventBus
import net.aiterp.git.ykonsole2.infrastructure.ExportTarget
import net.aiterp.git.ykonsole2.infrastructure.IConsole
import net.aiterp.git.ykonsole2.infrastructure.WorkoutExporter
import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer
import net.aiterp.git.ykonsole2.infrastructure.drivers.Skipper
import net.aiterp.git.ykonsole2.infrastructure.drivers.WorkoutWriter
import net.aiterp.git.ykonsole2.infrastructure.indigo1.Indigo1
import net.aiterp.git.ykonsole2.infrastructure.makeDataSource
import net.aiterp.git.ykonsole2.infrastructure.repositories.deviceRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.programRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.workoutRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.workoutStateRepo
import net.aiterp.git.ykonsole2.infrastructure.testing.TestDriver
import net.aiterp.git.ykonsole2.infrastructure.testing.*
import kotlin.time.Duration.Companion.seconds
fun main(): Unit = runBlocking {
makeDataSource(
url = strEnv("MYSQL_URL"),
username = strEnv("MYSQL_USERNAME"),
password = strEnv("MYSQL_PASSWORD"),
).apply {
initRepositories().apply {
val commandBus = CommandBus()
val eventBus = EventBus()
@ -32,8 +33,10 @@ fun main(): Unit = runBlocking {
workoutRepo.save(active)
}
val exporter = workoutExporterOrNull()
val iConsole = IConsole()
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo)
val skipper = Skipper()
val testDriver = TestDriver(secondLength = 1.seconds)
val workoutWriter = WorkoutWriter(workoutRepo, workoutStateRepo)
@ -47,9 +50,56 @@ fun main(): Unit = runBlocking {
).start(wait = false)
DriverStarter(
drivers = listOf(testDriver, iConsole, workoutWriter, programEnforcer),
drivers = listOfNotNull(
exporter,
iConsole,
programEnforcer,
skipper,
testDriver,
workoutWriter,
),
input = commandBus,
output = eventBus,
).startDrivers()
}
}
private fun initRepositories(): RepositorySet = when (val storageType = strEnv("STORAGE_TYPE")) {
"in_memory" -> RepositorySet(
deviceRepo = InMemoryDeviceRepository(),
programRepo = InMemoryProgramRepository(),
workoutRepo = InMemoryWorkoutRepository(),
workoutStateRepo = InMemoryWorkoutStateRepository(),
)
"mysql" -> makeDataSource(
url = strEnv("MYSQL_URL"),
username = strEnv("MYSQL_USERNAME"),
password = strEnv("MYSQL_PASSWORD"),
).run { RepositorySet(deviceRepo, programRepo, workoutRepo, workoutStateRepo) }
else -> error("Invalid storage type: $storageType")
}
private data class RepositorySet(
val deviceRepo: DeviceRepository,
val programRepo: ProgramRepository,
val workoutRepo: WorkoutRepository,
val workoutStateRepo: WorkoutStateRepository,
) {
fun workoutExporterOrNull(): WorkoutExporter? {
val exportTarget = makeExportTarget() ?: return null
return WorkoutExporter(workoutRepo, workoutStateRepo, deviceRepo, programRepo, exportTarget)
}
private fun makeExportTarget(): ExportTarget? {
val indigo1Endpoint = optStrEnv("INDIGO1_ENDPOINT")
if (indigo1Endpoint != null) {
val clientId = strEnv("INDIGO1_CLIENT_ID")
val clientSecret = strEnv("INDIGO1_CLIENT_SECRET")
return Indigo1(indigo1Endpoint, clientId, clientSecret)
}
return null
}
}
Loading…
Cancel
Save