feat(reports): carry time of day on report date columns

Eleven report columns bucketed their timestamp to a bare day with to_char,
which is wrong for anything a user reads as an event rather than a period:
two departures on the same date, or a wagon request fulfilled hours after it
was raised, were indistinguishable in the output.

The renderer only shows the time when the value actually has one, keyed off
the string rather than a per-column flag — a genuine day bucket would
otherwise render as 12:00 AM, which reads as data rather than as absence.
This commit is contained in:
Nathnael
2026-08-24 07:12:08 +00:00
parent c3894462e7
commit 2be1876460
12 changed files with 24 additions and 17 deletions

View File

@@ -68,7 +68,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select("COALESCE(ts.train_number, '—')", 'trainNumber') .select("COALESCE(ts.train_number, '—')", 'trainNumber')
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD')`, 'departedAt') .addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
.addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station') .addSelect("COALESCE(oy.label, oy.code, '?') || ' → ' || COALESCE(dy.label, dy.code, '?')", 'station')
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category') .addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons') .addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')

View File

@@ -91,8 +91,8 @@ export const contractUtilizationReport: ReportDefinition = {
.addSelect('c.name', 'customer') .addSelect('c.name', 'customer')
.addSelect('ct.status', 'status') .addSelect('ct.status', 'status')
.addSelect('ct.contract_kind', 'kind') .addSelect('ct.contract_kind', 'kind')
.addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD HH24:MI')`, 'validFrom')
.addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD HH24:MI')`, 'validUntil')
.addSelect('COALESCE(cap.committed, 0)::float8', 'committed') .addSelect('COALESCE(cap.committed, 0)::float8', 'committed')
.addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons') .addSelect('COALESCE(booked.tons, 0)::float8', 'bookedTons')
.addSelect('COALESCE(booked.cnt, 0)', 'bookings') .addSelect('COALESCE(booked.cnt, 0)', 'bookings')

View File

@@ -72,7 +72,7 @@ export const firstLastMileBookingsReport: ReportDefinition = {
.addSelect('fl.status', 'status') .addSelect('fl.status', 'status')
.addSelect("COALESCE(v.plate_number, '—')", 'truck') .addSelect("COALESCE(v.plate_number, '—')", 'truck')
.addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned') .addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned')
.addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt'); .addSelect(`to_char(fl.created_at, 'YYYY-MM-DD HH24:MI')`, 'createdAt');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)

View File

@@ -49,7 +49,7 @@ export const loadedCapacityReport: ReportDefinition = {
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select('ts.train_number', 'trainNumber') .select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons') .addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons') .addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')

View File

@@ -378,7 +378,7 @@ export const receivablesPayablesReport: ReportDefinition = {
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(SIDE_LABEL_OF('r.side_key'), 'side') .select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt') .addSelect("to_char(r.txn_date, 'YYYY-MM-DD HH24:MI')", 'issuedAt')
.addSelect('r.doc_ref', 'invoiceNumber') .addSelect('r.doc_ref', 'invoiceNumber')
.addSelect('r.booking_ref', 'bookingRef') .addSelect('r.booking_ref', 'bookingRef')
.addSelect('r.booking_status', 'bookingStatus') .addSelect('r.booking_status', 'bookingStatus')

View File

@@ -63,7 +63,7 @@ export const revenueReconciliationReport: ReportDefinition = {
defaultSort: { key: 'variance', dir: 'DESC' }, defaultSort: { key: 'variance', dir: 'DESC' },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber') .addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect(PAYER_EXPR, 'customer') .addSelect(PAYER_EXPR, 'customer')

View File

@@ -93,7 +93,7 @@ export const revenueTransactionsReport: ReportDefinition = {
defaultSort: { key: 'issuedAt', dir: 'DESC' }, defaultSort: { key: 'issuedAt', dir: 'DESC' },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') .select(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD HH24:MI')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber') .addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect("COALESCE(b.id::text, '')", 'bookingId') .addSelect("COALESCE(b.id::text, '')", 'bookingId')

View File

@@ -76,7 +76,7 @@ export const trainScheduleStatusReport: ReportDefinition = {
.addSelect('ts.direction', 'direction') .addSelect('ts.direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin') .addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination') .addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture') .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'scheduledDeparture')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture') .addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival'); .addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
}, },

View File

@@ -57,8 +57,8 @@ export const wagonRequestsReport: ReportDefinition = {
.addSelect('r.quantity', 'quantity') .addSelect('r.quantity', 'quantity')
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity') .addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
.addSelect('r.status', 'status') .addSelect('r.status', 'status')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt') .addSelect(`to_char(r.created_at, 'YYYY-MM-DD HH24:MI')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt') .addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD HH24:MI')`, 'fulfilledAt')
.addSelect( .addSelect(
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`, `ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
'delayDays', 'delayDays',

View File

@@ -69,7 +69,7 @@ export const wagonStatusDurationReport: ReportDefinition = {
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station') .addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status') .addSelect('w.status', 'status')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since') .addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD HH24:MI')`, 'since')
.addSelect( .addSelect(
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`, `FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
'daysInStatus', 'daysInStatus',

View File

@@ -53,7 +53,7 @@ export const wagonTeuUtilizationReport: ReportDefinition = {
.select('w.wagon_number', 'wagonNumber') .select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType') .addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('ts.train_number', 'trainNumber') .addSelect('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate') .addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, 'departureDate')
.addSelect('COUNT(c.id)::int', 'containers') .addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu') .addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.groupBy('w.wagon_number') .groupBy('w.wagon_number')

View File

@@ -17,10 +17,17 @@ export function formatReportCell(value: unknown, type: ReportColumnType): string
case "number": case "number":
return Number(value).toLocaleString(); return Number(value).toLocaleString();
case "date": { case "date": {
const d = new Date(String(value)); const raw = String(value);
return Number.isNaN(d.getTime()) const d = new Date(raw);
? String(value) if (Number.isNaN(d.getTime())) return raw;
: d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); // Day-bucket columns carry no time part — don't invent a 12:00 AM for them.
const hasTime = /\d:\d/.test(raw);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}),
});
} }
default: default:
return String(value); return String(value);