Stian Fredrik Aune
2 years ago
39 changed files with 1115 additions and 126 deletions
-
1docker/bakend/Dockerfile
-
1pom.xml
-
3webui-react/package.json
-
9webui-react/src/App.tsx
-
31webui-react/src/actions/programs.ts
-
2webui-react/src/actions/workouts.ts
-
9webui-react/src/contexts/RuntimeContext.tsx
-
12webui-react/src/contexts/WorkoutContext.tsx
-
11webui-react/src/models/Programs.ts
-
53webui-react/src/models/Shared.ts
-
2webui-react/src/models/Workouts.ts
-
27webui-react/src/pages/DevicePage.tsx
-
147webui-react/src/pages/EditProgramPage.tsx
-
28webui-react/src/pages/IndexPage.tsx
-
23webui-react/src/pages/LoadingPage.tsx
-
31webui-react/src/pages/PlayPage.tsx
-
97webui-react/src/pages/ProgramPage.tsx
-
34webui-react/src/pages/WorkoutPage.tsx
-
70webui-react/src/pages/runtime/ControlsBoi.tsx
-
13webui-react/src/pages/runtime/ProgramBoi.sass
-
83webui-react/src/pages/runtime/ProgramBoi.tsx
-
2webui-react/src/primitives/blob/Blob.sass
-
8webui-react/src/primitives/blob/Blob.tsx
-
4webui-react/src/primitives/boi/Boi.sass
-
7webui-react/src/primitives/boi/Boi.tsx
-
36webui-react/src/primitives/misc/Misc.tsx
-
5ykonsole-core/pom.xml
-
2ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt
-
2ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
-
26ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt
-
24ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt
-
30ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt
-
28ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt
-
52ykonsole-exporter/pom.xml
-
23ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
-
57ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
-
177ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
-
5ykonsole-server/pom.xml
-
66ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
@ -0,0 +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> |
||||
|
); |
||||
|
} |
@ -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,8 +1,44 @@ |
|||||
import "./Misc.sass"; |
import "./Misc.sass"; |
||||
import {WithChildren} from "../Shared"; |
import {WithChildren} from "../Shared"; |
||||
|
import {Values} from "../../models/Shared"; |
||||
|
|
||||
export function TitleLine({children}: WithChildren) { |
export function TitleLine({children}: WithChildren) { |
||||
return ( |
return ( |
||||
<div className="TitleLine">{children}</div> |
<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; |
||||
|
} |
@ -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 } |
||||
|
} |
||||
|
} |
@ -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 } |
||||
|
} |
||||
|
} |
@ -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 } |
||||
|
} |
||||
|
} |
@ -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> |
@ -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?, |
||||
|
) |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue