Skip to content

Commit 8a93a28

Browse files
committed
Add stats endpoint, trend chart, site submission form, leaderboard page
GET /stats (packages/functions/src/stats.ts): Returns pre-aggregated totalActiveSites, totalIndexesRun, averageCO2Grams, and greenHostedPercent. The homepage now calls /stats + /trend instead of downloading the full /emissions-unique payload (~hundreds of KB) just to compute three numbers. GET /trend (packages/functions/src/trend.ts): Returns average CO2 per day for the last N days (default 30, max 90), aggregated in the Lambda from lightweight date/co2/is_green rows. GET /leaderboard (packages/functions/src/leaderboard.ts): Returns top 10 cleanest and top 10 heaviest sites from the most recent scan date, excluding sites with zero CO2 (failed scans). Homepage (src/pages/index.astro): - Switches data fetching to fetchStats() + fetchTrend() in parallel. - Adds a 30-day trend area chart section between Quick Glance and The Data. - "Add Site" button now links to /add-site instead of the GitHub repo. Site submission form (src/components/SubmitSiteForm.tsx + src/pages/add-site.astro): React form that builds a pre-filled GitHub issue URL. Validates and normalises the domain (strips protocol/www), collects optional org/industry/ notes fields, then opens the issue in a new tab. No backend required. Leaderboard page (src/pages/leaderboard.astro): Static page with two tables — cleanest and heaviest — fetched at build time via fetchLeaderboard(). Each domain links to its /site/[website] detail page. api.ts: adds fetchStats(), fetchTrend(), fetchLeaderboard(). sst.config.ts: registers the three new Lambda routes. FEATURES.md: marks items 4–6 and leaderboard as complete. https://claude.ai/code/session_01YafYX7riVJoSoZxYm913F6
1 parent 4a2617e commit 8a93a28

File tree

10 files changed

+564
-61
lines changed

10 files changed

+564
-61
lines changed

FEATURES.md

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,25 @@ Tracks planned features and their implementation status.
1414
per-industry lookup table (Government, Education, Healthcare, Finance, Technology,
1515
Media, Retail, Non-profit) so the "Compared to Industry Avg." stat card is accurate.
1616

17-
## Backlog
17+
- [x] **`GET /stats` endpoint** — Returns pre-aggregated numbers (total scans, mean CO2,
18+
% green hosted) from the API. The homepage now calls `/stats` + `/trend` instead of
19+
downloading all emissions rows just to compute three numbers.
20+
21+
- [x] **Site submission form** (`/add-site`) — Form that builds a pre-filled GitHub
22+
issue URL so contributors can propose a new site without needing to know the repo
23+
structure. Validates the domain, strips protocol/www, and opens the issue in a new tab.
1824

19-
- [ ] **`GET /stats` endpoint** — Return pre-aggregated numbers (total scans, mean CO2,
20-
% green hosted) from the API instead of computing them on the client from the full
21-
`/emissions-unique` payload. Reduces homepage data transfer and makes the stats
22-
available to external callers.
25+
- [x] **All-sites trend chart on homepage** — 30-day average CO2 area chart on the
26+
homepage using the new `GET /trend` endpoint. Aggregates by date in the Lambda and
27+
passes the result as a static prop to `AreaChartWrapper` at build time.
2328

24-
- [ ] **Site submission form** — The hero CTA currently links to the GitHub repo. A
25-
simple form that opens a GitHub issue (or calls a Lambda that creates one via the
26-
GitHub API) would lower the barrier for adding a site without needing a PR.
29+
- [x] **Leaderboard page** (`/leaderboard`) — Static page showing the top 10 cleanest
30+
and top 10 heaviest sites from the most recent nightly scan, with links to each site's
31+
detail page. Backed by a new `GET /leaderboard` endpoint.
2732

28-
- [ ] **All-sites trend chart on homepage** — Add a time-series chart to the homepage
29-
showing average CO2 across all monitored sites over the past 30 days. Makes the
30-
homepage more compelling and shows whether the web is getting greener over time.
33+
## Backlog
3134

3235
- [ ] **Alerts / notifications** — After each nightly upsert, check whether a site's
3336
CO2 increased more than a configurable threshold (e.g. >20%) vs. the prior week and
3437
send an email or post a GitHub issue. Turns the tracker from a passive dashboard into
3538
something actionable.
36-
37-
- [ ] **Leaderboard page** — A `/leaderboard` route showing the top 10 cleanest and
38-
top 10 heaviest sites by average CO2. Shareable and link-worthy.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 the top 10 cleanest and top 10 heaviest sites from the most
10+
// recent scan date. Sites with zero CO2 (failed/timed-out scans) are excluded.
11+
export async function handler(_evt) {
12+
try {
13+
const { data: latestDateRow, error: dateError } = await supabase
14+
.from('website_emissions')
15+
.select('date')
16+
.order('date', { ascending: false })
17+
.limit(1)
18+
.single();
19+
20+
if (dateError) throw dateError;
21+
22+
const latestDate = latestDateRow?.date;
23+
24+
const [cleanestResult, heaviestResult] = await Promise.all([
25+
supabase
26+
.from('website_emissions')
27+
.select('domain, estimated_co2_grams, is_green, total_bytes')
28+
.eq('date', latestDate)
29+
.gt('estimated_co2_grams', 0)
30+
.order('estimated_co2_grams', { ascending: true })
31+
.limit(10),
32+
supabase
33+
.from('website_emissions')
34+
.select('domain, estimated_co2_grams, is_green, total_bytes')
35+
.eq('date', latestDate)
36+
.gt('estimated_co2_grams', 0)
37+
.order('estimated_co2_grams', { ascending: false })
38+
.limit(10),
39+
]);
40+
41+
if (cleanestResult.error) throw cleanestResult.error;
42+
if (heaviestResult.error) throw heaviestResult.error;
43+
44+
return {
45+
statusCode: 200,
46+
body: JSON.stringify({
47+
date: latestDate,
48+
cleanest: cleanestResult.data || [],
49+
heaviest: heaviestResult.data || [],
50+
}),
51+
headers: {
52+
"Content-Type": "application/json",
53+
"Cache-Control": "public, max-age=300",
54+
},
55+
};
56+
} catch (error) {
57+
console.error('Error fetching leaderboard:', error);
58+
return {
59+
statusCode: 500,
60+
body: JSON.stringify({ error: error.message }),
61+
headers: { "Content-Type": "application/json" },
62+
};
63+
}
64+
}

packages/functions/src/stats.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 pre-aggregated stats for the homepage so it doesn't need to
10+
// download and process the full /emissions-unique payload.
11+
export async function handler(_evt) {
12+
try {
13+
// Get active site count and the most recent scan date in parallel.
14+
const [activeSitesResult, latestDateRow] = await Promise.all([
15+
supabase
16+
.from('monitored_sites')
17+
.select('*', { count: 'exact', head: true })
18+
.eq('is_active', true),
19+
supabase
20+
.from('website_emissions')
21+
.select('date')
22+
.order('date', { ascending: false })
23+
.limit(1)
24+
.single(),
25+
]);
26+
27+
if (latestDateRow.error) throw latestDateRow.error;
28+
29+
const latestDate = latestDateRow.data?.date;
30+
31+
// Fetch lightweight rows for the latest date to compute aggregates.
32+
const { data: emissions, error } = await supabase
33+
.from('website_emissions')
34+
.select('estimated_co2_grams, is_green')
35+
.eq('date', latestDate)
36+
.limit(activeSitesResult.count || 20000);
37+
38+
if (error) throw error;
39+
40+
const totalIndexesRun = emissions?.length || 0;
41+
const avgCO2 = totalIndexesRun > 0
42+
? emissions.reduce((sum, e) => sum + (e.estimated_co2_grams || 0), 0) / totalIndexesRun
43+
: 0;
44+
const greenCount = emissions?.filter(e => e.is_green).length || 0;
45+
const greenPercent = totalIndexesRun > 0 ? (greenCount / totalIndexesRun) * 100 : 0;
46+
47+
return {
48+
statusCode: 200,
49+
body: JSON.stringify({
50+
totalActiveSites: activeSitesResult.count || 0,
51+
latestScanDate: latestDate,
52+
totalIndexesRun,
53+
averageCO2Grams: parseFloat(avgCO2.toFixed(3)),
54+
greenHostedPercent: parseFloat(greenPercent.toFixed(2)),
55+
}),
56+
headers: {
57+
"Content-Type": "application/json",
58+
"Cache-Control": "public, max-age=300",
59+
},
60+
};
61+
} catch (error) {
62+
console.error('Error fetching stats:', error);
63+
return {
64+
statusCode: 500,
65+
body: JSON.stringify({ error: error.message }),
66+
headers: { "Content-Type": "application/json" },
67+
};
68+
}
69+
}

