From 2ab162adadbc552abc49914f28cbb8de38ebb9ef Mon Sep 17 00:00:00 2001 From: Mikhail Kornilovich <66996619+Mond1c@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:04:48 +0300 Subject: [PATCH] Added Timeline (#188) * start timeline * added problems letters * timeline * clean up * added websocket support * fix api * fix timeline design * add constants * ignore run after accepted verdict * delete RUNS_URL * refactor backend * ioi support * backend rework * design improvements * refactor * refactor * fix const * fix admin linter * fix api * some refactor * move to typescript --- schemas/advanced.schema.json | 24 +++ .../main/kotlin/org/icpclive/api/Settings.kt | 1 + .../controllers/TeamViewController.kt | 3 + .../kotlin/org/icpclive/overlay/Routing.kt | 32 ++- .../icpclive/export/clics/ClicsExporter.kt | 1 + src/cds/core/api/core.api | 132 +++++++++++++ .../kotlin/org/icpclive/cds/api/MediaType.kt | 6 + .../org/icpclive/cds/api/TimeLineRunInfo.kt | 56 ++++++ .../admin/src/components/TeamView.jsx | 18 +- src/frontend/generated/api.ts | 8 + .../organisms/holder/ContestantViewHolder.tsx | 15 ++ .../components/organisms/holder/TimeLine.tsx | 184 ++++++++++++++++++ src/frontend/overlay/src/config.ts | 6 + 13 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 src/cds/core/src/main/kotlin/org/icpclive/cds/api/TimeLineRunInfo.kt create mode 100644 src/frontend/overlay/src/components/organisms/holder/TimeLine.tsx diff --git a/schemas/advanced.schema.json b/schemas/advanced.schema.json index 98fb64db0..28e32b1f5 100644 --- a/schemas/advanced.schema.json +++ b/schemas/advanced.schema.json @@ -109,6 +109,27 @@ ], "title": "TaskStatus" }, + "TimeLine": { + "type": "object", + "properties": { + "type": { + "const": "TimeLine", + "default": "TimeLine" + }, + "teamId": { + "type": "string" + }, + "isMedia": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "type", + "teamId" + ], + "title": "TimeLine" + }, "Video": { "type": "object", "properties": { @@ -204,6 +225,9 @@ { "$ref": "#/$defs/TaskStatus" }, + { + "$ref": "#/$defs/TimeLine" + }, { "$ref": "#/$defs/Video" }, diff --git a/src/backend-api/src/main/kotlin/org/icpclive/api/Settings.kt b/src/backend-api/src/main/kotlin/org/icpclive/api/Settings.kt index 1117bc837..ce0573e71 100644 --- a/src/backend-api/src/main/kotlin/org/icpclive/api/Settings.kt +++ b/src/backend-api/src/main/kotlin/org/icpclive/api/Settings.kt @@ -47,6 +47,7 @@ data class ExternalTeamViewSettings( val mediaTypes: List = emptyList(), val showTaskStatus: Boolean = true, val showAchievement: Boolean = false, + val showTimeLine: Boolean = false, val position: TeamViewPosition = TeamViewPosition.SINGLE_TOP_RIGHT, ) : ObjectSettings diff --git a/src/backend/src/main/kotlin/org/icpclive/controllers/TeamViewController.kt b/src/backend/src/main/kotlin/org/icpclive/controllers/TeamViewController.kt index 199bc06fb..6c11b8589 100644 --- a/src/backend/src/main/kotlin/org/icpclive/controllers/TeamViewController.kt +++ b/src/backend/src/main/kotlin/org/icpclive/controllers/TeamViewController.kt @@ -18,6 +18,9 @@ class TeamViewController(manager: Manager, val position: TeamVie if (settings.showTaskStatus) { settings.teamId?.let { teamId -> content.add(MediaType.TaskStatus(teamId)) } } + if (settings.showTimeLine) { + settings.teamId?.let { teamId -> content.add(MediaType.TimeLine(teamId)) } + } if (settings.showAchievement) { teamInfo?.medias?.get(TeamMediaType.ACHIEVEMENT)?.let { content.add(it.noMedia()) } } diff --git a/src/backend/src/main/kotlin/org/icpclive/overlay/Routing.kt b/src/backend/src/main/kotlin/org/icpclive/overlay/Routing.kt index bb147591f..6e93e9974 100644 --- a/src/backend/src/main/kotlin/org/icpclive/overlay/Routing.kt +++ b/src/backend/src/main/kotlin/org/icpclive/overlay/Routing.kt @@ -5,15 +5,19 @@ import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.flow.* import org.icpclive.Config import org.icpclive.admin.getExternalRun +import org.icpclive.cds.RunUpdate import org.icpclive.cds.api.* import org.icpclive.data.DataBus import org.icpclive.data.currentContestInfoFlow import org.icpclive.util.sendJsonFlow +import kotlin.time.Duration -inline fun Route.flowEndpoint(name: String, crossinline dataProvider: suspend () -> Flow) { +inline fun Route.flowEndpoint(name: String, crossinline dataProvider: suspend () -> Flow) { webSocket(name) { sendJsonFlow(dataProvider()) } get(name) { call.respond(dataProvider().first()) } } @@ -28,13 +32,37 @@ fun Route.configureOverlayRouting() { flowEndpoint("/mainScreen") { DataBus.mainScreenFlow.await() } flowEndpoint("/contestInfo") { DataBus.currentContestInfoFlow() } flowEndpoint("/runs") { DataBus.contestStateFlow.await().map { it.runsAfterEvent.values.sortedBy { it.time } } } + webSocket("/teamRuns/{id}") { + val teamIdStr = call.parameters["id"] + if (teamIdStr.isNullOrBlank()) { + close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid team id")) + return@webSocket + } + val teamId = teamIdStr.toTeamId() + val acceptedProblems = mutableSetOf() + val allRuns = mutableMapOf() + DataBus.contestStateFlow.await().first().runsAfterEvent.values + .filter { teamId == it.teamId && it.time != Duration.ZERO } + .forEach { allRuns[it.id] = it } + sendJsonFlow(DataBus.contestStateFlow.await() + .mapNotNull { (it.lastEvent as? RunUpdate)?.newInfo } + .runningFold(allRuns.toPersistentMap()) { acc, it -> + if (it.teamId == teamId) acc.put(it.id, it) else if (it.id in acc) acc.remove(it.id) else acc + } + .distinctUntilChanged { a, b -> a === b } + .map { runs -> runs.values.sortedBy { it.time } } + .map { runs -> + acceptedProblems.clear() + runs.mapNotNull { info -> TimeLineRunInfo.fromRunInfo(info, acceptedProblems) } + }) + } flowEndpoint("/queue") { DataBus.queueFlow.await() } flowEndpoint("/statistics") { DataBus.statisticFlow.await() } flowEndpoint("/ticker") { DataBus.tickerFlow.await() } route("/scoreboard") { setUpScoreboard { getScoreboardDiffs(it) } } - route("/svgAchievement"){ + route("/svgAchievement") { configureSvgAchievementRouting(Config.mediaDirectory) } get("/visualConfig.json") { call.respond(DataBus.visualConfigFlow.await().value) } diff --git a/src/cds-converter/src/main/kotlin/org/icpclive/export/clics/ClicsExporter.kt b/src/cds-converter/src/main/kotlin/org/icpclive/export/clics/ClicsExporter.kt index 3b8da22b3..207caca12 100644 --- a/src/cds-converter/src/main/kotlin/org/icpclive/export/clics/ClicsExporter.kt +++ b/src/cds-converter/src/main/kotlin/org/icpclive/export/clics/ClicsExporter.kt @@ -59,6 +59,7 @@ private fun MediaType.toClicsMedia() = when (this) { is MediaType.Object -> null is MediaType.Image -> File("image", Url(url)) is MediaType.TaskStatus -> null + is MediaType.TimeLine -> null is MediaType.Video -> File("video", Url(url)) is MediaType.M2tsVideo -> File("video/m2ts", Url(url)) is MediaType.HLSVideo -> File("application/vnd.apple.mpegurl", Url(url)) diff --git a/src/cds/core/api/core.api b/src/cds/core/api/core.api index 53cb81b94..2ef8fcc12 100644 --- a/src/cds/core/api/core.api +++ b/src/cds/core/api/core.api @@ -959,6 +959,34 @@ public final class org/icpclive/cds/api/MediaType$TaskStatus$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class org/icpclive/cds/api/MediaType$TimeLine : org/icpclive/cds/api/MediaType { + public static final field Companion Lorg/icpclive/cds/api/MediaType$TimeLine$Companion; + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-ed2mA_4 ()Ljava/lang/String; + public final fun copy-nUE0bHc (Ljava/lang/String;)Lorg/icpclive/cds/api/MediaType$TimeLine; + public static synthetic fun copy-nUE0bHc$default (Lorg/icpclive/cds/api/MediaType$TimeLine;Ljava/lang/String;ILjava/lang/Object;)Lorg/icpclive/cds/api/MediaType$TimeLine; + public fun equals (Ljava/lang/Object;)Z + public final fun getTeamId-ed2mA_4 ()Ljava/lang/String; + public fun hashCode ()I + public fun isMedia ()Z + public fun toString ()Ljava/lang/String; +} + +public synthetic class org/icpclive/cds/api/MediaType$TimeLine$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lorg/icpclive/cds/api/MediaType$TimeLine$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lorg/icpclive/cds/api/MediaType$TimeLine; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lorg/icpclive/cds/api/MediaType$TimeLine;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/MediaType$TimeLine$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public final class org/icpclive/cds/api/MediaType$Video : org/icpclive/cds/api/MediaType { public static final field Companion Lorg/icpclive/cds/api/MediaType$Video$Companion; public fun (Ljava/lang/String;Z)V @@ -1644,6 +1672,110 @@ public final class org/icpclive/cds/api/TeamMediaType$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public abstract class org/icpclive/cds/api/TimeLineRunInfo { + public static final field Companion Lorg/icpclive/cds/api/TimeLineRunInfo$Companion; + public synthetic fun (ILkotlinx/serialization/internal/SerializationConstructorMarker;)V + public static final synthetic fun write$Self (Lorg/icpclive/cds/api/TimeLineRunInfo;Lkotlinx/serialization/encoding/CompositeEncoder;Lkotlinx/serialization/descriptors/SerialDescriptor;)V +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$Companion { + public final fun fromRunInfo (Lorg/icpclive/cds/api/RunInfo;Ljava/util/Set;)Lorg/icpclive/cds/api/TimeLineRunInfo; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$ICPC : org/icpclive/cds/api/TimeLineRunInfo { + public static final field Companion Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC$Companion; + public synthetic fun (JLjava/lang/String;ZLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun component2-Xzdl60o ()Ljava/lang/String; + public final fun component3 ()Z + public final fun component4 ()Ljava/lang/String; + public final fun copy-4joZhgo (JLjava/lang/String;ZLjava/lang/String;)Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC; + public static synthetic fun copy-4joZhgo$default (Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC;JLjava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC; + public fun equals (Ljava/lang/Object;)Z + public final fun getProblemId-Xzdl60o ()Ljava/lang/String; + public final fun getShortName ()Ljava/lang/String; + public final fun getTime-UwyO8pc ()J + public fun hashCode ()I + public final fun isAccepted ()Z + public fun toString ()Ljava/lang/String; +} + +public synthetic class org/icpclive/cds/api/TimeLineRunInfo$ICPC$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lorg/icpclive/cds/api/TimeLineRunInfo$ICPC;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$ICPC$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$IOI : org/icpclive/cds/api/TimeLineRunInfo { + public static final field Companion Lorg/icpclive/cds/api/TimeLineRunInfo$IOI$Companion; + public synthetic fun (JLjava/lang/String;DLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun component2-Xzdl60o ()Ljava/lang/String; + public final fun component3 ()D + public final fun copy-nveW3u0 (JLjava/lang/String;D)Lorg/icpclive/cds/api/TimeLineRunInfo$IOI; + public static synthetic fun copy-nveW3u0$default (Lorg/icpclive/cds/api/TimeLineRunInfo$IOI;JLjava/lang/String;DILjava/lang/Object;)Lorg/icpclive/cds/api/TimeLineRunInfo$IOI; + public fun equals (Ljava/lang/Object;)Z + public final fun getProblemId-Xzdl60o ()Ljava/lang/String; + public final fun getScore ()D + public final fun getTime-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public synthetic class org/icpclive/cds/api/TimeLineRunInfo$IOI$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lorg/icpclive/cds/api/TimeLineRunInfo$IOI$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lorg/icpclive/cds/api/TimeLineRunInfo$IOI; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lorg/icpclive/cds/api/TimeLineRunInfo$IOI;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$IOI$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$InProgress : org/icpclive/cds/api/TimeLineRunInfo { + public static final field Companion Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress$Companion; + public synthetic fun (JLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1-UwyO8pc ()J + public final fun component2-Xzdl60o ()Ljava/lang/String; + public final fun copy-15fPPug (JLjava/lang/String;)Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress; + public static synthetic fun copy-15fPPug$default (Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress;JLjava/lang/String;ILjava/lang/Object;)Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress; + public fun equals (Ljava/lang/Object;)Z + public final fun getProblemId-Xzdl60o ()Ljava/lang/String; + public final fun getTime-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public synthetic class org/icpclive/cds/api/TimeLineRunInfo$InProgress$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lorg/icpclive/cds/api/TimeLineRunInfo$InProgress;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class org/icpclive/cds/api/TimeLineRunInfo$InProgress$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public abstract class org/icpclive/cds/api/Verdict { public static final field Companion Lorg/icpclive/cds/api/Verdict$Companion; public synthetic fun (Ljava/lang/String;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/src/cds/core/src/main/kotlin/org/icpclive/cds/api/MediaType.kt b/src/cds/core/src/main/kotlin/org/icpclive/cds/api/MediaType.kt index 1d9351861..0a1ca34ec 100644 --- a/src/cds/core/src/main/kotlin/org/icpclive/cds/api/MediaType.kt +++ b/src/cds/core/src/main/kotlin/org/icpclive/cds/api/MediaType.kt @@ -60,6 +60,12 @@ public sealed class MediaType { override val isMedia: Boolean = false } + @Serializable + @SerialName("TimeLine") + public data class TimeLine(val teamId: TeamId) : MediaType() { + override val isMedia: Boolean = false + } + public fun noMedia(): MediaType = when (this) { is Image -> copy(isMedia = false) is Video -> copy(isMedia = false) diff --git a/src/cds/core/src/main/kotlin/org/icpclive/cds/api/TimeLineRunInfo.kt b/src/cds/core/src/main/kotlin/org/icpclive/cds/api/TimeLineRunInfo.kt new file mode 100644 index 000000000..d3e73f4eb --- /dev/null +++ b/src/cds/core/src/main/kotlin/org/icpclive/cds/api/TimeLineRunInfo.kt @@ -0,0 +1,56 @@ +package org.icpclive.cds.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.icpclive.cds.util.serializers.DurationInMillisecondsSerializer +import kotlin.time.Duration + +@Serializable +public sealed class TimeLineRunInfo { + @Serializable + @SerialName("ICPC") + public data class ICPC( + @Serializable(with = DurationInMillisecondsSerializer::class) val time: Duration, + val problemId: ProblemId, + val isAccepted: Boolean, + val shortName: String) : TimeLineRunInfo() + + @Serializable + @SerialName("IOI") + public data class IOI(@Serializable(with = DurationInMillisecondsSerializer::class) val time: Duration, val problemId: ProblemId, val score: Double) : TimeLineRunInfo() + + @Serializable + @SerialName("IN_PROGRESS") + public data class InProgress(@Serializable(with = DurationInMillisecondsSerializer::class) val time: Duration, val problemId: ProblemId) : TimeLineRunInfo() + + public companion object { + public fun fromRunInfo(info: RunInfo, acceptedProblems: MutableSet): TimeLineRunInfo? { + return when (info.result) { + is RunResult.ICPC -> { + val icpcResult = info.result + if (!acceptedProblems.contains(info.problemId)) { + if (icpcResult.verdict.isAccepted) { + acceptedProblems.add(info.problemId) + } + ICPC(info.time, info.problemId, icpcResult.verdict.isAccepted, icpcResult.verdict.shortName) + } else { + null + } + } + + is RunResult.IOI -> { + val ioiResult = info.result + if (ioiResult.difference > 0) { + IOI(info.time, info.problemId, ioiResult.scoreAfter) + } else { + null + } + } + + else -> { + InProgress(info.time, info.problemId) + } + } + } + } +} diff --git a/src/frontend/admin/src/components/TeamView.jsx b/src/frontend/admin/src/components/TeamView.jsx index 7989e3e05..6ff89f108 100644 --- a/src/frontend/admin/src/components/TeamView.jsx +++ b/src/frontend/admin/src/components/TeamView.jsx @@ -320,6 +320,7 @@ const TeamViewManager = ({ singleService, pvpService, splitService }) => { const [mediaType2, setMediaType2] = useState(undefined); const [statusShown, setStatusShown] = useState(true); const [achievementShown, setAchievementShown] = useState(false); + const [timeLineShown, setTimeLineShown] = useState(false); const [allowedMediaTypes, disableMediaTypes] = useMemo(() => [ DEFAULT_MEDIA_TYPES.filter(m => m && (selectedTeam?.id ? selectedTeam.medias[m] : teamsAvailableMedias.includes(m))), @@ -371,6 +372,7 @@ const TeamViewManager = ({ singleService, pvpService, splitService }) => { teamId: selectedTeamId, showTaskStatus: statusShown, showAchievement: achievementShown && variant === "single", + showTimeLine: timeLineShown, }; if (isMultipleMode) { currentService.editPreset(selectedInstance, settings); @@ -380,7 +382,7 @@ const TeamViewManager = ({ singleService, pvpService, splitService }) => { setSelectedInstance(undefined); setSelectedTeamId(undefined); }, [selectedInstance, currentService, isMultipleMode, mediaType1, - mediaType2, selectedTeamId, statusShown, achievementShown, variant]); + mediaType2, selectedTeamId, statusShown, achievementShown, variant, timeLineShown]); const onInstanceSelect = useCallback((instance) => () => { if (instance === selectedInstance) { @@ -492,6 +494,20 @@ const TeamViewManager = ({ singleService, pvpService, splitService }) => { )} + {variant === "single" && ( + <> + + TimeLine + + + setTimeLineShown(v)} + sx={{ justifyContent: "flex-start" }} + /> + + + )}