- Ask's supplier loader didn't pass date_received, so supplier_balances and the supplier_balance dataset crashed - supplier_balances takes a period (thisMonth, thisWeek, month1-3) and returns what was received from each supplier in it (payable) - PocketBase's space-separated dates dropped deliveries on the 1st from period filters, including the Suppliers page's Payable KPI - The PDF route falls back to the answer text when a saved report has no sections (older format), instead of a 500 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
613 lines
19 KiB
TypeScript
613 lines
19 KiB
TypeScript
/**
|
|
* Shape loaded records + metrics output into tool results. Pure.
|
|
*
|
|
* Every numeric field here comes from @/lib/metrics. Tool results also
|
|
* carry a `display` block of preformatted strings so the model never has
|
|
* to format or round money itself.
|
|
*/
|
|
import {
|
|
computeSiteSpend,
|
|
computeSiteProjection,
|
|
resolveSiteStartDate,
|
|
parseDurationMonths,
|
|
computeFuelLogStatuses,
|
|
computeFuelCost,
|
|
resolveFuelPrice,
|
|
type EmployeeRateMap,
|
|
type FuelPriceLike,
|
|
type SiteStatus,
|
|
type VehicleAttention,
|
|
} from "@/lib/metrics";
|
|
import type { PnGItemLike } from "@/lib/partition-fixed-costs";
|
|
import { describeLimitMismatch } from "@/lib/fuel-variance";
|
|
import type { AttendanceGroups } from "@/lib/attendance";
|
|
import type { SupplierBalanceRow } from "@/lib/metrics/supplier-balance";
|
|
import type { InboxColumnCount } from "@/lib/dn-pipeline/board";
|
|
import type { RainOutlookData } from "@/lib/weather/load";
|
|
import { formatNumber } from "@/lib/utils";
|
|
import { RAIN_STOP_MM, RAIN_WATCH_MM, RAIN_WATCH_PROBABILITY_PCT } from "@/lib/weather/outlook";
|
|
|
|
/** ZAR, no decimals, thin-space thousands. */
|
|
export function formatMoney(n: number): string {
|
|
const neg = n < 0;
|
|
const abs = Math.abs(Math.round(n));
|
|
const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
|
return `${neg ? "-" : ""}R ${grouped}`;
|
|
}
|
|
|
|
export interface SiteFinancialsInput {
|
|
site: {
|
|
id: string;
|
|
name?: string;
|
|
project_code?: string;
|
|
duration?: string;
|
|
contract_value?: number;
|
|
job_start_date?: string;
|
|
};
|
|
fixedCosts: PnGItemLike[];
|
|
timesheets: { employee: string; hours_worked?: number; date_logged?: string }[];
|
|
employeeRates: EmployeeRateMap;
|
|
purchaseOrders: { total_value?: number; created?: string }[];
|
|
fuelLogs: { litres?: number; date_logged?: string }[];
|
|
income: { gross_amount?: number; paid_on?: string; period_end?: string }[];
|
|
/** Recorded diesel price changes. Omit to cost every litre at RATE_PER_LITRE. */
|
|
fuelPrices?: FuelPriceLike[];
|
|
now?: Date;
|
|
}
|
|
|
|
export interface SiteFinancialsResult {
|
|
site: string;
|
|
projectCode: string;
|
|
contractValue: number;
|
|
totalSpent: number;
|
|
breakdown: {
|
|
overheadsMonthly: number;
|
|
overheadsOneOff: number;
|
|
payroll: number;
|
|
materials: number;
|
|
fuel: number;
|
|
};
|
|
fuelLitres: number;
|
|
startDate: string;
|
|
startDateSource: string;
|
|
projection: {
|
|
durationMonths: number;
|
|
elapsedMonths: number;
|
|
percentElapsed: number;
|
|
percentBurnt: number;
|
|
remainingBudget: number;
|
|
projectedTotal: number;
|
|
projectedProfit: number;
|
|
status: SiteStatus;
|
|
};
|
|
display: {
|
|
contractValue: string;
|
|
totalSpent: string;
|
|
remainingBudget: string;
|
|
projectedTotal: string;
|
|
projectedProfit: string;
|
|
};
|
|
}
|
|
|
|
export function formatSiteFinancials(
|
|
input: SiteFinancialsInput,
|
|
): SiteFinancialsResult {
|
|
const spend = computeSiteSpend({
|
|
siteId: input.site.id,
|
|
fixedCosts: input.fixedCosts,
|
|
timesheets: input.timesheets,
|
|
employeeRates: input.employeeRates,
|
|
purchaseOrders: input.purchaseOrders,
|
|
fuelLogs: input.fuelLogs,
|
|
fuelPrices: input.fuelPrices,
|
|
});
|
|
|
|
const siteTimesheets = input.timesheets.filter(
|
|
(t) => input.employeeRates.get(t.employee)?.site === input.site.id,
|
|
);
|
|
|
|
const start = resolveSiteStartDate({
|
|
jobStartDate: input.site.job_start_date,
|
|
fixedCosts: input.fixedCosts,
|
|
timesheets: siteTimesheets,
|
|
fuelLogs: input.fuelLogs,
|
|
income: input.income,
|
|
now: input.now,
|
|
});
|
|
|
|
const projection = computeSiteProjection({
|
|
contractValue: input.site.contract_value ?? 0,
|
|
totalSpent: spend.totalSpent,
|
|
durationMonths: parseDurationMonths(input.site.duration),
|
|
startDate: start.startDate,
|
|
hasExplicitStart: start.source === "job_start_date",
|
|
now: input.now,
|
|
});
|
|
|
|
return {
|
|
site: input.site.name ?? "",
|
|
projectCode: input.site.project_code ?? "",
|
|
contractValue: input.site.contract_value ?? 0,
|
|
totalSpent: spend.totalSpent,
|
|
breakdown: {
|
|
overheadsMonthly: spend.fixedSum,
|
|
overheadsOneOff: spend.oneOffSum,
|
|
payroll: spend.payrollBurn,
|
|
materials: spend.materialSum,
|
|
fuel: spend.fuelBurn,
|
|
},
|
|
fuelLitres: spend.fuelLitres,
|
|
startDate: start.startDate,
|
|
startDateSource: start.source,
|
|
projection: {
|
|
durationMonths: projection.months,
|
|
elapsedMonths: projection.elapsedMonths,
|
|
percentElapsed: projection.elapsedPct,
|
|
percentBurnt: projection.burnPct,
|
|
remainingBudget: projection.remaining,
|
|
projectedTotal: projection.projectedTotal,
|
|
projectedProfit: projection.projectedProfit,
|
|
status: projection.status,
|
|
},
|
|
display: {
|
|
contractValue: formatMoney(input.site.contract_value ?? 0),
|
|
totalSpent: formatMoney(spend.totalSpent),
|
|
remainingBudget: formatMoney(projection.remaining),
|
|
projectedTotal: formatMoney(projection.projectedTotal),
|
|
projectedProfit: formatMoney(projection.projectedProfit),
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface AttentionResult {
|
|
/** Vehicles with real fuel alerts or warnings. */
|
|
totalFlagged: number;
|
|
/** Vehicles whose fuel limit is in the wrong unit (config to fix). */
|
|
limitProblems: number;
|
|
vehicles: {
|
|
vehicle: string;
|
|
projectCode: string;
|
|
qrCodeId: string;
|
|
site: string;
|
|
alerts: number;
|
|
warnings: number;
|
|
lastFlagged: string | null;
|
|
dismissed: boolean;
|
|
/** Plain-language wrong-unit explanation, or null. */
|
|
limitProblem: string | null;
|
|
}[];
|
|
}
|
|
|
|
export function formatAttentionList(
|
|
items: VehicleAttention[],
|
|
siteNames: Map<string, string>,
|
|
opts: { includeDismissed?: boolean },
|
|
): AttentionResult {
|
|
const visible = opts.includeDismissed
|
|
? items
|
|
: items.filter((i) => !i.acknowledged);
|
|
|
|
const vehicles = visible
|
|
.slice()
|
|
.sort((a, b) => {
|
|
if (b.alert !== a.alert) return b.alert - a.alert;
|
|
return b.warn - a.warn;
|
|
})
|
|
.map((i) => ({
|
|
vehicle: i.description,
|
|
projectCode: i.projectCode,
|
|
qrCodeId: i.qrCodeId,
|
|
site: siteNames.get(i.siteId) ?? i.siteId,
|
|
alerts: i.alert,
|
|
warnings: i.warn,
|
|
lastFlagged: i.maxFlaggedDate,
|
|
dismissed: i.acknowledged,
|
|
limitProblem: i.limitMismatch ? describeLimitMismatch(i.limitMismatch) : null,
|
|
}));
|
|
|
|
return {
|
|
totalFlagged: vehicles.filter((v) => v.alerts + v.warnings > 0).length,
|
|
limitProblems: vehicles.filter((v) => v.limitProblem).length,
|
|
vehicles,
|
|
};
|
|
}
|
|
export interface TopWorkersResult {
|
|
scope: string;
|
|
workers: {
|
|
worker: string;
|
|
position: string;
|
|
site: string;
|
|
hours: number;
|
|
cost: number;
|
|
ratePerHour: number;
|
|
display: { cost: string };
|
|
}[];
|
|
}
|
|
|
|
/**
|
|
* Roll up hours and cost per worker. The data already exists — this is
|
|
* `computeSiteSpend(...).payrollByEmployee` joined to an employee
|
|
* record — but no tool exposed it before.
|
|
*/
|
|
export function formatTopWorkers(
|
|
employees: {
|
|
id: string;
|
|
name?: string;
|
|
rate_per_hour?: number;
|
|
site?: string;
|
|
position?: string;
|
|
}[],
|
|
timesheets: { employee: string; hours_worked?: number }[],
|
|
siteNames: Map<string, string>,
|
|
opts: { siteId?: string; limit?: number },
|
|
): TopWorkersResult {
|
|
const byId = new Map(employees.map((e) => [e.id, e]));
|
|
const acc = new Map<string, number>();
|
|
|
|
for (const t of timesheets) {
|
|
const emp = byId.get(t.employee);
|
|
if (!emp) continue;
|
|
if (opts.siteId && emp.site !== opts.siteId) continue;
|
|
acc.set(t.employee, (acc.get(t.employee) ?? 0) + (t.hours_worked ?? 0));
|
|
}
|
|
|
|
const workers = Array.from(acc.entries())
|
|
.filter(([, hours]) => hours > 0)
|
|
.map(([id, hours]) => {
|
|
const e = byId.get(id)!;
|
|
const rate = e.rate_per_hour ?? 0;
|
|
const cost = hours * rate;
|
|
return {
|
|
worker: e.name ?? "(unnamed)",
|
|
position: e.position ?? "",
|
|
site: siteNames.get(e.site ?? "") ?? e.site ?? "",
|
|
hours,
|
|
cost,
|
|
ratePerHour: rate,
|
|
display: { cost: formatMoney(cost) },
|
|
};
|
|
})
|
|
.sort((a, b) => b.hours - a.hours)
|
|
.slice(0, opts.limit ?? 10);
|
|
|
|
return {
|
|
scope: opts.siteId
|
|
? siteNames.get(opts.siteId) ?? opts.siteId
|
|
: "all sites",
|
|
workers,
|
|
};
|
|
}
|
|
|
|
export interface FuelLogResult {
|
|
vehicle: string;
|
|
qrCodeId: string;
|
|
projectCode: string;
|
|
fuelLimit: string;
|
|
fillUpCount: number;
|
|
totalLitres: number;
|
|
totalSpend: number;
|
|
flagged: number;
|
|
fillUps: {
|
|
date: string;
|
|
litres: number;
|
|
odoReading: number;
|
|
variancePct: number | null;
|
|
status: string;
|
|
cost: number;
|
|
display: { cost: string };
|
|
}[];
|
|
display: { totalSpend: string };
|
|
}
|
|
|
|
/**
|
|
* Per-fill variance for one vehicle, reusing the same pass the dashboard
|
|
* uses so per-fill status matches `/dashboard/fuel/<id>`.
|
|
*/
|
|
export function formatFuelLog(
|
|
vehicle: {
|
|
id: string;
|
|
description?: string;
|
|
qr_code_id?: string;
|
|
project_code?: string;
|
|
fuel_limits?: string;
|
|
},
|
|
logs: {
|
|
id: string;
|
|
vehicle: string;
|
|
date_logged?: string;
|
|
odo_reading?: number;
|
|
litres?: number;
|
|
}[],
|
|
opts: { limit?: number; fuelPrices?: FuelPriceLike[] },
|
|
): FuelLogResult {
|
|
const fuelPrices = opts.fuelPrices ?? [];
|
|
// computeFuelLogStatuses takes a vehicles array; pass one-element.
|
|
const statuses = new Map(
|
|
computeFuelLogStatuses({ vehicles: [vehicle], fuelLogs: logs }).map(
|
|
(s) => [s.logId, s],
|
|
),
|
|
);
|
|
|
|
const totalLitres = logs.reduce((s, l) => s + (l.litres ?? 0), 0);
|
|
const totalSpend = computeFuelCost(logs, fuelPrices);
|
|
const flagged = Array.from(statuses.values()).filter(
|
|
(s) => s.status === "warn" || s.status === "alert",
|
|
).length;
|
|
|
|
const fillUps = logs
|
|
.slice()
|
|
.sort((a, b) => (a.date_logged ?? "") < (b.date_logged ?? "") ? 1 : -1)
|
|
.slice(0, opts.limit ?? 20)
|
|
.map((l) => {
|
|
const st = statuses.get(l.id);
|
|
const cost = (l.litres ?? 0) * resolveFuelPrice(l.date_logged, fuelPrices);
|
|
return {
|
|
date: l.date_logged ?? "",
|
|
litres: l.litres ?? 0,
|
|
odoReading: l.odo_reading ?? 0,
|
|
variancePct: st?.variance ?? null,
|
|
status: st?.status ?? "no-limit",
|
|
cost,
|
|
display: { cost: formatMoney(cost) },
|
|
};
|
|
});
|
|
|
|
return {
|
|
vehicle: vehicle.description ?? "",
|
|
qrCodeId: vehicle.qr_code_id ?? "",
|
|
projectCode: vehicle.project_code ?? "",
|
|
fuelLimit: vehicle.fuel_limits ?? "",
|
|
fillUpCount: logs.length,
|
|
totalLitres,
|
|
totalSpend,
|
|
flagged,
|
|
fillUps,
|
|
display: { totalSpend: formatMoney(totalSpend) },
|
|
};
|
|
}
|
|
|
|
/** "07:42" in Johannesburg. */
|
|
function saTime(iso: string): string {
|
|
return new Intl.DateTimeFormat("en-ZA", {
|
|
timeZone: "Africa/Johannesburg",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
}).format(new Date(iso));
|
|
}
|
|
|
|
/** "2026-09-23" in Johannesburg. */
|
|
function saDate(iso: string): string {
|
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Africa/Johannesburg" }).format(new Date(iso));
|
|
}
|
|
|
|
export interface AttendanceResult {
|
|
scope: string;
|
|
counts: { onSiteNow: number; clockedOutToday: number; notArrivedToday: number; needsAttention: number };
|
|
onSiteNow: { worker: string; site: string; since: string }[];
|
|
clockedOutToday: { worker: string; site: string; at: string }[];
|
|
notArrivedToday: { worker: string; site: string; lastSeen: string }[];
|
|
needsAttention: { worker: string; site: string; clockedInOn: string; at: string }[];
|
|
/** True when a list was cut to `limit`; counts are always complete. */
|
|
truncated: boolean;
|
|
href: string;
|
|
}
|
|
|
|
/**
|
|
* Today's attendance from groupAttendance (the On-site page's grouping),
|
|
* optionally narrowed to one site. Counts are the full list lengths;
|
|
* each list is capped so a big roster does not flood the answer.
|
|
*/
|
|
export function formatAttendance(
|
|
g: AttendanceGroups,
|
|
opts: { siteId?: string; scope?: string; limit?: number } = {},
|
|
): AttendanceResult {
|
|
const limit = opts.limit ?? 30;
|
|
const keep = <T extends { siteId: string }>(rows: T[]) =>
|
|
opts.siteId ? rows.filter((r) => r.siteId === opts.siteId) : rows;
|
|
const onSite = keep(g.onSite);
|
|
const out = keep(g.clockedOutToday);
|
|
const notArrived = keep(g.notArrivedToday);
|
|
const attention = keep(g.needsAttention);
|
|
return {
|
|
scope: opts.scope ?? "all sites",
|
|
counts: {
|
|
onSiteNow: onSite.length,
|
|
clockedOutToday: out.length,
|
|
notArrivedToday: notArrived.length,
|
|
needsAttention: attention.length,
|
|
},
|
|
onSiteNow: onSite.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, since: saTime(r.clockedInAt) })),
|
|
clockedOutToday: out.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, at: saTime(r.clockedOutAt) })),
|
|
notArrivedToday: notArrived.slice(0, limit).map((r) => ({ worker: r.employeeName, site: r.siteName, lastSeen: saDate(r.lastSeenAt) })),
|
|
needsAttention: attention.slice(0, limit).map((r) => ({
|
|
worker: r.employeeName,
|
|
site: r.siteName,
|
|
clockedInOn: saDate(r.clockedInAt),
|
|
at: saTime(r.clockedInAt),
|
|
})),
|
|
truncated: [onSite, out, notArrived, attention].some((l) => l.length > limit),
|
|
href: opts.siteId ? `/dashboard/workforce/on-site?site=${opts.siteId}` : "/dashboard/workforce/on-site",
|
|
};
|
|
}
|
|
|
|
export interface SupplierBalancesResult {
|
|
scope: string;
|
|
supplierCount: number;
|
|
showing: number;
|
|
/** Set when a period was asked for: the window and what was received in it. */
|
|
period?: { label: string; from: string | null; to: string | null };
|
|
totals: { display: { ordered: string; delivered: string; outstanding: string; receivedInPeriod?: string } };
|
|
suppliers: {
|
|
supplier: string;
|
|
purchaseOrders: number;
|
|
unresolvedDeliveryNotes: number;
|
|
display: { ordered: string; delivered: string; outstanding: string; receivedInPeriod?: string };
|
|
href: string;
|
|
}[];
|
|
}
|
|
|
|
/**
|
|
* Supplier balances (computeSupplierBalances), optionally narrowed by a
|
|
* name fragment. Totals are summed here so the model never adds rows.
|
|
*/
|
|
export function formatSupplierBalances(
|
|
rows: SupplierBalanceRow[],
|
|
opts: {
|
|
supplier?: string;
|
|
limit?: number;
|
|
/** Value received per supplier id in the period; rows with none are dropped. */
|
|
period?: { label: string; from: string | null; to: string | null; received: Map<string, number> };
|
|
} = {},
|
|
): SupplierBalancesResult | { error: string } {
|
|
const q = (opts.supplier ?? "").trim().toLowerCase();
|
|
const byName = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows;
|
|
const received = opts.period?.received;
|
|
// With a period, "who do I pay" = suppliers with something received in
|
|
// it, largest first.
|
|
const matched = received
|
|
? byName
|
|
.filter((r) => (received.get(r.id) ?? 0) > 0)
|
|
.sort((a, b) => (received.get(b.id) ?? 0) - (received.get(a.id) ?? 0))
|
|
: byName;
|
|
if (q && matched.length === 0) {
|
|
return {
|
|
error: `No supplier matches "${opts.supplier}". Suppliers with the most outstanding: ${rows
|
|
.slice(0, 8)
|
|
.map((r) => r.name)
|
|
.join(", ")}`,
|
|
};
|
|
}
|
|
let ordered = 0;
|
|
let delivered = 0;
|
|
let outstanding = 0;
|
|
let inPeriod = 0;
|
|
for (const r of matched) {
|
|
ordered += r.ordered;
|
|
delivered += r.delivered;
|
|
outstanding += r.outstanding;
|
|
inPeriod += received?.get(r.id) ?? 0;
|
|
}
|
|
const limit = opts.limit ?? 25;
|
|
return {
|
|
scope: q ? `suppliers matching "${opts.supplier}"` : "all suppliers",
|
|
...(opts.period ? { period: { label: opts.period.label, from: opts.period.from, to: opts.period.to } } : {}),
|
|
supplierCount: matched.length,
|
|
showing: Math.min(limit, matched.length),
|
|
totals: {
|
|
display: {
|
|
ordered: formatMoney(ordered),
|
|
delivered: formatMoney(delivered),
|
|
outstanding: formatMoney(outstanding),
|
|
...(received ? { receivedInPeriod: formatMoney(inPeriod) } : {}),
|
|
},
|
|
},
|
|
suppliers: matched.slice(0, limit).map((r) => ({
|
|
supplier: r.name,
|
|
purchaseOrders: r.poCount,
|
|
unresolvedDeliveryNotes: r.unresolvedCandidates,
|
|
display: {
|
|
ordered: formatMoney(r.ordered),
|
|
delivered: formatMoney(r.delivered),
|
|
outstanding: formatMoney(r.outstanding),
|
|
...(received ? { receivedInPeriod: formatMoney(received.get(r.id) ?? 0) } : {}),
|
|
},
|
|
href: `/dashboard/financial/suppliers/${r.id}`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export interface DnQueueCandidateLike {
|
|
siteName: string;
|
|
supplierId: string;
|
|
supplierName: string;
|
|
purchaseOrderId: string;
|
|
purchaseOrderNumber: string;
|
|
documentNumber: string;
|
|
reason: string;
|
|
unresolvedLineItems: number;
|
|
}
|
|
|
|
export interface DnQueueResult {
|
|
inbox: { columns: InboxColumnCount[]; failedBatches: number; href: string };
|
|
workQueue: {
|
|
open: number;
|
|
withoutSupplier: number;
|
|
withoutPurchaseOrder: number;
|
|
withUnmatchedLines: number;
|
|
byReason: { reason: string; count: number }[];
|
|
examples: { document: string; site: string; supplier: string; purchaseOrder: string; reason: string }[];
|
|
href: string;
|
|
};
|
|
}
|
|
|
|
/** Inbox board counts plus the open work-queue exceptions, display-ready. */
|
|
export function formatDnQueue(
|
|
board: { columns: InboxColumnCount[]; failedBatches: number },
|
|
candidates: DnQueueCandidateLike[],
|
|
opts: { examples?: number } = {},
|
|
): DnQueueResult {
|
|
const byReason = new Map<string, number>();
|
|
for (const c of candidates) byReason.set(c.reason, (byReason.get(c.reason) ?? 0) + 1);
|
|
return {
|
|
inbox: { ...board, href: "/dashboard/financial/delivery-notes/inbox" },
|
|
workQueue: {
|
|
open: candidates.length,
|
|
withoutSupplier: candidates.filter((c) => !c.supplierId).length,
|
|
withoutPurchaseOrder: candidates.filter((c) => !c.purchaseOrderId).length,
|
|
withUnmatchedLines: candidates.filter((c) => c.unresolvedLineItems > 0).length,
|
|
byReason: [...byReason.entries()]
|
|
.map(([reason, count]) => ({ reason, count }))
|
|
.sort((a, b) => b.count - a.count),
|
|
examples: candidates.slice(0, opts.examples ?? 10).map((c) => ({
|
|
document: c.documentNumber || "(no number yet)",
|
|
site: c.siteName,
|
|
supplier: c.supplierName || "(no supplier)",
|
|
purchaseOrder: c.purchaseOrderNumber || "(no PO)",
|
|
reason: c.reason,
|
|
})),
|
|
href: "/dashboard/financial/delivery-notes/work-queue",
|
|
},
|
|
};
|
|
}
|
|
|
|
const SEVERITY_TEXT = { dry: "dry", watch: "watch", "likely-stop": "likely stop" } as const;
|
|
|
|
export interface RainOutlookResult {
|
|
available: boolean;
|
|
rule: string;
|
|
sites: {
|
|
site: string;
|
|
projectCode: string;
|
|
likelyStopDays: number;
|
|
watchDays: number;
|
|
display: { totalRain: string };
|
|
days: { date: string; rain: string; chance: string; outlook: string }[];
|
|
href: string;
|
|
}[];
|
|
sitesWithoutLocation: string[];
|
|
}
|
|
|
|
/** The Overview rain card's data (loadRainOutlook), display-ready. */
|
|
export function formatRainOutlook(data: RainOutlookData): RainOutlookResult {
|
|
const mm = (n: number) => `${formatNumber(n, { maximumFractionDigits: 1 })} mm`;
|
|
return {
|
|
available: data.available,
|
|
rule: `A day with ${RAIN_STOP_MM} mm or more is a likely stop for earthworks; ${RAIN_WATCH_MM} mm or more, or a ${RAIN_WATCH_PROBABILITY_PCT}% chance, is one to watch.`,
|
|
sites: data.outlooks.map((o) => ({
|
|
site: o.siteName,
|
|
projectCode: o.projectCode,
|
|
likelyStopDays: o.likelyStopDays,
|
|
watchDays: o.watchDays,
|
|
display: { totalRain: mm(o.totalMm) },
|
|
days: o.days.map((d) => ({
|
|
date: d.date,
|
|
rain: mm(d.precipitationMm),
|
|
chance: `${formatNumber(d.probabilityPct, { maximumFractionDigits: 0 })}%`,
|
|
outlook: SEVERITY_TEXT[d.severity],
|
|
})),
|
|
href: `/dashboard/sites/${o.siteId}`,
|
|
})),
|
|
sitesWithoutLocation: data.sitesWithoutLocation,
|
|
};
|
|
}
|