From 5bec55397fd5526c1e52c9c407ae1db0ef22e373 Mon Sep 17 00:00:00 2001 From: Stian Fredrik Aune Date: Mon, 29 Aug 2022 23:47:09 +0200 Subject: [PATCH] wip --- docker/bakend/Dockerfile | 1 + pom.xml | 1 + webui-react/package.json | 3 +- webui-react/src/App.tsx | 9 +- webui-react/src/actions/programs.ts | 31 ++- webui-react/src/actions/workouts.ts | 2 +- webui-react/src/contexts/RuntimeContext.tsx | 9 +- webui-react/src/contexts/WorkoutContext.tsx | 12 +- webui-react/src/models/Programs.ts | 11 +- webui-react/src/models/Shared.ts | 53 ++++++ webui-react/src/models/Workouts.ts | 2 +- webui-react/src/pages/DevicePage.tsx | 27 ++- webui-react/src/pages/EditProgramPage.tsx | 147 +++++++++++++++ webui-react/src/pages/IndexPage.tsx | 28 +-- webui-react/src/pages/LoadingPage.tsx | 23 ++- webui-react/src/pages/PlayPage.tsx | 31 ++- webui-react/src/pages/ProgramPage.tsx | 97 ++++++++++ webui-react/src/pages/WorkoutPage.tsx | 34 +++- webui-react/src/pages/runtime/ControlsBoi.tsx | 70 +++++-- webui-react/src/pages/runtime/ProgramBoi.sass | 13 +- webui-react/src/pages/runtime/ProgramBoi.tsx | 83 +++++++- webui-react/src/primitives/blob/Blob.sass | 2 + webui-react/src/primitives/blob/Blob.tsx | 8 +- webui-react/src/primitives/boi/Boi.sass | 4 +- webui-react/src/primitives/boi/Boi.tsx | 7 +- webui-react/src/primitives/misc/Misc.tsx | 36 ++++ ykonsole-core/pom.xml | 5 + .../aiterp/git/ykonsole2/YKonsoleException.kt | 2 +- .../infrastructure/drivers/Skipper.kt | 2 +- .../testing/InMemoryDeviceRepository.kt | 26 +++ .../testing/InMemoryProgramRepository.kt | 24 +++ .../testing/InMemoryWorkoutRepository.kt | 30 +++ .../testing/InMemoryWorkoutStateRepository.kt | 28 +++ ykonsole-exporter/pom.xml | 52 +++++ .../ykonsole2/infrastructure/ExportTarget.kt | 23 +++ .../infrastructure/WorkoutExporter.kt | 57 ++++++ .../infrastructure/indigo1/Indigo1.kt | 177 ++++++++++++++++++ ykonsole-server/pom.xml | 5 + .../kotlin/net/aiterp/git/ykonsole2/Server.kt | 66 ++++++- 39 files changed, 1115 insertions(+), 126 deletions(-) create mode 100644 docker/bakend/Dockerfile create mode 100644 webui-react/src/pages/EditProgramPage.tsx create mode 100644 webui-react/src/pages/ProgramPage.tsx create mode 100644 ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt create mode 100644 ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt create mode 100644 ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt create mode 100644 ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt create mode 100644 ykonsole-exporter/pom.xml create mode 100644 ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt create mode 100644 ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt create mode 100644 ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt diff --git a/docker/bakend/Dockerfile b/docker/bakend/Dockerfile new file mode 100644 index 0000000..d3f5a12 --- /dev/null +++ b/docker/bakend/Dockerfile @@ -0,0 +1 @@ + diff --git a/pom.xml b/pom.xml index c262d92..61c0c04 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ ykonsole-mysql ykonsole-server ykonsole-ktor + ykonsole-exporter net.aiterp.git.trimlog 2.0.0 diff --git a/webui-react/package.json b/webui-react/package.json index 26f2f29..a6a1e3f 100644 --- a/webui-react/package.json +++ b/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" diff --git a/webui-react/src/App.tsx b/webui-react/src/App.tsx index 11b4133..5f86d5f 100644 --- a/webui-react/src/App.tsx +++ b/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() { }/> }/> + }/> + }/> + }/> + }/> + }/> }/> }/> diff --git a/webui-react/src/actions/programs.ts b/webui-react/src/actions/programs.ts index d598cf7..ef17eea 100644 --- a/webui-react/src/actions/programs.ts +++ b/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 + save(program: Partial): Promise + delete(program: Pick): Promise } 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): Promise { + 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): Promise { + try { + await deleteRequest(`/programs/${id}`); + return true; + } catch (e) { + return false; + } + }, }; diff --git a/webui-react/src/actions/workouts.ts b/webui-react/src/actions/workouts.ts index 3405b63..06f5949 100644 --- a/webui-react/src/actions/workouts.ts +++ b/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 } diff --git a/webui-react/src/contexts/RuntimeContext.tsx b/webui-react/src/contexts/RuntimeContext.tsx index 3d40b1a..28fa03c 100644 --- a/webui-react/src/contexts/RuntimeContext.tsx +++ b/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({ 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} diff --git a/webui-react/src/contexts/WorkoutContext.tsx b/webui-react/src/contexts/WorkoutContext.tsx index 8176b19..e3ce635 100644 --- a/webui-react/src/contexts/WorkoutContext.tsx +++ b/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({ workouts: [], loadingWorkouts: false, + expanded: false, getWorkout: unimplemented, fetchWorkout: unimplemented, getStates: unimplemented, @@ -33,7 +35,7 @@ export function WorkoutContextProvider({children}: WithChildren) { const [workouts, setWorkouts] = useState([]); const [cache, setCache] = useState>({}); const [loadingWorkouts, setLoadingWorkouts] = useState(false); - const [days, setDays] = useState(6); + const [expanded, setExpanded] = useState(false); const [ver, setVer] = useState(0); const [stateMap, setStateMap] = useState>({}); @@ -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 ( 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(" + "); diff --git a/webui-react/src/models/Shared.ts b/webui-react/src/models/Shared.ts index a6f8b18..a7c9a23 100644 --- a/webui-react/src/models/Shared.ts +++ b/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; +} diff --git a/webui-react/src/models/Workouts.ts b/webui-react/src/models/Workouts.ts index 98d6246..532a59c 100644 --- a/webui-react/src/models/Workouts.ts +++ b/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) { diff --git a/webui-react/src/pages/DevicePage.tsx b/webui-react/src/pages/DevicePage.tsx index 698505b..7c33a89 100644 --- a/webui-react/src/pages/DevicePage.tsx +++ b/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 ; } else if (edit && device) { return ; @@ -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 { {device.connectionString} - navigate(`/devices/${device.id}?edit=true`)}> + navigate(`/devices/${device.id}/edit`)}> @@ -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]; diff --git a/webui-react/src/pages/EditProgramPage.tsx b/webui-react/src/pages/EditProgramPage.tsx new file mode 100644 index 0000000..4196831 --- /dev/null +++ b/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([]); + + const [wait, setWait] = useState(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 ; + } else if (wait) { + return ; + } + + const title = program ? `Endre "${program.name}"` : "Nytt programm"; + const canSave = name.trim() !== "" && steps.length > 0 && !steps.find(p => p.level === 0); + + return ( + +
+ navigate(program ? `/programs/${program.id}` : "/")}> + + + {title} +
+ + + + Programm + + Navn + + + + + Lagre + + + + + Steg + {steps.map((s, i) => { + const onChange = (arg: Partial) => setSteps(prev => { + return prev.map((ps, pi) => (pi === i ? {...ps, ...arg} : ps)); + }); + + const onRemove = () => setSteps(prev => { + return prev.filter((ignored, pi) => pi !== i); + }) + + return ( + + + + + + onChange({level})} + /> + + + + + + onChange({duration})} + /> + + + + + + + + ); + })} + setSteps(prev => [...prev, {duration: "", level: 1}])}> + + Legg til + + + + + +
+ ); +} diff --git a/webui-react/src/pages/IndexPage.tsx b/webui-react/src/pages/IndexPage.tsx index f72c769..205d212 100644 --- a/webui-react/src/pages/IndexPage.tsx +++ b/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 {
YKonsole
- + navigate("/play")} color={isRunning ? "yellow" : "green"}> {isRunning ? "Fortsett" : "Start"} @@ -53,7 +53,7 @@ export default function IndexPage(): JSX.Element { - Siste økter ({workouts.length}) + Siste økter ({loadingWorkouts ? : workouts.length}) {workouts.map(w => ( navigate(`/workouts/${w.id}`)}> @@ -64,14 +64,16 @@ export default function IndexPage(): JSX.Element { ))} - - showMoreWorkouts()}> - - {loadingWorkouts && } - {!loadingWorkouts && <> Vis flere } - - - + {!expanded && ( + + showMoreWorkouts()}> + + {loadingWorkouts && } + {!loadingWorkouts && <> Vis flere } + + + + )} @@ -84,7 +86,7 @@ export default function IndexPage(): JSX.Element { ))} - navigate(`/programs/new?edit=true`)}> + navigate(`/programs/new`)}> @@ -102,7 +104,7 @@ export default function IndexPage(): JSX.Element { ))} - navigate(`/devices/new?edit=true`)}> + navigate("/devices/new")}> diff --git a/webui-react/src/pages/LoadingPage.tsx b/webui-react/src/pages/LoadingPage.tsx index 076d1de..c77c995 100644 --- a/webui-react/src/pages/LoadingPage.tsx +++ b/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 ( - -
- YKonsole -
+ + {!minimal && ( +
+ YKonsole +
+ )} @@ -31,8 +34,14 @@ interface LoadingSectionProps { export function LoadingSection({text}: LoadingSectionProps) { return ( - - {text ? `${text}` : ""} + +
+ +
+ {text &&
{text}
}
); } diff --git a/webui-react/src/pages/PlayPage.tsx b/webui-react/src/pages/PlayPage.tsx index 590ed4e..22a73a2 100644 --- a/webui-react/src/pages/PlayPage.tsx +++ b/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 ; } - return ; + return ; } const noProgram: Program = { @@ -94,18 +93,14 @@ function CreatePlayPage(): JSX.Element { } }, [devices]); - if (devices === null || programWithFake === null) { - return + if (devices === null) { + return + } else if (programWithFake === null) { + return } return ( -
- navigate("/")}> - - - Ny økt -
{device === null && ( Velg enhet @@ -180,21 +175,21 @@ function RunPlayPage(): JSX.Element { const lastState = useLastState(); if (!workout || workout.status === WorkoutStatus.Created) { - return ; + return ; } return ( {lastState && ( - - {stateString(lastState, "time")} + +
- {stateString(lastState, "calories")} +
- {stateString(lastState, "distance")} +
- {stateString(lastState, "level")} +
)} {workout?.status === WorkoutStatus.Connected && } diff --git a/webui-react/src/pages/ProgramPage.tsx b/webui-react/src/pages/ProgramPage.tsx new file mode 100644 index 0000000..26fef9c --- /dev/null +++ b/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 ; + } + + return ( + +
+ navigate("/")}> + + + {program.name} +
+ + + + Programm + + + {program.name} + + + navigate(`/programs/${program.id}/edit`)}> + + + + + + + + + + + + Steg + {program.steps.map((s, i) => ( + + + + {s.values.level} + + + + + {valuesToString(s.duration || {}) || "Manuell"} + + + + ))} + + + +
+ ); +} diff --git a/webui-react/src/pages/WorkoutPage.tsx b/webui-react/src/pages/WorkoutPage.tsx index cacbd9b..62d669b 100644 --- a/webui-react/src/pages/WorkoutPage.tsx +++ b/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 (
- navigate("/")}> + navigate("/")} shortcut="/"> Øktdetaljer @@ -80,8 +93,8 @@ export default function WorkoutPage(): JSX.Element { Målinger {states ? ( - states.map(s => ( - + states.filter(wsFilter).map(s => ( + {stateString(s, "time")} @@ -97,6 +110,15 @@ export default function WorkoutPage(): JSX.Element { )) ) : } + {!expanded && ( + + setExpanded(true)}> + + Vis alle + + + + )} diff --git a/webui-react/src/pages/runtime/ControlsBoi.tsx b/webui-react/src/pages/runtime/ControlsBoi.tsx index 935b7bb..fe3168c 100644 --- a/webui-react/src/pages/runtime/ControlsBoi.tsx +++ b/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 ( - - ); + return ; + } + + if (mode === "skip") { + return ; } return ( {options.map((o, i) => ( - + {o.icon && } {o.text} diff --git a/webui-react/src/pages/runtime/ProgramBoi.sass b/webui-react/src/pages/runtime/ProgramBoi.sass index a3a93d7..b6590fa 100644 --- a/webui-react/src/pages/runtime/ProgramBoi.sass +++ b/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 diff --git a/webui-react/src/pages/runtime/ProgramBoi.tsx b/webui-react/src/pages/runtime/ProgramBoi.tsx index a2cae67..95086bb 100644 --- a/webui-react/src/pages/runtime/ProgramBoi.tsx +++ b/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 ; } return ; @@ -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 ( + +
+ Steg {progress.currentIndex + 1} / {progress.steps.length} +
+ {ft.count && ( +
+
+ {ft.strCurr} / {ft.strTo} +
+
+
+
+
+
+ )} + {!ft.count && ( +
+ (Custom) +
+ )} + + ); +} + 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 (
@@ -139,7 +202,7 @@ function HealthBarProgress({progress}: ProgressProps) { )}
{stepIndex > progress.currentIndex && ( -
+
)} {stepIndex === progress.currentIndex && ( <> diff --git a/webui-react/src/primitives/blob/Blob.sass b/webui-react/src/primitives/blob/Blob.sass index cdfec6e..1a320ea 100644 --- a/webui-react/src/primitives/blob/Blob.sass +++ b/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 diff --git a/webui-react/src/primitives/blob/Blob.tsx b/webui-react/src/primitives/blob/Blob.tsx index c1ada5c..b82693f 100644 --- a/webui-react/src/primitives/blob/Blob.tsx +++ b/webui-react/src/primitives/blob/Blob.tsx @@ -93,9 +93,10 @@ interface BaseBlobInputProps { 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 ( actualOnChange(e.target.value || "")} /> ); diff --git a/webui-react/src/primitives/boi/Boi.sass b/webui-react/src/primitives/boi/Boi.sass index fd62c0c..c8f6696 100644 --- a/webui-react/src/primitives/boi/Boi.sass +++ b/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 diff --git a/webui-react/src/primitives/boi/Boi.tsx b/webui-react/src/primitives/boi/Boi.tsx index 7c0bc7a..20e58fe 100644 --- a/webui-react/src/primitives/boi/Boi.tsx +++ b/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]); diff --git a/webui-react/src/primitives/misc/Misc.tsx b/webui-react/src/primitives/misc/Misc.tsx index 51a6778..3efa5ee 100644 --- a/webui-react/src/primitives/misc/Misc.tsx +++ b/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 (
{children}
); } + +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 <>{minutes}'{seconds}"; + } + + if (valueKey === "calories") { + return <>{actual} kcal; + } + + if (valueKey === "distance") { + const km = actual / 1000; + const kmStr = km > 9.95 ? km.toFixed(1) : km.toFixed(2); + + return <>{kmStr} km; + } + + if (valueKey === "level") { + return <>{actual} lvl; + } + } + + return null; +} diff --git a/ykonsole-core/pom.xml b/ykonsole-core/pom.xml index 938d659..39d11bf 100644 --- a/ykonsole-core/pom.xml +++ b/ykonsole-core/pom.xml @@ -23,6 +23,11 @@ kotlinx-coroutines-core-jvm 1.6.4 + + org.jetbrains.kotlinx + kotlinx-coroutines-jdk8 + 1.6.4 + diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt index decea96..c3bb4da 100644 --- a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt +++ b/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) diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt index 0e0a4a1..5bebf7d 100644 --- a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt +++ b/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) { diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt new file mode 100644 index 0000000..729610b --- /dev/null +++ b/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() + + 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 } + } +} diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt new file mode 100644 index 0000000..efb5f1d --- /dev/null +++ b/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() + + override fun findById(id: String): Program? = programs.firstOrNull { it.id == id } + + override fun fetchAll(): List = 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 } + } +} \ No newline at end of file diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt new file mode 100644 index 0000000..6d1de10 --- /dev/null +++ b/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() + + override fun findById(id: String): Workout? = workouts.firstOrNull { it.id == id } + + override fun fetchAll(): List = 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 } + } +} diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt new file mode 100644 index 0000000..fd6c650 --- /dev/null +++ b/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>() + + override fun fetchByWorkoutId(workoutId: String): List { + 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) { + for (state in states) { + map[state.workoutId]?.removeIf { it.time == state.time } + } + } +} diff --git a/ykonsole-exporter/pom.xml b/ykonsole-exporter/pom.xml new file mode 100644 index 0000000..2da9312 --- /dev/null +++ b/ykonsole-exporter/pom.xml @@ -0,0 +1,52 @@ + + + + ykonsole + net.aiterp.git.trimlog + 2.0.0 + + 4.0.0 + + ykonsole-exporter + + + 11 + 11 + + + + + + false + + central + bintray + https://jcenter.bintray.com + + + + + + com.fasterxml.jackson.core + jackson-databind + 2.13.3 + + + com.fasterxml.jackson.module + jackson-module-kotlin + 2.13.3 + + + net.aiterp.git.trimlog + ykonsole-core + ${project.version} + + + me.lazmaid.kraph + kraph + 0.6.1 + + + diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt new file mode 100644 index 0000000..1381901 --- /dev/null +++ b/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, + device: Device?, + program: Program?, + ) +} diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt new file mode 100644 index 0000000..45f4243 --- /dev/null +++ b/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) { + if (event is Connected) { + workoutId = workoutRepo.findActive()?.id ?: "" + } + + if (event is Disconnected && workoutId != "") { + export(workoutId) + workoutId = "" + } + } + + override suspend fun start(input: FlowBus, output: FlowBus) { + 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) + } +} diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt new file mode 100644 index 0000000..4e438bc --- /dev/null +++ b/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, + 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? = null, + val addMetadataBatch: List? = null, + val addTagBatch: List? = null, + val exercises: List? = 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) +} \ No newline at end of file diff --git a/ykonsole-server/pom.xml b/ykonsole-server/pom.xml index d710b30..308b422 100644 --- a/ykonsole-server/pom.xml +++ b/ykonsole-server/pom.xml @@ -23,6 +23,11 @@ ykonsole-core ${project.version} + + net.aiterp.git.trimlog + ykonsole-exporter + ${project.version} + net.aiterp.git.trimlog ykonsole-iconsole diff --git a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt b/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt index b4e761d..42215be 100644 --- a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt +++ b/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 + } +}