Browse Source

Milestones, progressbar + boi

main 2.1.0
Stian Fredrik Aune 2 years ago
parent
commit
4fafc6d9ab
  1. 3
      webui-react/src/contexts/RuntimeContext.tsx
  2. 37
      webui-react/src/hooks/milestones.ts
  3. 8
      webui-react/src/models/Milestone.ts
  4. 1
      webui-react/src/models/Shared.ts
  5. 2
      webui-react/src/pages/PlayPage.tsx
  6. 5
      webui-react/src/pages/runtime/MilestoneBoi.sass
  7. 35
      webui-react/src/pages/runtime/MilestoneBoi.tsx
  8. 18
      webui-react/src/pages/runtime/ProgramBoi.sass
  9. 77
      webui-react/src/pages/runtime/ProgramBoi.tsx
  10. 3
      webui-react/src/primitives/misc/Misc.tsx
  11. 5
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt
  12. 1
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Event.kt
  13. 83
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/MilestoneChecker.kt
  14. 4
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt
  15. 14
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/PassiveDriver.kt
  16. 7
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt
  17. 16
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt
  18. 3
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt
  19. 2
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt
  20. 3
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

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

@ -5,6 +5,7 @@ import {WithChildren} from "../primitives/Shared";
import {Values} from "../models/Shared";
import runtimeRepo from "../actions/runtime";
import workoutRepo, {CreateWorkoutOptions} from "../actions/workouts";
import {Milestone} from "../models/Milestone";
interface RuntimeContextValue {
workout: CurrentWorkout | null
@ -61,11 +62,11 @@ function socketInput(obj: SocketInput): string {
return JSON.stringify(obj);
}
export type RuntimeEvent = SocketOutput
interface SocketOutput {
sentAt: string
workout: CurrentWorkout | null
workoutStates: WorkoutState[] | null
milestone: Milestone | null
event: { name: string } | null
error: { message: string } | null
}

37
webui-react/src/hooks/milestones.ts

@ -0,0 +1,37 @@
import {useContext, useEffect, useMemo, useReducer, useState} from "react";
import RuntimeContext from "../contexts/RuntimeContext";
import {Milestone} from "../models/Milestone";
import {ValueKey} from "../models/Shared";
export function useCurrentMilestone(): Milestone | null {
const {lastEvent} = useContext(RuntimeContext);
const [milestone, setMilestone] = useState<Milestone | null>(null);
useEffect(() => {
if (lastEvent?.milestone) {
setMilestone(lastEvent.milestone);
}
}, [lastEvent]);
useEffect(() => {
const handle = setTimeout(() => setMilestone(null), 1000);
return () => clearTimeout(handle);
}, [milestone]);
return milestone;
}
export function useLastMilestoneValue(valueKey: ValueKey, primaryKey: ValueKey) {
const [value, setValue] = useState<number>(0);
const current = useCurrentMilestone();
useEffect(() => {
if (current && current.primaryKey === primaryKey) {
setValue(prev => current.current[valueKey] || prev);
}
}, [current, primaryKey, valueKey]);
return value;
}

8
webui-react/src/models/Milestone.ts

@ -0,0 +1,8 @@
import {ValueKey, Values} from "./Shared";
export interface Milestone<K extends ValueKey = ValueKey> {
primary: Values & { [P in K]: number }
primaryKey: K
current: Values
diff: Values
}

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

@ -11,6 +11,7 @@ export interface ValuesWithTime extends Values {
time: number
}
export type ValueKey = keyof Values
export interface Values {
time?: number
calories?: number

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

@ -18,6 +18,7 @@ import {useLastState} from "./runtime/hooks";
import {ControlsBoi} from "./runtime/ControlsBoi";
import MessageBoi from "./runtime/MessageBoi";
import ProgramBoi from "./runtime/ProgramBoi";
import MilestoneBoi from "./runtime/MilestoneBoi";
function PlayPage(): JSX.Element {
const {active, ready, ended, workout, reset, resume} = useContext(RuntimeContext);
@ -197,6 +198,7 @@ function RunPlayPage(): JSX.Element {
{workout?.status === WorkoutStatus.Connected && <MessageBoi text="Trykk Enter for å begynne"/>}
{workout?.status === WorkoutStatus.Stopped && <MessageBoi text="Pause"/>}
{workout.program && workout.program.steps.length > 0 && <ProgramBoi/>}
<MilestoneBoi/>
</Page>
);
}

5
webui-react/src/pages/runtime/MilestoneBoi.sass

@ -0,0 +1,5 @@
.MilestoneBoi-inner
margin: 0.25em 0.5ch
.MilestoneBoi-top
font-size: 150%

35
webui-react/src/pages/runtime/MilestoneBoi.tsx

@ -0,0 +1,35 @@
import "./MilestoneBoi.sass";
import {useCurrentMilestone} from "../../hooks/milestones";
import {Boi} from "../../primitives/boi/Boi";
import {Value} from "../../primitives/misc/Misc";
import {ValueKey} from "../../models/Shared";
export default function MilestoneBoi() {
const milestone = useCurrentMilestone();
if (milestone === null) {
return null;
}
const {primary, primaryKey, diff} = milestone;
const top = <Value raw={primary} valueKey={primaryKey}/>;
const bottom = [];
for (const key in diff) {
bottom.push(<div>+<Value raw={diff} valueKey={(key) as ValueKey}/></div>);
}
return (
<Boi vertical="center" horizontal="center">
<div className="MilestoneBoi-inner">
<span className="MilestoneBoi-top">
{top}
</span>
<br/>
<span className="MilestoneBoi-bottom">
{bottom}
</span>
</div>
</Boi>
);
}

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

@ -37,17 +37,35 @@
.ProgressBar-level-5
background-color: $red-3
&.diff
background-color: $red-6
.ProgressBar-level-4
background-color: $yellow-3
&.diff
background-color: $yellow-6
.ProgressBar-level-3
background-color: $green-3
&.diff
background-color: $green-6
.ProgressBar-level-2
background-color: $cyan-3
&.diff
background-color: $cyan-6
.ProgressBar-level-1
background-color: $blue-3
&.diff
background-color: $blue-6
.ProgressBar-level-0
background-color: $indigo-3
&.diff
background-color: $indigo-6

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

@ -6,6 +6,7 @@ import {ProgramStep, weighting} from "../../models/Programs";
import {firstKey, WorkoutState} from "../../models/Workouts";
import {diffLinearValues, formatValue} from "../../models/Shared";
import {Boi} from "../../primitives/boi/Boi";
import {useLastMilestoneValue} from "../../hooks/milestones";
interface StepMeta {
actualDuration?: WorkoutState
@ -208,41 +209,57 @@ function HealthBarProgress({progress}: ProgressProps) {
return (
<div className="HealthBar">
{steps.map((step) => {
const stepIndex = progress.steps.indexOf(step);
const level = progress.steps.indexOf(step) + offset;
{steps.map((step) => <HealthBarProgressStep progress={progress} step={step} offset={offset}/>)}
</div>
);
}
interface HealthBarProgressStepProps {
progress: ProgressState
step: ProgramStep & StepMeta
offset: number
}
function HealthBarProgressStep({progress, step, offset}: HealthBarProgressStepProps) {
const stepIndex = progress.steps.indexOf(step);
const level = progress.steps.indexOf(step) + offset;
const max = Math.max(1, progress.toNext.max);
const key = firstKey(step.duration as WorkoutState);
const max = Math.max(1, progress.toNext.max);
const key = firstKey(step.duration as WorkoutState);
const lastMilestone = useLastMilestoneValue(key!, "time");
const duration = step.duration![key!]!;
const duration = step.duration![key!]!;
const durationStr = formatValue(duration - progress.toNext.current, key!);
const durationStr = formatValue(duration - progress.toNext.current, key!);
const currentVal = Math.max(0, progress.toNext.current);
return (
<div key={step.index} className="HealthBar-entry" style={{flex: weighting(step)}}>
{stepIndex === progress.currentIndex && (
<div className="HealthBar-entry-text">
{durationStr}
</div>
const preDiffed = key !== "time" ? Math.max(0, (progress.lastValue[key!] || 0) - lastMilestone) : -1;
const diffed = Math.min(preDiffed, currentVal);
return (
<div key={step.index} className="HealthBar-entry" style={{flex: weighting(step)}}>
{stepIndex === progress.currentIndex && (
<div className="HealthBar-entry-text">
{durationStr}
</div>
)}
<div className="ProgressBar">
{stepIndex > progress.currentIndex && (
<div className={`ProgressBar-inactive`} style={{flex: 1}}/>
)}
{stepIndex === progress.currentIndex && (
<>
<div className={`ProgressBar-level-${level}`} style={{flex: max - currentVal}}/>
{diffed >= 0 && (
<div className={`ProgressBar-level-${level} diff`} style={{flex: diffed}}/>
)}
<div className="ProgressBar">
{stepIndex > progress.currentIndex && (
<div className={`ProgressBar-inactive`} style={{flex: 1}}/>
)}
{stepIndex === progress.currentIndex && (
<>
<div className={`ProgressBar-level-${level}`} style={{flex: max - progress.toNext.current}}/>
<div className={`ProgressBar-bg`} style={{flex: progress.toNext.current}}/>
</>
)}
{stepIndex < progress.currentIndex && (
<div className={`ProgressBar-bg`} style={{flex: 1}}/>
)}
</div>
</div>
);
})}
<div className={`ProgressBar-bg`} style={{flex: currentVal - diffed}}/>
</>
)}
{stepIndex < progress.currentIndex && (
<div className={`ProgressBar-bg`} style={{flex: 1}}/>
)}
</div>
</div>
);
}

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

@ -30,9 +30,8 @@ export function Value({raw, valueKey}: ValueProps): JSX.Element | null {
if (valueKey === "distance") {
const km = actual / 1000;
const kmStr = km > 9.95 ? km.toFixed(1) : km.toFixed(2);
return <><strong>{kmStr}</strong> km</>;
return <><strong>{km.toFixed(1)}</strong> km</>;
}
if (valueKey === "level") {

5
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/WorkoutState.kt

@ -1,9 +1,6 @@
package net.aiterp.git.ykonsole2.domain.models
import net.aiterp.git.ykonsole2.domain.runtime.Calories
import net.aiterp.git.ykonsole2.domain.runtime.Distance
import net.aiterp.git.ykonsole2.domain.runtime.Level
import net.aiterp.git.ykonsole2.domain.runtime.Time
import net.aiterp.git.ykonsole2.domain.runtime.*
data class WorkoutState(
val workoutId: String = "",

1
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Event.kt

@ -10,6 +10,7 @@ data class ValuesReceived(val values: List<Value>) : Event()
data class ErrorOccurred(val message: String) : Event()
object Started : Event()
object Stopped : Event()
data class MilestoneReached(val keyValue: Value, val current: List<Value>, val diff: List<Value>) : Event()
object Connected : Event()
object Disconnected : Event()

83
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/MilestoneChecker.kt

@ -0,0 +1,83 @@
package net.aiterp.git.ykonsole2.infrastructure.drivers
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.PassiveDriver
import java.util.concurrent.ConcurrentHashMap
class MilestoneChecker : PassiveDriver() {
private val milestoneMap = ConcurrentHashMap<Value, List<Value>>()
override suspend fun onEvent(event: Event, output: FlowBus<Event>) {
if (event is Connected || event is Disconnected) {
milestoneMap.clear()
}
if (event is ValuesReceived) {
val time = event.values.find<Time>() ?: return
val calories = event.values.find<Calories>() ?: return
val distance = event.values.find<Distance>() ?: return
var next: MilestoneReached? = null
val roundDistance = distance.roundedDown
if (roundDistance.isMilestone && !milestoneMap.containsKey(roundDistance)) {
val last = milestoneMap.getOrDefault(distance.lastMilestone, default)
milestoneMap[roundDistance] = listOf(time, calories)
next = MilestoneReached(roundDistance, listOf(time, calories), listOf(
Time(time.seconds - last.findInt<Time>()),
Calories(calories.kcal - last.findInt<Calories>()),
))
}
val roundedCals = calories.roundedDown
if (roundedCals.isMilestone && !milestoneMap.containsKey(roundedCals)) {
val last = milestoneMap.getOrDefault(roundedCals.lastMilestone, default)
milestoneMap[roundedCals] = listOf(time, distance)
next = MilestoneReached(roundedCals, listOf(time, distance), listOf(
Time(time.seconds - last.findInt<Time>()),
Distance(distance.meters - last.findInt<Distance>()),
))
}
val roundedTime = time.roundedDown
if (roundedTime.isMilestone && !milestoneMap.containsKey(roundedTime)) {
val last = milestoneMap.getOrDefault(time.lastMilestone, default)
milestoneMap[roundedTime] = listOf(calories, distance)
next = MilestoneReached(roundedTime, listOf(calories, distance), listOf(
Calories(calories.kcal - last.findInt<Calories>()),
Distance(distance.meters - last.findInt<Distance>()),
))
}
next?.let { output.emit(it) }
}
}
companion object {
private val default = listOf(Time(0), Calories(0), Distance(0))
private val Value.roundedDown get() = when (this) {
is Time -> Time(seconds - (seconds % 2))
is Calories -> Calories(kcal - (kcal % 4))
is Distance -> Distance(meters - (meters % 10))
else -> error("Not milestone-able")
}
private val Value.isMilestone get() = when (this) {
is Time -> seconds > 0 && seconds % 60 == 0
is Calories -> kcal > 0 && kcal % 100 == 0
is Distance -> meters > 0 && meters % 1000 == 0
else -> error("Not milestone-able")
}
private val Value.lastMilestone get() = when (this) {
is Time -> Time(maxOf(0, (seconds - 1) - ((seconds - 1) % 60)))
is Calories -> Calories(maxOf(0, (kcal - 1) - ((kcal - 1) % 100)))
is Distance -> Distance(maxOf(0, (meters - 1) - ((meters - 1) % 1000)))
else -> error("Not milestone-able")
}
}
}

4
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt

@ -45,6 +45,10 @@ class WorkoutWriter(
workoutRepo.save(foundWorkout)
}
is MilestoneReached -> {
/* Do nothing */
}
is Skipped -> {
/* Do nothing */
}

14
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/abstracts/PassiveDriver.kt

@ -0,0 +1,14 @@
package net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts
import net.aiterp.git.ykonsole2.domain.runtime.Command
import net.aiterp.git.ykonsole2.domain.runtime.Driver
import net.aiterp.git.ykonsole2.domain.runtime.Event
import net.aiterp.git.ykonsole2.domain.runtime.FlowBus
abstract class PassiveDriver : Driver {
protected abstract suspend fun onEvent(event: Event, output: FlowBus<Event>)
override suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) {
output.collect { onEvent(it, output) }
}
}

7
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt

@ -4,6 +4,7 @@ import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
import kotlinx.coroutines.delay
import net.aiterp.git.ykonsole2.application.logging.log
import kotlin.math.abs
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Duration
@ -22,7 +23,7 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
Level(level),
Time(time),
Calories(calories.toInt()),
Distance(distance.toInt()),
Distance(if (abs(distance % 1000) < 0) distance.toInt() else distance.toInt()),
)
override suspend fun onCommand(command: Command, output: FlowBus<Event>) {
@ -71,8 +72,8 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
override suspend fun onTick(output: FlowBus<Event>) {
if (running && connected) {
time += 1
calories += Random.nextDouble(0.2, 0.8)
distance += 1.8
calories += (level.toDouble() / 10) * Random.nextDouble(0.1, 0.4)
distance += (level.toDouble() / 10) * Random.nextDouble(1.5, 2.5)
output.emit(ValuesReceived(currentState))
}

16
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt

@ -80,3 +80,19 @@ data class WorkoutDTO(
test = workout.test,
)
}
data class MilestoneDTO(
val primary: ValueDTO,
val primaryKey: String,
val current: ValueDTO,
val diff: ValueDTO,
) {
companion object {
fun from(mr: MilestoneReached) = MilestoneDTO(
primary = ValueDTO.from(mr.keyValue),
primaryKey = mr.keyValue.name.lowercase(),
current = ValueDTO.from(mr.current),
diff = ValueDTO.from(mr.diff),
)
}
}

3
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt

@ -8,10 +8,12 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.application.plugins.ykObjectMapper
import net.aiterp.git.ykonsole2.application.routes.MilestoneDTO
import net.aiterp.git.ykonsole2.application.routes.ValueDTO
import net.aiterp.git.ykonsole2.application.routes.WorkoutDTO
import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.MilestoneChecker
import java.lang.Exception
private object WebSocketHandler
@ -56,6 +58,7 @@ fun Route.sockets(
is ErrorOccurred -> sendSerialized(SocketOutput(error = SocketOutput.Error(event.message)))
is ValuesReceived -> sendSerialized(SocketOutput(workoutStates = listOf(ValueDTO.from(event.values))))
is MilestoneReached -> sendSerialized(SocketOutput(milestone = MilestoneDTO.from(event)))
}
}
}

2
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketOutput.kt

@ -1,6 +1,7 @@
package net.aiterp.git.ykonsole2.application.routes.ws
import com.fasterxml.jackson.annotation.JsonInclude
import net.aiterp.git.ykonsole2.application.routes.MilestoneDTO
import net.aiterp.git.ykonsole2.application.routes.ValueDTO
import net.aiterp.git.ykonsole2.application.routes.WorkoutDTO
import net.aiterp.git.ykonsole2.domain.models.WorkoutState
@ -13,6 +14,7 @@ data class SocketOutput(
val sentAt: Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS),
val workout: WorkoutDTO? = null,
val workoutStates: List<ValueDTO>? = null,
val milestone: MilestoneDTO? = null,
val event: EventDTO? = null,
val error: Error? = null,
) {

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

@ -11,6 +11,7 @@ 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.MilestoneChecker
import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer
import net.aiterp.git.ykonsole2.infrastructure.drivers.Skipper
import net.aiterp.git.ykonsole2.infrastructure.drivers.WorkoutWriter
@ -35,6 +36,7 @@ fun main(): Unit = runBlocking {
val exporter = workoutExporterOrNull()
val iConsole = IConsole()
val milestoneChecker = MilestoneChecker()
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo)
val skipper = Skipper()
val testDriver = TestDriver(secondLength = 1.seconds)
@ -53,6 +55,7 @@ fun main(): Unit = runBlocking {
drivers = listOfNotNull(
exporter,
iConsole,
milestoneChecker,
programEnforcer,
skipper,
testDriver,

Loading…
Cancel
Save