Skip to content

Commit 130c673

Browse files
ctrimmclaude
andcommitted
Add industry CO2 breakdown section to homepage
Backend: - New Lambda handler: industryStats.ts Finds most recent valid scan date, paginates all rows, groups by industry computing avgCO2 / siteCount / greenPercent, returns sorted cleanest-first with overall average - New SST route: GET /industry-stats - New fetchIndustryStats() in src/lib/api.ts Frontend: - IndustryBreakdown.tsx: Recharts horizontal BarChart Bars colored green (below avg) or amber (above avg) Dashed ReferenceLine at overall average Custom tooltip shows CO2 / site count / green % Chart height scales with number of industries - Mounted in HomepageStats.tsx after the 30-Day Trend section Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aaed3a1 commit 130c673

File tree

5 files changed

+214
-0
lines changed

5 files changed

+214
-0
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createClient } from '@supabase/supabase-js';
2+
import { Resource } from "sst";
3+
4+
const supabase = createClient(
5+
Resource.MySupabaseUrl.value,
6+
Resource.MySupabaseAnonRoleKey.value
7+
);
8+
9+
// Returns avg CO2, site count, and green % broken down by industry,
10+
// computed from the most recent date that has valid (non-zero) CO2 data.
11+
export async function handler(_evt) {
12+
try {
13+
// Find the most recent date with at least one valid CO2 reading.
14+
const { data: latestDateRow, error: dateError } = await supabase
15+
.from('website_emissions')
16+
.select('date')
17+
.gt('estimated_co2_grams', 0)
18+
.order('date', { ascending: false })
19+
.limit(1)
20+
.single();
21+
22+
if (dateError) throw dateError;
23+
24+
const latestDate = latestDateRow?.date;
25+
26+
// Paginate through all rows for that date.
27+
const PAGE_SIZE = 1000;
28+
const allRows: { industry: string | null; estimated_co2_grams: number; is_green: boolean }[] = [];
29+
let from = 0;
30+
while (true) {
31+
const { data, error } = await supabase
32+
.from('website_emissions')
33+
.select('industry, estimated_co2_grams, is_green')
34+
.eq('date', latestDate)
35+
.gt('estimated_co2_grams', 0)
36+
.range(from, from + PAGE_SIZE - 1);
37+
if (error) throw error;
38+
if (!data || data.length === 0) break;
39+
allRows.push(...data);
40+
if (data.length < PAGE_SIZE) break;
41+
from += PAGE_SIZE;
42+
}
43+
44+
// Aggregate by industry in Lambda.
45+
const byIndustry: Record<string, { sum: number; count: number; greenCount: number }> = {};
46+
for (const row of allRows) {
47+
const key = row.industry?.trim() || 'Other';
48+
if (!byIndustry[key]) byIndustry[key] = { sum: 0, count: 0, greenCount: 0 };
49+
byIndustry[key].sum += row.estimated_co2_grams;
50+
byIndustry[key].count++;
51+
if (row.is_green) byIndustry[key].greenCount++;
52+
}
53+
54+
const industries = Object.entries(byIndustry)
55+
.map(([industry, { sum, count, greenCount }]) => ({
56+
industry,
57+
avgCO2: parseFloat((sum / count).toFixed(3)),
58+
siteCount: count,
59+
greenPercent: parseFloat(((greenCount / count) * 100).toFixed(1)),
60+
}))
61+
.sort((a, b) => a.avgCO2 - b.avgCO2); // cleanest first
62+
63+
const overallSum = allRows.reduce((s, r) => s + r.estimated_co2_grams, 0);
64+
const overallAvgCO2 = allRows.length > 0
65+
? parseFloat((overallSum / allRows.length).toFixed(3))
66+
: 0;
67+
68+
return {
69+
statusCode: 200,
70+
body: JSON.stringify({ industries, overallAvgCO2, scanDate: latestDate }),
71+
headers: {
72+
"Content-Type": "application/json",
73+
"Cache-Control": "public, max-age=300",
74+
},
75+
};
76+
} catch (error) {
77+
console.error('Error fetching industry stats:', error);
78+
return {
79+
statusCode: 500,
80+
body: JSON.stringify({ error: error.message }),
81+
headers: { "Content-Type": "application/json" },
82+
};
83+
}
84+
}

