Files
dashboard-cpsp-executive/utils/kandangRanking.ts
T

169 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from '../services/apiClient.ts';
import type { Cycle, KPI, Site } from '../types/api.ts';
export type KandangRankingRow = {
rank: number;
siteId: string;
siteName: string;
kandangId: number;
kandangName: string;
cycleId: number;
startDate: string;
endDate: string;
totalDays: number;
eef: number | null;
fcr: number | null;
};
export type LoadKandangRankingArgs = {
sites: Site[];
months: number;
/** Defaults to today (local). */
now?: Date;
};
function toIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Inclusive lower bound: end_date >= (today − months). */
export function rankingWindowStart(months: number, now = new Date()): string {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
start.setMonth(start.getMonth() - months);
return toIsoDate(start);
}
export function isEndDateInWindow(endDate: string | null | undefined, windowStart: string): boolean {
if (!endDate) return false;
return endDate >= windowStart;
}
/**
* Latest valid FCR/EEF for a cycle.
* Matches EefDashboardPage: sort by age/date ascending, then walk from the end.
* (API KPI list is ordered `-date` / newest-first — do not assume array order.)
*/
export function latestValidRatios(kpis: KPI[]): { eef: number | null; fcr: number | null } {
const sorted = [...kpis].sort((a, b) => a.age - b.age || a.date.localeCompare(b.date));
let eef: number | null = null;
let fcr: number | null = null;
for (let i = sorted.length - 1; i >= 0; i--) {
const row = sorted[i];
if (!row) continue;
if (eef == null && row.eef > 0) eef = row.eef;
if (fcr == null && row.fcr > 0) fcr = row.fcr;
if (eef != null && fcr != null) break;
}
return { eef, fcr };
}
function assignRanks(
rows: Omit<KandangRankingRow, 'rank'>[]
): KandangRankingRow[] {
const withEef = rows
.filter((r) => r.eef != null)
.sort((a, b) => (b.eef as number) - (a.eef as number));
const withoutEef = rows.filter((r) => r.eef == null);
const ranked: KandangRankingRow[] = withEef.map((row, index) => ({
...row,
rank: index + 1,
}));
const unranked: KandangRankingRow[] = withoutEef.map((row) => ({
...row,
rank: 0,
}));
return [...ranked, ...unranked];
}
/**
* Load closed-cycle EEF rankings for the given sites within a months window.
* Uses one closed-cycles list + per-site kandangs + per-cycle KPI fetches.
*/
export async function loadKandangRanking({
sites,
months,
now = new Date(),
}: LoadKandangRankingArgs): Promise<KandangRankingRow[]> {
if (sites.length === 0 || months < 1) return [];
const windowStart = rankingWindowStart(months, now);
const siteById = new Map(sites.map((s) => [s.id, s]));
const kandangLists = await Promise.all(
sites.map((site) => api.kandangs.list({ site_id: site.id }))
);
type KandangMeta = { siteId: string; siteName: string; kandangName: string };
const kandangMeta = new Map<number, KandangMeta>();
kandangLists.forEach((list, i) => {
const site = sites[i];
if (!site) return;
for (const k of list) {
kandangMeta.set(k.id, {
siteId: site.id,
siteName: site.site_name,
kandangName: k.kandang_name,
});
}
});
if (kandangMeta.size === 0) return [];
let closedCycles: Cycle[] = [];
try {
closedCycles = await api.cycles.list({ status: 'closed' });
} catch {
// Fallback: per-kandang if global closed list fails
const perKandang = await Promise.all(
[...kandangMeta.keys()].map(async (kandangId) => {
try {
return await api.cycles.list({ kandang_id: kandangId, status: 'closed' });
} catch {
return [] as Cycle[];
}
})
);
closedCycles = perKandang.flat();
}
const inScope = closedCycles.filter((cycle) => {
if (!kandangMeta.has(cycle.kandang)) return false;
return isEndDateInWindow(cycle.end_date, windowStart);
});
const metricRows = await Promise.all(
inScope.map(async (cycle) => {
const meta = kandangMeta.get(cycle.kandang);
if (!meta || !cycle.end_date) return null;
// Ensure site still in requested set (defensive)
if (!siteById.has(meta.siteId)) return null;
let eef: number | null = null;
let fcr: number | null = null;
try {
const kpis = await api.kpis.list({ cycle_id: cycle.id });
({ eef, fcr } = latestValidRatios(kpis));
} catch {
// leave nulls
}
return {
siteId: meta.siteId,
siteName: meta.siteName,
kandangId: cycle.kandang,
kandangName: meta.kandangName || cycle.kandang_name,
cycleId: cycle.id,
startDate: cycle.start_date,
endDate: cycle.end_date,
totalDays: cycle.total_days || cycle.current_day || 0,
eef,
fcr,
} satisfies Omit<KandangRankingRow, 'rank'>;
})
);
return assignRanks(metricRows.filter((r): r is Omit<KandangRankingRow, 'rank'> => r != null));
}