diff --git a/app/src/pages/ReportOutput.page.tsx b/app/src/pages/ReportOutput.page.tsx index b34984c1..5d981deb 100644 --- a/app/src/pages/ReportOutput.page.tsx +++ b/app/src/pages/ReportOutput.page.tsx @@ -5,6 +5,7 @@ import { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideC import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useUserReportById } from '@/hooks/useUserReports'; +import { formatReportTimestamp } from '@/utils/dateUtils'; import { HouseholdReportOutput } from './report-output/HouseholdReportOutput'; import ReportOutputLayout from './report-output/ReportOutputLayout'; import { SocietyWideReportOutput } from './report-output/SocietyWideReportOutput'; @@ -118,8 +119,8 @@ export default function ReportOutputPage() { navigate(`/${countryId}/report-output/${userReportId}/${tabValue}`); }; - // Format timestamp (placeholder for now) - const timestamp = 'Ran today at 05:23:41'; + // Format timestamp from userReport createdAt + const timestamp = formatReportTimestamp(userReport?.createdAt); // Show loading state while fetching data if (dataLoading) { diff --git a/app/src/pages/report-output/ReportOutputLayout.tsx b/app/src/pages/report-output/ReportOutputLayout.tsx index 07ede9fc..7741a26c 100644 --- a/app/src/pages/report-output/ReportOutputLayout.tsx +++ b/app/src/pages/report-output/ReportOutputLayout.tsx @@ -1,22 +1,5 @@ -import { - IconChevronLeft, - IconClock, - IconPencil, - IconRefresh, - IconShare, - IconStack2, -} from '@tabler/icons-react'; -import { - ActionIcon, - Anchor, - Box, - Button, - Container, - Group, - Stack, - Text, - Title, -} from '@mantine/core'; +import { IconClock, IconStack2 } from '@tabler/icons-react'; +import { Box, Container, Group, Stack, Text, Title } from '@mantine/core'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { getComparativeAnalysisTree } from './comparativeAnalysisTree'; @@ -30,9 +13,10 @@ interface ReportOutputLayoutProps { tabs: Array<{ value: string; label: string }>; activeTab: string; onTabChange: (tabValue: string) => void; - onRunAgain?: () => void; - onShare?: () => void; - onEditName?: () => void; + // TODO: Re-enable when functionality is implemented + // onRunAgain?: () => void; + // onShare?: () => void; + // onEditName?: () => void; showSidebar?: boolean; outputType?: 'household' | 'societyWide'; activeView?: string; @@ -58,9 +42,10 @@ export default function ReportOutputLayout({ tabs, activeTab, onTabChange, - onRunAgain, - onShare, - onEditName, + // TODO: Re-enable when functionality is implemented + // onRunAgain, + // onShare, + // onEditName, showSidebar = false, outputType = 'societyWide', activeView = '', @@ -75,13 +60,13 @@ export default function ReportOutputLayout({ return ( - {/* Back navigation */} - + {/* TODO: Re-enable back navigation once Reports list page is implemented */} + {/* Reports - + */} {/* Header Section */} @@ -96,7 +81,8 @@ export default function ReportOutputLayout({ > {reportLabel || reportId} - - + */} - + {/* TODO: Re-enable "Run Again" and "Share" buttons once functionality is implemented */} + {/* - + */} {/* Timestamp and View All */} @@ -137,9 +124,10 @@ export default function ReportOutputLayout({ {timestamp} - + {/* TODO: Re-enable "View All" link once functionality is implemented */} + {/* View All - + */} diff --git a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx index 37711c50..fc9d62c8 100644 --- a/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/WinnersLosersIncomeDecileSubPage.tsx @@ -238,7 +238,7 @@ export default function WinnersLosersIncomeDecileSubPage({ output }: Props) { }, margin: { t: 0, - b: 80, + b: 100, l: 40, r: 0, }, @@ -262,7 +262,7 @@ export default function WinnersLosersIncomeDecileSubPage({ output }: Props) { ...DEFAULT_CHART_CONFIG, locale: localeCode(countryId), }} - style={{ width: '100%', height: chartHeight, marginBottom: mobile ? 0 : 50 }} + style={{ width: '100%', height: chartHeight }} /> {description} diff --git a/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx b/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx index ea646710..c29b0203 100644 --- a/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx +++ b/app/src/pages/report-output/distributional-impact/WinnersLosersWealthDecileSubPage.tsx @@ -140,46 +140,101 @@ export default function WinnersLosersWealthDecileSubPage({ output }: Props) { downloadCsv([header, ...rows], 'winners-losers-wealth-decile.csv'); }; - // Prepare data for stacked bar chart - const chartData = CATEGORIES.map((category) => ({ - x: [...decileNumbers.map((d) => d.toString()), 'All'], - y: [...decileNumbers.map((d) => (deciles as any)[category]?.[d - 1] || 0), all[category]], - type: 'bar' as const, - name: LEGEND_TEXT_MAP[category], - marker: { - color: COLOR_MAP[category], - }, - customdata: [ - ...decileNumbers.map((d) => - hoverMessage((deciles as any)[category]?.[d - 1] || 0, d.toString(), category) - ), - hoverMessage(all[category], 'All', category), - ] as any, - hovertemplate: '%{customdata}', - })); + // Create traces for both "All" and individual deciles + // Following v1's approach with separate axes for All and Deciles + const hoverTitle = (y: string | number) => (y === 'All' ? 'All households' : `Decile ${y}`); + + const createTrace = (type: 'all' | 'decile', category: (typeof CATEGORIES)[number]) => { + const xArray = + type === 'all' + ? [all[category]] + : decileNumbers.map((d) => (deciles as any)[category][d - 1]); + const yArray = type === 'all' ? ['All'] : decileNumbers; + + return { + x: xArray, + y: yArray, + xaxis: type === 'all' ? 'x' : 'x2', + yaxis: type === 'all' ? 'y' : 'y2', + type: 'bar' as const, + orientation: 'h' as const, + name: LEGEND_TEXT_MAP[category], + legendgroup: category, + showlegend: type === 'decile', + marker: { + color: COLOR_MAP[category], + }, + text: xArray.map((value: number) => `${(value * 100).toFixed(0)}%`) as any, + textposition: 'inside' as const, + textangle: 0, + customdata: xArray.map((x: number, i: number) => ({ + title: hoverTitle(yArray[i]), + msg: hoverMessage(x, yArray[i].toString(), category), + })) as any, + hovertemplate: '%{customdata.title}

