diff --git a/docs/homepage-redesign-roadmap.md b/docs/homepage-redesign-roadmap.md index a63bca7..d245020 100644 --- a/docs/homepage-redesign-roadmap.md +++ b/docs/homepage-redesign-roadmap.md @@ -1,6 +1,6 @@ # Homepage Redesign Roadmap -Status: Active — PRs 1–4 merged; calendar event details in progress +Status: Active — PRs 1–4 and 4A merged; Community links and page order in progress Primary audience: People considering their first Kyoto Tech Meetup Secondary audience: Existing community members looking for events, venue details, conversations, and member work @@ -8,7 +8,7 @@ Secondary audience: Existing community members looking for events, venue details Redesign the homepage so a first-time visitor can understand the meetup and reach an RSVP as quickly as possible, while preserving the site's role as a useful community resource hub. -The redesign should replace generic, text-heavy marketing sections with concrete information, real community evidence, and direct actions. Existing members should be able to bypass newcomer-oriented content through clear navigation and task-based shortcuts. +The redesign should replace generic, text-heavy marketing sections with concrete information, real community evidence, and direct actions. Existing members should be able to bypass newcomer-oriented content through clear navigation and recognizable links to frequently used resources. ## Audience priority @@ -21,7 +21,7 @@ Prioritizing new visitors does not mean burying member resources. The homepage s ```text Fixed navigation - Next meetup · Calendar · Locations · Community Hub · Language · RSVP + Next meetup · Calendar · Community links · Language · RSVP Event-led hero Clear invitation · Next-event details · Direct RSVP · Community photograph @@ -29,8 +29,8 @@ Event-led hero First-meetup expectations Practical first-visit facts · Secondary RSVP -Community Hub - Events · Discord · Member posts · GitHub · Contact +Community links + Meetup · Discord · GitHub · Contact Upcoming events Mobile event list · Desktop calendar · RSVP counts · Maps links @@ -61,7 +61,7 @@ Final invitation and footer | 3 | Event-led hero | PR 2 | New visitors see and can RSVP for the next event immediately | | 4 | First-meetup onboarding | PR 3 | Generic end-of-page marketing sections are replaced with practical guidance | | 4A | Calendar event details | PR 2 | Calendar events show RSVP counts and direct Maps links without a separate locations section | -| 5 | Community Hub and page order | PRs 1 and 4 | Returning members have task-based shortcuts and the page has a coherent narrative | +| 5 | Community links and page order | PRs 1 and 4 | Returning members have fast access to frequently used resources and the page has a coherent narrative | | 6 | Responsive events | PRs 2, 4A, and 5 | Mobile event discovery becomes easier and lighter | | 7 | Member feed refinement | PR 5 | Member work becomes authentic community evidence rather than a long set of carousels | | 8 | Accessibility, performance, analytics, and final QA | PRs 3–7 | The complete journey is verified and measurable | @@ -322,33 +322,31 @@ Make each calendar event more useful at a glance and consolidate venue discovery --- -## PR 5: Community Hub and page order +## PR 5: Community links and page order ### Objective -Give existing members fast, task-oriented access to resources without interrupting the newcomer joining path. +Give existing members fast access to frequently used resources without interrupting the newcomer joining path. ### Scope - Move the current quick links below the first-meetup onboarding section. -- Rename the section to “Community Hub.” -- Mix internal destination links with external community services. +- Rename the section to “Community links.” +- Preserve the four existing external destinations: Meetup, Discord, GitHub, and Contact. - Reorder the remainder of the homepage according to the target structure. - Add a fixed-navigation destination for the hub. - Restore a compact community-size signal near the hub introduction, backed by Meetup data rather than a hard-coded claim. -### Suggested shortcuts +### Resource links -- Upcoming events → `#calendar` +- Meetup → community Meetup group - Discord → community Discord -- Member posts → `#community-feed` - GitHub → community GitHub organization -- Contact organizers → contact form +- Contact → organizer contact form ### Implementation notes - Rename `#quick-links` to `#community-hub` in `src/pages/index.astro`. -- Update the link data to describe tasks rather than repeat service names alone. - Show external-link indicators only for external destinations. - Keep the link cards concise; one label and, at most, one short supporting line. - Update header anchors and localized navigation strings. @@ -360,12 +358,12 @@ Give existing members fast, task-oriented access to resources without interrupti - Preserve the last valid member count when Meetup is unavailable or the count cannot be parsed; a count failure must not discard otherwise valid event data. - Convert the exact count to a conservative public milestone by rounding down to the nearest 25. For example, counts from 225 through 249 render as “225+ members and growing.” - Do not render the module when there is no valid cached count or the rounded milestone is below 25. -- Keep the module out of the event-led hero. Place it as concise community-level social proof near the Community Hub introduction. +- Use the module as a small eyebrow above the hero heading. Keep it free of borders, fills, pill shapes, and hover states so it cannot be mistaken for a button or alert. - Add aligned English and Japanese strings, and keep formatting in a small tested helper rather than embedding rounding logic in the template. ### Acceptance criteria -- A returning visitor can reach every core resource from either the header or Community Hub. +- A returning visitor can reach every core resource from either the header or Community links. - The newcomer path remains hero → expectations → RSVP before the resource hub begins. - Internal and external destinations are visually distinguishable without excessive decoration. - Mobile navigation exposes the same essential destinations as desktop navigation. @@ -565,7 +563,7 @@ These decisions should not block PR 1 or PR 2, but they must be resolved before - A member login or private portal - Machine translation of member-authored posts - Replacing Meetup as the RSVP system -- Splitting the Community Hub into a separate route before the resource set requires it +- Splitting Community links into a separate route before the resource set requires it ## Definition of success @@ -573,7 +571,7 @@ The redesign is successful when: - A new visitor can identify and reach the next meetup's RSVP from the first viewport. - First-time concerns are answered with concise, factual guidance instead of generic marketing copy. -- Returning members can reach core resources directly from navigation or the Community Hub. +- Returning members can reach core resources directly from navigation or Community links. - The mobile event experience does not depend on horizontal calendar scrolling. - Real event imagery and member work provide the page's primary social proof. - Both locale routes respect explicit visitor choice. diff --git a/scripts/fetch-meetup-events.mjs b/scripts/fetch-meetup-events.mjs index 1762fbe..8c299c2 100644 --- a/scripts/fetch-meetup-events.mjs +++ b/scripts/fetch-meetup-events.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { DEFAULT_MEETUP_EVENTS_URL, DEFAULT_MEETUP_TIMEOUT_MS, - fetchMeetupEvents, + fetchMeetupSnapshot, isMeetupEvent, } from "../src/lib/meetup-events.ts"; @@ -57,6 +57,13 @@ export function isValidMeetupEventCache(value) { ) { return false; } + if ( + value.memberCount !== undefined && + value.memberCount !== null && + (!Number.isInteger(value.memberCount) || value.memberCount < 0) + ) { + return false; + } return value.events.every(isMeetupEvent); } @@ -79,7 +86,7 @@ async function writeMeetupEventCache(outputPath, payload) { export async function refreshMeetupEventCache({ eventsUrl = DEFAULT_EVENTS_URL, - fetchEventsFn = fetchMeetupEvents, + fetchSnapshotFn = fetchMeetupSnapshot, logger = console, now = new Date(), outputPath = DEFAULT_OUTPUT_PATH, @@ -87,9 +94,21 @@ export async function refreshMeetupEventCache({ timeoutMs = DEFAULT_TIMEOUT_MS, } = {}) { try { - const events = await fetchEventsFn({ eventsUrl, now, timeoutMs }); + const snapshot = await fetchSnapshotFn({ eventsUrl, now, timeoutMs }); + const events = snapshot?.events; + if (!Array.isArray(events) || !events.every(isMeetupEvent)) { + throw new Error("Meetup response did not contain valid event data."); + } + + const parsedMemberCount = snapshot?.memberCount; + const existing = await readMeetupEventCache(outputPath); + const memberCount = + Number.isInteger(parsedMemberCount) && parsedMemberCount >= 0 + ? parsedMemberCount + : (existing?.memberCount ?? null); const payload = { generatedAt: now.toISOString(), + memberCount, events, }; diff --git a/src/components/Hero.astro b/src/components/Hero.astro index edc6ccd..532faad 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -1,5 +1,6 @@ --- import type { MeetupEvent } from "../lib/meetup-events"; +import { FaUserGroup } from "react-icons/fa6"; import NextEventCard from "./NextEventCard.astro"; type Props = { @@ -9,6 +10,7 @@ type Props = { imageAlt: string; imageSrc: string; lang?: "en" | "ja"; + memberMilestoneLabel?: string | null; now?: Date; primaryCtaLabel: string; secondaryCtaLabel: string; @@ -23,6 +25,7 @@ const { imageAlt, imageSrc, lang = "en", + memberMilestoneLabel = null, now = new Date(), primaryCtaLabel, secondaryCtaLabel, @@ -49,6 +52,14 @@ const primaryCtaUrl = event?.link ?? fallbackUrl;
+ { + memberMilestoneLabel ? ( +

+

+ ) : null + }

