667 lines
26 KiB
TypeScript
667 lines
26 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
|
import axios from "axios";
|
|
import { Card, CardBody, CardHeader, Divider, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, Spinner, Tooltip } from "@heroui/react";
|
|
import { saveAs } from "file-saver";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
type LogResultItem = {
|
|
id: number;
|
|
log_id: number;
|
|
band_id?: number | null;
|
|
category_id?: number | null;
|
|
power_category_id?: number | null;
|
|
sixhr_category?: boolean | null;
|
|
claimed_qso_count?: number | null;
|
|
claimed_score?: number | null;
|
|
total_qso_count?: number | null;
|
|
discarded_qso_count?: number | null;
|
|
discarded_qso_percent?: number | null;
|
|
discarded_points?: number | null;
|
|
unique_qso_count?: number | null;
|
|
official_score?: number | null;
|
|
valid_qso_count?: number | null;
|
|
score_per_qso?: number | null;
|
|
rank_overall?: number | null;
|
|
rank_in_category?: number | null;
|
|
rank_overall_ok?: number | null;
|
|
rank_in_category_ok?: number | null;
|
|
status?: string | null;
|
|
status_reason?: string | null;
|
|
log?: {
|
|
id: number;
|
|
pcall?: string | null;
|
|
sixhr_category?: boolean | null;
|
|
pwwlo?: string | null;
|
|
power_watt?: number | null;
|
|
codxc?: string | null;
|
|
sante?: string | null;
|
|
santh?: string | null;
|
|
} | null;
|
|
band?: { id: number; name?: string | null; order?: number | null } | null;
|
|
category?: { id: number; name?: string | null; order?: number | null } | null;
|
|
power_category?: { id: number; name?: string | null; order?: number | null } | null;
|
|
evaluation_run?: { id: number; result_type?: string | null; rule_set?: { sixhr_ranking_mode?: string | null } | null } | null;
|
|
};
|
|
|
|
type ApiResponse<T> = {
|
|
data: T[];
|
|
};
|
|
|
|
type ResultsTablesProps = {
|
|
roundId: number | null;
|
|
contestId?: number | null;
|
|
filter?: "ALL" | "OK";
|
|
mode?: "claimed" | "final";
|
|
showResultTypeLabel?: boolean;
|
|
onResultTypeChange?: (label: string | null, className: string, resultType: string | null) => void;
|
|
refreshKey?: string | number | null;
|
|
evaluationRunId?: number | null;
|
|
isFinalPublished?: boolean;
|
|
isAdmin?: boolean;
|
|
};
|
|
|
|
|
|
export default function ResultsTables({
|
|
roundId,
|
|
contestId = null,
|
|
filter = "ALL",
|
|
mode = "claimed",
|
|
showResultTypeLabel = true,
|
|
onResultTypeChange,
|
|
refreshKey = null,
|
|
evaluationRunId = null,
|
|
isFinalPublished = false,
|
|
isAdmin = false,
|
|
}: ResultsTablesProps) {
|
|
const [items, setItems] = useState<LogResultItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [overrideReasons, setOverrideReasons] = useState<Record<number, string | null>>({});
|
|
const [overrideFlags, setOverrideFlags] = useState<Record<number, {
|
|
forced_log_status?: string | null;
|
|
forced_band_id?: number | null;
|
|
forced_category_id?: number | null;
|
|
forced_power_category_id?: number | null;
|
|
forced_power_w?: number | null;
|
|
forced_sixhr_category?: boolean | null;
|
|
}>>({});
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const showScoreTotal = mode === "final";
|
|
const showLocatorOdx = mode === "final" || isAdmin || isFinalPublished;
|
|
|
|
useEffect(() => {
|
|
if (!roundId) return;
|
|
let active = true;
|
|
|
|
(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const params: Record<string, unknown> = {
|
|
per_page: 5000,
|
|
only_ok: filter === "OK",
|
|
};
|
|
if (evaluationRunId) {
|
|
params.evaluation_run_id = evaluationRunId;
|
|
}
|
|
if (mode === "claimed") {
|
|
params.round_id = roundId;
|
|
params.status = "CLAIMED";
|
|
} else if (!evaluationRunId) {
|
|
params.round_id = roundId;
|
|
params.result_type = "AUTO";
|
|
}
|
|
const res = await axios.get<ApiResponse<LogResultItem>>("/api/log-results", {
|
|
params,
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!active) return;
|
|
setItems(res.data.data ?? []);
|
|
} catch (e: any) {
|
|
if (!active) return;
|
|
const msg =
|
|
e?.response?.data?.message ||
|
|
(mode === "final"
|
|
? "Nepodařilo se načíst finální výsledky."
|
|
: "Nepodařilo se načíst deklarované výsledky.");
|
|
setError(msg);
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [roundId, filter, mode, refreshKey, evaluationRunId]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "final") {
|
|
setOverrideReasons({});
|
|
return;
|
|
}
|
|
const evaluationRunId = items[0]?.evaluation_run?.id ?? null;
|
|
if (!evaluationRunId) {
|
|
setOverrideReasons({});
|
|
return;
|
|
}
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const res = await axios.get<ApiResponse<{
|
|
log_id: number;
|
|
reason?: string | null;
|
|
forced_log_status?: string | null;
|
|
forced_band_id?: number | null;
|
|
forced_category_id?: number | null;
|
|
forced_power_category_id?: number | null;
|
|
forced_power_w?: number | null;
|
|
forced_sixhr_category?: boolean | null;
|
|
}>>("/api/log-overrides", {
|
|
params: {
|
|
evaluation_run_id: evaluationRunId,
|
|
per_page: 5000,
|
|
},
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
});
|
|
if (!active) return;
|
|
const map: Record<number, string | null> = {};
|
|
const flagMap: Record<number, {
|
|
forced_log_status?: string | null;
|
|
forced_band_id?: number | null;
|
|
forced_category_id?: number | null;
|
|
forced_power_category_id?: number | null;
|
|
forced_power_w?: number | null;
|
|
forced_sixhr_category?: boolean | null;
|
|
}> = {};
|
|
(res.data.data ?? []).forEach((item) => {
|
|
map[item.log_id] = item.reason ?? null;
|
|
flagMap[item.log_id] = {
|
|
forced_log_status: item.forced_log_status ?? null,
|
|
forced_band_id: item.forced_band_id ?? null,
|
|
forced_category_id: item.forced_category_id ?? null,
|
|
forced_power_category_id: item.forced_power_category_id ?? null,
|
|
forced_power_w: item.forced_power_w ?? null,
|
|
forced_sixhr_category: item.forced_sixhr_category ?? null,
|
|
};
|
|
});
|
|
setOverrideReasons(map);
|
|
setOverrideFlags(flagMap);
|
|
} catch {
|
|
if (!active) return;
|
|
setOverrideReasons({});
|
|
setOverrideFlags({});
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [mode, items]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const isSixhr = (r: LogResultItem) => (r.sixhr_category ?? r.log?.sixhr_category) === true;
|
|
const sixhrRankingMode =
|
|
mode === "final"
|
|
? (items[0]?.evaluation_run?.rule_set?.sixhr_ranking_mode ?? "IARU")
|
|
: "CRK";
|
|
const sixh = items.filter((r) => isSixhr(r));
|
|
const standard = items.filter((r) => !isSixhr(r));
|
|
const powerEligible = standard.filter(
|
|
(r) => !isCheckCategory(r) && !isPowerA(r) && r.power_category_id && r.rank_in_category !== null
|
|
);
|
|
|
|
const groupOverall = (list: LogResultItem[]) =>
|
|
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}`);
|
|
const groupSixhOverall = (list: LogResultItem[]) =>
|
|
groupBy(list, (r) =>
|
|
sixhrRankingMode === "IARU"
|
|
? `${r.band_id ?? "null"}|ALL`
|
|
: `${r.band_id ?? "null"}|${r.category_id ?? "null"}`
|
|
);
|
|
const groupPower = (list: LogResultItem[]) =>
|
|
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}|${r.power_category_id ?? "null"}`);
|
|
|
|
return {
|
|
sixhOverall: groupSixhOverall(sixh),
|
|
standardOverall: groupOverall(standard),
|
|
standardPower: groupPower(powerEligible),
|
|
sixhrRankingMode,
|
|
};
|
|
}, [items, mode]);
|
|
|
|
const overrideVersion = useMemo(() => Object.keys(overrideReasons).sort().join(","), [overrideReasons]);
|
|
const showCalculatedColumns = mode === "final";
|
|
|
|
const renderGroup = (
|
|
group: GroupedResults,
|
|
title: string,
|
|
rankField: RankField,
|
|
includePowerInHeading = true,
|
|
includeCategoryInHeading = true
|
|
) => {
|
|
if (group.length === 0) return [];
|
|
return group.map(({ key, items }) => {
|
|
const sample = items[0];
|
|
const [bandName, categoryName, powerName] = resolveNames(sample);
|
|
const headingParts = [bandName, includeCategoryInHeading ? categoryName : null, title];
|
|
if (includePowerInHeading && !isCheckCategory(sample)) {
|
|
headingParts.push(powerName);
|
|
}
|
|
const heading = headingParts.filter(Boolean).join(" ");
|
|
const sorted = sortResults(items, rankField, mode);
|
|
const headerColumns = [
|
|
<TableColumn key="rank" className="whitespace-nowrap">{t("results_table_rank") ?? "Pořadí"}</TableColumn>,
|
|
<TableColumn key="callsign" className="whitespace-nowrap">{t("results_table_callsign") ?? "Značka v závodě"}</TableColumn>,
|
|
showLocatorOdx
|
|
? <TableColumn key="locator" className="whitespace-nowrap">{t("results_table_locator") ?? "Lokátor"}</TableColumn>
|
|
: null,
|
|
<TableColumn key="category" className="whitespace-nowrap">{t("results_table_category") ?? "Kategorie"}</TableColumn>,
|
|
<TableColumn key="band" className="whitespace-nowrap">{t("results_table_band") ?? "Pásmo"}</TableColumn>,
|
|
<TableColumn key="power_watt" className="whitespace-nowrap">{t("results_table_power_watt") ?? "Výkon [W]"}</TableColumn>,
|
|
<TableColumn key="power_category" className="whitespace-nowrap">{t("results_table_power_category") ?? "Výkonová kat."}</TableColumn>,
|
|
showScoreTotal
|
|
? <TableColumn key="score_total" className="whitespace-nowrap">{t("results_table_score_total") ?? "Body celkem"}</TableColumn>
|
|
: null,
|
|
<TableColumn key="claimed_score" className="whitespace-nowrap">{t("results_table_claimed_score") ?? "Deklarované body"}</TableColumn>,
|
|
<TableColumn key="qso_count" className="whitespace-nowrap">{t("results_table_qso_count") ?? "Počet QSO"}</TableColumn>,
|
|
showCalculatedColumns
|
|
? (
|
|
<TableColumn key="discarded_qso" className="whitespace-nowrap">
|
|
<Tooltip content={t("results_table_discarded_qso_help") ?? "Počet QSO s is_valid=false."}>
|
|
<span className="cursor-help">{t("results_table_discarded_qso") ?? "Vyřazeno QSO"}</span>
|
|
</Tooltip>
|
|
</TableColumn>
|
|
)
|
|
: null,
|
|
showCalculatedColumns
|
|
? <TableColumn key="discarded_points" className="whitespace-nowrap">{t("results_table_discarded_points") ?? "Vyřazeno bodů"}</TableColumn>
|
|
: null,
|
|
showCalculatedColumns
|
|
? <TableColumn key="unique_qso" className="whitespace-nowrap">{t("results_table_unique_qso") ?? "Unique QSO"}</TableColumn>
|
|
: null,
|
|
<TableColumn key="score_per_qso" className="whitespace-nowrap">{t("results_table_score_per_qso") ?? "Body / QSO"}</TableColumn>,
|
|
showLocatorOdx
|
|
? <TableColumn key="odx" className="whitespace-nowrap">{t("results_table_odx") ?? "ODX"}</TableColumn>
|
|
: null,
|
|
<TableColumn key="antenna" className="whitespace-nowrap">{t("results_table_antenna") ?? "Anténa"}</TableColumn>,
|
|
<TableColumn key="antenna_height" className="whitespace-nowrap">{t("results_table_antenna_height") ?? "Ant. height"}</TableColumn>,
|
|
showCalculatedColumns
|
|
? <TableColumn key="status" className="whitespace-nowrap">{t("results_table_status") ?? "Status"}</TableColumn>
|
|
: null,
|
|
showCalculatedColumns
|
|
? <TableColumn key="override_reason" className="whitespace-nowrap">{t("results_table_override_reason") ?? "Komentář rozhodčího"}</TableColumn>
|
|
: null,
|
|
].filter(Boolean);
|
|
|
|
return (
|
|
<Card key={key} className="mb-4">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between w-full gap-2">
|
|
<span className="text-md font-semibold">{heading}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv(heading, sorted, rankField, mode, showLocatorOdx, showScoreTotal)}
|
|
className="text-xs text-primary-600 hover:text-primary-700 underline"
|
|
>
|
|
CSV
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<Divider />
|
|
<CardBody>
|
|
<Table
|
|
key={`${key}-${overrideVersion}`}
|
|
radius="sm"
|
|
isCompact
|
|
aria-label={heading}
|
|
removeWrapper
|
|
fullWidth
|
|
classNames={{
|
|
th: "py-1 px-1 text-[11px] leading-tight",
|
|
td: "py-0.5 px-1 text-[11px] leading-tight bg-transparent",
|
|
}}
|
|
>
|
|
<TableHeader>{headerColumns}</TableHeader>
|
|
<TableBody emptyContent="No rows to display" items={sorted} className="text-[11px] leading-tight">
|
|
{(item) => {
|
|
const logId = item.log_id ?? item.log?.id;
|
|
const hasOverride =
|
|
logId != null && Object.prototype.hasOwnProperty.call(overrideReasons, logId);
|
|
const callsignClassName = "whitespace-nowrap";
|
|
const rowCells = [
|
|
<TableCell key="rank" className="whitespace-nowrap">{item[rankField] ?? "—"}</TableCell>,
|
|
<TableCell key="callsign" className={callsignClassName}>
|
|
{item.log?.pcall ?? "—"}
|
|
</TableCell>,
|
|
showLocatorOdx
|
|
? <TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>
|
|
: null,
|
|
<TableCell key="category" className="whitespace-nowrap">
|
|
{item.category?.name ?? "—"}
|
|
</TableCell>,
|
|
<TableCell key="band" className="whitespace-nowrap">
|
|
{item.band?.name ?? "—"}
|
|
</TableCell>,
|
|
<TableCell key="power_watt" className="whitespace-nowrap">
|
|
{formatNumber(item.log?.power_watt)}
|
|
</TableCell>,
|
|
<TableCell key="power_category" className="whitespace-nowrap">
|
|
{item.power_category?.name ?? "—"}
|
|
</TableCell>,
|
|
showScoreTotal
|
|
? <TableCell key="score_total" className="whitespace-nowrap">{formatScore(item, mode)}</TableCell>
|
|
: null,
|
|
<TableCell key="claimed_score" className="whitespace-nowrap">{formatNumber(item.claimed_score)}</TableCell>,
|
|
<TableCell key="qso_count" className="whitespace-nowrap">{formatQsoCount(item, mode)}</TableCell>,
|
|
showCalculatedColumns
|
|
? <TableCell key="discarded_qso" className="whitespace-nowrap">{formatDiscardedQso(item)}</TableCell>
|
|
: null,
|
|
showCalculatedColumns
|
|
? <TableCell key="discarded_points" className="whitespace-nowrap">{formatNumber(item.discarded_points)}</TableCell>
|
|
: null,
|
|
showCalculatedColumns
|
|
? <TableCell key="unique_qso" className="whitespace-nowrap">{formatNumber(item.unique_qso_count)}</TableCell>
|
|
: null,
|
|
<TableCell key="score_per_qso" className="whitespace-nowrap">{formatScorePerQso(item.score_per_qso)}</TableCell>,
|
|
showLocatorOdx
|
|
? <TableCell key="odx" className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>
|
|
: null,
|
|
<TableCell key="antenna" className="whitespace-nowrap">{item.log?.sante ?? "—"}</TableCell>,
|
|
<TableCell key="antenna_height" className="whitespace-nowrap">{item.log?.santh ?? "—"}</TableCell>,
|
|
showCalculatedColumns
|
|
? <TableCell key="status" className="whitespace-nowrap">{item.status ?? "—"}</TableCell>
|
|
: null,
|
|
showCalculatedColumns
|
|
? (
|
|
<TableCell key="override_reason" className="whitespace-nowrap">
|
|
{(() => {
|
|
const overrideReason = hasOverride && logId != null ? overrideReasons[logId] : null;
|
|
const statusReason = item.status_reason ?? null;
|
|
if (overrideReason && statusReason) {
|
|
return `${overrideReason}; ${statusReason}`;
|
|
}
|
|
return overrideReason || statusReason || "—";
|
|
})()}
|
|
</TableCell>
|
|
)
|
|
: null,
|
|
].filter(Boolean);
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
onClick={() => {
|
|
if (contestId && roundId) {
|
|
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.log_id}`, {
|
|
state: { from: `${location.pathname}${location.search}` },
|
|
});
|
|
}
|
|
}}
|
|
className={contestId && roundId ? "cursor-pointer hover:bg-default-100" : undefined}
|
|
>
|
|
{rowCells}
|
|
</TableRow>
|
|
);
|
|
}}
|
|
</TableBody>
|
|
</Table>
|
|
</CardBody>
|
|
</Card>
|
|
);
|
|
});
|
|
};
|
|
|
|
const rankOverallField: RankField = filter === "OK" ? "rank_overall_ok" : "rank_overall";
|
|
const rankInCategoryField: RankField = filter === "OK" ? "rank_in_category_ok" : "rank_in_category";
|
|
|
|
const renderOverallWithPowers = (
|
|
overalls: GroupedResults,
|
|
powers: GroupedResults,
|
|
title: string,
|
|
includeCategoryInHeading = true
|
|
) => {
|
|
if (overalls.length === 0) return null;
|
|
|
|
const powerIndex = powers.reduce<Record<string, GroupedResults>>((acc, group) => {
|
|
const [bandId, categoryId] = group.key.split("|");
|
|
const key = `${bandId}|${categoryId}`;
|
|
acc[key] = acc[key] || [];
|
|
acc[key].push(group);
|
|
return acc;
|
|
}, {});
|
|
|
|
return sortGroups(overalls).flatMap(({ key, items }) => {
|
|
const [bandId, categoryId] = key.split("|");
|
|
const powerGroups = sortGroups(powerIndex[`${bandId}|${categoryId}`] ?? []);
|
|
|
|
return [
|
|
...renderGroup([{ key, items }], title, rankOverallField, false, includeCategoryInHeading),
|
|
...renderGroup(powerGroups, "", rankInCategoryField, true),
|
|
];
|
|
});
|
|
};
|
|
|
|
if (!roundId) return null;
|
|
|
|
const resultType = items[0]?.evaluation_run?.result_type ?? null;
|
|
const resultTypeLabel =
|
|
resultType === "FINAL"
|
|
? (t("results_type_final") as string) || "Finální výsledky"
|
|
: resultType === "PRELIMINARY"
|
|
? (t("results_type_preliminary") as string) || "Předběžné výsledky"
|
|
: resultType === "TEST"
|
|
? (t("results_type_test") as string) || "Testovací výsledky"
|
|
: null;
|
|
const resultTypeClass =
|
|
resultType === "FINAL"
|
|
? "bg-success-200 text-success-900"
|
|
: resultType === "PRELIMINARY"
|
|
? "bg-success-100 text-success-900"
|
|
: resultType === "TEST"
|
|
? "bg-warning-200 text-warning-900"
|
|
: "";
|
|
useEffect(() => {
|
|
if (!onResultTypeChange) return;
|
|
onResultTypeChange(resultTypeLabel, resultTypeClass, resultType);
|
|
}, [onResultTypeChange, resultTypeLabel, resultTypeClass, resultType]);
|
|
if (loading) {
|
|
const label = mode === "final" ? "Načítám finální výsledky…" : "Načítám deklarované výsledky…";
|
|
return <div className="flex items-center gap-2 text-sm text-foreground-500"><Spinner size="sm" /> {label}</div>;
|
|
}
|
|
if (error) return <div className="text-sm text-red-600">{error}</div>;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{mode === "final" && showResultTypeLabel && resultTypeLabel && (
|
|
<div
|
|
className={[
|
|
"inline-flex items-center rounded px-2 py-1 text-xs font-semibold",
|
|
resultTypeClass,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
{resultTypeLabel}
|
|
</div>
|
|
)}
|
|
{renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "")}
|
|
{renderOverallWithPowers(grouped.sixhOverall, [], "6H", grouped.sixhrRankingMode !== "IARU")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type GroupedResults = Array<{ key: string; items: LogResultItem[] }>;
|
|
|
|
function groupBy(items: LogResultItem[], getKey: (r: LogResultItem) => string): GroupedResults {
|
|
const map = new Map<string, LogResultItem[]>();
|
|
items.forEach((item) => {
|
|
const key = getKey(item);
|
|
if (!map.has(key)) map.set(key, []);
|
|
map.get(key)!.push(item);
|
|
});
|
|
return Array.from(map.entries()).map(([key, list]) => ({ key, items: list }));
|
|
}
|
|
|
|
function sortGroups(groups: GroupedResults): GroupedResults {
|
|
return [...groups].sort((a, b) => {
|
|
const aItem = a.items[0];
|
|
const bItem = b.items[0];
|
|
const bandOrder = (aItem?.band?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.band?.order ?? Number.MAX_SAFE_INTEGER);
|
|
if (bandOrder !== 0) return bandOrder;
|
|
const categoryOrder = (aItem?.category?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.category?.order ?? Number.MAX_SAFE_INTEGER);
|
|
if (categoryOrder !== 0) return categoryOrder;
|
|
const powerOrder =
|
|
(aItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER) -
|
|
(bItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER);
|
|
if (powerOrder !== 0) return powerOrder;
|
|
const bandName = aItem?.band?.name ?? "";
|
|
const bandNameB = bItem?.band?.name ?? "";
|
|
if (bandName !== bandNameB) return bandName.localeCompare(bandNameB);
|
|
const categoryName = aItem?.category?.name ?? "";
|
|
const categoryNameB = bItem?.category?.name ?? "";
|
|
if (categoryName !== categoryNameB) return categoryName.localeCompare(categoryNameB);
|
|
const powerName = aItem?.power_category?.name ?? "";
|
|
const powerNameB = bItem?.power_category?.name ?? "";
|
|
return powerName.localeCompare(powerNameB);
|
|
});
|
|
}
|
|
|
|
function resolveNames(item: LogResultItem): [string | null, string | null, string | null] {
|
|
const band = item.band?.name ?? null;
|
|
const category = item.category?.name ?? null;
|
|
const power = item.power_category?.name ?? null;
|
|
return [band, category, power];
|
|
}
|
|
|
|
type RankField = "rank_overall" | "rank_in_category" | "rank_overall_ok" | "rank_in_category_ok";
|
|
|
|
function sortResults(items: LogResultItem[], rankField: RankField, mode: "claimed" | "final") {
|
|
return [...items].sort((a, b) => {
|
|
const ra = a[rankField] ?? Number.MAX_SAFE_INTEGER;
|
|
const rb = b[rankField] ?? Number.MAX_SAFE_INTEGER;
|
|
if (ra !== rb) return ra - rb;
|
|
const sa = getScore(a, mode) ?? 0;
|
|
const sb = getScore(b, mode) ?? 0;
|
|
if (sa !== sb) return sb - sa;
|
|
const qa = getQsoCount(a, mode) ?? 0;
|
|
const qb = getQsoCount(b, mode) ?? 0;
|
|
return qb - qa;
|
|
});
|
|
}
|
|
|
|
function formatNumber(value: number | null | undefined) {
|
|
if (value === null || value === undefined) return "—";
|
|
if (Number.isInteger(value)) return value.toString();
|
|
return value.toFixed(1);
|
|
}
|
|
|
|
function formatDiscardedQso(item: LogResultItem) {
|
|
const count = item.discarded_qso_count;
|
|
if (count === null || count === undefined) return "—";
|
|
const percent = item.discarded_qso_percent;
|
|
if (percent === null || percent === undefined) return `${count}`;
|
|
return `${count} (${percent.toFixed(2)}%)`;
|
|
}
|
|
|
|
function formatScorePerQso(value?: number | null) {
|
|
if (value === null || value === undefined) return "—";
|
|
return value.toFixed(2);
|
|
}
|
|
|
|
function getScore(item: LogResultItem, mode: "claimed" | "final") {
|
|
return mode === "final" ? item.official_score ?? null : item.claimed_score ?? null;
|
|
}
|
|
|
|
function getQsoCount(item: LogResultItem, mode: "claimed" | "final") {
|
|
return mode === "final" ? item.valid_qso_count ?? null : item.claimed_qso_count ?? null;
|
|
}
|
|
|
|
function formatScore(item: LogResultItem, mode: "claimed" | "final") {
|
|
return getScore(item, mode) ?? "—";
|
|
}
|
|
|
|
function formatQsoCount(item: LogResultItem, mode: "claimed" | "final") {
|
|
return getQsoCount(item, mode) ?? "—";
|
|
}
|
|
|
|
function isCheckCategory(item: LogResultItem) {
|
|
const name = item.category?.name?.toLowerCase() ?? "";
|
|
return name.includes("check");
|
|
}
|
|
|
|
function isPowerA(item: LogResultItem) {
|
|
const name = item.power_category?.name?.toLowerCase() ?? "";
|
|
return name === "a";
|
|
}
|
|
|
|
function exportCsv(
|
|
title: string,
|
|
rows: LogResultItem[],
|
|
rankField: RankField,
|
|
mode: "claimed" | "final",
|
|
showLocatorOdx: boolean,
|
|
showScoreTotal: boolean
|
|
) {
|
|
const includeCalculated = mode === "final";
|
|
const header = [
|
|
"Poradi",
|
|
"Značka v závodě",
|
|
...(showLocatorOdx ? ["Lokátor"] : []),
|
|
"Kategorie",
|
|
"Pásmo",
|
|
"Výkon [W]",
|
|
"Výkonová kat.",
|
|
...(showScoreTotal ? ["Body celkem"] : []),
|
|
"Deklarované body",
|
|
"Počet QSO",
|
|
...(includeCalculated ? ["Vyřazeno QSO", "Vyřazeno bodů", "Unique QSO"] : []),
|
|
"Body / QSO",
|
|
...(showLocatorOdx ? ["ODX"] : []),
|
|
"Anténa",
|
|
"Ant. height",
|
|
];
|
|
|
|
const lines = rows.map((r) => {
|
|
const score = getScore(r, mode);
|
|
const qsoCount = getQsoCount(r, mode);
|
|
const ratio = formatScorePerQso(r.score_per_qso);
|
|
const discardedQso = formatDiscardedQso(r);
|
|
const base: Array<string | number> = [];
|
|
base.push(r[rankField] ?? "");
|
|
base.push(r.log?.pcall ?? "");
|
|
if (showLocatorOdx) {
|
|
base.push(r.log?.pwwlo ?? "");
|
|
}
|
|
base.push(r.category?.name ?? "");
|
|
base.push(r.band?.name ?? "");
|
|
base.push(r.log?.power_watt ?? "");
|
|
base.push(r.power_category?.name ?? "");
|
|
if (showScoreTotal) {
|
|
base.push(score ?? "");
|
|
}
|
|
base.push(r.claimed_score ?? "");
|
|
base.push(qsoCount ?? "");
|
|
if (includeCalculated) {
|
|
base.push(discardedQso === "—" ? "" : discardedQso);
|
|
base.push(r.discarded_points ?? "");
|
|
base.push(r.unique_qso_count ?? "");
|
|
}
|
|
base.push(ratio === "—" ? "" : ratio);
|
|
if (showLocatorOdx) {
|
|
base.push(r.log?.codxc ?? "");
|
|
}
|
|
base.push(r.log?.sante ?? "");
|
|
base.push(r.log?.santh ?? "");
|
|
return base.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",");
|
|
});
|
|
|
|
const csv = [header.join(","), ...lines].join("\n");
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
|
const safeTitle = title && title.trim() !== "" ? title : "results";
|
|
saveAs(blob, `${safeTitle.replace(/\s+/g, "_")}.csv`);
|
|
}
|