@@ -0,0 +1 @@
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +2,7 @@ import {getRequest, postRequest} from "./shared";
|
||||
import {PastWorkout, WorkoutState} from "../models/Workouts";
|
||||
|
||||
interface WorkoutFilter {
|
||||
daysBack: number
|
||||
daysBack?: number
|
||||
includeTest: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" + ");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +64,7 @@ export default function IndexPage(): JSX.Element {
|
||||
</BlobText>
|
||||
</Blob>
|
||||
))}
|
||||
{!expanded && (
|
||||
<PageFlexRow>
|
||||
<Blob onClick={loadingWorkouts ? undefined : () => showMoreWorkouts()}>
|
||||
<BlobText>
|
||||
@@ -72,6 +73,7 @@ export default function IndexPage(): JSX.Element {
|
||||
</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}/>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"/>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
const [sel, setSel] = usePlusMinus(mode === "level" ? 32 : options.length);
|
||||
let length = options.length;
|
||||
if (mode === "level") length = 32;
|
||||
if (mode === "skip") length = 3;
|
||||
|
||||
const [sel, setSel] = usePlusMinus(length);
|
||||
|
||||
useKey("Enter", () => {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || "")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user