import { api } from '../services/apiClient.ts'; import type { Cycle, KPI, Site } from '../types/api.ts'; export type KandangRankingRow = { rank: number; siteId: number; 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[] { 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 { 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: number; siteName: string; kandangName: string }; const kandangMeta = new Map(); 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; }) ); return assignRanks(metricRows.filter((r): r is Omit => r != null)); }