Skip to content

Commit 209f235

Browse files
Merge pull request #242 from CodeForPhilly/feat/home-refresh
feat: liven the home page and fix Volunteer links
2 parents 2f77363 + b95d110 commit 209f235

11 files changed

Lines changed: 222 additions & 47 deletions

File tree

‎apps/api/src/services/project.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface ProjectListResult {
3535
readonly facets: ProjectFacets;
3636
}
3737

38-
const ALLOWED_SORT_KEYS = new Set(['createdAt', 'updatedAt', 'title', 'stage']);
38+
const ALLOWED_SORT_KEYS = new Set(['createdAt', 'updatedAt', 'title', 'stage', 'activity']);
3939
const STAGE_ORDER = ['commenting', 'bootstrapping', 'prototyping', 'testing', 'maintaining', 'drifting', 'hibernating'];
4040

4141
function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boolean }> | null {
@@ -51,10 +51,28 @@ function parseSortSpec(sort: string | undefined): Array<{ key: string; desc: boo
5151
return result;
5252
}
5353

54-
function compareProjects(a: Project, b: Project, sortSpec: Array<{ key: string; desc: boolean }>): number {
54+
function compareProjects(
55+
a: Project,
56+
b: Project,
57+
sortSpec: Array<{ key: string; desc: boolean }>,
58+
activity?: Map<string, string>,
59+
): number {
5560
for (const { key, desc } of sortSpec) {
5661
let cmp = 0;
57-
if (key === 'title') {
62+
if (key === 'activity') {
63+
// Projects with no activity sort last in either direction
64+
// (specs/api/projects.md), then by most recently updated.
65+
const aAt = activity?.get(a.slug);
66+
const bAt = activity?.get(b.slug);
67+
if (!aAt || !bAt) {
68+
if (aAt) return -1;
69+
if (bAt) return 1;
70+
cmp = b.updatedAt.localeCompare(a.updatedAt);
71+
if (cmp !== 0) return cmp;
72+
continue;
73+
}
74+
cmp = aAt.localeCompare(bAt);
75+
} else if (key === 'title') {
5876
cmp = a.title.localeCompare(b.title);
5977
} else if (key === 'stage') {
6078
cmp = STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage);
@@ -226,9 +244,10 @@ export class ProjectService {
226244
const filtered = allProjects.filter(buildMatcher());
227245
// A search with no explicit sort lists by relevance (specs/api/projects.md);
228246
// otherwise by the sort spec (default -updatedAt).
247+
const activity = sortSpec.some((s) => s.key === 'activity') ? this.#lastActivity() : undefined;
229248
const rank = ftsRank;
230249
filtered.sort((a, b) =>
231-
rank ? (rank.get(a.slug) ?? Infinity) - (rank.get(b.slug) ?? Infinity) : compareProjects(a, b, sortSpec),
250+
rank ? (rank.get(a.slug) ?? Infinity) - (rank.get(b.slug) ?? Infinity) : compareProjects(a, b, sortSpec, activity),
232251
);
233252
const totalItems = filtered.length;
234253

@@ -291,6 +310,27 @@ export class ProjectService {
291310
});
292311
}
293312

313+
/**
314+
* Each project's most recent sign of life: an RSVP naming it, an update, or
315+
* a buzz item logged (specs/api/projects.md → `sort=activity`).
316+
*/
317+
// Keyed by project slug.
318+
#lastActivity(): Map<string, string> {
319+
const latest = new Map<string, string>();
320+
const note = (projectSlug: string | undefined, at: string) => {
321+
if (!projectSlug) return;
322+
const prev = latest.get(projectSlug);
323+
if (!prev || at > prev) latest.set(projectSlug, at);
324+
};
325+
for (const r of this.#state.eventRsvps.values()) {
326+
// An RSVP still names its project by id (not a path field).
327+
if (r.plan === 'project' && r.projectId) note(this.#state.projects.get(r.projectId)?.slug, r.updatedAt);
328+
}
329+
for (const u of this.#state.projectUpdates.values()) note(u.projectSlug, u.createdAt);
330+
for (const b of this.#state.projectBuzz.values()) note(b.projectSlug, b.createdAt);
331+
return latest;
332+
}
333+
294334
#serializeListItem(project: Project): ProjectListItem {
295335
const memberships = this.#getMembershipsForProject(project.slug);
296336
const memberPeople = this.#getPeopleForMemberships(memberships);

‎apps/api/tests/project-filters.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,36 @@ describe('ProjectService search order', () => {
9797
});
9898
});
9999

100+
describe('ProjectService activity sort', () => {
101+
it('orders by the latest RSVP, update or buzz; projects with none come last', () => {
102+
const { state } = setupState();
103+
const live = [...state.projects.values()].filter((p) => !p.deletedAt);
104+
const [a, b, c] = live;
105+
if (!a || !b || !c) throw new Error('fixture needs three live projects');
106+
// Only the fields the activity sort reads.
107+
state.projectUpdates.set('u1', { id: 'u1', projectSlug: a.slug, createdAt: '2026-01-01T00:00:00Z' } as never);
108+
state.projectBuzz.set('z1', { id: 'z1', projectSlug: b.slug, createdAt: '2026-03-01T00:00:00Z' } as never);
109+
state.eventRsvps.set('r1', {
110+
id: 'r1',
111+
plan: 'project',
112+
projectId: c.id,
113+
updatedAt: '2026-05-01T00:00:00Z',
114+
} as never);
115+
// A newcomer RSVP names no project and counts for nothing.
116+
state.eventRsvps.set('r2', { id: 'r2', plan: 'newcomer', projectId: null, updatedAt: '2026-09-01T00:00:00Z' } as never);
117+
const service = new ProjectService(state, noopFts);
118+
119+
const desc = service.list({ sort: '-activity', perPage: 100 });
120+
if ('error' in desc) throw new Error(desc.error);
121+
expect(desc.items.slice(0, 3).map((p) => p.slug)).toEqual([c.slug, b.slug, a.slug]);
122+
expect(desc.items).toHaveLength(live.length);
123+
124+
const asc = service.list({ sort: 'activity', perPage: 100 });
125+
if ('error' in asc) throw new Error(asc.error);
126+
expect(asc.items.slice(0, 3).map((p) => p.slug)).toEqual([a.slug, b.slug, c.slug]);
127+
});
128+
});
129+
100130
function setupState(): {
101131
state: InMemoryState;
102132
service: ProjectService;

‎apps/web/src/screens/Home.tsx‎

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,37 @@ import { Button } from '@/components/ui/button';
55
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
66
import { HelpWantedCard } from '@/components/HelpWantedCard';
77
import { HeroSlideshow } from '@/components/HeroSlideshow';
8+
import { StageBadge } from '@/components/StageBadge';
89
import { UpcomingEventsStrip } from '@/components/UpcomingEventsStrip';
910
import { useAuth } from '@/hooks/useAuth';
1011
import { api } from '@/lib/api';
1112
import { cn } from '@/lib/utils';
1213

13-
function FeaturedTile({ title, summary, slug, imageUrl }: { title: string; summary: string | null; slug: string; imageUrl?: string | null }) {
14+
function FeaturedTile({
15+
title,
16+
summary,
17+
slug,
18+
stage,
19+
imageUrl,
20+
}: {
21+
title: string;
22+
summary: string | null;
23+
slug: string;
24+
stage: string;
25+
imageUrl?: string | null;
26+
}) {
1427
return (
1528
<Link
1629
to={`/projects/${slug}`}
1730
className="group flex flex-col rounded-lg overflow-hidden border border-border bg-card hover:shadow-lg transition-shadow"
1831
>
19-
{imageUrl ? (
20-
<img src={imageUrl} alt="" className="w-full h-44 object-cover" loading="lazy" />
21-
) : (
22-
<div className="w-full h-44 bg-gradient-to-br from-primary/10 to-primary/30" />
23-
)}
32+
{/* No placeholder panel without an image: six blank panels read as a
33+
dead page (specs/screens/home.md). */}
34+
{imageUrl && <img src={imageUrl} alt="" className="w-full h-44 object-cover" loading="lazy" />}
2435
<div className="p-4">
36+
<div className="mb-2">
37+
<StageBadge stage={stage} />
38+
</div>
2539
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
2640
{title}
2741
</h3>
@@ -33,6 +47,8 @@ function FeaturedTile({ title, summary, slug, imageUrl }: { title: string; summa
3347
);
3448
}
3549

50+
const FEATURED_TILES = 6;
51+
3652
const ACTIVITY_FILTERS = ['all', 'updates', 'buzz'] as const;
3753
type ActivityFilter = (typeof ACTIVITY_FILTERS)[number];
3854

@@ -41,10 +57,24 @@ export function Home() {
4157
const [activityFilter, setActivityFilter] = useState<ActivityFilter>('all');
4258

4359
const featuredQ = useQuery({
44-
queryKey: ['projects', { featured: true, perPage: 8 }],
45-
queryFn: () => api.projects.list({ featured: true, perPage: 8 }),
60+
queryKey: ['projects', { featured: true, perPage: FEATURED_TILES }],
61+
queryFn: () => api.projects.list({ featured: true, perPage: FEATURED_TILES }),
62+
});
63+
64+
// Tops up the curated picks so the section is never empty for want of
65+
// curation (specs/screens/home.md → Featured projects).
66+
const activeQ = useQuery({
67+
queryKey: ['projects', { sort: '-activity', perPage: FEATURED_TILES * 2 }],
68+
queryFn: () => api.projects.list({ sort: '-activity', perPage: FEATURED_TILES * 2 }),
4669
});
4770

71+
const tiles = useMemo(() => {
72+
const seen = new Set<string>();
73+
return [...(featuredQ.data?.data ?? []), ...(activeQ.data?.data ?? [])]
74+
.filter((p) => !seen.has(p.slug) && seen.add(p.slug))
75+
.slice(0, FEATURED_TILES);
76+
}, [featuredQ.data, activeQ.data]);
77+
4878
const totalCountQ = useQuery({
4979
queryKey: ['projects', { perPage: 1, count: true }],
5080
queryFn: () => api.projects.list({ perPage: 1 }),
@@ -104,16 +134,17 @@ export function Home() {
104134
{/* Featured projects */}
105135
<section className="container mx-auto px-4 py-12">
106136
<h2 className="text-2xl font-bold mb-6">Join a Project</h2>
107-
{featuredQ.isLoading ? (
108-
<p className="text-muted-foreground">Loading featured projects…</p>
137+
{featuredQ.isLoading || activeQ.isLoading ? (
138+
<p className="text-muted-foreground">Loading projects…</p>
109139
) : (
110140
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
111-
{(featuredQ.data?.data ?? []).map((p) => (
141+
{tiles.map((p) => (
112142
<FeaturedTile
113143
key={p.slug}
114144
title={p.title}
115145
summary={p.summary || p.overviewExcerpt}
116146
slug={p.slug}
147+
stage={p.stage}
117148
imageUrl={p.featuredImageUrl ?? null}
118149
/>
119150
))}
@@ -130,10 +161,11 @@ export function Home() {
130161
<section className="bg-muted/30 border-y border-border">
131162
<div className="container mx-auto px-4 py-12">
132163
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
133-
<Link to="/sponsor" className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow">
134-
<h3 className="text-lg font-semibold mb-1">Sponsor</h3>
135-
<p className="text-sm text-muted-foreground">Sponsor an event</p>
136-
</Link>
164+
{/* A plain anchor: /chat is a server redirect (specs/screens/chat.md). */}
165+
<a href="/chat" className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow">
166+
<h3 className="text-lg font-semibold mb-1">Join us on Slack</h3>
167+
<p className="text-sm text-muted-foreground">Say hello in #general and meet the community</p>
168+
</a>
137169
<Link
138170
to={person ? '/projects/create' : '/login?return=/projects/create'}
139171
className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow"

‎apps/web/src/screens/Volunteer.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { api } from '@/lib/api';
77

88
// The codeforphilly.gitbook.io space is gone (404 "Content owner not found"),
99
// so both of these point at the surviving live equivalents.
10-
const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
1110
const START_PROJECT_URL =
1211
'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';
1312

@@ -65,15 +64,12 @@ export function Volunteer() {
6564
</Button>
6665
</div>
6766
<div className="rounded-lg border border-border bg-card p-5">
68-
<h3 className="font-semibold mb-2">3. Show up to meetups</h3>
67+
<h3 className="font-semibold mb-2">3. Come to a hack night</h3>
6968
<p className="text-sm text-muted-foreground mb-3">
70-
We meet weekly. Bring your laptop, or just yourself.
69+
We meet every month. Bring your laptop, or just yourself.
7170
</p>
7271
<Button asChild variant="outline" size="sm">
73-
<a href={MEETUP_URL} target="_blank" rel="noopener noreferrer">
74-
When we meet →
75-
<span className="sr-only"> (opens in new tab)</span>
76-
</a>
72+
<Link to="/events">When we meet →</Link>
7773
</Button>
7874
</div>
7975
</div>
@@ -113,7 +109,7 @@ export function Volunteer() {
113109
<p className="text-sm text-muted-foreground mb-3">
114110
Help shape how civic tools look and feel.
115111
</p>
116-
<Link to="/tags/topic/design" className="text-sm text-primary hover:underline">
112+
<Link to="/projects?q=design" className="text-sm text-primary hover:underline">
117113
Design projects →
118114
</Link>
119115
</div>
@@ -122,7 +118,7 @@ export function Volunteer() {
122118
<p className="text-sm text-muted-foreground mb-3">
123119
Interview neighbors, analyze open data, document needs.
124120
</p>
125-
<Link to="/tags/topic/research" className="text-sm text-primary hover:underline">
121+
<Link to="/projects?q=research" className="text-sm text-primary hover:underline">
126122
Research projects →
127123
</Link>
128124
</div>
@@ -131,7 +127,7 @@ export function Volunteer() {
131127
<p className="text-sm text-muted-foreground mb-3">
132128
Help us connect with partner organizations and city programs.
133129
</p>
134-
<Link to="/tags/topic/civic-engagement" className="text-sm text-primary hover:underline">
130+
<Link to="/tags/topic/civicengagement" className="text-sm text-primary hover:underline">
135131
Civic engagement projects →
136132
</Link>
137133
</div>

‎apps/web/tests/Home.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('Home', () => {
6464
</AuthProvider>,
6565
);
6666

67-
expect(screen.getByRole('heading', { name: 'Sponsor' })).toBeInTheDocument();
67+
expect(screen.getByRole('heading', { name: 'Join us on Slack' }).closest('a')).toHaveAttribute('href', '/chat');
6868
expect(screen.getByRole('heading', { name: 'Start a Project' })).toBeInTheDocument();
6969
});
7070

‎apps/web/tests/Volunteer.test.tsx‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { renderScreen, mockPaginated } from './test-utils.js';
44
import { Volunteer } from '../src/screens/Volunteer.js';
55
import { AuthProvider } from '../src/hooks/useAuth.js';
66

7-
const MEETUP_URL = 'https://www.meetup.com/Code-for-Philly/';
87
const START_PROJECT_URL =
98
'https://github.com/CodeForPhilly/partnerships/blob/master/creating-new-partnerships/first-steps.md';
109

@@ -55,12 +54,9 @@ describe('Volunteer', () => {
5554
await screen.findByText(/browse 268 active projects/i);
5655
});
5756

58-
it('points "When we meet →" at the live Meetup group, not the dead GitBook page', async () => {
57+
it('points "When we meet →" at the site\'s own events page', async () => {
5958
renderVolunteer();
60-
const link = screen.getByRole('link', { name: /when we meet/i });
61-
expect(link).toHaveAttribute('href', MEETUP_URL);
62-
expect(link).toHaveAttribute('target', '_blank');
63-
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
59+
expect(screen.getByRole('link', { name: /when we meet/i })).toHaveAttribute('href', '/events');
6460
await screen.findByText(/browse 268 active projects/i);
6561
});
6662

‎plans/home-page-alive.md‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
status: done
3+
depends: []
4+
specs:
5+
- specs/screens/home.md
6+
- specs/screens/volunteer.md
7+
- specs/api/projects.md
8+
- specs/screens/chat.md
9+
issues: []
10+
pr: 242
11+
---
12+
13+
# Plan: Make the home page look alive
14+
15+
## Scope
16+
17+
Chris's review of the live home page (2026-10-15): "Join a Project" was an
18+
empty heading, the get-involved row led with "Sponsor", and on the
19+
Volunteer page "When we meet" went to Meetup and every "Not a coder?" link
20+
404'd.
21+
22+
**In:** `sort=activity` on `GET /api/projects`; "Join a Project" shows six
23+
tiles, staff-featured first, then the most active projects; tiles without
24+
an image drop the placeholder panel and show a stage badge; a "Join us on
25+
Slack" card replaces "Sponsor"; Volunteer step 3 becomes "Come to a hack
26+
night" → `/events`; the three "Not a coder?" links point at lists that
27+
exist.
28+
29+
**Out:** Open Collective sponsors (next plan); curating featured projects
30+
and images (data, not code).
31+
32+
## Implements
33+
34+
- [screens/home.md](../specs/screens/home.md) — Featured projects, Get involved.
35+
- [screens/volunteer.md](../specs/screens/volunteer.md) — How it works,
36+
Not a coder.
37+
- [api/projects.md](../specs/api/projects.md#get-apiprojects) — `activity` sort key.
38+
39+
## Approach
40+
41+
1. `ProjectService` computes each project's latest RSVP naming it, update,
42+
or buzz logged; projects with none sort last either way, then by
43+
`-updatedAt`.
44+
2. Home fetches the featured list and `sort=-activity`, dedupes, takes six.
45+
46+
## Validation
47+
48+
- [x] `npm run type-check && npm run lint && npm test` clean.
49+
- [x] Service test: activity order across RSVP, update and buzz; projects
50+
with none last in both directions; a newcomer RSVP counts for nothing.
51+
- [x] Home on the dev server with the devbox data: six tiles led by the
52+
projects named in RSVPs, stage badges, no blank panels; Slack card first.
53+
- [x] Volunteer: "Come to a hack night" → `/events`; pathway links resolve
54+
(`/projects?q=design` 42, `?q=research` 18, `topic.civicengagement` 14 on
55+
production).
56+
57+
## Risks / unknowns
58+
59+
- The activity sort reads `projectId` on updates, buzz and RSVPs, which the
60+
in-flight slug-references work renames; whichever lands second converts.
61+
62+
## Notes
63+
64+
- No project on production had `featured = true`, so the section rendered
65+
an empty heading; the activity fill means curation is now optional.
66+
- The old pathway links targeted tags that never existed
67+
(`topic.design`, `topic.research`, `topic.civic-engagement`).
68+
69+
## Follow-ups
70+
71+
- **Deferred to plan:** Open Collective "Supported by" strip and sponsor
72+
links (`opencollective-sponsors`).

0 commit comments

Comments
 (0)