packages/functions/src/trend.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 average CO2 per day for the last N days (default 30, max 90).
10+
// Used by the homepage trend chart.
11+
export async function handler(event) {
12+
try {
13+
const rawDays = event.queryStringParameters?.days;
14+
const days = Math.min(Math.max(parseInt(rawDays || '30', 10) || 30, 7), 90);
15+
16+
const since = new Date();
17+
since.setDate(since.getDate() - days);
18+
const sinceStr = since.toISOString().split('T')[0];
19+
20+
// Fetch lightweight rows for the date range. At ~1 000 sites × 90 days
21+
// this is at most ~90 000 rows with 3 small fields — manageable in Lambda.
22+
const { data, error } = await supabase
23+
.from('website_emissions')
24+
.select('date, estimated_co2_grams, is_green')
25+
.gte('date', sinceStr)
26+
.order('date', { ascending: true })
27+
.limit(100000);
28+
29+
if (error) throw error;
30+
31+
// Aggregate by date in Lambda.
32+
const byDate: Record<string, { sum: number; count: number; greenCount: number }> = {};
33+
for (const row of data || []) {
34+
if (!byDate[row.date]) byDate[row.date] = { sum: 0, count: 0, greenCount: 0 };
35+
byDate[row.date].sum += row.estimated_co2_grams || 0;
36+
byDate[row.date].count++;
37+
if (row.is_green) byDate[row.date].greenCount++;
38+
}
39+
40+
const trend = Object.entries(byDate).map(([date, { sum, count, greenCount }]) => ({
41+
date,
42+
averageCO2: parseFloat((sum / count).toFixed(3)),
43+
siteCount: count,
44+
greenPercent: parseFloat(((greenCount / count) * 100).toFixed(1)),
45+
}));
46+
47+
return {
48+
statusCode: 200,
49+
body: JSON.stringify({ trend }),
50+
headers: {
51+
"Content-Type": "application/json",
52+
"Cache-Control": "public, max-age=300",
53+
},
54+
};
55+
} catch (error) {
56+
console.error('Error fetching trend:', error);
57+
return {
58+
statusCode: 500,
59+
body: JSON.stringify({ error: error.message }),
60+
headers: { "Content-Type": "application/json" },
61+
};
62+
}
63+
}

