mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
refactor(lens): drop client-side replay in favour of real in-flight rows
Removes the playback reducer and its pacing. liveRows lists the traces the worker is reading, from job.reading, followed by completed reviews newest first, keyed by execution_id so a trace keeps its row when it finishes.
This commit is contained in:
parent
b00cb8d733
commit
0376a8d7c6
3 changed files with 77 additions and 315 deletions
|
|
@ -1,51 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
|
||||
import { playbackPhase, playbackReducer, startPlayback, type Phase, type Playback } from "../../model/live";
|
||||
import type { Review } from "../../model/types";
|
||||
|
||||
const FRAME_MS = 40;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export function useReviewPlayback(
|
||||
reviews: readonly Review[],
|
||||
live: boolean,
|
||||
): Playback & { phase: Phase; replay: () => void } {
|
||||
const [still] = useState(prefersReducedMotion);
|
||||
const [state, dispatch] = useReducer(playbackReducer, undefined, () =>
|
||||
playbackReducer(startPlayback(reviews, live), still ? { type: "settle" } : { type: "tick", now: Date.now() }),
|
||||
);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "enqueue", reviews });
|
||||
if (still) dispatch({ type: "settle" });
|
||||
}, [reviews, still]);
|
||||
|
||||
const busy = state.pending.length > 0 || now - state.startedAt < state.duration;
|
||||
useEffect(() => {
|
||||
if (!busy || still) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const at = Date.now();
|
||||
setNow(at);
|
||||
dispatch({ type: "tick", now: at });
|
||||
}, FRAME_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [busy, still]);
|
||||
|
||||
const replay = useCallback(() => {
|
||||
dispatch({ type: "replay" });
|
||||
if (still) dispatch({ type: "settle" });
|
||||
}, [still]);
|
||||
|
||||
const current = state.current;
|
||||
const phase =
|
||||
still || !current
|
||||
? { span: -1, typed: current?.reasoning.length ?? 0, verdict: true }
|
||||
: playbackPhase(now - state.startedAt, state.duration, current.spans.length, current.reasoning.length);
|
||||
return { ...state, phase, replay };
|
||||
}
|
||||
|
|
@ -4,8 +4,11 @@ import {
|
|||
briefReasoning,
|
||||
checkLabel,
|
||||
conclusions,
|
||||
traceRows,
|
||||
decidedReviews,
|
||||
durationLabel,
|
||||
newestFirst,
|
||||
nowLine,
|
||||
inFlight,
|
||||
liveRows,
|
||||
grownGroups,
|
||||
inGroup,
|
||||
share,
|
||||
|
|
@ -17,16 +20,10 @@ import {
|
|||
liveJob,
|
||||
liveStats,
|
||||
outcome,
|
||||
playbackPhase,
|
||||
playbackReducer,
|
||||
providerOf,
|
||||
queueRows,
|
||||
rateLabel,
|
||||
reviewKey,
|
||||
secondsToFinishReading,
|
||||
shownCount,
|
||||
startPlayback,
|
||||
stepDuration,
|
||||
tokenLabel,
|
||||
} from "./live";
|
||||
import type { Job, Review } from "./types";
|
||||
|
|
@ -145,141 +142,12 @@ describe("conclusions", () => {
|
|||
expect([...grownGroups(before, after)]).toEqual(["y"]);
|
||||
});
|
||||
|
||||
it("lists upcoming traces above the one being read and finished ones below, newest first", () => {
|
||||
const [a, b, c, d] = ["a", "b", "c", "d"].map((id) => review(id));
|
||||
const rows = traceRows({ played: [a], current: b, pending: [c, d] }, 10);
|
||||
expect(rows.map((row) => [row.review.execution_id, row.state])).toEqual([
|
||||
["d", "queued"],
|
||||
["c", "queued"],
|
||||
["b", "reviewing"],
|
||||
["a", "done"],
|
||||
]);
|
||||
expect(traceRows({ played: [a], current: b, pending: [c, d] }, 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("gives a bar share bounded to the total", () => {
|
||||
expect(share(3, 12)).toBe(0.25);
|
||||
expect(share(5, 0)).toBe(0);
|
||||
expect(share(9, 4)).toBe(1);
|
||||
});
|
||||
|
||||
it("only counts the trace being read once its verdict is on screen", () => {
|
||||
const [a, b] = [review("a"), review("b")];
|
||||
expect(decidedReviews({ played: [a], current: b }, false)).toEqual([a]);
|
||||
expect(decidedReviews({ played: [a], current: b }, true)).toEqual([a, b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback pacing", () => {
|
||||
it("slows to a full window for one review and speeds up as the backlog grows", () => {
|
||||
expect(stepDuration(1)).toBe(2400);
|
||||
expect(stepDuration(0)).toBe(2400);
|
||||
expect(stepDuration(2)).toBeLessThan(stepDuration(1));
|
||||
});
|
||||
|
||||
it("keeps every trace on screen long enough to read, however large the backlog", () => {
|
||||
expect(stepDuration(3)).toBeGreaterThanOrEqual(1200);
|
||||
expect(stepDuration(10_000)).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
|
||||
it("highlights spans one at a time, then types reasoning, then leaves the verdict up", () => {
|
||||
const duration = 2000;
|
||||
expect(playbackPhase(0, duration, 4, 100)).toEqual({ span: 0, typed: 0, verdict: false });
|
||||
expect(playbackPhase(duration * 0.29, duration, 4, 100).span).toBe(3);
|
||||
const typing = playbackPhase(duration * 0.475, duration, 4, 100);
|
||||
expect(typing).toEqual({ span: -1, typed: 50, verdict: false });
|
||||
const done = playbackPhase(duration * 0.65, duration, 4, 100);
|
||||
expect(done).toEqual({ span: -1, typed: 100, verdict: true });
|
||||
expect(playbackPhase(duration * 0.99, duration, 4, 100)).toEqual(done);
|
||||
});
|
||||
|
||||
it("shows a settled review whole", () => {
|
||||
expect(playbackPhase(0, 0, 4, 100)).toEqual({ span: -1, typed: 100, verdict: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback queue", () => {
|
||||
const reviews = ["a", "b", "c", "d", "e"].map((id) => review(id));
|
||||
|
||||
it("opens a live job replaying the last few reviews and a finished job on its final review", () => {
|
||||
const live = startPlayback(reviews, true);
|
||||
expect(live.played.map((r) => r.execution_id)).toEqual(["a", "b"]);
|
||||
expect(live.current).toBeNull();
|
||||
expect(live.pending.map((r) => r.execution_id)).toEqual(["c", "d", "e"]);
|
||||
|
||||
const done = startPlayback(reviews, false);
|
||||
expect(done.played.map((r) => r.execution_id)).toEqual(["a", "b", "c", "d"]);
|
||||
expect(done.current?.execution_id).toBe("e");
|
||||
expect(done.pending).toEqual([]);
|
||||
});
|
||||
|
||||
it("enqueues only reviews it has not seen, so repeated polls do not replay", () => {
|
||||
const start = startPlayback(reviews.slice(0, 2), true);
|
||||
const polled = playbackReducer(start, { type: "enqueue", reviews: reviews.slice(0, 4) });
|
||||
expect(polled.pending.map((r) => r.execution_id)).toEqual(["a", "b", "c", "d"]);
|
||||
expect(playbackReducer(polled, { type: "enqueue", reviews: reviews.slice(0, 4) })).toBe(polled);
|
||||
});
|
||||
|
||||
it("treats a re-reviewed execution at a new time as new", () => {
|
||||
const start = startPlayback([reviews[0]], false);
|
||||
const again = review("a", { at: "2026-10-03T17:00:00Z" });
|
||||
expect(reviewKey(again)).not.toBe(reviewKey(reviews[0]));
|
||||
expect(playbackReducer(start, { type: "enqueue", reviews: [again] }).pending).toEqual([again]);
|
||||
});
|
||||
|
||||
it("advances one review per step and holds until the step finishes", () => {
|
||||
const start = startPlayback(reviews.slice(0, 2), true);
|
||||
expect(start.pending).toHaveLength(2);
|
||||
const first = playbackReducer(start, { type: "tick", now: 1000 });
|
||||
expect(first.current?.execution_id).toBe("a");
|
||||
expect(first.duration).toBe(stepDuration(2));
|
||||
expect(playbackReducer(first, { type: "tick", now: 1000 + first.duration - 1 })).toBe(first);
|
||||
const second = playbackReducer(first, { type: "tick", now: 1000 + first.duration });
|
||||
expect(second.current?.execution_id).toBe("b");
|
||||
expect(second.played.map((r) => r.execution_id)).toEqual(["a"]);
|
||||
expect(playbackReducer(second, { type: "tick", now: 1e9 })).toBe(second);
|
||||
});
|
||||
|
||||
it("catches up on a big backlog by skipping to the newest few, never by shortening steps", () => {
|
||||
const many = Array.from({ length: 40 }, (_, n) => review(`r${n}`));
|
||||
const step = playbackReducer(startPlayback([], true), { type: "enqueue", reviews: many });
|
||||
const first = playbackReducer(step, { type: "tick", now: 0 });
|
||||
expect(first.current?.execution_id).toBe("r37");
|
||||
expect(first.pending.map((r) => r.execution_id)).toEqual(["r38", "r39"]);
|
||||
expect(first.played.map((r) => r.execution_id)).toEqual(many.slice(0, 37).map((r) => r.execution_id));
|
||||
expect(first.duration).toBeGreaterThanOrEqual(1200);
|
||||
expect(playbackReducer(first, { type: "tick", now: 1199 })).toBe(first);
|
||||
});
|
||||
|
||||
it("replays a finished run, jumping to the last few", () => {
|
||||
const done = startPlayback(reviews, false);
|
||||
const again = playbackReducer(done, { type: "replay" });
|
||||
expect(again.current).toBeNull();
|
||||
expect(again.played).toEqual([]);
|
||||
expect(again.pending.map((r) => r.execution_id)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(playbackReducer(again, { type: "tick", now: 0 }).current?.execution_id).toBe("c");
|
||||
});
|
||||
|
||||
it("settles everything at once for reduced motion", () => {
|
||||
const start = startPlayback(reviews, true);
|
||||
const settled = playbackReducer(start, { type: "settle" });
|
||||
expect(settled.current?.execution_id).toBe("e");
|
||||
expect(settled.played.map((r) => r.execution_id)).toEqual(["a", "b", "c", "d"]);
|
||||
expect(settled.pending).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists the newest review first, starting with the one being read", () => {
|
||||
const start = startPlayback(reviews, false);
|
||||
expect(queueRows(start, 3).map((r) => r.execution_id)).toEqual(["e", "d", "c"]);
|
||||
const live = playbackReducer(startPlayback(reviews, true), { type: "tick", now: 0 });
|
||||
expect(queueRows(live, 10).map((r) => r.execution_id)).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
it("counts reviews beyond the capped list without counting the unplayed backlog", () => {
|
||||
const start = startPlayback(reviews, true);
|
||||
expect(shownCount(5, start)).toBe(2);
|
||||
expect(shownCount(120, start)).toBe(117);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which job the live run shows", () => {
|
||||
|
|
@ -462,3 +330,49 @@ describe("live stats", () => {
|
|||
expect(tokenLabel(3_400_000)).toBe("3.4M tok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("honest live list", () => {
|
||||
it("lists completed reviews newest first in the order they finished, never re-sorted by time", () => {
|
||||
const reviews = [review("a", { at: "2026-10-03T16:05:00Z" }), review("b", { at: "2026-10-03T16:01:00Z" }), review("c")];
|
||||
expect(newestFirst(reviews, 10).map((r) => r.execution_id)).toEqual(["c", "b", "a"]);
|
||||
expect(newestFirst(reviews, 2).map((r) => r.execution_id)).toEqual(["c", "b"]);
|
||||
expect(reviews.map((r) => r.execution_id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("says how many traces are in flight and how many are done", () => {
|
||||
const job = (reviewed: number, selected: number) => ({ reviewed, coverage: { selected } }) as unknown as Job;
|
||||
expect(nowLine(job(18, 30), 4)).toBe("Reviewing 4 at a time · 18 of 30 done");
|
||||
expect(nowLine(job(30, 30), 0)).toBe("30 of 30 done");
|
||||
expect(nowLine(job(3, 0), 1)).toBe("Reviewing 1 at a time · 3 done");
|
||||
});
|
||||
|
||||
it("keeps a trace as the same row from in flight to finished", () => {
|
||||
const reading = (id: string) => ({ execution_id: id, trace_id: `t-${id}`, agent: "bot", started_at: "2026-10-03T16:00:00Z" });
|
||||
const before = liveRows([reading("x"), reading("y")], [review("a")], 10);
|
||||
expect(before.map((row) => [row.kind, row.key])).toEqual([
|
||||
["reading", "x"],
|
||||
["reading", "y"],
|
||||
["done", "a"],
|
||||
]);
|
||||
const after = liveRows([reading("x"), reading("y")], [review("a"), review("y")], 10);
|
||||
expect(after.map((row) => [row.kind, row.key])).toEqual([
|
||||
["reading", "x"],
|
||||
["done", "y"],
|
||||
["done", "a"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("only shows in-flight traces while the job runs", () => {
|
||||
const item = { execution_id: "x", trace_id: "t", agent: "bot", started_at: "2026-10-03T16:00:00Z" };
|
||||
const job = (status: Job["status"]) => ({ status, reading: [item] }) as unknown as Job;
|
||||
expect(inFlight(job("running"))).toEqual([item]);
|
||||
expect(inFlight(job("completed"))).toEqual([]);
|
||||
expect(inFlight({ status: "running" } as Job)).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats review time", () => {
|
||||
expect(durationLabel(420)).toBe("420ms");
|
||||
expect(durationLabel(1420)).toBe("1.4s");
|
||||
expect(durationLabel(83_000)).toBe("1m 23s");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,12 +12,6 @@ export interface Conclusion {
|
|||
issue: boolean;
|
||||
}
|
||||
|
||||
export interface Phase {
|
||||
span: number;
|
||||
typed: number;
|
||||
verdict: boolean;
|
||||
}
|
||||
|
||||
export interface LiveStats {
|
||||
perSecond: number | null;
|
||||
tokens: number;
|
||||
|
|
@ -25,13 +19,6 @@ export interface LiveStats {
|
|||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
const PACE_WINDOW_MS = 2400;
|
||||
const MIN_VISIBLE_MS = 1500;
|
||||
const CATCH_UP_BACKLOG = 3;
|
||||
const READ_SHARE = 0.3;
|
||||
const TYPE_SHARE = 0.35;
|
||||
const REPLAY_ON_OPEN = 3;
|
||||
|
||||
export function liveJob(jobs: readonly Job[]): Job | undefined {
|
||||
const active = jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
if (active) return active;
|
||||
|
|
@ -116,23 +103,10 @@ export function unseen(reviews: readonly Review[], seen: ReadonlySet<string>): R
|
|||
return reviews.filter((review) => !seen.has(reviewKey(review)));
|
||||
}
|
||||
|
||||
export function stepDuration(backlog: number): number {
|
||||
return Math.max(MIN_VISIBLE_MS, Math.min(PACE_WINDOW_MS, Math.round(PACE_WINDOW_MS / Math.max(1, backlog))));
|
||||
}
|
||||
|
||||
export function analysisModel(candidates: readonly string[]): string {
|
||||
return candidates.find((model) => providerOf(model)) ?? candidates.find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
export function playbackPhase(elapsed: number, duration: number, spans: number, chars: number): Phase {
|
||||
if (duration <= 0) return { span: -1, typed: chars, verdict: true };
|
||||
const t = Math.max(0, elapsed) / duration;
|
||||
const reading = t < READ_SHARE;
|
||||
const span = reading && spans > 0 ? Math.min(spans - 1, Math.floor((t / READ_SHARE) * spans)) : -1;
|
||||
const typing = Math.min(1, Math.max(0, (t - READ_SHARE) / TYPE_SHARE));
|
||||
return { span, typed: Math.round(typing * chars), verdict: t >= READ_SHARE + TYPE_SHARE };
|
||||
}
|
||||
|
||||
const SHORT_LABEL = 48;
|
||||
|
||||
function humanize(checkId: string): string {
|
||||
|
|
@ -192,23 +166,6 @@ export function grownGroups(before: readonly Conclusion[], after: readonly Concl
|
|||
);
|
||||
}
|
||||
|
||||
export type TraceRowState = "queued" | "reviewing" | "done";
|
||||
|
||||
export function traceRows(
|
||||
playback: Pick<Playback, "played" | "current" | "pending">,
|
||||
limit: number,
|
||||
): { review: Review; state: TraceRowState }[] {
|
||||
return [
|
||||
...[...playback.pending].reverse().map((review) => ({ review, state: "queued" as const })),
|
||||
...(playback.current ? [{ review: playback.current, state: "reviewing" as const }] : []),
|
||||
...[...playback.played].reverse().map((review) => ({ review, state: "done" as const })),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
export function decidedReviews(playback: Pick<Playback, "played" | "current">, verdictShown: boolean): Review[] {
|
||||
return [...playback.played, ...(playback.current && verdictShown ? [playback.current] : [])];
|
||||
}
|
||||
|
||||
export function readingStart(job: Pick<Job, "steps" | "created_at">): string {
|
||||
return job.steps.find((step) => step.kind === "stage" && step.label === "Reading executions")?.at ?? job.created_at;
|
||||
}
|
||||
|
|
@ -245,98 +202,40 @@ export function tokenLabel(tokens: number): string {
|
|||
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tok` : `${tokens} tok`;
|
||||
}
|
||||
|
||||
export interface Playback {
|
||||
played: readonly Review[];
|
||||
current: Review | null;
|
||||
pending: readonly Review[];
|
||||
seen: ReadonlySet<string>;
|
||||
startedAt: number;
|
||||
duration: number;
|
||||
export function newestFirst(reviews: readonly Review[], limit: number): Review[] {
|
||||
return [...reviews].reverse().slice(0, limit);
|
||||
}
|
||||
|
||||
export type PlaybackAction =
|
||||
| { type: "enqueue"; reviews: readonly Review[] }
|
||||
| { type: "tick"; now: number }
|
||||
| { type: "settle" }
|
||||
| { type: "replay" };
|
||||
|
||||
const PLAYED_LIMIT = 200;
|
||||
|
||||
export function startPlayback(reviews: readonly Review[], live: boolean): Playback {
|
||||
const replay = live ? Math.min(REPLAY_ON_OPEN, reviews.length) : 0;
|
||||
const shown = reviews.slice(0, reviews.length - replay);
|
||||
return {
|
||||
played: live ? shown : shown.slice(0, -1),
|
||||
current: live ? null : shown.at(-1) ?? null,
|
||||
pending: reviews.slice(shown.length),
|
||||
seen: new Set(reviews.map(reviewKey)),
|
||||
startedAt: Number.NEGATIVE_INFINITY,
|
||||
duration: 0,
|
||||
};
|
||||
export interface InFlight {
|
||||
execution_id: string;
|
||||
trace_id: string;
|
||||
agent: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
function enqueue(state: Playback, reviews: readonly Review[]): Playback {
|
||||
const fresh = unseen(reviews, state.seen);
|
||||
if (!fresh.length) return state;
|
||||
return {
|
||||
...state,
|
||||
pending: [...state.pending, ...fresh],
|
||||
seen: new Set([...state.seen, ...fresh.map(reviewKey)]),
|
||||
};
|
||||
export function inFlight(job: Job): readonly InFlight[] {
|
||||
const reading = (job as Job & { reading?: readonly InFlight[] }).reading;
|
||||
return job.status === "running" ? (reading ?? []) : [];
|
||||
}
|
||||
|
||||
function advance(state: Playback, now: number): Playback {
|
||||
if (!state.pending.length) return state;
|
||||
if (state.current && now - state.startedAt < state.duration) return state;
|
||||
const skip = Math.max(0, state.pending.length - CATCH_UP_BACKLOG);
|
||||
const [next, ...rest] = state.pending.slice(skip);
|
||||
const finished = [...(state.current ? [state.current] : []), ...state.pending.slice(0, skip)];
|
||||
return {
|
||||
...state,
|
||||
played: [...state.played, ...finished].slice(-PLAYED_LIMIT),
|
||||
current: next,
|
||||
pending: rest,
|
||||
startedAt: now,
|
||||
duration: stepDuration(rest.length + 1),
|
||||
};
|
||||
export type LiveRow = { kind: "reading"; key: string; item: InFlight } | { kind: "done"; key: string; review: Review };
|
||||
|
||||
export function liveRows(reading: readonly InFlight[], reviews: readonly Review[], limit: number): LiveRow[] {
|
||||
const finished = new Set(reviews.map((review) => review.execution_id));
|
||||
const open = reading.filter((item) => !finished.has(item.execution_id));
|
||||
return [
|
||||
...open.map((item) => ({ kind: "reading" as const, key: item.execution_id, item })),
|
||||
...newestFirst(reviews, limit).map((review) => ({ kind: "done" as const, key: review.execution_id, review })),
|
||||
];
|
||||
}
|
||||
|
||||
function settle(state: Playback): Playback {
|
||||
const all = [...state.played, ...(state.current ? [state.current] : []), ...state.pending];
|
||||
return {
|
||||
...state,
|
||||
played: all.slice(0, -1).slice(-PLAYED_LIMIT),
|
||||
current: all.at(-1) ?? null,
|
||||
pending: [],
|
||||
startedAt: Number.NEGATIVE_INFINITY,
|
||||
duration: 0,
|
||||
};
|
||||
export function nowLine(job: Pick<Job, "coverage" | "reviewed">, reading: number): string {
|
||||
const { selected } = job.coverage;
|
||||
const done = selected ? `${Math.min(job.reviewed, selected)} of ${selected} done` : `${job.reviewed} done`;
|
||||
return reading ? `Reviewing ${reading} at a time · ${done}` : done;
|
||||
}
|
||||
|
||||
function replay(state: Playback): Playback {
|
||||
const all = [...state.played, ...(state.current ? [state.current] : []), ...state.pending];
|
||||
return { ...state, played: [], current: null, pending: all, startedAt: Number.NEGATIVE_INFINITY, duration: 0 };
|
||||
}
|
||||
|
||||
export function playbackReducer(state: Playback, action: PlaybackAction): Playback {
|
||||
switch (action.type) {
|
||||
case "enqueue":
|
||||
return enqueue(state, action.reviews);
|
||||
case "tick":
|
||||
return advance(state, action.now);
|
||||
case "settle":
|
||||
return settle(state);
|
||||
case "replay":
|
||||
return replay(state);
|
||||
}
|
||||
}
|
||||
|
||||
export function queueRows(playback: Pick<Playback, "played" | "current">, limit: number): Review[] {
|
||||
const newest = playback.current ? [playback.current] : [];
|
||||
return [...newest, ...[...playback.played].reverse()].slice(0, limit);
|
||||
}
|
||||
|
||||
export function shownCount(reviewed: number, playback: Pick<Playback, "played" | "current" | "pending">): number {
|
||||
const local = playback.played.length + (playback.current ? 1 : 0);
|
||||
return Math.max(local, reviewed - playback.pending.length);
|
||||
export function durationLabel(ms: number): string {
|
||||
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
|
||||
return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue