diff --git a/pb_migrations/1787443200_badge_progress_unique_index.js b/pb_migrations/1787443200_badge_progress_unique_index.js
new file mode 100644
index 0000000..72f2ff9
--- /dev/null
+++ b/pb_migrations/1787443200_badge_progress_unique_index.js
@@ -0,0 +1,36 @@
+///
+migrate((app) => {
+ // Dedupe before adding the unique index: keep the best row per
+ // (player, badge) — earned first, then highest progress, then newest.
+ app.db().newQuery(`
+ DELETE FROM badge_progress
+ WHERE id NOT IN (
+ SELECT id FROM (
+ SELECT id, ROW_NUMBER() OVER (
+ PARTITION BY player, badge
+ ORDER BY earned DESC, progress DESC, updated DESC
+ ) AS rn
+ FROM badge_progress
+ )
+ WHERE rn = 1
+ )
+ `).execute()
+
+ const collection = app.findCollectionByNameOrId("pbc_1063824264")
+
+ unmarshal({
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_badge_progress_player_badge` ON `badge_progress` (`player`, `badge`)"
+ ]
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1063824264")
+
+ unmarshal({
+ "indexes": []
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx
index 91acb29..ed784b7 100644
--- a/src/features/admin/components/admin-page.tsx
+++ b/src/features/admin/components/admin-page.tsx
@@ -1,5 +1,5 @@
-import { List } from "@mantine/core";
-import { useLingui } from "@lingui/react/macro";
+import { List, Text } from "@mantine/core";
+import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import {
DatabaseIcon,
@@ -10,19 +10,38 @@ import {
ListIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
-import { migrateBadgeProgress } from "@/features/badges/server";
-import { useState } from "react";
+import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
+import { useServerQuery } from "@/lib/tanstack-query/hooks";
+import toast from "@/lib/sonner";
const AdminPage = () => {
const { t } = useLingui();
- const [isMigrating, setIsMigrating] = useState(false);
+
+ const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
+ queryKey: ["admin", "badge-migration-status"],
+ queryFn: () => getBadgeMigrationStatus(),
+ options: {
+ refetchInterval: (query) =>
+ query.state.data?.state === "running" ? 2000 : false,
+ },
+ });
+
+ const isMigrating = migrationStatus?.state === "running";
const handleMigrateBadges = async () => {
if (isMigrating) return;
- setIsMigrating(true);
- await migrateBadgeProgress();
- setIsMigrating(false);
+ const result = await migrateBadgeProgress();
+ if (result.success) {
+ toast.success(
+ result.data.started
+ ? t`Badge recalculation started in the background`
+ : t`Badge recalculation is already running`
+ );
+ } else {
+ toast.error(t`Failed to start badge recalculation`);
+ }
+ await refetchMigrationStatus();
};
return (
@@ -43,6 +62,30 @@ const AdminPage = () => {
onClick={handleMigrateBadges}
loading={isMigrating}
/>
+ {migrationStatus?.state === "running" && (
+
+
+ Recalculating badges in the background ({migrationStatus.playersProcessed}/{migrationStatus.totalPlayers})
+
+
+ )}
+ {migrationStatus?.state === "completed" && (
+
+
+ Last recalculation:{" "}
+
+
+
+ )}
+ {migrationStatus?.state === "failed" && (
+
+ Badge recalculation failed. Check the server logs.
+
+ )}
status;
+
+export const startBadgeMigration = (): { started: boolean; status: BadgeMigrationStatus } => {
+ if (status.state === "running") {
+ return { started: false, status };
+ }
+
+ const startedAt = new Date().toISOString();
+ status = { state: "running", startedAt, playersProcessed: 0, totalPlayers: 0 };
+
+ void pbAdmin
+ .migrateBadgeProgress((playersProcessed, totalPlayers) => {
+ if (status.state === "running") {
+ status = { ...status, playersProcessed, totalPlayers };
+ }
+ })
+ .then((result) => {
+ status = { state: "completed", startedAt, finishedAt: new Date().toISOString(), result };
+ logger.info("Badge migration completed", result);
+ emitServerEvent({ type: "badge" });
+ })
+ .catch((error) => {
+ status = {
+ state: "failed",
+ startedAt,
+ finishedAt: new Date().toISOString(),
+ error: error instanceof Error ? error.message : String(error),
+ };
+ logger.error("Badge migration failed", error);
+ });
+
+ return { started: true, status };
+};
diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts
index 1b9ad54..a1dc509 100644
--- a/src/features/badges/server.ts
+++ b/src/features/badges/server.ts
@@ -4,6 +4,7 @@ import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { emitServerEvent } from "@/lib/events/emitter";
+import { getBadgeMigrationJobStatus, startBadgeMigration } from "./migration.server";
export const getPlayerBadges = createServerFn()
.validator(z.string())
@@ -15,11 +16,13 @@ export const getPlayerBadges = createServerFn()
export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.handler(async () =>
- toServerResult(async () => {
- const result = await pbAdmin.migrateBadgeProgress();
- emitServerEvent({ type: "badge" });
- return result;
- })
+ toServerResult(async () => startBadgeMigration())
+ );
+
+export const getBadgeMigrationStatus = createServerFn()
+ .middleware([superTokensAdminFunctionMiddleware])
+ .handler(async () =>
+ toServerResult(async () => getBadgeMigrationJobStatus())
);
export const getAllBadges = createServerFn()
diff --git a/src/lib/pocketbase/services/badges.ts b/src/lib/pocketbase/services/badges.ts
index 5eb5e83..5378ec3 100644
--- a/src/lib/pocketbase/services/badges.ts
+++ b/src/lib/pocketbase/services/badges.ts
@@ -70,16 +70,6 @@ export function createBadgesService(pb: PocketBase) {
return true;
},
- async clearAllBadgeProgress(): Promise {
- const existingProgress = await pb.collection("badge_progress").getFullList({
- filter: 'badge.type != "manual"',
- });
- for (const progress of existingProgress) {
- await pb.collection("badge_progress").delete(progress.id);
- }
- return existingProgress.length;
- },
-
async calculateBadgeProgress(playerId: string, badge: Badge): Promise {
if (badge.type === "manual") {
return 0;
@@ -651,27 +641,36 @@ export function createBadgesService(pb: PocketBase) {
return transformBadgeProgress(created);
},
- async migrateBadgeProgress(): Promise<{
- success: boolean;
+ async migrateBadgeProgress(
+ onProgress?: (playersProcessed: number, totalPlayers: number) => void
+ ): Promise<{
playersProcessed: number;
- progressRecordsCreated: number;
totalBadgesEarned: number;
+ created: number;
+ updated: number;
+ deleted: number;
averageBadgesPerPlayer: string;
}> {
- await this.clearAllBadgeProgress();
-
const badges = await this.listBadges();
+ const autoBadges = badges.filter((badge) => badge.type !== "manual");
+ const players = await pb.collection("players").getFullList();
- const allPlayers = await pb.collection("players").getFullList();
- const uniquePlayers = new Set(allPlayers.map((p: any) => p.id));
+ // Phase 1: read-only calculation. Existing badge_progress rows stay
+ // untouched until the full desired state is known.
+ const desired = new Map();
+ const failedKeys = new Set();
+ let playersProcessed = 0;
- let totalProgressRecords = 0;
- let totalBadgesEarned = 0;
-
- for (const playerId of uniquePlayers) {
- for (const badge of badges) {
+ for (const player of players) {
+ for (const badge of autoBadges) {
+ const key = `${player.id}|${badge.id}`;
try {
- const progress = await this.calculateBadgeProgress(playerId, badge);
+ const progress = await this.calculateBadgeProgress(player.id, badge);
const target = this.getTargetProgress(badge);
const isPlacementBadge = badge.criteria.placement !== undefined;
@@ -681,31 +680,77 @@ export function createBadgesService(pb: PocketBase) {
: progress === target;
if (progress > 0 || earned) {
- await this.createBadgeProgress({
+ desired.set(key, {
badge: badge.id,
- player: playerId,
- progress: progress,
- earned: earned,
+ player: player.id,
+ progress,
+ earned,
});
-
- totalProgressRecords++;
-
- if (earned) {
- totalBadgesEarned++;
- }
}
} catch (error: any) {
- console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message);
+ // Keep whatever row already exists when a calculation fails.
+ failedKeys.add(key);
+ console.error(`Error processing badge "${badge.name}" for player ${player.id}:`, error.message);
}
}
+ playersProcessed++;
+ onProgress?.(playersProcessed, players.length);
+ }
+
+ // Phase 2: apply as a diff — update changed rows, create missing ones,
+ // delete stale/duplicate ones. No window where all badges are gone.
+ const existing = await pb.collection("badge_progress").getFullList({
+ filter: 'badge.type != "manual"',
+ });
+
+ const existingByKey = new Map();
+ const toDelete: string[] = [];
+ for (const row of existing) {
+ const key = `${row.player}|${row.badge}`;
+ if (existingByKey.has(key)) {
+ toDelete.push(row.id);
+ continue;
+ }
+ existingByKey.set(key, { id: row.id, progress: row.progress, earned: row.earned });
+ if (!desired.has(key) && !failedKeys.has(key)) {
+ toDelete.push(row.id);
+ }
+ }
+
+ let created = 0;
+ let updated = 0;
+ let totalBadgesEarned = 0;
+
+ for (const [key, want] of desired) {
+ if (want.earned) {
+ totalBadgesEarned++;
+ }
+ const row = existingByKey.get(key);
+ if (!row) {
+ await this.createBadgeProgress(want);
+ created++;
+ } else if (row.progress !== want.progress || row.earned !== want.earned) {
+ await this.updateBadgeProgress(row.id, {
+ progress: want.progress,
+ earned: want.earned,
+ });
+ updated++;
+ }
+ }
+
+ for (const id of toDelete) {
+ await this.deleteBadgeProgress(id);
}
return {
- success: true,
- playersProcessed: uniquePlayers.size,
- progressRecordsCreated: totalProgressRecords,
- totalBadgesEarned: totalBadgesEarned,
- averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2),
+ playersProcessed: players.length,
+ totalBadgesEarned,
+ created,
+ updated,
+ deleted: toDelete.length,
+ averageBadgesPerPlayer: players.length
+ ? (totalBadgesEarned / players.length).toFixed(2)
+ : "0",
};
},
};
diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po
index 76ba343..234e6e6 100644
--- a/src/locales/de/messages.po
+++ b/src/locales/de/messages.po
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Aktives Gerät: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21
-#: src/features/admin/components/admin-page.tsx:47
+#: src/features/admin/components/admin-page.tsx:90
msgid "Activities"
msgstr "Aktivitäten"
@@ -520,7 +520,7 @@ msgstr "Ø Sieg-Marge"
msgid "Award Badge"
msgstr "Abzeichen verleihen"
-#: src/features/admin/components/admin-page.tsx:36
+#: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges"
msgstr "Abzeichen verleihen"
@@ -554,6 +554,18 @@ msgstr "Zurück"
msgid "Badge awarded to {playerName}"
msgstr "Abzeichen an {playerName} verliehen"
+#: src/features/admin/components/admin-page.tsx:86
+msgid "Badge recalculation failed. Check the server logs."
+msgstr "Abzeichen-Neuberechnung fehlgeschlagen. Prüfe die Server-Logs."
+
+#: src/features/admin/components/admin-page.tsx:39
+msgid "Badge recalculation is already running"
+msgstr "Abzeichen-Neuberechnung läuft bereits"
+
+#: src/features/admin/components/admin-page.tsx:38
+msgid "Badge recalculation started in the background"
+msgstr "Abzeichen-Neuberechnung im Hintergrund gestartet"
+
#: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type"
msgstr "Abzeichentyp"
@@ -581,7 +593,7 @@ msgid "Bracket"
msgstr "Turnierbaum"
#: src/app/routes/_authed/admin/preview.tsx:13
-#: src/features/admin/components/admin-page.tsx:59
+#: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview"
msgstr "Turnierbaum-Vorschau"
@@ -1060,6 +1072,10 @@ msgstr "Aktives Gerät konnte nicht festgelegt werden"
msgid "Failed to set volume"
msgstr "Lautstärke konnte nicht festgelegt werden"
+#: src/features/admin/components/admin-page.tsx:42
+msgid "Failed to start badge recalculation"
+msgstr "Abzeichen-Neuberechnung konnte nicht gestartet werden"
+
#: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team"
msgstr "Team konnte nicht aktualisiert werden"
@@ -1322,6 +1338,11 @@ msgstr "Der Nachname muss 2-20 Zeichen lang sein und darf nur Buchstaben enthalt
msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "Der Nachname muss 3-20 Zeichen lang sein und darf nur Buchstaben enthalten"
+#. placeholder {0}: migrationStatus.result.totalBadgesEarned
+#: src/features/admin/components/admin-page.tsx:74
+msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
+msgstr "Letzte Neuberechnung: {0, plural, one {# Abzeichen verdient} other {# Abzeichen verdient}}"
+
#: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light"
msgstr "Hell"
@@ -1410,7 +1431,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Team-Songs/Logos verwalten"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
-#: src/features/admin/components/admin-page.tsx:31
+#: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments"
msgstr "Turniere verwalten"
@@ -1489,7 +1510,7 @@ msgstr "Matchup-Details"
msgid "Me"
msgstr "Ich"
-#: src/features/admin/components/admin-page.tsx:41
+#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress"
msgstr "Abzeichen-Fortschritt migrieren"
@@ -1729,7 +1750,7 @@ msgstr "Sobald du angemeldet bist, kannst du andere Free Agents und ihre Telefon
msgid "One time code"
msgstr "Einmalcode"
-#: src/features/admin/components/admin-page.tsx:52
+#: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase"
msgstr "Pocketbase öffnen"
@@ -1927,6 +1948,12 @@ msgstr "Neu würfeln"
msgid "Reactions"
msgstr "Reaktionen"
+#. placeholder {0}: migrationStatus.playersProcessed
+#. placeholder {1}: migrationStatus.totalPlayers
+#: src/features/admin/components/admin-page.tsx:67
+msgid "Recalculating badges in the background ({0}/{1})"
+msgstr "Abzeichen werden im Hintergrund neu berechnet ({0}/{1})"
+
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished"
msgstr "Kürzlich beendet"
diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po
index 6fa5db5..706241e 100644
--- a/src/locales/en/messages.po
+++ b/src/locales/en/messages.po
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Active device: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21
-#: src/features/admin/components/admin-page.tsx:47
+#: src/features/admin/components/admin-page.tsx:90
msgid "Activities"
msgstr "Activities"
@@ -520,7 +520,7 @@ msgstr "Avg Win Margin"
msgid "Award Badge"
msgstr "Award Badge"
-#: src/features/admin/components/admin-page.tsx:36
+#: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges"
msgstr "Award Badges"
@@ -554,6 +554,18 @@ msgstr "Back"
msgid "Badge awarded to {playerName}"
msgstr "Badge awarded to {playerName}"
+#: src/features/admin/components/admin-page.tsx:86
+msgid "Badge recalculation failed. Check the server logs."
+msgstr "Badge recalculation failed. Check the server logs."
+
+#: src/features/admin/components/admin-page.tsx:39
+msgid "Badge recalculation is already running"
+msgstr "Badge recalculation is already running"
+
+#: src/features/admin/components/admin-page.tsx:38
+msgid "Badge recalculation started in the background"
+msgstr "Badge recalculation started in the background"
+
#: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type"
msgstr "Badge Type"
@@ -581,7 +593,7 @@ msgid "Bracket"
msgstr "Bracket"
#: src/app/routes/_authed/admin/preview.tsx:13
-#: src/features/admin/components/admin-page.tsx:59
+#: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview"
msgstr "Bracket Preview"
@@ -1060,6 +1072,10 @@ msgstr "Failed to set active device"
msgid "Failed to set volume"
msgstr "Failed to set volume"
+#: src/features/admin/components/admin-page.tsx:42
+msgid "Failed to start badge recalculation"
+msgstr "Failed to start badge recalculation"
+
#: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team"
msgstr "Failed to update team"
@@ -1322,6 +1338,11 @@ msgstr "Last name must be 2-20 characters long and contain only letters"
msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "Last name must be 3-20 characters long and contain only letters"
+#. placeholder {0}: migrationStatus.result.totalBadgesEarned
+#: src/features/admin/components/admin-page.tsx:74
+msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
+msgstr "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
+
#: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light"
msgstr "Light"
@@ -1410,7 +1431,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Manage Team Songs/Logos"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
-#: src/features/admin/components/admin-page.tsx:31
+#: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments"
msgstr "Manage Tournaments"
@@ -1489,7 +1510,7 @@ msgstr "Matchup details"
msgid "Me"
msgstr "Me"
-#: src/features/admin/components/admin-page.tsx:41
+#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress"
msgstr "Migrate Badge Progress"
@@ -1729,7 +1750,7 @@ msgstr "Once enrolled, you can view other free agents and their phone number in
msgid "One time code"
msgstr "One time code"
-#: src/features/admin/components/admin-page.tsx:52
+#: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase"
msgstr "Open Pocketbase"
@@ -1927,6 +1948,12 @@ msgstr "Re-roll"
msgid "Reactions"
msgstr "Reactions"
+#. placeholder {0}: migrationStatus.playersProcessed
+#. placeholder {1}: migrationStatus.totalPlayers
+#: src/features/admin/components/admin-page.tsx:67
+msgid "Recalculating badges in the background ({0}/{1})"
+msgstr "Recalculating badges in the background ({0}/{1})"
+
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished"
msgstr "Recently finished"
diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po
index 92cc0f4..660003e 100644
--- a/src/locales/es/messages.po
+++ b/src/locales/es/messages.po
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Dispositivo activo: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21
-#: src/features/admin/components/admin-page.tsx:47
+#: src/features/admin/components/admin-page.tsx:90
msgid "Activities"
msgstr "Actividades"
@@ -520,7 +520,7 @@ msgstr "Prom. Margen de Victoria"
msgid "Award Badge"
msgstr "Otorgar insignia"
-#: src/features/admin/components/admin-page.tsx:36
+#: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges"
msgstr "Otorgar insignias"
@@ -554,6 +554,18 @@ msgstr "Atrás"
msgid "Badge awarded to {playerName}"
msgstr "Insignia otorgada a {playerName}"
+#: src/features/admin/components/admin-page.tsx:86
+msgid "Badge recalculation failed. Check the server logs."
+msgstr "Falló el recálculo de insignias. Revisa los registros del servidor."
+
+#: src/features/admin/components/admin-page.tsx:39
+msgid "Badge recalculation is already running"
+msgstr "El recálculo de insignias ya está en curso"
+
+#: src/features/admin/components/admin-page.tsx:38
+msgid "Badge recalculation started in the background"
+msgstr "Recálculo de insignias iniciado en segundo plano"
+
#: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type"
msgstr "Tipo de insignia"
@@ -581,7 +593,7 @@ msgid "Bracket"
msgstr "Bracket"
#: src/app/routes/_authed/admin/preview.tsx:13
-#: src/features/admin/components/admin-page.tsx:59
+#: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview"
msgstr "Vista previa del bracket"
@@ -1060,6 +1072,10 @@ msgstr "No se pudo establecer el dispositivo activo"
msgid "Failed to set volume"
msgstr "No se pudo ajustar el volumen"
+#: src/features/admin/components/admin-page.tsx:42
+msgid "Failed to start badge recalculation"
+msgstr "No se pudo iniciar el recálculo de insignias"
+
#: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team"
msgstr "No se pudo actualizar el equipo"
@@ -1322,6 +1338,11 @@ msgstr "El apellido debe tener entre 2 y 20 caracteres y contener solo letras"
msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "El apellido debe tener entre 3 y 20 caracteres y contener solo letras"
+#. placeholder {0}: migrationStatus.result.totalBadgesEarned
+#: src/features/admin/components/admin-page.tsx:74
+msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
+msgstr "Último recálculo: {0, plural, one {# insignia obtenida} other {# insignias obtenidas}}"
+
#: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light"
msgstr "Claro"
@@ -1410,7 +1431,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Administrar canciones/logos del equipo"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
-#: src/features/admin/components/admin-page.tsx:31
+#: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments"
msgstr "Administrar torneos"
@@ -1489,7 +1510,7 @@ msgstr "Detalles del enfrentamiento"
msgid "Me"
msgstr "Yo"
-#: src/features/admin/components/admin-page.tsx:41
+#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress"
msgstr "Migrar progreso de insignias"
@@ -1729,7 +1750,7 @@ msgstr "Una vez inscrito, puedes ver a otros agentes libres y su número de tel
msgid "One time code"
msgstr "Código de un solo uso"
-#: src/features/admin/components/admin-page.tsx:52
+#: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase"
msgstr "Abrir Pocketbase"
@@ -1927,6 +1948,12 @@ msgstr "Volver a sortear"
msgid "Reactions"
msgstr "Reacciones"
+#. placeholder {0}: migrationStatus.playersProcessed
+#. placeholder {1}: migrationStatus.totalPlayers
+#: src/features/admin/components/admin-page.tsx:67
+msgid "Recalculating badges in the background ({0}/{1})"
+msgstr "Recalculando insignias en segundo plano ({0}/{1})"
+
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished"
msgstr "Recién terminados"
diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po
index 64466e7..ca7c8f6 100644
--- a/src/locales/ja/messages.po
+++ b/src/locales/ja/messages.po
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "アクティブなデバイス: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21
-#: src/features/admin/components/admin-page.tsx:47
+#: src/features/admin/components/admin-page.tsx:90
msgid "Activities"
msgstr "アクティビティ"
@@ -520,7 +520,7 @@ msgstr "平均勝差"
msgid "Award Badge"
msgstr "バッジを付与"
-#: src/features/admin/components/admin-page.tsx:36
+#: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges"
msgstr "バッジを付与"
@@ -554,6 +554,18 @@ msgstr "戻る"
msgid "Badge awarded to {playerName}"
msgstr "{playerName}にバッジを付与しました"
+#: src/features/admin/components/admin-page.tsx:86
+msgid "Badge recalculation failed. Check the server logs."
+msgstr "バッジの再計算に失敗しました。サーバーログを確認してください。"
+
+#: src/features/admin/components/admin-page.tsx:39
+msgid "Badge recalculation is already running"
+msgstr "バッジの再計算はすでに実行中です"
+
+#: src/features/admin/components/admin-page.tsx:38
+msgid "Badge recalculation started in the background"
+msgstr "バックグラウンドでバッジの再計算を開始しました"
+
#: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type"
msgstr "バッジの種類"
@@ -581,7 +593,7 @@ msgid "Bracket"
msgstr "ブラケット"
#: src/app/routes/_authed/admin/preview.tsx:13
-#: src/features/admin/components/admin-page.tsx:59
+#: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview"
msgstr "ブラケットプレビュー"
@@ -1060,6 +1072,10 @@ msgstr "アクティブなデバイスの設定に失敗しました"
msgid "Failed to set volume"
msgstr "音量の設定に失敗しました"
+#: src/features/admin/components/admin-page.tsx:42
+msgid "Failed to start badge recalculation"
+msgstr "バッジの再計算を開始できませんでした"
+
#: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team"
msgstr "チームの更新に失敗しました"
@@ -1322,6 +1338,11 @@ msgstr "姓は2〜20文字の英字のみで入力してください"
msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "姓は3〜20文字の英字のみで入力してください"
+#. placeholder {0}: migrationStatus.result.totalBadgesEarned
+#: src/features/admin/components/admin-page.tsx:74
+msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
+msgstr "前回の再計算:{0, plural, one {# 個のバッジを獲得} other {# 個のバッジを獲得}}"
+
#: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light"
msgstr "ライト"
@@ -1410,7 +1431,7 @@ msgid "Manage Team Songs/Logos"
msgstr "チームの曲・ロゴを管理"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17
-#: src/features/admin/components/admin-page.tsx:31
+#: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments"
msgstr "トーナメント管理"
@@ -1489,7 +1510,7 @@ msgstr "対戦カードの詳細"
msgid "Me"
msgstr "自分"
-#: src/features/admin/components/admin-page.tsx:41
+#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress"
msgstr "バッジ進捗を移行"
@@ -1729,7 +1750,7 @@ msgstr "登録が完了すると、他のフリーエージェントとその電
msgid "One time code"
msgstr "ワンタイムコード"
-#: src/features/admin/components/admin-page.tsx:52
+#: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase"
msgstr "Pocketbaseを開く"
@@ -1927,6 +1948,12 @@ msgstr "再抽選"
msgid "Reactions"
msgstr "リアクション"
+#. placeholder {0}: migrationStatus.playersProcessed
+#. placeholder {1}: migrationStatus.totalPlayers
+#: src/features/admin/components/admin-page.tsx:67
+msgid "Recalculating badges in the background ({0}/{1})"
+msgstr "バックグラウンドでバッジを再計算中({0}/{1})"
+
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished"
msgstr "最近終了した試合"