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
59 changes: 19 additions & 40 deletions app/src/components/ChartContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import { useRef, type ReactNode } from 'react';
import { IconDownload } from '@tabler/icons-react';
import {
Button,
Group,
Stack,
Text,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui';
import { ChartDownloadMenu, type ChartCsvData } from '@/components/ChartDownloadMenu';
import { Group, Stack, Text } from '@/components/ui';
import { typography } from '@/designTokens';
import { trackChartCsvDownloaded } from '@/utils/analytics';
import { downloadChartAsSvg } from '@/utils/chartUtils';

interface ChartContainerProps {
children: ReactNode;
title: string;
/** When set, renders a download button that exports the chart as SVG */
downloadFilename?: string;
/** Optional chart data for CSV export. When set alongside downloadFilename,
* the download button becomes a dropdown with SVG + CSV options. */
csvData?: ChartCsvData;
}

/**
* A consistent container for charts with standard border, padding, and background styling.
* Automatically renders a header with title and SVG download icon button.
*
* @param title - Chart title text
* @param downloadFilename - SVG filename (enables download button when set)
* @param children - Main content (description and chart) displayed inside the white card
* Automatically renders a header with title and a download icon button (SVG only by default,
* or a dropdown with SVG + CSV when csvData is provided).
*/
export function ChartContainer({ children, title, downloadFilename }: ChartContainerProps) {
export function ChartContainer({
children,
title,
downloadFilename,
csvData,
}: ChartContainerProps) {
const contentRef = useRef<HTMLDivElement>(null);

return (
Expand All @@ -38,28 +33,12 @@ export function ChartContainer({ children, title, downloadFilename }: ChartConta
{title}
</Text>
{downloadFilename && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="tw:shrink-0"
onClick={() => {
trackChartCsvDownloaded();
if (contentRef.current) {
downloadChartAsSvg(contentRef.current, {
title,
filename: downloadFilename,
});
}
}}
aria-label="Download as SVG"
>
<IconDownload size={18} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Download as SVG</TooltipContent>
</Tooltip>
<ChartDownloadMenu
containerRef={contentRef}
svgFilename={downloadFilename}
title={title}
csvData={csvData}
/>
)}
</Group>

Expand Down
124 changes: 124 additions & 0 deletions app/src/components/ChartDownloadMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { IconDownload } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { trackChartCsvDownloaded, trackChartSvgDownloaded } from '@/utils/analytics';
import { downloadChartAsSvg, downloadCsv } from '@/utils/chartUtils';

export type ChartCsvData = string[][] | (() => string[][]);

interface ChartDownloadMenuProps {
/** Ref to the element containing the chart SVG(s) to export. */
containerRef: React.RefObject<HTMLDivElement | null>;
/** SVG filename (e.g. "winners-losers-income-decile.svg"). */
svgFilename: string;
/** Title written into the SVG header. */
title?: string;
/** Subtitle written into the SVG header. */
subtitle?: string;
/** Optional CSV data. When provided, the button becomes a dropdown with SVG + CSV options. */
csvData?: ChartCsvData;
/** Icon size for the trigger (default 18). */
iconSize?: number;
/** Button size variant. */
buttonSize?: 'icon' | 'icon-xs';
}

function deriveCsvFilename(svgFilename: string): string {
return svgFilename.replace(/\.svg$/i, '.csv');
}

function resolveCsvData(csvData: ChartCsvData): string[][] {
return typeof csvData === 'function' ? csvData() : csvData;
}

/**
* Download trigger for charts. Renders a single SVG-download button when no
* CSV data is provided, or a dropdown menu with "Download as SVG" and
* "Download data (CSV)" when csvData is supplied.
*/
export function ChartDownloadMenu({
containerRef,
svgFilename,
title,
subtitle,
csvData,
iconSize = 18,
buttonSize = 'icon',
}: ChartDownloadMenuProps) {
const handleSvgDownload = (e?: React.MouseEvent) => {
e?.stopPropagation();
trackChartSvgDownloaded();
if (containerRef.current) {
downloadChartAsSvg(containerRef.current, {
title,
subtitle,
filename: svgFilename,
});
}
};

const handleCsvDownload = (e?: React.MouseEvent) => {
e?.stopPropagation();
if (!csvData) {
return;
}
trackChartCsvDownloaded();
const rows = resolveCsvData(csvData);
if (rows.length === 0) {
return;
}
downloadCsv(rows, deriveCsvFilename(svgFilename));
};

if (!csvData) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size={buttonSize}
className="tw:shrink-0"
onClick={handleSvgDownload}
aria-label="Download as SVG"
>
<IconDownload size={iconSize} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Download as SVG</TooltipContent>
</Tooltip>
);
}

return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={buttonSize}
className="tw:shrink-0"
onClick={(e) => e.stopPropagation()}
aria-label="Download chart"
>
<IconDownload size={iconSize} />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="left">Download</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleSvgDownload()}>Download as SVG</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCsvDownload()}>
Download data (CSV)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
50 changes: 23 additions & 27 deletions app/src/components/report/DashboardCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { IconArrowsMinimize, IconDownload } from '@tabler/icons-react';
import { IconArrowsMinimize } from '@tabler/icons-react';
import { motion } from 'framer-motion';
import { ChartDownloadMenu, type ChartCsvData } from '@/components/ChartDownloadMenu';
import { Text } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { colors, spacing } from '@/designTokens';
import { typography } from '@/designTokens/typography';
import { trackChartCsvDownloaded } from '@/utils/analytics';
import { downloadChartAsSvg } from '@/utils/chartUtils';

const FADE_MS = 150;
const RESIZE_S = 0.35;
Expand Down Expand Up @@ -42,6 +41,9 @@ interface DashboardCardProps {
expandedTitle?: string;
/** SVG download filename — renders a download button in the expanded toolbar */
downloadFilename?: string;
/** Optional CSV data for the expanded chart. When set alongside
* downloadFilename, the download button becomes a dropdown with SVG + CSV. */
csvData?: ChartCsvData;

// Style overrides (apply only when shrunken/idle)
shrunkenBackground?: string;
Expand Down Expand Up @@ -84,6 +86,7 @@ export default function DashboardCard({
expandedControls,
expandedTitle,
downloadFilename,
csvData,
shrunkenBackground,
shrunkenBorderColor,
padding: paddingProp,
Expand Down Expand Up @@ -192,6 +195,13 @@ export default function DashboardCard({
const shrunkenContentOpacity = phase === 'idle' ? 1 : 0;
const mountExpanded = phase === 'expanded' || phase === 'pre-collapse';
const expandedContentOpacity = phase === 'expanded' && expandedVisible ? 1 : 0;
// Clip overflow during animations and in idle state so mini-chart content
// doesn't bleed past the rounded corners. While the card sits at expanded
// dimensions (either fully expanded or fading out on pre-collapse), let
// overflow be visible so Recharts tooltips near the chart edges (e.g.
// decile 8–10 on Winners & Losers) don't get cut off by the card boundary.
const cardOverflow =
phase === 'expanded' || phase === 'pre-collapse' ? 'visible' : 'hidden';

// Animate target: cell size when shrinking, expanded size when growing
const getAnimateTarget = (): { width: number; height: number } | undefined => {
Expand Down Expand Up @@ -265,15 +275,15 @@ export default function DashboardCard({
border: `1px solid ${cardBorderColor}`,
padding: cardPadding,
cursor: !isExpanded && onToggleMode ? 'pointer' : undefined,
overflow: 'hidden',
overflow: cardOverflow,
boxShadow: isLifted ? '0 8px 32px rgba(0,0,0,0.12)' : 'none',
display: 'flex',
flexDirection: 'column',
}}
onClick={!isExpanded ? onToggleMode : undefined}
>
{/* Content area */}
<div style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ position: 'relative', flex: 1, minHeight: 0, overflow: cardOverflow }}>
{/* Shrunken layer — always mounted, opacity-controlled */}
<div
style={{
Expand Down Expand Up @@ -378,28 +388,14 @@ export default function DashboardCard({
}}
>
{downloadFilename && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.stopPropagation();
trackChartCsvDownloaded();
if (expandedContentRef.current) {
downloadChartAsSvg(expandedContentRef.current, {
title: expandedTitle,
filename: downloadFilename,
});
}
}}
aria-label="Download as SVG"
>
<IconDownload size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Download as SVG</TooltipContent>
</Tooltip>
<ChartDownloadMenu
containerRef={expandedContentRef}
svgFilename={downloadFilename}
title={expandedTitle}
csvData={csvData}
iconSize={16}
buttonSize="icon-xs"
/>
)}
{onToggleMode && expandButton}
</div>
Expand Down
8 changes: 6 additions & 2 deletions app/src/pages/ReportOutput.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,12 @@ export default function ReportOutputPage({
const activeView = view || '';
const versionMetadata = extractReportVersionMetadata(report?.output);

// Format the report creation timestamp using the current country's locale
const timestamp = formatReportTimestamp(userReport?.createdAt, countryId);
// Show the most recent run timestamp (updatedAt is set whenever a report is
// replaced/rerun; fall back to createdAt for reports that have never been rerun).
const timestamp = formatReportTimestamp(
userReport?.updatedAt ?? userReport?.createdAt,
countryId
);

// Hook for saving shared reports with all ingredients
const { saveSharedReport, saveResult, setSaveResult } = useSaveSharedReport();
Expand Down
Loading
Loading