diff --git a/components/hub/NotificationsPanel.vue b/components/hub/NotificationsPanel.vue index bc493520..2e7c4cb5 100644 --- a/components/hub/NotificationsPanel.vue +++ b/components/hub/NotificationsPanel.vue @@ -178,6 +178,10 @@ export default { }); } } + // Synthetic notifications have no backing row to delete. + if (notification.__synthetic) { + return; + } await this.deleteNotification(notification.id); }, async dismissNotification(id: string) { diff --git a/components/player/PlayerIntroDashboard.vue b/components/player/PlayerIntroDashboard.vue index 3218ed54..302c8bba 100644 --- a/components/player/PlayerIntroDashboard.vue +++ b/components/player/PlayerIntroDashboard.vue @@ -54,10 +54,18 @@ const props = defineProps<{ source?: string | null; limit?: number | null; since?: string | null; + until?: string | null; }>(); const { t, te } = useI18n(); +// A null/absent limit means "no match cap" (a date-range / season filter is +// driving the window); only fall back to the recent-30 view when there is no +// range at all. Bounded so an all-time query can't be unbounded. +const effectiveLimit = computed(() => + props.limit != null ? props.limit : props.since ? 1000 : 30, +); + function statTitle(key: string): string { return te(`stat_glossary.${key}.label`) ? t(`stat_glossary.${key}.label`) @@ -92,8 +100,11 @@ function buildMatchesWhere() { }, }; } - if (props.since) { - where.started_at = { _gte: props.since }; + if (props.since || props.until) { + where.started_at = { + ...(props.since ? { _gte: props.since } : {}), + ...(props.until ? { _lte: props.until } : {}), + }; } return where; } @@ -185,7 +196,7 @@ async function load() { variables: { steamId: props.steamId, matchesWhere: buildMatchesWhere(), - limit: props.limit ?? 30, + limit: effectiveLimit.value, statsLimit: 200, hltvLimit: 600, }, @@ -218,6 +229,7 @@ watch( props.matchType, props.limit, props.since, + props.until, ], load, { immediate: true }, @@ -465,7 +477,7 @@ const { (steamId) => ({ steamId, matchesWhere: buildMatchesWhere(), - limit: props.limit ?? 30, + limit: effectiveLimit.value, statsLimit: 200, hltvLimit: 600, }), @@ -474,7 +486,7 @@ const { rawStats: (data?.playerIntroStats ?? []) as RawStats[], hltvRows: (data?.playerIntroHltv ?? []) as any[], }), - () => [props.source, props.matchType, props.limit, props.since], + () => [props.source, props.matchType, props.limit, props.since, props.until], ); const comparePoints = computed(() => { diff --git a/components/seasons/SeasonRebuildProgress.vue b/components/seasons/SeasonRebuildProgress.vue new file mode 100644 index 00000000..8472482e --- /dev/null +++ b/components/seasons/SeasonRebuildProgress.vue @@ -0,0 +1,60 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 3bc5423f..a59913e9 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1604,7 +1604,7 @@ "refresh_dialog_title": "Reindex the search index?", "refresh_dialog_description": "This will re-sync every player into the Typesense search index in batches. It runs in the background and you can watch its progress here.", "recompute_elo_title": "Recompute Player ELO", - "recompute_elo_description": "Wipe and rebuild every player's ELO by replaying all finished matches in order, in batches. Use this after an ELO formula change or if ratings look wrong. Search results update automatically as ELO is recalculated.", + "recompute_elo_description": "Wipe and rebuild every player's ELO by replaying all finished matches in order, in batches. When seasons are enabled this also rebuilds each season's ELO from the matches that fall within it. Use this after an ELO formula change or if ratings look wrong. Search results update automatically as ELO is recalculated.", "recompute_elo_button": "Recompute ELO", "recomputing_elo": "Recomputing...", "recompute_elo_queued": "ELO recompute started", @@ -2203,6 +2203,9 @@ "upcoming_title": "Upcoming Seasons", "past_title": "Past Seasons", "rebuild": "Rebuild", + "rebuild_elo": "Rebuild ELO", + "rebuild_required": "Rebuild Required", + "needs_rebuild_notice": "This season's ELO is out of date and its standings are incorrect. Rebuild to recompute ELO and stats from its matches.", "delete": "Delete", "deleted": "Season deleted", "delete_confirm_title": "Delete this season?", diff --git a/layouts/components/AppNotifications.vue b/layouts/components/AppNotifications.vue index 01f71af1..0b461e47 100644 --- a/layouts/components/AppNotifications.vue +++ b/layouts/components/AppNotifications.vue @@ -158,6 +158,12 @@ export default { } } + // Synthetic notifications have no backing row โ€” they clear on their own + // when the underlying state changes (e.g. seasons.needs_rebuild flips). + if (notification.__synthetic) { + return; + } + await this.deleteNotification(notification.id); }, async dismissNotification(id: string) { diff --git a/layouts/components/LeftNav.vue b/layouts/components/LeftNav.vue index 67728c31..cb954770 100644 --- a/layouts/components/LeftNav.vue +++ b/layouts/components/LeftNav.vue @@ -704,7 +704,7 @@ function onLeftNavTouchEnd(e: TouchEvent) { {{ $t("layouts.app_nav.administration.seasons") }} + @@ -1090,6 +1094,9 @@ export default { seasonsEnabled() { return useApplicationSettingsStore().seasonsEnabled; }, + seasonsRebuildCount() { + return useNotificationStore().seasonRebuildCount; + }, newsLabel() { return useApplicationSettingsStore().newsLabel; }, diff --git a/pages/players/[id].vue b/pages/players/[id].vue index a089aa3c..ab0b235f 100644 --- a/pages/players/[id].vue +++ b/pages/players/[id].vue @@ -2603,6 +2603,7 @@ const playerHeroTeamChipDotClasses = :source="effectiveSource" :limit="statsMatchLimit" :since="sinceTimestamp" + :until="untilTimestamp" /> diff --git a/pages/seasons/index.vue b/pages/seasons/index.vue index bd882437..74349bca 100644 --- a/pages/seasons/index.vue +++ b/pages/seasons/index.vue @@ -37,6 +37,7 @@ import { AlertDialogTitle, } from "~/components/ui/alert-dialog"; import { useSeasonBackfill } from "~/composables/useSeasonBackfill"; +import SeasonRebuildProgress from "~/components/seasons/SeasonRebuildProgress.vue"; definePageMeta({ middleware: "admin", @@ -45,11 +46,21 @@ definePageMeta({ // Shared singleton; also used from the Options API block via useSeasonBackfill(). const backfill = useSeasonBackfill(); +// True while THIS season is the one currently being rebuilt. +function isRebuilding(seasonId: string): boolean { + return ( + backfill.running.value && backfill.status.value?.season_id === seasonId + ); +} + const actionBtn = [filterTriggerBase, filterTriggerIdle, "h-8"]; const dangerBtn = [ filterTriggerBase, "h-8 border-[hsl(var(--destructive)/0.5)] bg-[hsl(var(--destructive)/0.12)] text-destructive hover:bg-[hsl(var(--destructive)/0.2)]", ]; +// Prominent amber CTA used when a season's ELO is stale and must be rebuilt. +const rebuildCta = + "inline-flex items-center gap-1.5 rounded-md border border-[hsl(var(--tac-amber)/0.6)] bg-[hsl(var(--tac-amber)/0.16)] px-3 h-8 font-mono text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-[hsl(var(--tac-amber))] transition-colors hover:bg-[hsl(var(--tac-amber)/0.28)] disabled:opacity-40"; - -
-
- - - {{ $t("pages.seasons.backfill_running") }} - - -
-
-
-
-
- {{ backfill.status.value?.completed || 0 }} / - {{ backfill.status.value?.total || 0 }} - {{ $t("pages.seasons.matches_label") }} -
-
-
{{ $t("pages.seasons.active") }} + + + {{ $t("pages.seasons.rebuild_required") }} + + +
+ + + {{ $t("pages.seasons.needs_rebuild_notice") }} + + +
+
+