%{customdata.msg}', + }; + }; + + // Generate all traces (cartesian product of types and categories) + const chartData = []; + for (const type of ['all', 'decile'] as const) { + for (const category of CATEGORIES) { + chartData.push(createTrace(type, category)); + } + } const layout = { barmode: 'stack' as const, + grid: { + rows: 2, + columns: 1, + }, + // Y-axis for "All" row (top) + yaxis: { + title: { text: '' }, + tickvals: ['All'], + domain: [0.91, 1] as [number, number], + }, + // X-axis for "All" row xaxis: { - title: { text: 'Wealth decile' }, + title: { text: '' }, + tickformat: '.0%', + anchor: 'y', + matches: 'x2', + showgrid: false, + showticklabels: false, fixedrange: true, }, - yaxis: { + // X-axis for deciles (bottom) + xaxis2: { title: { text: 'Population share' }, - tickformat: ',.0%', + tickformat: '.0%', + anchor: 'y2', fixedrange: true, }, + // Y-axis for deciles (bottom) + yaxis2: { + title: { text: 'Wealth decile' }, + tickvals: decileNumbers, + anchor: 'x2', + domain: [0, 0.85] as [number, number], + }, + showlegend: true, legend: { - orientation: mobile ? ('h' as const) : ('v' as const), - yanchor: 'top', - y: mobile ? -0.2 : 1, - xanchor: mobile ? 'center' : 'left', - x: mobile ? 0.5 : 1.02, + title: { + text: 'Change in income
', + }, + tracegroupgap: 10, + }, + uniformtext: { + mode: 'hide', + minsize: mobile ? 7 : 10, }, margin: { t: 0, - b: mobile ? 120 : 80, - r: mobile ? 0 : 200, + b: 100, + l: 40, + r: 0, }, } as Partial; diff --git a/app/src/tests/unit/utils/dateUtils.test.ts b/app/src/tests/unit/utils/dateUtils.test.ts index 9c1097d6..3439992c 100644 --- a/app/src/tests/unit/utils/dateUtils.test.ts +++ b/app/src/tests/unit/utils/dateUtils.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { EXPECTED_FORMATS, TEST_COUNTRIES, TEST_DATES, } from '@/tests/fixtures/utils/dateUtilsMocks'; -import { formatDate } from '@/utils/dateUtils'; +import { formatDate, formatReportTimestamp } from '@/utils/dateUtils'; describe('dateUtils', () => { describe('formatDate', () => { @@ -167,4 +167,91 @@ describe('dateUtils', () => { }); }); }); + + describe('formatReportTimestamp', () => { + beforeEach(() => { + // Reset Date mocks before each test + vi.useRealTimers(); + }); + + describe('given undefined or null timestamp', () => { + it('then returns fallback message', () => { + // When + const result = formatReportTimestamp(undefined); + + // Then + expect(result).toBe('Ran recently'); + }); + }); + + describe('given timestamp from today', () => { + it('then returns "Ran today at HH:MM:SS"', () => { + // Given - Mock current time + const now = new Date('2024-01-15T14:30:45'); + vi.setSystemTime(now); + + // Create timestamp from earlier today + const todayTimestamp = new Date('2024-01-15T10:23:41').toISOString(); + + // When + const result = formatReportTimestamp(todayTimestamp); + + // Then + expect(result).toMatch(/^Ran today at \d{2}:\d{2}:\d{2}$/); + expect(result).toContain('10:23:41'); + }); + }); + + describe('given timestamp from this year but not today', () => { + it('then returns "Ran on Month Day at HH:MM:SS" without year', () => { + // Given - Mock current time in December + const now = new Date('2024-12-31T23:59:59'); + vi.setSystemTime(now); + + // Create timestamp from earlier this year + const thisYearTimestamp = new Date('2024-06-15T14:23:41').toISOString(); + + // When + const result = formatReportTimestamp(thisYearTimestamp); + + // Then + expect(result).toMatch(/^Ran on [A-Z][a-z]+ \d{1,2} at \d{2}:\d{2}:\d{2}$/); + expect(result).toContain('Jun 15'); + expect(result).toContain('14:23:41'); + expect(result).not.toContain('2024'); // Year should not be shown + }); + }); + + describe('given timestamp from previous year', () => { + it('then returns "Ran on Month Day, Year at HH:MM:SS"', () => { + // Given - Mock current time in 2025 + const now = new Date('2025-01-15T12:00:00'); + vi.setSystemTime(now); + + // Create timestamp from 2024 + const lastYearTimestamp = new Date('2024-06-15T14:23:41').toISOString(); + + // When + const result = formatReportTimestamp(lastYearTimestamp); + + // Then + expect(result).toMatch(/^Ran on [A-Z][a-z]+ \d{1,2}, \d{4} at \d{2}:\d{2}:\d{2}$/); + expect(result).toContain('Jun 15, 2024'); + expect(result).toContain('14:23:41'); + }); + }); + + describe('given timestamp with different time zones', () => { + it('then formats in user local time', () => { + // Given - Timestamp in UTC + const utcTimestamp = '2024-01-15T10:00:00.000Z'; + + // When + const result = formatReportTimestamp(utcTimestamp); + + // Then - Should contain a time (exact time depends on system timezone) + expect(result).toMatch(/at \d{2}:\d{2}:\d{2}$/); + }); + }); + }); }); diff --git a/app/src/utils/dateUtils.ts b/app/src/utils/dateUtils.ts index 685790da..f3a23e5c 100644 --- a/app/src/utils/dateUtils.ts +++ b/app/src/utils/dateUtils.ts @@ -114,3 +114,38 @@ function getFormatOptions(formatType: DateFormatType): Intl.DateTimeFormatOption }; } } + +/** + * Formats a timestamp for display in report output pages + * Shows relative time (today) or absolute date with time + * @param dateString - ISO date string or timestamp + * @returns Formatted timestamp string (e.g., "Ran today at 14:23:41" or "Ran on Jan 15 at 14:23:41") + */ +export function formatReportTimestamp(dateString?: string): string { + if (!dateString) { + return 'Ran recently'; + } + + const date = new Date(dateString); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + const timeString = date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + + if (isToday) { + return `Ran today at ${timeString}`; + } + + const dateStr = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); + + return `Ran on ${dateStr} at ${timeString}`; +}