Initial commit
This commit is contained in:
640
resources/js/components/ResultsTables.tsx
Normal file
640
resources/js/components/ResultsTables.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
export default function ResultsTables({
|
||||
roundId,
|
||||
contestId = null,
|
||||
filter = "ALL",
|
||||
mode = "claimed",
|
||||
showResultTypeLabel = true,
|
||||
onResultTypeChange,
|
||||
refreshKey = null,
|
||||
evaluationRunId = null,
|
||||
}: 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();
|
||||
|
||||
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>,
|
||||
<TableColumn key="locator" className="whitespace-nowrap">{t("results_table_locator") ?? "Lokátor"}</TableColumn>,
|
||||
<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>,
|
||||
<TableColumn key="score_total" className="whitespace-nowrap">{t("results_table_score_total") ?? "Body celkem"}</TableColumn>,
|
||||
<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>,
|
||||
<TableColumn key="odx" className="whitespace-nowrap">{t("results_table_odx") ?? "ODX"}</TableColumn>,
|
||||
<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)}
|
||||
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>,
|
||||
<TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>,
|
||||
<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>,
|
||||
<TableCell key="score_total" className="whitespace-nowrap">{formatScore(item, mode)}</TableCell>,
|
||||
<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>,
|
||||
<TableCell key="odx" className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>,
|
||||
<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") {
|
||||
const includeCalculated = mode === "final";
|
||||
const header = [
|
||||
"Poradi",
|
||||
"Značka v závodě",
|
||||
"Lokátor",
|
||||
"Kategorie",
|
||||
"Pásmo",
|
||||
"Výkon [W]",
|
||||
"Výkonová kat.",
|
||||
"Body celkem",
|
||||
"Deklarované body",
|
||||
"Počet QSO",
|
||||
...(includeCalculated ? ["Vyřazeno QSO", "Vyřazeno bodů", "Unique QSO"] : []),
|
||||
"Body / QSO",
|
||||
"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 = [
|
||||
r[rankField] ?? "",
|
||||
r.log?.pcall ?? "",
|
||||
r.log?.pwwlo ?? "",
|
||||
r.category?.name ?? "",
|
||||
r.band?.name ?? "",
|
||||
r.log?.power_watt ?? "",
|
||||
r.power_category?.name ?? "",
|
||||
score ?? "",
|
||||
r.claimed_score ?? "",
|
||||
qsoCount ?? "",
|
||||
ratio === "—" ? "" : ratio,
|
||||
r.log?.codxc ?? "",
|
||||
r.log?.sante ?? "",
|
||||
r.log?.santh ?? "",
|
||||
];
|
||||
if (includeCalculated) {
|
||||
base.splice(
|
||||
10,
|
||||
0,
|
||||
discardedQso === "—" ? "" : discardedQso,
|
||||
r.discarded_points ?? "",
|
||||
r.unique_qso_count ?? ""
|
||||
);
|
||||
}
|
||||
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`);
|
||||
}
|
||||
Reference in New Issue
Block a user