Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/src/pages/ReportOutput.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 22 additions & 34 deletions app/src/pages/report-output/ReportOutputLayout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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 = '',
Expand All @@ -75,13 +60,13 @@ export default function ReportOutputLayout({
return (
<Container size="xl" px={spacing.xl}>
<Stack gap={spacing.xl}>
{/* Back navigation */}
<Group gap={spacing.xs} align="center">
{/* TODO: Re-enable back navigation once Reports list page is implemented */}
{/* <Group gap={spacing.xs} align="center">
<IconChevronLeft size={20} color={colors.text.secondary} />
<Text size="md" c={colors.text.secondary}>
Reports
</Text>
</Group>
</Group> */}

{/* Header Section */}
<Box>
Expand All @@ -96,18 +81,20 @@ export default function ReportOutputLayout({
>
{reportLabel || reportId}
</Title>
<ActionIcon
{/* TODO: Re-enable edit name button once functionality is implemented */}
{/* <ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label="Edit report name"
onClick={onEditName}
>
<IconPencil size={18} />
</ActionIcon>
</ActionIcon> */}
</Group>

<Group gap={spacing.sm}>
{/* TODO: Re-enable "Run Again" and "Share" buttons once functionality is implemented */}
{/* <Group gap={spacing.sm}>
<Button
variant="filled"
leftSection={<IconRefresh size={18} />}
Expand All @@ -128,7 +115,7 @@ export default function ReportOutputLayout({
<Button variant="default" leftSection={<IconShare size={18} />} onClick={onShare}>
Share
</Button>
</Group>
</Group> */}
</Group>

{/* Timestamp and View All */}
Expand All @@ -137,9 +124,10 @@ export default function ReportOutputLayout({
<Text size="sm" c="dimmed">
{timestamp}
</Text>
<Anchor size="sm" underline="always" c={colors.blue[700]}>
{/* TODO: Re-enable "View All" link once functionality is implemented */}
{/* <Anchor size="sm" underline="always" c={colors.blue[700]}>
View All
</Anchor>
</Anchor> */}
</Group>
</Box>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export default function WinnersLosersIncomeDecileSubPage({ output }: Props) {
},
margin: {
t: 0,
b: 80,
b: 100,
l: 40,
r: 0,
},
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}<extra></extra>',
}));
// 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: '<b>%{customdata.title}</b><br><br>%{customdata.msg}<extra></extra>',
};
};

// 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<br />',
},
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<Layout>;

Expand Down
91 changes: 89 additions & 2 deletions app/src/tests/unit/utils/dateUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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}$/);
});
});
});
});
Loading
Loading