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 {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; |
|||
} |
@ -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