2026-08-29 18:25:39 +00:00
/ * *
* 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 ,
2026-08-30 06:42:00 +00:00
computeFuelLogStatuses ,
2026-09-06 17:59:52 +00:00
computeFuelCost ,
resolveFuelPrice ,
2026-08-29 18:25:39 +00:00
type EmployeeRateMap ,
2026-09-06 17:59:52 +00:00
type FuelPriceLike ,
2026-08-29 18:25:39 +00:00
type SiteStatus ,
type VehicleAttention ,
} from "@/lib/metrics" ;
import type { PnGItemLike } from "@/lib/partition-fixed-costs" ;
2026-09-25 20:04:08 +00:00
import { describeLimitMismatch } from "@/lib/fuel-variance" ;
2026-09-25 20:35:48 +00:00
import type { AttendanceGroups } from "@/lib/attendance" ;
2026-09-26 07:37:27 +00:00
import type { SupplierBalanceRow } from "@/lib/metrics/supplier-balance" ;
2026-09-26 07:39:38 +00:00
import type { InboxColumnCount } from "@/lib/dn-pipeline/board" ;
2026-09-26 07:44:01 +00:00
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" ;
2026-08-29 18:25:39 +00:00
/** 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 } [ ] ;
2026-09-06 17:59:52 +00:00
/** Recorded diesel price changes. Omit to cost every litre at RATE_PER_LITRE. */
fuelPrices? : FuelPriceLike [ ] ;
2026-08-29 18:25:39 +00:00
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 ,
2026-09-06 17:59:52 +00:00
fuelPrices : input.fuelPrices ,
2026-08-29 18:25:39 +00:00
} ) ;
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 {
2026-09-25 20:04:08 +00:00
/** Vehicles with real fuel alerts or warnings. */
2026-08-29 18:25:39 +00:00
totalFlagged : number ;
2026-09-25 20:04:08 +00:00
/** Vehicles whose fuel limit is in the wrong unit (config to fix). */
limitProblems : number ;
2026-08-29 18:25:39 +00:00
vehicles : {
vehicle : string ;
projectCode : string ;
qrCodeId : string ;
site : string ;
alerts : number ;
warnings : number ;
lastFlagged : string | null ;
dismissed : boolean ;
2026-09-25 20:04:08 +00:00
/** Plain-language wrong-unit explanation, or null. */
limitProblem : string | null ;
2026-08-29 18:25:39 +00:00
} [ ] ;
}
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 ,
2026-09-25 20:04:08 +00:00
limitProblem : i.limitMismatch ? describeLimitMismatch ( i . limitMismatch ) : null ,
2026-08-29 18:25:39 +00:00
} ) ) ;
2026-09-25 20:04:08 +00:00
return {
totalFlagged : vehicles.filter ( ( v ) = > v . alerts + v . warnings > 0 ) . length ,
limitProblems : vehicles.filter ( ( v ) = > v . limitProblem ) . length ,
vehicles ,
} ;
2026-08-30 06:42:00 +00:00
}
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 ;
} [ ] ,
2026-09-06 17:59:52 +00:00
opts : { limit? : number ; fuelPrices? : FuelPriceLike [ ] } ,
2026-08-30 06:42:00 +00:00
) : FuelLogResult {
2026-09-06 17:59:52 +00:00
const fuelPrices = opts . fuelPrices ? ? [ ] ;
2026-08-30 06:42:00 +00:00
// 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 ) ;
2026-09-06 17:59:52 +00:00
const totalSpend = computeFuelCost ( logs , fuelPrices ) ;
2026-08-30 06:42:00 +00:00
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 ) ;
2026-09-06 17:59:52 +00:00
const cost = ( l . litres ? ? 0 ) * resolveFuelPrice ( l . date_logged , fuelPrices ) ;
2026-08-30 06:42:00 +00:00
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 ,
2026-09-06 17:59:52 +00:00
totalSpend ,
2026-08-30 06:42:00 +00:00
flagged ,
fillUps ,
2026-09-06 17:59:52 +00:00
display : { totalSpend : formatMoney ( totalSpend ) } ,
2026-08-30 06:42:00 +00:00
} ;
}
2026-09-25 20:35:48 +00:00
/** "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" ,
} ;
}
2026-09-26 07:37:27 +00:00
export interface SupplierBalancesResult {
scope : string ;
supplierCount : number ;
showing : number ;
2026-09-27 08:56:40 +00:00
/** 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 } } ;
2026-09-26 07:37:27 +00:00
suppliers : {
supplier : string ;
purchaseOrders : number ;
unresolvedDeliveryNotes : number ;
2026-09-27 08:56:40 +00:00
display : { ordered : string ; delivered : string ; outstanding : string ; receivedInPeriod? : string } ;
2026-09-26 07:37:27 +00:00
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 [ ] ,
2026-09-27 08:56:40 +00:00
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 > } ;
} = { } ,
2026-09-26 07:37:27 +00:00
) : SupplierBalancesResult | { error : string } {
const q = ( opts . supplier ? ? "" ) . trim ( ) . toLowerCase ( ) ;
2026-09-27 08:56:40 +00:00
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 ;
2026-09-26 07:37:27 +00:00
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 ;
2026-09-27 08:56:40 +00:00
let inPeriod = 0 ;
2026-09-26 07:37:27 +00:00
for ( const r of matched ) {
ordered += r . ordered ;
delivered += r . delivered ;
outstanding += r . outstanding ;
2026-09-27 08:56:40 +00:00
inPeriod += received ? . get ( r . id ) ? ? 0 ;
2026-09-26 07:37:27 +00:00
}
const limit = opts . limit ? ? 25 ;
return {
scope : q ? ` suppliers matching " ${ opts . supplier } " ` : "all suppliers" ,
2026-09-27 08:56:40 +00:00
. . . ( opts . period ? { period : { label : opts.period.label , from : opts . period . from , to : opts.period.to } } : { } ) ,
2026-09-26 07:37:27 +00:00
supplierCount : matched.length ,
showing : Math.min ( limit , matched . length ) ,
totals : {
display : {
ordered : formatMoney ( ordered ) ,
delivered : formatMoney ( delivered ) ,
outstanding : formatMoney ( outstanding ) ,
2026-09-27 08:56:40 +00:00
. . . ( received ? { receivedInPeriod : formatMoney ( inPeriod ) } : { } ) ,
2026-09-26 07:37:27 +00:00
} ,
} ,
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 ) ,
2026-09-27 08:56:40 +00:00
. . . ( received ? { receivedInPeriod : formatMoney ( received . get ( r . id ) ? ? 0 ) } : { } ) ,
2026-09-26 07:37:27 +00:00
} ,
href : ` /dashboard/financial/suppliers/ ${ r . id } ` ,
} ) ) ,
} ;
}
2026-09-26 07:39:38 +00:00
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" ,
} ,
} ;
}
2026-09-26 07:44:01 +00:00
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 ,
} ;
}