This commit is contained in:
Kalle
2026-08-07 14:27:11 +03:00
parent 07d5664bd9
commit 05ccac3319
9 changed files with 188 additions and 45 deletions

View File

@@ -2,7 +2,10 @@ import * as React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { SendouButtonProps } from "~/components/elements/Button";
import {
SendouButton,
type SendouButtonProps,
} from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useHydrated } from "~/hooks/useHydrated";
import invariant from "~/utils/invariant";
@@ -26,6 +29,7 @@ export function FormWithConfirm({
fetcher: _fetcher,
isOpen,
onOpenChange,
onConfirm,
}: {
fields?: (
| [name: string, value: string | number]
@@ -43,6 +47,8 @@ export function FormWithConfirm({
/** Controls the dialog open state. When provided, no child trigger is needed. */
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
/** Confirming runs this callback instead of submitting a form (client only action) */
onConfirm?: () => void;
}) {
const componentsFetcher = useFetcher();
const fetcher = _fetcher ?? componentsFetcher;
@@ -69,7 +75,7 @@ export function FormWithConfirm({
return (
<>
{isHydrated
{isHydrated && !onConfirm
? // using portal here makes nesting this component in another form work
createPortal(
<fetcher.Form
@@ -99,13 +105,26 @@ export function FormWithConfirm({
<FormMessage type="info">{description}</FormMessage>
) : null}
<div className="stack horizontal md justify-center mt-2">
<SubmitButton
form={id}
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
>
{submitButtonText ?? t("common:actions.delete")}
</SubmitButton>
{onConfirm ? (
<SendouButton
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
onPress={() => {
closeDialog();
onConfirm();
}}
>
{submitButtonText ?? t("common:actions.delete")}
</SendouButton>
) : (
<SubmitButton
form={id}
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
>
{submitButtonText ?? t("common:actions.delete")}
</SubmitButton>
)}
</div>
</div>
</SendouDialog>

View File

@@ -9,7 +9,11 @@ import { logger } from "~/utils/logger";
import { badRequestIfFalsy, forbidden, parseBody } from "~/utils/remix.server";
import * as Scoreboards from "../core/Scoreboards";
import * as ScannerIngestRepository from "../ScannerIngestRepository.server";
import { ingestBodySchema } from "../scanner-ingest-schemas";
import {
type IngestedMatchLink,
type IngestResponse,
ingestBodySchema,
} from "../scanner-ingest-schemas";
// xxx: dont only attach scoreboard on ingest, also when score is reported (for e.g. tournament stuff)
// xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result layout bad
@@ -30,15 +34,17 @@ export const action: ActionFunction = async ({ request }) => {
badRequestIfFalsy(await UserRepository.findLeanById(povUserId));
}
const matches = data.matches.filter(
(match) => match.lobby === null || match.lobby === "PRIVATE",
);
const indexedMatches = data.matches
.map((match, requestIndex) => ({ match, requestIndex }))
.filter(({ match }) => match.lobby === null || match.lobby === "PRIVATE");
const matches = indexedMatches.map(({ match }) => match);
if (matches.length === 0) {
return {
storedMatchesCount: 0,
mergedMatchesCount: 0,
linkedGamesCount: 0,
};
linkedMatches: [],
} satisfies IngestResponse;
}
const resolved = await resolveIngestContext({
@@ -56,6 +62,7 @@ export const action: ActionFunction = async ({ request }) => {
});
let linkedGamesCount = 0;
let linkedMatches: IngestResponse["linkedMatches"] = [];
if (resolved) {
const matched = Scoreboards.matchedGames({
matches: effectiveMatches.map((effective) => effective.data),
@@ -71,6 +78,11 @@ export const action: ActionFunction = async ({ request }) => {
povUserId,
});
linkedMatches = matched.map(({ matchIndex, game }) => ({
matchIndex: indexedMatches[matchIndex]!.requestIndex,
link: ingestedMatchLink(resolved.context, game.target),
}));
logger.debug(
`ingest: ${Scoreboards.contextKey(resolved.context)} matched ${matched.length} games, ` +
`${linkedGamesCount} newly linked (stored ${insertedCount}, merged ${mergedCount})`,
@@ -86,9 +98,27 @@ export const action: ActionFunction = async ({ request }) => {
storedMatchesCount: insertedCount,
mergedMatchesCount: mergedCount,
linkedGamesCount,
};
linkedMatches,
} satisfies IngestResponse;
};
function ingestedMatchLink(
context: Scoreboards.IngestContext,
target: Scoreboards.IngestableGameTarget,
): IngestedMatchLink {
if (target.type === "tournament" && context.type === "tournament") {
return {
type: "tournament",
tournamentId: context.tournamentId,
matchId: target.tournamentMatchId,
};
}
if (target.type === "sendouq") {
return { type: "sendouq", groupMatchId: target.groupMatchId };
}
throw new Error("ingest link target does not match its resolved context");
}
/**
* How far back the POV user's reported games are considered as content-
* resolution candidates

View File

@@ -14,3 +14,16 @@ export const ingestBodySchema = z.object({
povUserId: id.optional(),
matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST),
});
/** The sendou.ink match an ingested match's scoreboard was linked to. */
export type IngestedMatchLink =
| { type: "tournament"; tournamentId: number; matchId: number }
| { type: "sendouq"; groupMatchId: number };
export interface IngestResponse {
storedMatchesCount: number;
mergedMatchesCount: number;
linkedGamesCount: number;
/** per request match (by its index in the body's `matches`), the match it linked to */
linkedMatches: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}

View File

@@ -12,17 +12,18 @@ import { useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage, WeaponImage } from "~/components/Image";
import { StageBannerBox } from "~/components/StageBannerBox";
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls";
import type { IngestSkipReason } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
const SEND_CHIP_LABELS: Record<SendStatus["state"], string> = {
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
queued: "queued",
sending: "sending…",
sent: "ingested",
failed: "failed",
};
@@ -262,20 +263,50 @@ function StatusChip({
</span>
);
}
if (send?.state === "sent") {
return (
<span
className="match-chip sent"
title={`ingested ${new Date(send.at).toLocaleTimeString()}`}
>
{send.link ? (
<a
href={ingestedMatchUrl(send.link)}
target="_blank"
rel="noreferrer"
>
{ingestedMatchLabel(send.link)}
</a>
) : null}
</span>
);
}
if (send) {
return (
<span className={clsx("match-chip", send.state)} title={send.error}>
{send.state === "queued" || send.state === "sending" ? (
<span className="dot" />
) : null}
{send.state === "sent" ? "✓ " : null}
{SEND_CHIP_LABELS[send.state]}
{send.state === "sent"
? ` ${new Date(send.at).toLocaleTimeString()}`
: null}
</span>
);
}
if (live) return null;
return <span className="match-chip">not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {
return link.type === "tournament"
? tournamentMatchPage({
tournamentId: link.tournamentId,
matchId: link.matchId,
})
: sendouQMatchPage(link.groupMatchId);
}
function ingestedMatchLabel(link: IngestedMatchLink): string {
return link.type === "tournament"
? `Match ID #${link.matchId}`
: `SQ Match ID #${link.groupMatchId}`;
}

View File

@@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
@@ -44,6 +45,7 @@ import {
loadVodEvents,
saveVod,
saveVodResultsSend,
type VodResultsSend,
type VodSummary,
} from "../store/vods";
import {
@@ -105,13 +107,7 @@ interface Progress {
/** "Send results" progress/outcome shown next to the button. */
type ResultsSend =
| { state: "sending"; sent: number; total: number }
| {
state: "done";
sent: number;
total: number;
error: string | null;
at: number;
};
| ({ state: "done" } & VodResultsSend);
export function VodPage({
sendouUser,
@@ -161,6 +157,14 @@ export function VodPage({
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const skipReasons = ingestSkipReasons(builtMatches);
// mirrors sendVodResults' ingestable-match order, which the send outcome's
// links are keyed by
const ingestableBuilt = builtMatches.filter((b) => !skipReasons.has(b));
const linkByIngestableIndex = new Map(
(resultsSend?.state === "done" ? (resultsSend.links ?? []) : []).map(
(linked) => [linked.matchIndex, linked.link] as const,
),
);
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
@@ -213,11 +217,12 @@ export function VodPage({
const report = await sendVodResults(events, (sent, total) =>
setResultsSend({ state: "sending", sent, total }),
);
const outcome = {
const outcome: VodResultsSend = {
sent: report.sentMatches,
total: report.totalMatches,
error: report.error,
at: Date.now(),
links: report.links,
};
setResultsSend({ state: "done", ...outcome });
// the scan is saved under its file name, so its send outcome can be
@@ -518,7 +523,6 @@ export function VodPage({
const removeVod = useCallback(
async (name: string) => {
if (!window.confirm(`Delete saved analysis of "${name}"?`)) return;
await deleteVod(name);
await refreshVods();
},
@@ -665,15 +669,19 @@ export function VodPage({
<button type="button" onClick={() => void openStored(vod)}>
Open
</button>
<SendouButton
variant="destructive"
size="small"
shape="square"
className="vod-delete"
icon={<Trash2 />}
aria-label="Delete"
onPress={() => void removeVod(vod.name)}
/>
<FormWithConfirm
dialogHeading={`Delete saved analysis of "${vod.name}"?`}
onConfirm={() => void removeVod(vod.name)}
>
<SendouButton
variant="destructive"
size="small"
shape="square"
className="vod-delete"
icon={<Trash2 />}
aria-label="Delete"
/>
</FormWithConfirm>
</div>
))}
</div>
@@ -716,6 +724,10 @@ export function VodPage({
keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key}
renderMatch={(built, justFormed) => {
const skipReason = skipReasons.get(built);
const link = linkByIngestableIndex.get(
ingestableBuilt.indexOf(built),
);
const send = skipReason ? undefined : bulkSend;
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
@@ -736,7 +748,9 @@ export function VodPage({
}
skipReason={skipReason}
justFormed={justFormed}
send={skipReason ? undefined : bulkSend}
send={
send?.state === "sent" && link ? { ...send, link } : send
}
>
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline

View File

@@ -11,6 +11,10 @@
* hash, merges partials, and scoreboards first-ingest-wins.
*/
import type {
IngestedMatchLink,
IngestResponse,
} from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
@@ -62,8 +66,15 @@ export async function sendMatches({
await updateEventsSend(ids, { state: "sending", at: Date.now() });
onStatus();
try {
await postIngestMatches([built.match]);
await updateEventsSend(ids, { state: "sent", at: Date.now() });
const response = await postIngestMatches([built.match]);
const link = response.linkedMatches?.find(
(linked) => linked.matchIndex === 0,
)?.link;
await updateEventsSend(ids, {
state: "sent",
at: Date.now(),
...(link ? { link } : null),
});
result.sentMatches++;
} catch (err) {
await updateEventsSend(ids, {
@@ -83,6 +94,8 @@ export interface VodResultsSendReport {
totalMatches: number;
/** last failure's message; null when every request went through */
error: string | null;
/** links /ingest reported, keyed by index into the scan's ingestable matches */
links: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}
/**
@@ -103,17 +116,21 @@ export async function sendVodResults(
let sentMatches = 0;
let error: string | null = null;
const links: VodResultsSendReport["links"] = [];
for (let i = 0; i < matches.length; i += MAX_MATCHES_PER_REQUEST) {
const request = matches.slice(i, i + MAX_MATCHES_PER_REQUEST);
try {
await postIngestMatches(request);
const response = await postIngestMatches(request);
for (const linked of response.linkedMatches ?? []) {
links.push({ matchIndex: i + linked.matchIndex, link: linked.link });
}
sentMatches += request.length;
onProgress?.(sentMatches, matches.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
return { sentMatches, totalMatches: matches.length, error };
return { sentMatches, totalMatches: matches.length, error, links };
}
/** The number of matches a set of events would send to /ingest. */
@@ -170,7 +187,9 @@ function ingestableBuilt<E extends DetectedEvent>(
return built.filter((match) => !skipped.has(match));
}
async function postIngestMatches(matches: ScannerMatch[]) {
async function postIngestMatches(
matches: ScannerMatch[],
): Promise<IngestResponse> {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -181,6 +200,7 @@ async function postIngestMatches(matches: ScannerMatch[]) {
res.status === 401 ? "not logged in to sendou.ink" : await errorText(res),
);
}
return res.json();
}
/**

View File

@@ -963,6 +963,16 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
color: var(--color-success-high);
border-color: var(--color-success-low);
background-color: var(--color-success-low);
& a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--color-text-high);
}
}
}
&.failed {

View File

@@ -6,6 +6,7 @@
* so listing the feed never deserializes megabytes of blobs. The store is
* capped: saving past MAX_EVENTS evicts the oldest events and their frames.
*/
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import { db, EVENTS_STORE, FRAMES_STORE, tx } from "./db";
@@ -19,6 +20,8 @@ export interface SendStatus {
at: number;
/** failure detail, set when state is "failed" */
error?: string;
/** the sendou.ink match /ingest linked the sent match to, when it reported one */
link?: IngestedMatchLink;
}
export interface StoredEvent {

View File

@@ -7,6 +7,7 @@
* their events stays cheap. Re-scanning the same file name overwrites the
* previous save.
*/
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import { db, tx, VOD_EVENTS_STORE, VOD_FRAMES_STORE, VODS_STORE } from "./db";
/** How a VoD's last "Send results" went; absent = never attempted. */
@@ -18,6 +19,8 @@ export interface VodResultsSend {
error: string | null;
/** wall-clock time the send finished */
at: number;
/** links /ingest reported, keyed by index into the scan's ingestable matches */
links?: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}
export interface VodSummary {