src/components/SubmitSiteForm.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"use client"
2+
3+
import { useState } from 'react';
4+
import { Button } from "@/components/ui/button";
5+
import { Input } from "@/components/ui/input";
6+
7+
const GITHUB_REPO = 'https://github.com/ctrimm/co2-emission-tracker';
8+
9+
const INDUSTRIES = [
10+
'Federal Government',
11+
'State Government',
12+
'Local Government / Municipal',
13+
'Education',
14+
'Healthcare',
15+
'Finance',
16+
'Technology',
17+
'Media / News',
18+
'Retail / E-commerce',
19+
'Non-profit',
20+
'Other',
21+
] as const;
22+
23+
export function SubmitSiteForm() {
24+
const [domain, setDomain] = useState('');
25+
const [organization, setOrganization] = useState('');
26+
const [industry, setIndustry] = useState('');
27+
const [notes, setNotes] = useState('');
28+
const [error, setError] = useState('');
29+
30+
function handleSubmit(e: React.FormEvent) {
31+
e.preventDefault();
32+
setError('');
33+
34+
// Strip protocol and path, leaving just the bare domain.
35+
const cleaned = domain.trim()
36+
.replace(/^https?:\/\//, '')
37+
.replace(/^www\./, '')
38+
.replace(/\/.*$/, '');
39+
40+
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
41+
if (!domainRegex.test(cleaned)) {
42+
setError('Please enter a valid domain, e.g. example.gov');
43+
return;
44+
}
45+
46+
const title = `Add Site: ${cleaned}`;
47+
const body = [
48+
`**Domain:** ${cleaned}`,
49+
organization.trim() ? `**Organization:** ${organization.trim()}` : null,
50+
industry ? `**Industry:** ${industry}` : null,
51+
notes.trim() ? `**Additional notes:** ${notes.trim()}` : null,
52+
].filter(Boolean).join('\n');
53+
54+
const issueUrl = `${GITHUB_REPO}/issues/new`
55+
+ `?title=${encodeURIComponent(title)}`
56+
+ `&body=${encodeURIComponent(body)}`
57+
+ `&labels=add-site`;
58+
59+
window.open(issueUrl, '_blank', 'noopener,noreferrer');
60+
}
61+
62+
const selectClass =
63+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm " +
64+
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 " +
65+
"focus-visible:ring-ring focus-visible:ring-offset-2";
66+
67+
const textareaClass =
68+
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm " +
69+
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none " +
70+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none";
71+
72+
return (
73+
<form onSubmit={handleSubmit} className="space-y-5 max-w-lg mx-auto">
74+
<div className="space-y-1.5">
75+
<label htmlFor="domain" className="text-sm font-medium">
76+
Domain <span className="text-red-500">*</span>
77+
</label>
78+
<Input
79+
id="domain"
80+
type="text"
81+
placeholder="example.gov"
82+
value={domain}
83+
onChange={e => setDomain(e.target.value)}
84+
required
85+
/>
86+
<p className="text-xs text-muted-foreground">
87+
Enter just the domain, without <code>https://</code> or a trailing slash.
88+
</p>
89+
</div>
90+
91+
<div className="space-y-1.5">
92+
<label htmlFor="organization" className="text-sm font-medium">
93+
Organization / Agency
94+
</label>
95+
<Input
96+
id="organization"
97+
type="text"
98+
placeholder="U.S. Department of Example"
99+
value={organization}
100+
onChange={e => setOrganization(e.target.value)}
101+
/>
102+
</div>
103+
104+
<div className="space-y-1.5">
105+
<label htmlFor="industry" className="text-sm font-medium">Industry</label>
106+
<select
107+
id="industry"
108+
value={industry}
109+
onChange={e => setIndustry(e.target.value)}
110+
className={selectClass}
111+
>
112+
<option value="">Select an industry…</option>
113+
{INDUSTRIES.map(i => <option key={i} value={i}>{i}</option>)}
114+
</select>
115+
</div>
116+
117+
<div className="space-y-1.5">
118+
<label htmlFor="notes" className="text-sm font-medium">Additional notes</label>
119+
<textarea
120+
id="notes"
121+
placeholder="Any context about why this site should be tracked…"
122+
value={notes}
123+
onChange={e => setNotes(e.target.value)}
124+
rows={3}
125+
className={textareaClass}
126+
/>
127+
</div>
128+
129+
{error && <p className="text-sm text-red-500">{error}</p>}
130+
131+
<Button type="submit" className="w-full">
132+
Open GitHub Issue →
133+
</Button>
134+
<p className="text-xs text-muted-foreground text-center">
135+
Clicking the button opens a pre-filled GitHub issue for review.
136+
A GitHub account is required to submit.
137+
</p>
138+
</form>
139+
);
140+
}

0 commit comments

Comments
 (0)