+ +

@@ -480,8 +495,9 @@ const dangerBtn = [
+
@@ -515,6 +531,13 @@ const dangerBtn = [ : $t("pages.seasons.ended") }} + + + {{ $t("pages.seasons.rebuild_required") }} +

+ + +
+ + @@ -613,6 +660,7 @@ type Season = { starts_at: string; ends_at: string | null; created_at: string; + needs_rebuild: boolean; }; export default { @@ -631,6 +679,7 @@ export default { starts_at: true, ends_at: true, created_at: true, + needs_rebuild: true, }, ], }), diff --git a/stores/NotificationStore.ts b/stores/NotificationStore.ts index 6cd8bb79..fb47e695 100644 --- a/stores/NotificationStore.ts +++ b/stores/NotificationStore.ts @@ -18,6 +18,9 @@ type Notification = { is_read: boolean; deletable: boolean; created_at: string; + // Set on client-derived notifications (e.g. seasons needing a rebuild) that + // have no backing row โ€” action handlers must not try to mutate them by id. + __synthetic?: boolean; actions?: Array<{ label: string; graphql: { @@ -50,6 +53,54 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { const tournament_team_invites = ref([]); const draft_invites = ref([]); const notifications = ref([]); + const seasonRebuilds = ref>([]); + + // Admin-only, derived from seasons.needs_rebuild โ€” not stored rows. They carry + // a Rebuild action and clear themselves when the backfill flips needs_rebuild + // false, so nothing is ever created or deleted in the notifications table. + const seasonRebuildNotifications = computed(() => { + if (!useApplicationSettingsStore().seasonsEnabled) { + return []; + } + if (!useAuthStore().isAdmin) { + return []; + } + return seasonRebuilds.value.map((season) => ({ + id: `season-rebuild:${season.id}`, + __synthetic: true, + title: `Season ${season.number ?? "?"} ELO rebuild required`, + message: + "This season's ELO is out of date. Rebuild to recompute its ELO and standings.", + steam_id: "", + type: "EloRecompute", + role: "administrator", + entity_id: `season-rebuild:${season.id}`, + is_read: false, + deletable: false, + created_at: new Date().toISOString(), + actions: [ + { + label: "Rebuild Season ELO", + graphql: { + type: "mutation", + action: "backfillSeasonElo", + selection: { success: true, running: true }, + variables: { season_id: season.id }, + }, + }, + ], + })); + }); + + // DB notifications + client-derived ones, in one list for the panel + counts. + const allNotifications = computed(() => [ + ...seasonRebuildNotifications.value, + ...notifications.value, + ]); + + const seasonRebuildCount = computed( + () => seasonRebuildNotifications.value.length, + ); const latestNewsArticle = ref(null); const lastReadNewsAt = ref(null); @@ -115,11 +166,13 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { // let them pile up. const personalUnread = computed( () => - notifications.value.filter((n) => !n.is_read && n.role === "user").length, + allNotifications.value.filter((n) => !n.is_read && n.role === "user") + .length, ); const adminUnread = computed( () => - notifications.value.filter((n) => !n.is_read && n.role !== "user").length, + allNotifications.value.filter((n) => !n.is_read && n.role !== "user") + .length, ); const hasPersonalNotifications = computed( @@ -151,7 +204,7 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { const groups = new Map(); const singles: Notification[] = []; - for (const n of notifications.value) { + for (const n of allNotifications.value) { const groupKey = n.type === "PlayerSanctioned" ? `type:PlayerSanctioned:${n.role}` @@ -372,6 +425,26 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { }), ); + if (useAuthStore().isAdmin) { + subscribe( + "notifications:season_rebuilds", + getGraphqlClient() + .subscribe({ + query: typedGql("subscription")({ + seasons: [ + { where: { needs_rebuild: { _eq: true } } }, + { id: true, number: true }, + ], + }), + }) + .subscribe({ + next: ({ data }) => { + seasonRebuilds.value = data?.seasons ?? []; + }, + }), + ); + } + subscribe( "notifications:latest_news", getGraphqlClient() @@ -432,8 +505,10 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { unsubscribe("notifications:tournament_team_invites"); unsubscribe("notifications:draft_invites"); unsubscribe("notifications:notifications"); + unsubscribe("notifications:season_rebuilds"); unsubscribe("notifications:latest_news"); unsubscribe("notifications:news_read_state"); + seasonRebuilds.value = []; lastReadNewsAt.value = null; } }, @@ -449,7 +524,8 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { team_invites, tournament_team_invites, draft_invites, - notifications, + notifications: allNotifications, + seasonRebuildCount, stackedNotifications, unreadNotificationCount, hasNotifications,