src/components/HomepageStats.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect } from 'react';
22
import { fetchStats, fetchTrend } from '@/lib/api';
3+
import { IndustryBreakdown } from '@/components/IndustryBreakdown';
34
import {
45
Card,
56
CardContent,
@@ -119,6 +120,9 @@ export function HomepageStats() {
119120
)}
120121
</div>
121122
</section>
123+
124+
{/* Industry Breakdown */}
125+
<IndustryBreakdown />
122126
</>
123127
);
124128
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { useState, useEffect } from 'react';
2+
import { fetchIndustryStats } from '@/lib/api';
3+
import { Skeleton } from '@/components/ui/skeleton';
4+
import {
5+
BarChart,
6+
Bar,
7+
XAxis,
8+
YAxis,
9+
CartesianGrid,
10+
Tooltip,
11+
ReferenceLine,
12+
Cell,
13+
ResponsiveContainer,
14+
} from 'recharts';
15+
16+
interface IndustryRow {
17+
industry: string;
18+
avgCO2: number;
19+
siteCount: number;
20+
greenPercent: number;
21+
}
22+
23+
interface TooltipPayload {
24+
payload: IndustryRow;
25+
}
26+
27+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) {
28+
if (!active || !payload?.length) return null;
29+
const d = payload[0].payload;
30+
return (
31+
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
32+
<p className="font-semibold mb-1">{d.industry}</p>
33+
<p className="text-muted-foreground">Avg CO2: <span className="text-foreground font-medium">{d.avgCO2.toFixed(3)} g</span></p>
34+
<p className="text-muted-foreground">Sites: <span className="text-foreground font-medium">{d.siteCount.toLocaleString()}</span></p>
35+
<p className="text-muted-foreground">Green hosted: <span className="text-foreground font-medium">{d.greenPercent.toFixed(1)}%</span></p>
36+
</div>
37+
);
38+
}
39+
40+
export function IndustryBreakdown() {
41+
const [industries, setIndustries] = useState<IndustryRow[]>([]);
42+
const [overallAvg, setOverallAvg] = useState(0);
43+
const [loading, setLoading] = useState(true);
44+
45+
useEffect(() => {
46+
fetchIndustryStats()
47+
.then((data) => {
48+
setIndustries(data.industries ?? []);
49+
setOverallAvg(data.overallAvgCO2 ?? 0);
50+
})
51+
.catch((e) => console.error('Failed to load industry stats:', e))
52+
.finally(() => setLoading(false));
53+
}, []);
54+
55+
// Chart height scales with the number of industries (min 300px).
56+
const chartHeight = Math.max(300, industries.length * 40);
57+
58+
return (
59+
<section id="industry-breakdown" className="container py-8 md:py-12 lg:py-12">
60+
<div className="mx-auto flex flex-col items-center space-y-4 text-center pb-8">
61+
<h2 className="font-heading text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
62+
By Industry
63+
</h2>
64+
<p className="max-w-[85%] lg:max-w-[55%] leading-normal text-muted-foreground sm:text-lg sm:leading-7 balance-text">
65+
Average CO2 per page view grouped by industry. Dashed line marks the
66+
overall average. Hover a bar for site count and green-hosting rate.
67+
</p>
68+
</div>
69+
70+
<div className="mx-auto max-w-[64rem] rounded-lg border bg-background p-4">
71+
{loading ? (
72+
<Skeleton className="rounded-lg" style={{ height: `${chartHeight}px` }} />
73+
) : industries.length === 0 ? (
74+
<p className="text-center text-muted-foreground py-12">No industry data available.</p>
75+
) : (
76+
<ResponsiveContainer width="100%" height={chartHeight}>
77+
<BarChart
78+
layout="vertical"
79+
data={industries}
80+
margin={{ top: 4, right: 48, left: 8, bottom: 4 }}
81+
>
82+
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
83+
<XAxis
84+
type="number"
85+
tickFormatter={(v) => `${v.toFixed(2)}g`}
86+
domain={[0, 'dataMax + 0.05']}
87+
tick={{ fontSize: 12 }}
88+
/>
89+
<YAxis
90+
type="category"
91+
dataKey="industry"
92+
width={130}
93+
tick={{ fontSize: 12 }}
94+
/>
95+
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--muted))' }} />
96+
<ReferenceLine
97+
x={overallAvg}
98+
stroke="hsl(var(--muted-foreground))"
99+
strokeDasharray="4 3"
100+
label={{ value: 'avg', position: 'top', fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
101+
/>
102+
<Bar dataKey="avgCO2" radius={[0, 4, 4, 0]} maxBarSize={28}>
103+
{industries.map((entry) => (
104+
<Cell
105+
key={entry.industry}
106+
fill={entry.avgCO2 <= overallAvg ? '#10b981' : '#f59e0b'}
107+
fillOpacity={0.85}
108+
/>
109+
))}
110+
</Bar>
111+
</BarChart>
112+
</ResponsiveContainer>
113+
)}
114+
</div>
115+
</section>
116+
);
117+
}

src/lib/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,11 @@ export async function fetchLeaderboard() {
5050
}
5151
return response.json();
5252
}
53+
54+
export async function fetchIndustryStats() {
55+
const response = await fetch(`${API_URL}/industry-stats`);
56+
if (!response.ok) {
57+
throw new Error('Failed to fetch industry stats');
58+
}
59+
return response.json();
60+
}

sst.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ export default $config({
3737
api.route("GET /stats", "packages/functions/src/stats.handler");
3838
api.route("GET /trend", "packages/functions/src/trend.handler");
3939
api.route("GET /leaderboard", "packages/functions/src/leaderboard.handler");
40+
api.route("GET /industry-stats", "packages/functions/src/industryStats.handler");
4041
}
4142
});

0 commit comments

Comments
 (0)