Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/assets/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,28 @@ main.mega-report {
font-size: 0.7rem;
}

/* Inline 📺 icon per schedule row — opens the first available match video in a new tab.
* First entry is admin-curated when present (see MatchVideoEnricher). No text, no underline; the
* title attribute carries the hover/screen-reader hint. */
.schedule-video-icon {
text-decoration: none;
font-size: 0.9em;
margin-left: 0.3rem;
opacity: 0.75;
cursor: pointer;
}
.schedule-video-icon:hover {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.schedule-video-icon {
opacity: 0.85;
}
.schedule-video-icon:hover {
opacity: 1;
}
}

.schedule-table .schedule-score-cell {
font-weight: 700;
}
Expand Down
69 changes: 69 additions & 0 deletions app/common/sync/TbaSyncButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState } from "react";
import { rbfetch } from "~/common/storage/rbauth.ts";

/**
* Polls /api/tba-sync/status until the sync completes or times out. Resolves on "idle",
* rejects on timeout.
*/
async function waitForSyncComplete(
maxWaitMs: number = 300_000,
intervalMs: number = 3_000,
): Promise<void> {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, intervalMs));
const resp = await rbfetch("/api/tba-sync/status", {});
if (resp.ok) {
const status = await resp.text();
if (status === "idle") return;
}
}
throw new Error("TBA sync timed out after " + maxWaitMs / 1000 + "s");
}

