Skip to content
Merged
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
48 changes: 44 additions & 4 deletions apps/api/src/services/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface ProjectListResult {
readonly facets: ProjectFacets;
}

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

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

function compareProjects(a: Project, b: Project, sortSpec: Array<{ key: string; desc: boolean }>): number {
function compareProjects(
a: Project,
b: Project,
sortSpec: Array<{ key: string; desc: boolean }>,
activity?: Map<string, string>,
): number {
for (const { key, desc } of sortSpec) {
let cmp = 0;
if (key === 'title') {
if (key === 'activity') {
// Projects with no activity sort last in either direction
// (specs/api/projects.md), then by most recently updated.
const aAt = activity?.get(a.slug);
const bAt = activity?.get(b.slug);
if (!aAt || !bAt) {
if (aAt) return -1;
if (bAt) return 1;
cmp = b.updatedAt.localeCompare(a.updatedAt);
if (cmp !== 0) return cmp;
continue;
}
cmp = aAt.localeCompare(bAt);
} else if (key === 'title') {
cmp = a.title.localeCompare(b.title);
} else if (key === 'stage') {
cmp = STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage);
Expand Down Expand Up @@ -226,9 +244,10 @@ export class ProjectService {
const filtered = allProjects.filter(buildMatcher());
// A search with no explicit sort lists by relevance (specs/api/projects.md);
// otherwise by the sort spec (default -updatedAt).
const activity = sortSpec.some((s) => s.key === 'activity') ? this.#lastActivity() : undefined;
const rank = ftsRank;
filtered.sort((a, b) =>
rank ? (rank.get(a.slug) ?? Infinity) - (rank.get(b.slug) ?? Infinity) : compareProjects(a, b, sortSpec),
rank ? (rank.get(a.slug) ?? Infinity) - (rank.get(b.slug) ?? Infinity) : compareProjects(a, b, sortSpec, activity),
);
const totalItems = filtered.length;

Expand Down Expand Up @@ -291,6 +310,27 @@ export class ProjectService {
});
}

/**
* Each project's most recent sign of life: an RSVP naming it, an update, or
* a buzz item logged (specs/api/projects.md → `sort=activity`).
*/
// Keyed by project slug.
#lastActivity(): Map<string, string> {
const latest = new Map<string, string>();
const note = (projectSlug: string | undefined, at: string) => {
if (!projectSlug) return;
const prev = latest.get(projectSlug);
if (!prev || at > prev) latest.set(projectSlug, at);
};
for (const r of this.#state.eventRsvps.values()) {
// An RSVP still names its project by id (not a path field).
if (r.plan === 'project' && r.projectId) note(this.#state.projects.get(r.projectId)?.slug, r.updatedAt);
}
for (const u of this.#state.projectUpdates.values()) note(u.projectSlug, u.createdAt);
for (const b of this.#state.projectBuzz.values()) note(b.projectSlug, b.createdAt);
return latest;
}

#serializeListItem(project: Project): ProjectListItem {
const memberships = this.#getMembershipsForProject(project.slug);
const memberPeople = this.#getPeopleForMemberships(memberships);
Expand Down
30 changes: 30 additions & 0 deletions apps/api/tests/project-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ describe('ProjectService search order', () => {
});
});

describe('ProjectService activity sort', () => {
it('orders by the latest RSVP, update or buzz; projects with none come last', () => {
const { state } = setupState();
const live = [...state.projects.values()].filter((p) => !p.deletedAt);
const [a, b, c] = live;
if (!a || !b || !c) throw new Error('fixture needs three live projects');
// Only the fields the activity sort reads.
state.projectUpdates.set('u1', { id: 'u1', projectSlug: a.slug, createdAt: '2026-01-01T00:00:00Z' } as never);
state.projectBuzz.set('z1', { id: 'z1', projectSlug: b.slug, createdAt: '2026-03-01T00:00:00Z' } as never);
state.eventRsvps.set('r1', {
id: 'r1',
plan: 'project',
projectId: c.id,
updatedAt: '2026-05-01T00:00:00Z',
} as never);
// A newcomer RSVP names no project and counts for nothing.
state.eventRsvps.set('r2', { id: 'r2', plan: 'newcomer', projectId: null, updatedAt: '2026-09-01T00:00:00Z' } as never);
const service = new ProjectService(state, noopFts);

const desc = service.list({ sort: '-activity', perPage: 100 });
if ('error' in desc) throw new Error(desc.error);
expect(desc.items.slice(0, 3).map((p) => p.slug)).toEqual([c.slug, b.slug, a.slug]);
expect(desc.items).toHaveLength(live.length);

const asc = service.list({ sort: 'activity', perPage: 100 });
if ('error' in asc) throw new Error(asc.error);
expect(asc.items.slice(0, 3).map((p) => p.slug)).toEqual([a.slug, b.slug, c.slug]);
});
});

function setupState(): {
state: InMemoryState;
service: ProjectService;
Expand Down
62 changes: 47 additions & 15 deletions apps/web/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,37 @@ import { Button } from '@/components/ui/button';
import { ActivityCard, mergeActivity, type ActivityItem } from '@/components/ActivityCard';
import { HelpWantedCard } from '@/components/HelpWantedCard';
import { HeroSlideshow } from '@/components/HeroSlideshow';
import { StageBadge } from '@/components/StageBadge';
import { UpcomingEventsStrip } from '@/components/UpcomingEventsStrip';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';

function FeaturedTile({ title, summary, slug, imageUrl }: { title: string; summary: string | null; slug: string; imageUrl?: string | null }) {
function FeaturedTile({
title,
summary,
slug,
stage,
imageUrl,
}: {
title: string;
summary: string | null;
slug: string;
stage: string;
imageUrl?: string | null;
}) {
return (
<Link
to={`/projects/${slug}`}
className="group flex flex-col rounded-lg overflow-hidden border border-border bg-card hover:shadow-lg transition-shadow"
>
{imageUrl ? (
<img src={imageUrl} alt="" className="w-full h-44 object-cover" loading="lazy" />
) : (
<div className="w-full h-44 bg-gradient-to-br from-primary/10 to-primary/30" />
)}
{/* No placeholder panel without an image: six blank panels read as a
dead page (specs/screens/home.md). */}
{imageUrl && <img src={imageUrl} alt="" className="w-full h-44 object-cover" loading="lazy" />}
<div className="p-4">
<div className="mb-2">
<StageBadge stage={stage} />
</div>
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
Expand All @@ -33,6 +47,8 @@ function FeaturedTile({ title, summary, slug, imageUrl }: { title: string; summa
);
}

const FEATURED_TILES = 6;

const ACTIVITY_FILTERS = ['all', 'updates', 'buzz'] as const;
type ActivityFilter = (typeof ACTIVITY_FILTERS)[number];

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

const featuredQ = useQuery({
queryKey: ['projects', { featured: true, perPage: 8 }],
queryFn: () => api.projects.list({ featured: true, perPage: 8 }),
queryKey: ['projects', { featured: true, perPage: FEATURED_TILES }],
queryFn: () => api.projects.list({ featured: true, perPage: FEATURED_TILES }),
});

// Tops up the curated picks so the section is never empty for want of
// curation (specs/screens/home.md → Featured projects).
const activeQ = useQuery({
queryKey: ['projects', { sort: '-activity', perPage: FEATURED_TILES * 2 }],
queryFn: () => api.projects.list({ sort: '-activity', perPage: FEATURED_TILES * 2 }),
});

const tiles = useMemo(() => {
const seen = new Set<string>();
return [...(featuredQ.data?.data ?? []), ...(activeQ.data?.data ?? [])]
.filter((p) => !seen.has(p.slug) && seen.add(p.slug))
.slice(0, FEATURED_TILES);
}, [featuredQ.data, activeQ.data]);

const totalCountQ = useQuery({
queryKey: ['projects', { perPage: 1, count: true }],
queryFn: () => api.projects.list({ perPage: 1 }),
Expand Down Expand Up @@ -104,16 +134,17 @@ export function Home() {
{/* Featured projects */}
<section className="container mx-auto px-4 py-12">
<h2 className="text-2xl font-bold mb-6">Join a Project</h2>
{featuredQ.isLoading ? (
<p className="text-muted-foreground">Loading featured projects…</p>
{featuredQ.isLoading || activeQ.isLoading ? (
<p className="text-muted-foreground">Loading projects…</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{(featuredQ.data?.data ?? []).map((p) => (
{tiles.map((p) => (
<FeaturedTile
key={p.slug}
title={p.title}
summary={p.summary || p.overviewExcerpt}
slug={p.slug}
stage={p.stage}
imageUrl={p.featuredImageUrl ?? null}
/>
))}
Expand All @@ -130,10 +161,11 @@ export function Home() {
<section className="bg-muted/30 border-y border-border">
<div className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link to="/sponsor" className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-1">Sponsor</h3>
<p className="text-sm text-muted-foreground">Sponsor an event</p>
</Link>
{/* A plain anchor: /chat is a server redirect (specs/screens/chat.md). */}
<a href="/chat" className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow">
<h3 className="text-lg font-semibold mb-1">Join us on Slack</h3>
<p className="text-sm text-muted-foreground">Say hello in #general and meet the community</p>
</a>
<Link
to={person ? '/projects/create' : '/login?return=/projects/create'}
className="block rounded-lg border border-border bg-card p-6 hover:shadow-md transition-shadow"
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/screens/Volunteer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { api } from '@/lib/api';

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

Expand Down Expand Up @@ -65,15 +64,12 @@ export function Volunteer() {
</Button>
</div>
<div className="rounded-lg border border-border bg-card p-5">
<h3 className="font-semibold mb-2">3. Show up to meetups</h3>
<h3 className="font-semibold mb-2">3. Come to a hack night</h3>
<p className="text-sm text-muted-foreground mb-3">
We meet weekly. Bring your laptop, or just yourself.
We meet every month. Bring your laptop, or just yourself.
</p>
<Button asChild variant="outline" size="sm">
<a href={MEETUP_URL} target="_blank" rel="noopener noreferrer">
When we meet →
<span className="sr-only"> (opens in new tab)</span>
</a>
<Link to="/events">When we meet →</Link>
</Button>
</div>
</div>
Expand Down Expand Up @@ -113,7 +109,7 @@ export function Volunteer() {
<p className="text-sm text-muted-foreground mb-3">
Help shape how civic tools look and feel.
</p>
<Link to="/tags/topic/design" className="text-sm text-primary hover:underline">
<Link to="/projects?q=design" className="text-sm text-primary hover:underline">
Design projects →
</Link>
</div>
Expand All @@ -122,7 +118,7 @@ export function Volunteer() {
<p className="text-sm text-muted-foreground mb-3">
Interview neighbors, analyze open data, document needs.
</p>
<Link to="/tags/topic/research" className="text-sm text-primary hover:underline">
<Link to="/projects?q=research" className="text-sm text-primary hover:underline">
Research projects →
</Link>
</div>
Expand All @@ -131,7 +127,7 @@ export function Volunteer() {
<p className="text-sm text-muted-foreground mb-3">
Help us connect with partner organizations and city programs.
</p>
<Link to="/tags/topic/civic-engagement" className="text-sm text-primary hover:underline">
<Link to="/tags/topic/civicengagement" className="text-sm text-primary hover:underline">
Civic engagement projects →
</Link>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/tests/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('Home', () => {
</AuthProvider>,
);

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

Expand Down
8 changes: 2 additions & 6 deletions apps/web/tests/Volunteer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { renderScreen, mockPaginated } from './test-utils.js';
import { Volunteer } from '../src/screens/Volunteer.js';
import { AuthProvider } from '../src/hooks/useAuth.js';

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

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

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

Expand Down
72 changes: 72 additions & 0 deletions plans/home-page-alive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
status: done
depends: []
specs:
- specs/screens/home.md
- specs/screens/volunteer.md
- specs/api/projects.md
- specs/screens/chat.md
issues: []
pr: 242
---

# Plan: Make the home page look alive

## Scope

Chris's review of the live home page (2026-10-15): "Join a Project" was an
empty heading, the get-involved row led with "Sponsor", and on the
Volunteer page "When we meet" went to Meetup and every "Not a coder?" link
404'd.

**In:** `sort=activity` on `GET /api/projects`; "Join a Project" shows six
tiles, staff-featured first, then the most active projects; tiles without
an image drop the placeholder panel and show a stage badge; a "Join us on
Slack" card replaces "Sponsor"; Volunteer step 3 becomes "Come to a hack
night" → `/events`; the three "Not a coder?" links point at lists that
exist.

**Out:** Open Collective sponsors (next plan); curating featured projects
and images (data, not code).

## Implements

- [screens/home.md](../specs/screens/home.md) — Featured projects, Get involved.
- [screens/volunteer.md](../specs/screens/volunteer.md) — How it works,
Not a coder.
- [api/projects.md](../specs/api/projects.md#get-apiprojects) — `activity` sort key.

## Approach

1. `ProjectService` computes each project's latest RSVP naming it, update,
or buzz logged; projects with none sort last either way, then by
`-updatedAt`.
2. Home fetches the featured list and `sort=-activity`, dedupes, takes six.

## Validation

- [x] `npm run type-check && npm run lint && npm test` clean.
- [x] Service test: activity order across RSVP, update and buzz; projects
with none last in both directions; a newcomer RSVP counts for nothing.
- [x] Home on the dev server with the devbox data: six tiles led by the
projects named in RSVPs, stage badges, no blank panels; Slack card first.
- [x] Volunteer: "Come to a hack night" → `/events`; pathway links resolve
(`/projects?q=design` 42, `?q=research` 18, `topic.civicengagement` 14 on
production).

## Risks / unknowns

- The activity sort reads `projectId` on updates, buzz and RSVPs, which the
in-flight slug-references work renames; whichever lands second converts.

## Notes

- No project on production had `featured = true`, so the section rendered
an empty heading; the activity fill means curation is now optional.
- The old pathway links targeted tags that never existed
(`topic.design`, `topic.research`, `topic.civic-engagement`).

## Follow-ups

- **Deferred to plan:** Open Collective "Supported by" strip and sponsor
links (`opencollective-sponsors`).
Loading
Loading