Use usable recent DEB training rankings
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
|
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
|
||||||
|
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||||
|
|
||||||
type MetricPayload = {
|
type MetricPayload = {
|
||||||
hit_rate: number;
|
hit_rate: number;
|
||||||
@@ -178,6 +179,11 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
const data = payload?.accuracy ?? null;
|
const data = payload?.accuracy ?? null;
|
||||||
const debSummary = payload?.deb_summary;
|
const debSummary = payload?.deb_summary;
|
||||||
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
|
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
|
||||||
|
const debRecentRanked = useMemo(() => buildDebRecentRankingRows(data || []), [data]);
|
||||||
|
const debRecentRankIndex = useMemo(
|
||||||
|
() => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
|
||||||
|
[debRecentRanked],
|
||||||
|
);
|
||||||
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
|
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
|
||||||
|
|
||||||
const debStats = useMemo(() => {
|
const debStats = useMemo(() => {
|
||||||
@@ -206,12 +212,19 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
}, [muSorted]);
|
}, [muSorted]);
|
||||||
|
|
||||||
const debHitChart = useMemo(
|
const debHitChart = useMemo(
|
||||||
() => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })),
|
() => debRecentRanked.slice(0, 18).map((c) => ({ name: c.name, value: c.hitRate })),
|
||||||
[debSorted],
|
[debRecentRanked],
|
||||||
);
|
);
|
||||||
const debMaeChart = useMemo(
|
const debMaeChart = useMemo(
|
||||||
() => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })),
|
() => [...debRecentRanked]
|
||||||
[debSorted],
|
.sort((a, b) => {
|
||||||
|
if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
|
||||||
|
if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
|
||||||
|
return a.mae - b.mae;
|
||||||
|
})
|
||||||
|
.slice(0, 18)
|
||||||
|
.map((c) => ({ name: c.name, value: c.mae })),
|
||||||
|
[debRecentRanked],
|
||||||
);
|
);
|
||||||
const muHitChart = useMemo(
|
const muHitChart = useMemo(
|
||||||
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
|
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
|
||||||
@@ -296,7 +309,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<ChartCard title={isEn ? "Forecast Hit Rate by City" : "预报命中率 by 城市"}>
|
<ChartCard title={isEn ? "Usable Recent Hit Rate by City" : "可用近期命中率 by 城市"}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={debHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
<BarChart data={debHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
@@ -307,7 +320,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title={isEn ? "Forecast Error by City (lower = better)" : "预报误差 by 城市(越低越好)"}>
|
<ChartCard title={isEn ? "Usable Recent Error by City (lower = better)" : "可用近期误差 by 城市(越低越好)"}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={debMaeChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
<BarChart data={debMaeChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
@@ -401,6 +414,9 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
}
|
}
|
||||||
const merged = [...cities.entries()]
|
const merged = [...cities.entries()]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
const aDebRank = debRecentRankIndex.get(a[0]) ?? 9999;
|
||||||
|
const bDebRank = debRecentRankIndex.get(b[0]) ?? 9999;
|
||||||
|
if (aDebRank !== bDebRank) return aDebRank - bDebRank;
|
||||||
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
|
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
|
||||||
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
|
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
|
||||||
return bMax - aMax;
|
return bMax - aMax;
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const rows = buildDebRecentRankingRows([
|
||||||
|
{
|
||||||
|
city_id: "legacy",
|
||||||
|
name: "Legacy Good",
|
||||||
|
deb: { hit_rate: 95, mae: 0.9, total_days: 40 },
|
||||||
|
deb_recent: {
|
||||||
|
recent_7d: { hit_rate: 0, mae: 2.5, samples: 4 },
|
||||||
|
recent_14d: { hit_rate: 15, mae: 2.1, samples: 8 },
|
||||||
|
trust_tier: "low",
|
||||||
|
recommendation: "context_only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city_id: "usable",
|
||||||
|
name: "Usable Recent",
|
||||||
|
deb: { hit_rate: 40, mae: 1.4, total_days: 20 },
|
||||||
|
deb_recent: {
|
||||||
|
recent_7d: { hit_rate: 75, mae: 0.7, samples: 4 },
|
||||||
|
recent_14d: { hit_rate: 70, mae: 0.8, samples: 8 },
|
||||||
|
trust_tier: "high",
|
||||||
|
recommendation: "primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
city_id: "support",
|
||||||
|
name: "Support Recent",
|
||||||
|
deb: { hit_rate: 35, mae: 1.5, total_days: 20 },
|
||||||
|
deb_recent: {
|
||||||
|
recent_7d: { hit_rate: 50, mae: 1.2, samples: 4 },
|
||||||
|
recent_14d: { hit_rate: 50, mae: 1.2, samples: 8 },
|
||||||
|
trust_tier: "medium",
|
||||||
|
recommendation: "supporting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
assert(rows[0].cityId === "usable", "high-trust recent DEB city should rank first");
|
||||||
|
assert(rows[1].cityId === "support", "supporting recent DEB city should rank before low-trust legacy hit rate");
|
||||||
|
assert(rows[2].cityId === "legacy", "low-trust historical performer should rank after usable recent cities");
|
||||||
|
assert(rows[0].hitRate === 75, "ranking rows should use recent hit rate for chart value");
|
||||||
|
assert(rows[0].mae === 0.7, "ranking rows should use recent MAE for chart value");
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function readFrontend(...parts: string[]) {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const trainingPage = readFrontend("components", "ops", "training", "TrainingPageClient.tsx");
|
||||||
|
const charts = readFrontend("components", "ops", "training", "TrainingAccuracyCharts.tsx");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
trainingPage,
|
||||||
|
/buildDebRecentRankingRows/,
|
||||||
|
"ops training page should share the DEB usable-recent ranking helper with the terminal dashboard",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
trainingPage,
|
||||||
|
/debRecentRanked/,
|
||||||
|
"ops training page should build chart rows from the usable-recent DEB ranking",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
trainingPage,
|
||||||
|
/debRecentRankIndex/,
|
||||||
|
"ops training detail table should follow the same usable-recent DEB order before falling back to historical scores",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
trainingPage,
|
||||||
|
/sort\(\s*\(a,\s*b\)\s*=>\s*\(\(b\.deb\?\.hit_rate/,
|
||||||
|
"ops training DEB chart must not regress to sorting by long-term historical hit rate",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
charts,
|
||||||
|
/DEB 可用近期命中率 by 城市/,
|
||||||
|
"ops DEB hit-rate chart should label the metric as usable recent accuracy",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
charts,
|
||||||
|
/DEB 可用近期 MAE by 城市/,
|
||||||
|
"ops DEB MAE chart should label the metric as usable recent MAE",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
charts,
|
||||||
|
/可用近期命中率/,
|
||||||
|
"ops DEB hit-rate tooltip should describe the usable recent metric",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ export function TrainingAccuracyCharts({
|
|||||||
{debChartData.length > 0 ? (
|
{debChartData.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
<CardHeader><CardTitle>DEB 可用近期命中率 by 城市</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
<div className="h-[400px]">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
@@ -78,7 +78,7 @@ export function TrainingAccuracyCharts({
|
|||||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={CHART_TOOLTIP_STYLE}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "可用近期命中率"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
{debChartData.map((entry, i) => (
|
{debChartData.map((entry, i) => (
|
||||||
@@ -93,7 +93,7 @@ export function TrainingAccuracyCharts({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
<CardHeader><CardTitle>DEB 可用近期 MAE by 城市</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-[400px]">
|
<div className="h-[400px]">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
@@ -103,7 +103,7 @@ export function TrainingAccuracyCharts({
|
|||||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={CHART_TOOLTIP_STYLE}
|
contentStyle={CHART_TOOLTIP_STYLE}
|
||||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "可用近期 MAE"]}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||||
{debChartData.map((entry, i) => (
|
{debChartData.map((entry, i) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-r
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { SystemStatusPayload } from "@/types/ops";
|
import type { SystemStatusPayload } from "@/types/ops";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -183,19 +184,23 @@ export function TrainingPageClient() {
|
|||||||
const usableWindowLabel = (window?: string) =>
|
const usableWindowLabel = (window?: string) =>
|
||||||
window === "recent_14d" ? "DEB 可用近 14 天命中" : "DEB 可用近 7 天命中";
|
window === "recent_14d" ? "DEB 可用近 14 天命中" : "DEB 可用近 7 天命中";
|
||||||
|
|
||||||
|
const debRecentRanked = useMemo(() => buildDebRecentRankingRows(accuracy || []), [accuracy]);
|
||||||
|
const debRecentRankIndex = useMemo(
|
||||||
|
() => new Map(debRecentRanked.map((row, index) => [row.cityId, index])),
|
||||||
|
[debRecentRanked],
|
||||||
|
);
|
||||||
|
|
||||||
const debChartData = useMemo(() => {
|
const debChartData = useMemo(() => {
|
||||||
if (!accuracy?.length) return [];
|
return debRecentRanked
|
||||||
return accuracy
|
.slice(0, 24)
|
||||||
.filter((c) => c.deb && c.deb.total_days >= 5)
|
|
||||||
.sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0))
|
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
name: c.name,
|
name: c.name,
|
||||||
cityId: c.city_id,
|
cityId: c.cityId,
|
||||||
hitRate: Number((c.deb?.hit_rate ?? 0).toFixed(1)),
|
hitRate: c.hitRate,
|
||||||
mae: Number((c.deb?.mae ?? 0).toFixed(1)),
|
mae: c.mae,
|
||||||
days: c.deb?.total_days ?? 0,
|
days: c.samples,
|
||||||
}));
|
}));
|
||||||
}, [accuracy]);
|
}, [debRecentRanked]);
|
||||||
|
|
||||||
const muChartData = useMemo(() => {
|
const muChartData = useMemo(() => {
|
||||||
if (!accuracy?.length) return [];
|
if (!accuracy?.length) return [];
|
||||||
@@ -212,6 +217,18 @@ export function TrainingPageClient() {
|
|||||||
}));
|
}));
|
||||||
}, [accuracy]);
|
}, [accuracy]);
|
||||||
|
|
||||||
|
const sortedAccuracy = useMemo(() => {
|
||||||
|
if (!accuracy?.length) return [];
|
||||||
|
return [...accuracy].sort((a, b) => {
|
||||||
|
const aDebRank = debRecentRankIndex.get(a.city_id) ?? 9999;
|
||||||
|
const bDebRank = debRecentRankIndex.get(b.city_id) ?? 9999;
|
||||||
|
if (aDebRank !== bDebRank) return aDebRank - bDebRank;
|
||||||
|
const aMax = Math.max(a.deb?.hit_rate ?? 0, a.mu?.hit_rate ?? 0);
|
||||||
|
const bMax = Math.max(b.deb?.hit_rate ?? 0, b.mu?.hit_rate ?? 0);
|
||||||
|
return bMax - aMax;
|
||||||
|
});
|
||||||
|
}, [accuracy, debRecentRankIndex]);
|
||||||
|
|
||||||
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||||
if (!status) return <div className="text-red-400">加载失败</div>;
|
if (!status) return <div className="text-red-400">加载失败</div>;
|
||||||
|
|
||||||
@@ -350,8 +367,8 @@ export function TrainingPageClient() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-white/5">
|
||||||
{accuracy && accuracy.length > 0 ? (
|
{sortedAccuracy.length > 0 ? (
|
||||||
accuracy.map((row) => (
|
sortedAccuracy.map((row) => (
|
||||||
<tr key={row.city_id} className="hover:bg-white/5 transition-colors">
|
<tr key={row.city_id} className="hover:bg-white/5 transition-colors">
|
||||||
<td className="px-4 py-3 font-medium text-white capitalize">
|
<td className="px-4 py-3 font-medium text-white capitalize">
|
||||||
{row.name}
|
{row.name}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type DebTrainingMetric = {
|
||||||
|
hit_rate?: number | null;
|
||||||
|
mae?: number | null;
|
||||||
|
total_days?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DebTrainingWindow = {
|
||||||
|
hit_rate?: number | null;
|
||||||
|
mae?: number | null;
|
||||||
|
samples?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DebTrainingRecent = {
|
||||||
|
recent_7d?: DebTrainingWindow | null;
|
||||||
|
recent_14d?: DebTrainingWindow | null;
|
||||||
|
trust_tier?: string | null;
|
||||||
|
recommendation?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DebTrainingCity = {
|
||||||
|
city_id: string;
|
||||||
|
name: string;
|
||||||
|
deb?: DebTrainingMetric | null;
|
||||||
|
deb_recent?: DebTrainingRecent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DebRecentRankingRow = {
|
||||||
|
cityId: string;
|
||||||
|
name: string;
|
||||||
|
hitRate: number;
|
||||||
|
mae: number;
|
||||||
|
samples: number;
|
||||||
|
trustScore: number;
|
||||||
|
usableScore: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trustScore(tier?: string | null) {
|
||||||
|
if (tier === "high") return 3;
|
||||||
|
if (tier === "medium") return 2;
|
||||||
|
if (tier === "low") return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usableScore(recommendation?: string | null) {
|
||||||
|
if (recommendation === "primary") return 2;
|
||||||
|
if (recommendation === "supporting") return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRecentWindow(recent?: DebTrainingRecent | null): DebTrainingWindow | null {
|
||||||
|
const recent7 = recent?.recent_7d || null;
|
||||||
|
if (Number(recent7?.samples || 0) > 0) return recent7;
|
||||||
|
const recent14 = recent?.recent_14d || null;
|
||||||
|
if (Number(recent14?.samples || 0) > 0) return recent14;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDebRecentRankingRows(cities: DebTrainingCity[]): DebRecentRankingRow[] {
|
||||||
|
return (cities || [])
|
||||||
|
.filter((city) => city.deb)
|
||||||
|
.map((city) => {
|
||||||
|
const recentWindow = selectRecentWindow(city.deb_recent);
|
||||||
|
return {
|
||||||
|
cityId: city.city_id,
|
||||||
|
name: city.name,
|
||||||
|
hitRate: Number(
|
||||||
|
(recentWindow?.hit_rate ?? city.deb?.hit_rate ?? 0).toFixed(1),
|
||||||
|
),
|
||||||
|
mae: Number(
|
||||||
|
(recentWindow?.mae ?? city.deb?.mae ?? 0).toFixed(2),
|
||||||
|
),
|
||||||
|
samples: Number(recentWindow?.samples || 0),
|
||||||
|
trustScore: trustScore(city.deb_recent?.trust_tier),
|
||||||
|
usableScore: usableScore(city.deb_recent?.recommendation),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore;
|
||||||
|
if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore;
|
||||||
|
if (a.hitRate !== b.hitRate) return b.hitRate - a.hitRate;
|
||||||
|
if (a.samples !== b.samples) return b.samples - a.samples;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user