diff --git a/frontend/src/app/data-viewer/FieldLabel.tsx b/frontend/src/app/data-viewer/FieldLabel.tsx
new file mode 100644
index 0000000..1f2b315
--- /dev/null
+++ b/frontend/src/app/data-viewer/FieldLabel.tsx
@@ -0,0 +1,25 @@
+import { FONT_MONO } from './theme';
+import { Text } from '@mantine/core';
+
+export function FieldLabel({
+ children,
+ small,
+}: {
+ children: React.ReactNode;
+ small?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/app/data-viewer/MetricsPanels.tsx b/frontend/src/app/data-viewer/MetricsPanels.tsx
new file mode 100644
index 0000000..5ea50e0
--- /dev/null
+++ b/frontend/src/app/data-viewer/MetricsPanels.tsx
@@ -0,0 +1,48 @@
+import { FONT_DISPLAY, COLOR } from './theme';
+import { FieldLabel } from './FieldLabel';
+
+import { Grid, Text } from '@mantine/core';
+
+const METRICS: { label: string; field: string; prefix?: string }[] = [
+ { label: 'Population', field: 'Population (ACS)' },
+ {
+ label: 'Household Income',
+ field: 'Median Household Income',
+ prefix: '$',
+ },
+ { label: 'Median Age', field: 'Median Age' },
+ {
+ label: 'In Labor Force (16+)',
+ field: 'Labor Force Participation Rate (16+)',
+ },
+ { label: 'Median Home Value', field: 'Median Home Value', prefix: '$' },
+];
+
+const metricValueStyle = {
+ fontFamily: FONT_DISPLAY,
+ fontSize: '1.9rem',
+ fontWeight: 600,
+ lineHeight: 1,
+ color: COLOR.birch,
+ marginBottom: 8,
+};
+
+const formatNumber = (v?: number) =>
+ v === undefined || v === null ? '—' : v.toLocaleString();
+
+export function MetricsPanel({ metrics }: { metrics: Record }) {
+ return (
+
+ {METRICS.map(({ label, field, prefix }) => (
+
+ s
+
+ {prefix}
+ {formatNumber(metrics[field])}
+
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/app/data-viewer/Tabs.module.css b/frontend/src/app/data-viewer/Tabs.module.css
index d54fb67..032cac8 100644
--- a/frontend/src/app/data-viewer/Tabs.module.css
+++ b/frontend/src/app/data-viewer/Tabs.module.css
@@ -1,4 +1,4 @@
.tab:hover {
background-color: white;
- color: #143460;
-}
\ No newline at end of file
+ color: #122820;
+}
diff --git a/frontend/src/app/data-viewer/page.tsx b/frontend/src/app/data-viewer/page.tsx
index 284e4df..e101df8 100644
--- a/frontend/src/app/data-viewer/page.tsx
+++ b/frontend/src/app/data-viewer/page.tsx
@@ -1,7 +1,25 @@
'use client';
-import { Box, Grid, Text, Title, Tabs, ScrollArea } from '@mantine/core';
-import { HouseLineIcon, UserListIcon, TreeIcon, GraduationCapIcon, TrendUpIcon, Icon } from '@phosphor-icons/react';
+import {
+ Box,
+ Grid,
+ Text,
+ Title,
+ Tabs,
+ Container,
+ Group,
+ Button,
+ Stack,
+} from '@mantine/core';
+import {
+ HouseLineIcon,
+ UserListIcon,
+ TreeIcon,
+ GraduationCapIcon,
+ TrendUpIcon,
+ PencilSimpleIcon,
+ Icon,
+} from '@phosphor-icons/react';
import { createChartItem, createTableItem } from '@/utils/itemFactory';
import { ChartStack } from '@/components/Charts';
import { useProfile } from '@/components/profile/profileStore';
@@ -10,9 +28,15 @@ import {
buildFilters,
} from '@/components/FilterUI/useApplyFilters';
-import classes from './Tabs.module.css';
+import { motion } from 'motion/react';
+import classes from './Tabs.module.css';
import { useEffect, useState } from 'react';
+
+// within data viewer imports
import { ChartDef, chartDefs } from '@/components/Charts/configs/ChartDefs';
+import { FONT_MONO, COLOR } from './theme';
+import { FieldLabel } from './FieldLabel';
+import { MetricsPanel } from './MetricsPanels';
// Imports needed for stat cards
import { BASE_API_URL } from '@/config';
@@ -48,62 +72,20 @@ function StatCards() {
return acc;
}, {});
- const formatNumber = (v?: number) =>
- v === undefined || v === null ? '—' : v.toLocaleString();
-
- return (
-
-
-
- {formatNumber(metrics['Population (ACS)'])}
-
-
- Population
-
-
-
-
-
- ${formatNumber(metrics['Median Household Income'])}
-
-
- Household Income
-
-
-
-
-
- {formatNumber(metrics['Median Age'])}
-
-
- Median Age
-
-
-
-
-
- {formatNumber(metrics['Labor Force Participation Rate (16+)'])}
-
-
- In Labor Force (16+)
-
-
-
-
-
- ${formatNumber(metrics['Median Home Value'])}
-
-
- Median Home Value
-
-
-
- );
+ return ;
}
-interface Section {id: string; label: string; icon: Icon}
+interface Section {
+ id: string;
+ label: string;
+ icon: Icon;
+}
-function ChartTabs({sections, activeTab, setActiveTab}: {
+function ChartTabs({
+ sections,
+ activeTab,
+ setActiveTab,
+}: {
sections: Section[];
activeTab: string | null;
setActiveTab: (v: string | null) => void;
@@ -112,11 +94,10 @@ function ChartTabs({sections, activeTab, setActiveTab}: {
@@ -134,9 +115,232 @@ function ChartTabs({sections, activeTab, setActiveTab}: {
);
}
+function HeroSection({
+ myLocation,
+ comparison,
+ interests,
+ yearMin,
+ yearMax,
+ openProfileModal,
+ sections,
+ activeTab,
+ setActiveTab,
+}: {
+ myLocation: any;
+ comparison: any;
+ interests: string[];
+ yearMin: number;
+ yearMax: number;
+ openProfileModal: () => void;
+ sections: Section[];
+ activeTab: string | null;
+ setActiveTab: (v: string | null) => void;
+}) {
+ return (
+ // Full-bleed: breaks out of the page's centered Container so the hero
+ // touches both edges of the viewport instead of floating as a card.
+
+
+
+
+
+
+ Data Viewer
+
+
+
+ {myLocation?.name || 'No Location Selected'}
+ {comparison?.name && (
+
+ compared to {comparison.name}
+
+ )}
+
+
+ {/* Profile panel — sits beside the title, always on the dark
+ background so the light text stays legible. Kept compact so
+ it doesn't compete with the location title. */}
+
+
+
+
+
+ Your profile
+
+ }
+ onClick={openProfileModal}
+ styles={{ root: { color: COLOR.amberSoft } }}
+ >
+ Edit
+
+
+
+
+
+
+
+ Interests
+ {interests.length > 0 ? (
+
+ {interests.join(' · ')}
+
+ ) : (
+
+ None selected
+
+ )}
+
+
+
+
+
+
+
+ {/* Stat cards — quick-glance snapshot for the selected location */}
+
+
+
+
+ {/* Category tabs live in the hero so they read as part of the
+ location's summary rather than a separate page section. */}
+
+
+
+
+
+
+
+ );
+}
+
+function ProfileField({
+ label,
+ value,
+ small,
+}: {
+ label: string;
+ value?: string;
+ small?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value || '—'}
+
+
+ );
+}
export default function DataViewerPage() {
- const { myLocation, comparison, interests, yearMin, yearMax } = useProfile();
+ const {
+ myLocation,
+ comparison,
+ interests,
+ yearMin,
+ yearMax,
+ openProfileModal,
+ } = useProfile();
const [chartData, setChartData] = useState<
Record
>({});
@@ -147,8 +351,8 @@ export default function DataViewerPage() {
Record
>({});
-
const [focusMode, setFocusMode] = useState<'all' | 'focus'>('all');
+ const [activeTab, setActiveTab] = useState(null);
const applyFilters = useApplyFilters();
const tableDefs = chartDefs.filter((c) =>
@@ -166,26 +370,30 @@ export default function DataViewerPage() {
'Labor & Economy': TrendUpIcon,
};
- const sections = [...new Set(chartDefs.flatMap((c) => c.categories ?? []))]
- .map((category) => ({
+ const sections = [
+ ...new Set(chartDefs.flatMap((c) => c.categories ?? [])),
+ ].map((category) => ({
id: category.toLowerCase().replace(/\s+/g, '-'),
label: category,
icon: categoryIcons[category] ?? HouseLineIcon,
}));
-
useEffect(() => {
nonTableDefs.forEach((chart: ChartDef) => {
const url = chart.url;
const filters = buildFilters(myLocation, {
col: 'year',
- selected: [chart.chartParams?.fixedYear ?? yearMin,
- chart.chartParams?.fixedYear ?? yearMax],
+ selected: [
+ chart.chartParams?.fixedYear ?? yearMin,
+ chart.chartParams?.fixedYear ?? yearMax,
+ ],
});
const compFilters = buildFilters(comparison, {
col: 'year',
- selected: [chart.chartParams?.fixedYear ?? yearMin,
- chart.chartParams?.fixedYear ?? yearMax],
+ selected: [
+ chart.chartParams?.fixedYear ?? yearMin,
+ chart.chartParams?.fixedYear ?? yearMax,
+ ],
});
applyFilters({
@@ -279,7 +487,7 @@ export default function DataViewerPage() {
notes: `County-level data (${employmentCounty} County) — QCEW does not report employment at the town level.`,
});
}
-
+
return createChartItem({
title: myLocation.name,
xField: chart.xField,
@@ -321,7 +529,6 @@ export default function DataViewerPage() {
);
const allItems = [...charts, ...tableItems];
-
let filteredItems = allItems;
if (focusMode === 'focus' && interests.length > 0) {
@@ -330,81 +537,43 @@ export default function DataViewerPage() {
);
}
- const [activeTab, setActiveTab] = useState(null);
-
-
if (activeTab) {
- const section = sections.find(
- (s) => s.id === activeTab
- );
+ const section = sections.find((s) => s.id === activeTab);
- if (section) {
- filteredItems = filteredItems.filter((item) =>
- item.categories?.includes(section.label)
- );
+ if (section) {
+ filteredItems = filteredItems.filter((item) =>
+ item.categories?.includes(section.label),
+ );
+ }
}
-}
-
-const visibleItems = filteredItems;
-
-return (
-
-
- {/* Hero */}
-
- {/* Hero content */}
-
-
- Data Viewer
-
-
-
- {myLocation.name}
-
-
- Compared with {comparison.name}
-
+ const visibleItems = filteredItems;
-
-
-
-
-
-
-
-
-
- {/* Charts... */}
-
+ return (
+
+
+
+ {/* Charts... */}
+
c.chartParams?.defId
- )}
+ defIds={visibleItems.map((c) => c.chartParams?.defId)}
view="gallery"
/>
-
+
-
-);
-}
\ No newline at end of file
+ );
+}
diff --git a/frontend/src/app/data-viewer/theme.ts b/frontend/src/app/data-viewer/theme.ts
new file mode 100644
index 0000000..202a050
--- /dev/null
+++ b/frontend/src/app/data-viewer/theme.ts
@@ -0,0 +1,15 @@
+export const COLOR = {
+ spruce: '#1B3A2F',
+ spruceDeep: '#122820',
+ slate: '#40525A',
+ birch: '#F6F5EF',
+ birchDim: '#EEEBE0',
+ ink: '#1B211D',
+ amber: '#dd9a2f',
+ amberSoft: '#E7B563',
+ amberYellow: '#FFD100',
+ line: 'rgba(27, 58, 47, 0.14)',
+};
+export const FONT_DISPLAY = "'Fraunces', 'Iowan Old Style', serif";
+export const FONT_BODY = "'General Sans', 'Inter', sans-serif";
+export const FONT_MONO = "'IBM Plex Mono', ui-monospace, monospace";
diff --git a/frontend/src/app/working-report/page.tsx b/frontend/src/app/working-report/page.tsx
index 44adda1..3ec3c05 100644
--- a/frontend/src/app/working-report/page.tsx
+++ b/frontend/src/app/working-report/page.tsx
@@ -22,11 +22,303 @@ import {
useApplyFilters,
buildFilters,
} from '@/components/FilterUI/useApplyFilters';
+import { motion } from 'motion/react';
+import {
+ PencilSimpleIcon,
+ DownloadSimpleIcon,
+ Icon,
+ XIcon,
+} from '@phosphor-icons/react';
import { ChartDef, chartDefs } from '@/components/Charts/configs/ChartDefs';
import { createChartItem, createTableItem } from '@/utils/itemFactory';
import { useItems } from '@/components/ItemsProvider';
import { PdfModeContext } from '@/contexts/PdfModeContext';
+const COLOR = {
+ spruce: '#1B3A2F',
+ spruceDeep: '#122820',
+ slate: '#40525A',
+ birch: '#F6F5EF',
+ birchDim: '#EEEBE0',
+ ink: '#1B211D',
+ amber: '#dd9a2f',
+ amberSoft: '#E7B563',
+ amberYellow: '#FFD100',
+ line: 'rgba(27, 58, 47, 0.14)',
+};
+const FONT_DISPLAY = "'Fraunces', 'Iowan Old Style', serif";
+const FONT_BODY = "'General Sans', 'Inter', sans-serif";
+const FONT_MONO = "'IBM Plex Mono', ui-monospace, monospace";
+
+function HeroSection({
+ myLocation,
+ comparison,
+ interests,
+ yearMin,
+ yearMax,
+ openProfileModal,
+ isGenerating,
+ handleDownloadPdf,
+ handleClearReport,
+}: {
+ myLocation: any;
+ comparison: any;
+ interests: string[];
+ yearMin: number;
+ yearMax: number;
+ openProfileModal: () => void;
+ isGenerating: boolean;
+ handleDownloadPdf: () => void;
+ handleClearReport: () => void;
+}) {
+ return (
+ // Full-bleed: breaks out of the page's centered Container so the hero
+ // touches both edges of the viewport instead of floating as a card.
+
+
+
+
+
+
+ Working Report
+
+
+
+ {myLocation?.name || 'No Location Selected'}
+ {comparison?.name && (
+
+ compared to {comparison.name}
+
+ )}
+
+
+
+ {/* Profile panel — sits beside the title, always on the dark
+ background so the light text stays legible. Kept compact so
+ it doesn't compete with the location title. */}
+
+
+
+
+
+ Report Summary
+
+ }
+ onClick={openProfileModal}
+ styles={{ root: { color: COLOR.amberSoft } }}
+ >
+ Edit
+
+
+
+
+
+
+
+ Interests
+ {interests.length > 0 ? (
+
+ {interests.join(' · ')}
+
+ ) : (
+
+ None selected
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function FieldLabel({
+ children,
+ small,
+}: {
+ children: React.ReactNode;
+ small?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ProfileField({
+ label,
+ value,
+ small,
+}: {
+ label: string;
+ value?: string;
+ small?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value || '—'}
+
+
+ );
+}
+
+type ReportActionsProps = {
+ isGenerating: boolean;
+ onDownload: () => void;
+ onClear: () => void;
+};
+
+function ReportActions({
+ isGenerating,
+ onDownload,
+ onClear,
+}: ReportActionsProps) {
+ return (
+
+ }
+ style={{
+ backgroundColor: COLOR.birchDim,
+ color: COLOR.spruceDeep,
+ border: 'none',
+ fontFamily: FONT_BODY,
+ }}
+ >
+ Download PDF
+
+
+ }
+ style={{
+ border: 'none',
+ fontFamily: FONT_BODY,
+ }}
+ >
+ Clear report
+
+
+ );
+}
+
export default function WorkingReport() {
const chartsRef = useRef(null);
const [isPdfMode, setIsPdfMode] = useState(false);
@@ -113,13 +405,17 @@ export default function WorkingReport() {
const url = chart.url;
const filters = buildFilters(myLocation, {
col: 'year',
- selected: [chart.chartParams?.fixedYear ?? yearMin,
- chart.chartParams?.fixedYear ?? yearMax],
+ selected: [
+ chart.chartParams?.fixedYear ?? yearMin,
+ chart.chartParams?.fixedYear ?? yearMax,
+ ],
});
const compFilters = buildFilters(comparison, {
col: 'year',
- selected: [chart.chartParams?.fixedYear ?? yearMin,
- chart.chartParams?.fixedYear ?? yearMax],
+ selected: [
+ chart.chartParams?.fixedYear ?? yearMin,
+ chart.chartParams?.fixedYear ?? yearMax,
+ ],
});
applyFilters({
@@ -299,124 +595,27 @@ export default function WorkingReport() {
}
};
- function reportSummary() {
- return (
-
-
-
- Report Summary
-
-
-
- LOCATION
-
-
- {myLocation.name}
-
-
-
-
- COMPARED WITH
-
-
-
- {comparison.name}
-
-
-
-
-
- REPORT PERIOD
-
-
-
- {yearMin}–{yearMax}
-
-
-
-
- );
- }
-
- function reportActionButtons() {
- return (
-
-
-
-
- );
- }
-
- function reportHeaderCard() {
- return (
-
-
-
-
-
- Working Report
-
-
-
- Tailor your personalized report to your specific interests and
- needs.
-
- {reportActionButtons()}
-
- {`${includedPairs.length} of ${allPairs.length} charts included in report`}
-
-
-
- {reportSummary()}
-
-
- );
- }
+ const handleClearReport = () => {
+ clearItems();
+ clearExclusions();
+ setPendingReset(true);
+ openProfileModal();
+ };
return (
- {reportHeaderCard()}
+