diff --git a/apps/api/src/services/project.ts b/apps/api/src/services/project.ts index 7e500e4..84804b3 100644 --- a/apps/api/src/services/project.ts +++ b/apps/api/src/services/project.ts @@ -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 { @@ -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, +): 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); @@ -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; @@ -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 { + const latest = new Map(); + 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); diff --git a/apps/api/tests/project-filters.test.ts b/apps/api/tests/project-filters.test.ts index 03ef033..1e3631f 100644 --- a/apps/api/tests/project-filters.test.ts +++ b/apps/api/tests/project-filters.test.ts @@ -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; diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 89cbdde..8ff4eb6 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -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 ( - {imageUrl ? ( - - ) : ( -
- )} + {/* No placeholder panel without an image: six blank panels read as a + dead page (specs/screens/home.md). */} + {imageUrl && }
+
+ +

{title}

@@ -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]; @@ -41,10 +57,24 @@ export function Home() { const [activityFilter, setActivityFilter] = useState('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(); + 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 }), @@ -104,16 +134,17 @@ export function Home() { {/* Featured projects */}

Join a Project

- {featuredQ.isLoading ? ( -

Loading featured projects…

+ {featuredQ.isLoading || activeQ.isLoading ? ( +

Loading projects…

) : (
- {(featuredQ.data?.data ?? []).map((p) => ( + {tiles.map((p) => ( ))} @@ -130,10 +161,11 @@ export function Home() {
- -

Sponsor

-

Sponsor an event

- + {/* A plain anchor: /chat is a server redirect (specs/screens/chat.md). */} + +

Join us on Slack

+

Say hello in #general and meet the community

+
-

3. Show up to meetups

+

3. Come to a hack night

- We meet weekly. Bring your laptop, or just yourself. + We meet every month. Bring your laptop, or just yourself.

@@ -113,7 +109,7 @@ export function Volunteer() {

Help shape how civic tools look and feel.

- + Design projects →
@@ -122,7 +118,7 @@ export function Volunteer() {

Interview neighbors, analyze open data, document needs.

- + Research projects →
@@ -131,7 +127,7 @@ export function Volunteer() {

Help us connect with partner organizations and city programs.

- + Civic engagement projects →
diff --git a/apps/web/tests/Home.test.tsx b/apps/web/tests/Home.test.tsx index 7dfec7e..75e32a5 100644 --- a/apps/web/tests/Home.test.tsx +++ b/apps/web/tests/Home.test.tsx @@ -64,7 +64,7 @@ describe('Home', () => { , ); - 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(); }); diff --git a/apps/web/tests/Volunteer.test.tsx b/apps/web/tests/Volunteer.test.tsx index af17c99..c616bab 100644 --- a/apps/web/tests/Volunteer.test.tsx +++ b/apps/web/tests/Volunteer.test.tsx @@ -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'; @@ -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); }); diff --git a/plans/home-page-alive.md b/plans/home-page-alive.md new file mode 100644 index 0000000..97dafc7 --- /dev/null +++ b/plans/home-page-alive.md @@ -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`). diff --git a/specs/api/projects.md b/specs/api/projects.md index 5d68b22..1fe9629 100644 --- a/specs/api/projects.md +++ b/specs/api/projects.md @@ -30,7 +30,7 @@ See [data-model.md](../data-model.md#project) for the entity shape, [behaviors/p | `memberSlug` | string | Person slug — returns projects where this person is in `project-memberships`. | | `helpWanted` | bool | If `true`, only projects with at least one `helpWantedRoles.status = 'open'`. | | `featured` | bool | If `true`, only projects with `featured = true`. Used by the home page. | -| `sort` | sort spec | Default `-updatedAt`, except that a request with `q` and no `sort` lists in relevance order. Allowed keys: `createdAt`, `updatedAt`, `title`, `stage` (stage ordered by `project-stages.md` rank). | +| `sort` | sort spec | Default `-updatedAt`, except that a request with `q` and no `sort` lists in relevance order. Allowed keys: `createdAt`, `updatedAt`, `title`, `stage` (stage ordered by `project-stages.md` rank), `activity` — the project's most recent sign of life: the latest of an RSVP naming it (`updatedAt`), a project update (`createdAt`), or a buzz item logged (`createdAt`, not the article's `publishedAt`). Projects with no activity sort after every project that has some, in either direction, then by `-updatedAt`. Used by the home page to top up "Join a Project". | | `page`, `perPage` | int | See [conventions.md](conventions.md#pagination). | Soft-deleted projects (`deletedAt is not null`) are excluded for non-staff and included for staff. Staff filter: `?includeDeleted=true`. diff --git a/specs/screens/chat.md b/specs/screens/chat.md index 68e50ae..f8671cd 100644 --- a/specs/screens/chat.md +++ b/specs/screens/chat.md @@ -37,7 +37,7 @@ Use **302** (temporary) rather than 301 so we can change the destination later w ## Linking to it from the app -Every in-app link to `/chat` (the footer's "Slack", the Volunteer page's +Every in-app link to `/chat` (the header and footer "Slack", the home page's "Join us on Slack" card, the Volunteer page's "Open Slack", the events empty state, the project "Chat Channel" button and the project card's "Chat") is a **full-page navigation** — a plain anchor, never a client-side router link. `/chat` exists only on the server; a diff --git a/specs/screens/home.md b/specs/screens/home.md index 156a227..d72c098 100644 --- a/specs/screens/home.md +++ b/specs/screens/home.md @@ -13,7 +13,8 @@ This v1 page is a blend: marketing hero + featured projects + live activity. The ## Data Requirements -- `GET /api/projects?featured=true&perPage=8` — featured project list (Project list items) +- `GET /api/projects?featured=true&perPage=6` — featured project list (Project list items) +- `GET /api/projects?sort=-activity&perPage=12` — the most active projects, to top up the featured list - `featured` is a new boolean column on `projects` (add to [data-model.md](../data-model.md#project)). Staff toggles it. - `GET /api/events?when=upcoming&perPage=3` — the upcoming-events strip - `GET /api/project-updates?perPage=10` — recent activity for the activity stream @@ -49,22 +50,22 @@ When there are no upcoming events the strip is **hidden entirely** (unlike the f - Heading: "Join a Project" - Grid: responsive — 3 columns ≥ md, 2 columns ≥ sm, 1 column < sm -- Up to 8 featured tiles. Each tile shows: - - Project image — `project.featuredImageKey` (required field when `project.featured = true`; see [data-model.md#project](../data-model.md#project)) +- Six tiles: the staff-featured projects first (in their list order), then the most active projects (`sort=-activity`) not already shown, until there are six. A curated pick always wins a slot; the activity fill means the section is never empty just because nobody has curated it. Each tile shows: + - Project image — `project.featuredImageKey` when set (required when `project.featured = true`; see [data-model.md#project](../data-model.md#project)); otherwise no image area at all, since a grid of blank placeholder panels reads as a dead page + - Stage badge - Title - Tagline = `project.summary`, or first line of `overviewExcerpt` if `summary` is null - Click: links to `/projects/` - Below the grid: "See all 268 projects →" link to `/projects`. The count is `metadata.totalItems` from a separate cheap `HEAD`-style call (or piggybacked from the featured response — implementer's call). -If no featured projects have a `featuredImageKey` set, the section renders no tiles (rendered empty rather than hidden — the heading stays so staff notice). ### 3. Get involved -Three side-by-side cards (current site has these; they translate cleanly): +Three side-by-side cards. Slack comes first: it's where a newcomer actually meets people. Sponsoring stays one click away in the footer and the About menu. | Card | Heading | Body | Link | | ---- | ------- | ---- | ---- | -| Sponsor | "Sponsor" | "Sponsor an event" | `/sponsor` | +| Slack | "Join us on Slack" | "Say hello in #general and meet the community" | `/chat` (a full-page navigation — [chat.md](chat.md#linking-to-it-from-the-app)) | | Start a Project | "Start a Project" | "Start or get help on a project" | `/projects/create` for signed-in users, else `/login?return=/projects/create` | | Volunteer | "Volunteer" | "Join our projects" | `/volunteer` | diff --git a/specs/screens/volunteer.md b/specs/screens/volunteer.md index 2bf03e1..c4d88b1 100644 --- a/specs/screens/volunteer.md +++ b/specs/screens/volunteer.md @@ -27,7 +27,7 @@ Three cards in a row at ≥ md, stacked below: 1. **Join Slack** — "We coordinate everything in our Slack workspace." Button: "Open Slack →" → `/chat` 2. **Pick a project** — "Browse 268 active projects and find one that matches your interests." Button: "Browse projects →" → `/projects` -3. **Show up to meetups** — "We meet weekly. Bring your laptop, or just yourself." Button: "When we meet →" → external link to the Meetup group (`https://www.meetup.com/Code-for-Philly/`), where upcoming hack nights are listed. Same target as the footer's Meetup social link. +3. **Come to a hack night** — "We meet every month. Bring your laptop, or just yourself." Button: "When we meet →" → `/events`, the site's own calendar and RSVP. The "268" is read from a cheap call to `GET /api/projects?perPage=1` and rendered live; falls back to "hundreds of" if the call fails. @@ -42,7 +42,15 @@ If the help-wanted call returns zero results, hide the section. ### "Not a coder?" section -Static content emphasizing the non-developer roles (designers, project managers, researchers, community organizers). Three example "non-coder" pathways with paragraph descriptions and links to relevant tag pages (`/tags/topic/research`, etc.). +Static content emphasizing the non-developer roles (designers, project managers, researchers, community organizers). Three example "non-coder" pathways, each linking to a list that actually has projects in it: + +| Pathway | Link | +| ------- | ---- | +| Design & UX | `/projects?q=design` | +| Research | `/projects?q=research` | +| Community organizing | `/tags/topic/civicengagement` | + +A link to a tag that doesn't exist is a 404 to the visitor most likely to give up; when a tag is renamed or merged, these links move with it. ### "Start your own project" CTA @@ -59,7 +67,7 @@ All navigation. No mutations. **To here:** Home page hero CTA, header nav, every "Volunteer" link across the site. -**From here:** `/login`, `/projects`, `/chat`, `/help-wanted`, `/projects/create`. +**From here:** `/login`, `/projects`, `/chat`, `/events`, `/help-wanted`, `/projects/create`, `/tags/topic/civicengagement`. ## Authorization