([\s\S]*?)<\/script>/, ); @@ -224,7 +229,7 @@ export function parseMeetupEventsHtml(html: string): MeetupEvent[] { return null; }; - return Object.entries(apolloState) + const events = Object.entries(apolloState) .filter( ([key, value]) => key.startsWith("Event:") && (value as any)?.dateTime, ) @@ -250,6 +255,23 @@ export function parseMeetupEventsHtml(html: string): MeetupEvent[] { }) .filter(isMeetupEvent) .sort(compareMeetupEvents); + + const group = Object.entries(apolloState).find( + ([key, value]) => + key.startsWith("Group:") && + (value as any)?.urlname === "kyoto-tech-meetup", + )?.[1] as any; + const parsedMemberCount = group?.stats?.memberCounts?.all; + const memberCount = + Number.isInteger(parsedMemberCount) && parsedMemberCount >= 0 + ? parsedMemberCount + : null; + + return { events, memberCount }; +} + +export function parseMeetupEventsHtml(html: string): MeetupEvent[] { + return parseMeetupSnapshotHtml(html).events; } export function selectUpcomingMeetupEvents( @@ -293,12 +315,12 @@ export function selectNextMeetupEvent( return selectUpcomingMeetupEvents(events, options)[0] ?? null; } -export async function fetchMeetupEvents({ +export async function fetchMeetupSnapshot({ eventsUrl = DEFAULT_MEETUP_EVENTS_URL, fetchFn = fetch, now = new Date(), timeoutMs = DEFAULT_MEETUP_TIMEOUT_MS, -}: FetchMeetupEventsOptions = {}): Promise { +}: FetchMeetupEventsOptions = {}): Promise { const requestUrl = new URL(eventsUrl); requestUrl.searchParams.set("_cb", String(now.valueOf())); @@ -324,8 +346,11 @@ export async function fetchMeetupEvents({ ); } - const events = parseMeetupEventsHtml(await response.text()); - return selectUpcomingMeetupEvents(events, { now }); + const snapshot = parseMeetupSnapshotHtml(await response.text()); + return { + events: selectUpcomingMeetupEvents(snapshot.events, { now }), + memberCount: snapshot.memberCount, + }; } catch (error) { if (controller.signal.aborted) { throw new Error(`Meetup request timed out after ${timeoutMs}ms.`, { @@ -337,3 +362,9 @@ export async function fetchMeetupEvents({ clearTimeout(timeout); } } + +export async function fetchMeetupEvents( + options: FetchMeetupEventsOptions = {}, +): Promise { + return (await fetchMeetupSnapshot(options)).events; +} diff --git a/src/lib/member-milestone.ts b/src/lib/member-milestone.ts new file mode 100644 index 0000000..ae8e2c6 --- /dev/null +++ b/src/lib/member-milestone.ts @@ -0,0 +1,15 @@ +const MEMBER_MILESTONE_SIZE = 25; + +export function getMemberMilestone(memberCount: unknown): number | null { + if ( + typeof memberCount !== "number" || + !Number.isInteger(memberCount) || + memberCount < MEMBER_MILESTONE_SIZE + ) { + return null; + } + + return ( + Math.floor(memberCount / MEMBER_MILESTONE_SIZE) * MEMBER_MILESTONE_SIZE + ); +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 9d8c217..c429a21 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -22,6 +22,7 @@ import { selectNextMeetupEvent, selectUpcomingMeetupEvents, } from "../lib/meetup-events"; +import { getMemberMilestone } from "../lib/member-milestone"; const { lang = "en" } = Astro.props; const t = useTranslations(lang); @@ -123,6 +124,15 @@ const events = selectUpcomingMeetupEvents(cachedMeetupEvents, { }); const nextEvent = selectNextMeetupEvent(events, { now: buildNow }); const nextEventUrl = nextEvent?.link ?? meetupUrl; +const memberMilestone = getMemberMilestone( + (meetupEventData as { memberCount?: unknown }).memberCount, +); +const memberMilestoneLabel = memberMilestone + ? t("home.quickLinks.memberMilestone").replace( + "{count}", + String(memberMilestone), + ) + : null; const memberFeedList = (memberFeeds as MemberFeed[]) ?? []; const compositeFeedEntries = Array.isArray(compositeFeed?.feeds) @@ -170,6 +180,7 @@ const formatDate = (value: string) => { imageAlt={t("home.hero.imageAlt")} imageSrc="/images/kyoto-tech-community-presentation.jpeg" lang={lang} + memberMilestoneLabel={memberMilestoneLabel} now={buildNow} primaryCtaLabel={t("home.hero.primaryCta")} secondaryCtaLabel={t("home.hero.secondaryCta")} @@ -239,6 +250,45 @@ const formatDate = (value: string) => {

+
+
+

+ {t("home.quickLinks.title")} +

+ +
+
+

@@ -289,45 +339,6 @@ const formatDate = (value: string) => {

-
-
-

- {t("home.quickLinks.title")} -

- -
-
-
{ test("writes a successful refresh atomically", async () => { const events = [makeMeetupEvent()]; - const fetchEventsFn = vi.fn(async () => events); + const fetchSnapshotFn = vi.fn(async () => ({ events, memberCount: 214 })); const result = await refreshMeetupEventCache({ eventsUrl: "https://example.com/events", - fetchEventsFn, + fetchSnapshotFn, logger, now: fixedNow, outputPath, @@ -58,13 +58,14 @@ describe("Meetup event cache", () => { }); expect(result.status).toBe("updated"); - expect(fetchEventsFn).toHaveBeenCalledWith({ + expect(fetchSnapshotFn).toHaveBeenCalledWith({ eventsUrl: "https://example.com/events", now: fixedNow, timeoutMs: 4321, }); await expect(readMeetupEventCache(outputPath)).resolves.toEqual({ generatedAt: fixedNow.toISOString(), + memberCount: 214, events, }); expect(logger.info).toHaveBeenCalledOnce(); @@ -79,7 +80,7 @@ describe("Meetup event cache", () => { const before = await fs.readFile(outputPath, "utf8"); const result = await refreshMeetupEventCache({ - fetchEventsFn: async () => { + fetchSnapshotFn: async () => { throw new Error("Meetup unavailable"); }, logger, @@ -101,7 +102,7 @@ describe("Meetup event cache", () => { await fs.writeFile(outputPath, JSON.stringify(existing)); const result = await refreshMeetupEventCache({ - fetchEventsFn: async () => { + fetchSnapshotFn: async () => { throw new Error("Meetup unavailable"); }, logger, @@ -115,7 +116,7 @@ describe("Meetup event cache", () => { test("strict mode fails instead of writing fallback data", async () => { await expect( refreshMeetupEventCache({ - fetchEventsFn: async () => { + fetchSnapshotFn: async () => { throw new Error("Meetup unavailable"); }, logger, @@ -131,7 +132,7 @@ describe("Meetup event cache", () => { await expect( refreshMeetupEventCache({ - fetchEventsFn: async () => { + fetchSnapshotFn: async () => { throw new Error("Meetup unavailable"); }, logger, @@ -140,6 +141,32 @@ describe("Meetup event cache", () => { }), ).rejects.toThrow("no valid cache exists"); }); + + test("preserves the last valid member count when fresh events omit it", async () => { + const existing = { + generatedAt: "2026-07-09T03:00:00.000Z", + memberCount: 214, + events: [makeMeetupEvent()], + }; + await fs.writeFile(outputPath, JSON.stringify(existing)); + const freshEvents = [makeMeetupEvent({ title: "Fresh event" })]; + + const result = await refreshMeetupEventCache({ + fetchSnapshotFn: async () => ({ + events: freshEvents, + memberCount: null, + }), + logger, + now: fixedNow, + outputPath, + }); + + expect(result.payload).toEqual({ + generatedAt: fixedNow.toISOString(), + memberCount: 214, + events: freshEvents, + }); + }); }); test("cache validation and CLI parsing reject invalid inputs", () => { @@ -152,6 +179,13 @@ test("cache validation and CLI parsing reject invalid inputs", () => { expect(isValidMeetupEventCache({ generatedAt: "invalid", events: [] })).toBe( false, ); + expect( + isValidMeetupEventCache({ + generatedAt: fixedNow.toISOString(), + memberCount: -1, + events: [], + }), + ).toBe(false); expect(() => parseArgs(["--timeout", "0"])).toThrow("positive number"); expect(parseArgs(["--stale-ok", "--timeout", "5000"])).toMatchObject({ staleOk: true, diff --git a/test/meetup-events.test.mjs b/test/meetup-events.test.mjs index b800d5d..24433f7 100644 --- a/test/meetup-events.test.mjs +++ b/test/meetup-events.test.mjs @@ -5,6 +5,7 @@ import { isMeetupEvent, normalizeMeetupEventTitle, parseMeetupEventsHtml, + parseMeetupSnapshotHtml, selectNextMeetupEvent, selectUpcomingMeetupEvents, } from "../src/lib/meetup-events.ts"; @@ -129,6 +130,19 @@ test("parseMeetupEventsHtml resolves referenced Meetup event data", () => { ]); }); +test("parseMeetupSnapshotHtml reads the Kyoto Tech group member count", () => { + const snapshot = parseMeetupSnapshotHtml( + makeMeetupHtml({ + "Group:38124378": { + urlname: "kyoto-tech-meetup", + stats: { memberCounts: { all: 214 } }, + }, + }), + ); + + expect(snapshot).toEqual({ events: [], memberCount: 214 }); +}); + describe("Meetup event selection", () => { test("keeps upcoming and ongoing events while excluding expired and distant events", () => { const events = selectUpcomingMeetupEvents( diff --git a/test/member-milestone.test.mjs b/test/member-milestone.test.mjs new file mode 100644 index 0000000..8816417 --- /dev/null +++ b/test/member-milestone.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, test } from "vitest"; +import { getMemberMilestone } from "../src/lib/member-milestone.ts"; + +describe("getMemberMilestone", () => { + test.each([ + [224, 200], + [225, 225], + [249, 225], + [250, 250], + ])("rounds %i down to %i", (memberCount, expected) => { + expect(getMemberMilestone(memberCount)).toBe(expected); + }); + + test("omits missing, invalid, and sub-25 counts", () => { + expect(getMemberMilestone(null)).toBeNull(); + expect(getMemberMilestone(24)).toBeNull(); + expect(getMemberMilestone(25.5)).toBeNull(); + }); +});