const TbaSyncButton = () => {
const [syncing, setSyncing] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

const handleTbaSync = async () => {
setSyncing(true);
setError(null);
setSuccess(false);
setStatus(null);
try {
const resp = await rbfetch("/api/tba-sync", { method: "POST" });
if (resp.status === 409) {
setStatus("Sync already in progress, waiting...");
} else if (!resp.ok) {
throw new Error("TBA sync failed: " + resp.status);
} else {
setStatus("Sync started on server, waiting for completion...");
}
await waitForSyncComplete();
// Unlike FRC, TBA data (webcasts + match videos) is read from RavenBrain at render time —
// no local IndexedDB copy to refresh. So we just report success.
setSuccess(true);
setStatus(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setStatus(null);
} finally {
setSyncing(false);
}
};

return (
<div>
<button onClick={handleTbaSync} disabled={syncing}>
{syncing ? "Syncing..." : "Force Sync with TBA"}
</button>
{status && <span> {status}</span>}
{error && <span className="banner banner-warning">{error}</span>}
{success && <span> Done!</span>}
</div>
);
};

export default TbaSyncButton;
100 changes: 62 additions & 38 deletions app/routes/admin/match-videos-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,38 @@ function MatchRow({
<td>
{matchVideos.length > 0 ? (
<ul className="match-video-list">
{matchVideos.map((v) => (
<li key={v.id}>
<span className="match-video-label">{v.label}:</span>{" "}
<a href={v.videoUrl} target="_blank" rel="noopener noreferrer">
{v.videoUrl}
</a>
{isAdmin && (
<button
className="match-video-delete"
onClick={() => handleDelete(v.id)}
title="Delete"
>
&times;
</button>
)}
</li>
))}
{matchVideos.map((v, idx) => {
const isTba = v.source === "tba";
// TBA rows have no RB_MATCH_VIDEO row to delete; admin rows always have id.
const key = v.id != null ? `m-${v.id}` : `t-${idx}-${v.videoUrl}`;
return (
<li key={key}>
<span className="match-video-label">{v.label}:</span>{" "}
<a href={v.videoUrl} target="_blank" rel="noopener noreferrer">
{v.videoUrl}
</a>
<span className={isTba ? "badge-tba" : "badge-manual"}>
{isTba ? "From TBA" : "Manual"}
</span>
{isAdmin && (
<button
className="match-video-delete"
onClick={() => {
if (v.id != null) handleDelete(v.id);
}}
disabled={isTba || v.id == null}
title={
isTba
? "Served by TBA — remove by clearing the TBA event key or contacting TBA."
: "Delete"
}
>
&times;
</button>
)}
</li>
);
})}
</ul>
) : (
<span className="match-video-none">No videos</span>
Expand Down Expand Up @@ -136,28 +151,37 @@ function MatchVideoContent({ tournamentId }: { tournamentId: string }) {
return la !== lb ? la - lb : a.match - b.match;
});

const hasStale = videos.some((v) => v.stale === true);

return (
<table className="match-video-table">
<thead>
<tr>
<th>Match</th>
<th>Videos</th>
<th>Add</th>
</tr>
</thead>
<tbody>
{sortedMatches.map((m) => (
<MatchRow
key={`${m.level}-${m.match}`}
match={m}
tournamentId={tournamentId}
videos={videos}
isAdmin={admin}
onChanged={loadData}
/>
))}
</tbody>
</table>
<>
{hasStale && (
<div className="banner banner-info">
(i) TBA match video sync last failed — some links may be stale or missing.
</div>
)}
<table className="match-video-table">
<thead>
<tr>
<th>Match</th>
<th>Videos</th>
<th>Add</th>
</tr>
</thead>
<tbody>
{sortedMatches.map((m) => (
<MatchRow
key={`${m.level}-${m.match}`}
match={m}
tournamentId={tournamentId}
videos={videos}
isAdmin={admin}
onChanged={loadData}
/>
))}
</tbody>
</table>
</>
);
}

Expand Down
44 changes: 42 additions & 2 deletions app/routes/report/team-schedule-page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchTournamentSchedule,
getActiveTeamTournaments,
getMatchVideos,
getNexusQueueStatus,
getTeamSchedulePublic,
getTournamentList,
} from "~/common/storage/rb.ts";
import type { MatchVideo } from "~/types/MatchVideo.ts";
import TournamentPicker from "~/common/components/TournamentPicker.tsx";
import { useLoginStatus, useRole } from "~/common/storage/rbauth.ts";
import Spinner from "~/common/Spinner.tsx";
Expand Down Expand Up @@ -250,6 +252,7 @@ function ScheduleTable({
loggedIn,
highlightMatch,
rankings,
videosByMatch,
}: {
label: string;
level: string;
Expand All @@ -267,6 +270,7 @@ function ScheduleTable({
loggedIn: boolean;
highlightMatch?: number | null;
rankings: TeamRanking[];
videosByMatch: Map<string, MatchVideo[]>;
}) {
const isElimination = level === "Playoff";
const allLevelMatches = (matches ?? []).filter((m) => m.level === level);
Expand Down Expand Up @@ -327,9 +331,23 @@ function ScheduleTable({
alliance === "red" ? "schedule-row-our-red" : alliance === "blue" ? "schedule-row-our-blue" : "",
highlightMatch === m.match ? "schedule-row-highlight" : "",
].filter(Boolean).join(" ");
const videos = videosByMatch.get(`${m.level}:${m.match}`) ?? [];
return (
<tr key={`${m.level}-${m.match}`} className={classes}>
<td className="schedule-match-num">{m.match}</td>
<td className="schedule-match-num">
{m.match}
{videos.length > 0 && (
<a
className="schedule-video-icon"
href={videos[0].videoUrl}
target="_blank"
rel="noopener noreferrer"
title="Watch match video (opens in new tab)"
>
📺
</a>
)}
</td>
<td className="schedule-time">
{formatMatchTime(m.startTime)}
</td>
Expand Down Expand Up @@ -487,6 +505,7 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
const [manualTournament, setManualTournament] = useState<RBTournament | null>(null);
const [allTournaments, setAllTournaments] = useState<RBTournament[]>([]);
const [tournamentsListLoading, setTournamentsListLoading] = useState(true);
const [matchVideos, setMatchVideos] = useState<MatchVideo[]>([]);
const autoSelectedRef = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -594,6 +613,13 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
setShowAll(true);
setLoading(true);
loadSchedule(selectedTournamentId, false).finally(() => setLoading(false));
// Match videos fetch is additive — the page renders fine with an empty list if this fails.
getMatchVideos(selectedTournamentId)
.then(setMatchVideos)
.catch((e) => {
console.error("Failed to load match videos", e);
setMatchVideos([]);
});

setCountdown(countdownStart);
countdownRef.current = setInterval(() => {
Expand Down Expand Up @@ -683,6 +709,17 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
}
}

const videosByMatch = useMemo(() => {
const map = new Map<string, MatchVideo[]>();
for (const v of matchVideos) {
const key = `${v.matchLevel}:${v.matchNumber}`;
const list = map.get(key);
if (list) list.push(v);
else map.set(key, [v]);
}
return map;
}, [matchVideos]);

// Livestream links — only show for active tournaments
const isActiveTournament = activeTournaments.some(
(t) => t.id === selectedTournamentId,
Expand Down Expand Up @@ -863,6 +900,7 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
loggedIn={loggedIn}
highlightMatch={highlightByLevel[section.level]}
rankings={schedule.rankings}
videosByMatch={videosByMatch}
/>
) : section.level === "Playoff" && hasPlayoffBracket ? (
<div key={section.level}>
Expand Down Expand Up @@ -892,6 +930,7 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
loggedIn={loggedIn}
highlightMatch={highlightByLevel[section.level]}
rankings={schedule.rankings}
videosByMatch={videosByMatch}
/>
</div>
) : (
Expand All @@ -910,6 +949,7 @@ const TeamScheduleContent = ({ autoSelect = false }: { autoSelect?: boolean }) =
loggedIn={loggedIn}
highlightMatch={highlightByLevel[section.level]}
rankings={schedule.rankings}
videosByMatch={videosByMatch}
/>
),
)}
Expand Down
13 changes: 13 additions & 0 deletions app/routes/sync-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SyncDashboardData from "~/common/sync/SyncDashboardData.tsx";
import SyncNowButton from "~/common/sync/SyncNowButton.tsx";
import SyncServerDataButton from "~/common/sync/SyncServerDataButton.tsx";
import FrcSyncButton from "~/common/sync/FrcSyncButton.tsx";
import TbaSyncButton from "~/common/sync/TbaSyncButton.tsx";
import ClearReportCacheButton from "~/common/ClearReportCacheButton.tsx";
import { useRole } from "~/common/storage/rbauth.ts";
import { useManualSyncStatus } from "~/common/sync/sync.ts";
Expand Down Expand Up @@ -80,6 +81,18 @@ const SyncPage = () => {
<FrcSyncButton />
</section>
)}
{isSuperuser && (
<section className="card">
<h2>Force Sync with TBA</h2>
<p>
Forces RavenBrain to immediately re-sync event webcasts and match
videos from The Blue Alliance, without waiting for the hourly
scheduled sync. Covers watched/active tournaments that have a TBA
event key mapped.
</p>
<TbaSyncButton />
</section>
)}
{(isAdmin || isSuperuser) && (
<section className="card">
<h2>Report Cache</h2>
Expand Down
6 changes: 5 additions & 1 deletion app/types/MatchVideo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
export interface MatchVideo {
id: number;
// Null for TBA-sourced synthetic entries (no admin-table identity); numeric for admin rows.
id: number | null;
tournamentId: string;
matchLevel: string;
matchNumber: number;
label: string;
videoUrl: string;
// Present on enriched responses from GET /api/match-video/...; absent on older clients.
source?: "manual" | "tba";
stale?: boolean;
}
Loading
Loading