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
2 changes: 1 addition & 1 deletion skills/context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Recommended pattern:
- no CMS or external content source yet
- revisit content source later based on team workflow and editing needs

The root layout and homepage route export `revalidate = EVENTS_DATE_REVALIDATE_SECONDS` (3600) from `src/lib/events/upcoming.ts` so date-aware event UI—`HeaderUpcomingEvent` in the site header and `EventsSection` on the homepage—can re-run filtering after event dates pass without a manual rebuild. Event content is still hardcoded TypeScript, so hourly revalidation only refreshes which events are shown—not content from an external source. Events whose `date` is earlier than the current UTC `YYYY-MM-DD` are hidden from the Upcoming Events list; when no upcoming events remain, the Events section renders only its follow-up bridge copy.
The root layout and homepage route export `revalidate = EVENTS_DATE_REVALIDATE_SECONDS` (3600) from `src/lib/events/upcoming.ts` so date-aware event UI—`HeaderUpcomingEvent` in the site header and `EventsSection` on the homepage—can re-run filtering after event dates pass without a manual rebuild. Event content is still hardcoded TypeScript, so hourly revalidation only refreshes which events are shown—not content from an external source. Each event keeps two machine-readable instants sourced from Luma as UTC: `startsAt` and `endsAt`. Upcoming-event visibility filters by the exact `startsAt` timestamp (events disappear once their start instant passes). The client-side `EventCountdown` ticks every second and shows three states: a countdown clock before start, "Happening now" between `startsAt` and `endsAt`, and "Event ended" after `endsAt`. `dateLabel` remains display copy.

## Observability

Expand Down
5 changes: 3 additions & 2 deletions skills/context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,6 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran
- **Rate-limit IP hardening** — Parses `x-vercel-forwarded-for`, `Forwarded`, IPv4-with-port, bracketed IPv6; **skips loopback** in production-like runtimes. Unknown IP returns `null` identifier; **fail closed** in production (no shared `local` bucket).
- **Footer hash nav** — `hash-navigation.ts` keeps `#footer` while footer is visible and while focus is in the newsletter/contact region (prevents demotion to `#events` during footer interaction).
- **Resend re-subscribe** — Reactivates unsubscribed contacts only; reactivated members get success message, not already-submitted.
- **Tests** — `submit.test.ts`, `turnstile.test.ts`, `rate-limit.test.ts`, `resend.test.ts`.
- **Verified** — `bun run test`, `bun run typecheck`, `bun run build`. Localhost `#footer` Turnstile submit confirmed against Resend.
- **Event timing source** — Each event carries two Luma UTC instants: `startsAt` and `endsAt`. Upcoming-event visibility (`getUpcomingEvents`) filters by exact `startsAt` timestamp — events are removed once their start instant passes. The client-side `EventCountdown` uses `getEventTimingState` (discriminated union: `countdown` | `live` | `ended`) to show a ticking clock before start, "Happening now" between start and end, and "Event ended" after `endsAt`. CONNEX: `startsAt` `2026-06-27T13:00:00Z` (9 AM EDT), `endsAt` `2026-06-28T01:00:00Z` (9 PM EDT).
- **Tests** — `submit.test.ts`, `turnstile.test.ts`, `rate-limit.test.ts`, `resend.test.ts`, `countdown.test.ts`, `upcoming.test.ts`.
- **Verified** — `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad `_section` warning only), `bun run build`. Localhost `#footer` Turnstile submit confirmed against Resend.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function HeaderUpcomingEventLink({
<span className="header-upcoming-event-content">
<span className="header-upcoming-event-pill">{event.label}</span>
<span className="header-upcoming-event-copy">
<time className="header-upcoming-event-date" dateTime={event.date}>
<time className="header-upcoming-event-date" dateTime={event.startsAt}>
{event.dateLabel}
</time>
<span className="header-upcoming-event-title">{event.title}</span>
Expand Down
8 changes: 6 additions & 2 deletions src/components/sections/events-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function EventMetaDetails({
}) {
return (
<div className={`event-meta-details${className ? ` ${className}` : ""}`}>
<time dateTime={event.date}>{event.dateLabel}</time>
<time dateTime={event.startsAt}>{event.dateLabel}</time>
<span className="event-location">
<MapPin className="event-location-icon" aria-hidden="true" />
<span>{event.location.city}</span>
Expand All @@ -49,7 +49,11 @@ function EventMeta({ event }: { event: CortexEvent }) {
return (
<div className="event-meta">
<EventMetaDetails className="event-meta-details-inline" event={event} />
<EventCountdown className="event-countdown-slot" date={event.date} />
<EventCountdown
className="event-countdown-slot"
startsAt={event.startsAt}
endsAt={event.endsAt}
/>
</div>
);
}
Expand Down
33 changes: 25 additions & 8 deletions src/components/sections/events/event-countdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@

import { useState } from "react";
import { useOnMount } from "@/hooks/use-on-mount";
import { getEventCountdownState } from "@/lib/events/countdown";
import { getEventTimingState } from "@/lib/events/countdown";
import { cn } from "@/lib/utils";

type EventCountdownProps = {
className?: string;
date: string;
startsAt: string;
endsAt: string;
};

export function EventCountdown({ className, date }: EventCountdownProps) {
export function EventCountdown({
className,
startsAt,
endsAt,
}: EventCountdownProps) {
const [now, setNow] = useState<number | null>(null);

useOnMount(() => {
Expand All @@ -36,27 +41,39 @@ export function EventCountdown({ className, date }: EventCountdownProps) {
);
}

const countdown = getEventCountdownState(date, now);
const timing = getEventTimingState(startsAt, endsAt, now);

if (!countdown) {
if (!timing) {
return null;
}

if (timing.status !== "countdown") {
return (
<p
className={cn("event-countdown", className)}
aria-live="polite"
aria-atomic="true"
>
<span className="event-countdown-label">{timing.label}</span>
</p>
);
}

return (
<p
className={cn("event-countdown", className)}
aria-live="polite"
aria-atomic="true"
>
<span className="event-countdown-label">{countdown.label}</span>
<span className="event-countdown-label">{timing.label}</span>
<span className="event-countdown-separator" aria-hidden="true">
·
</span>
<time
className="event-countdown-clock"
dateTime={`P${countdown.days}DT${countdown.hours}H${countdown.minutes}M${countdown.seconds}S`}
dateTime={`P${timing.days}DT${timing.hours}H${timing.minutes}M${timing.seconds}S`}
>
{countdown.clock}
{timing.clock}
</time>
</p>
);
Expand Down
10 changes: 7 additions & 3 deletions src/lib/content/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export type CortexEvent = {
id: string;
label: string;
title: string;
/** ISO calendar date (YYYY-MM-DD), interpreted as UTC midnight. */
date: string;
/** ISO UTC event start instant, sourced from Luma. */
startsAt: `${string}T${string}Z`;
/** ISO UTC event end instant, sourced from Luma. */
endsAt: `${string}T${string}Z`;
/** Display label for the event date. */
dateLabel: string;
location: EventLocation;
description: string;
Expand All @@ -34,7 +37,8 @@ export const eventsSection = {
id: "cortex-connex-tech-fest",
label: "GLOBAL",
title: "Cortex CONNEX Tech Fest",
date: "2026-06-27",
startsAt: "2026-06-27T13:00:00Z", // 9:00 AM EDT
endsAt: "2026-06-28T01:00:00Z", // 9:00 PM EDT
dateLabel: "Jun 27, 2026",
location: {
city: "Online",
Expand Down
111 changes: 111 additions & 0 deletions src/lib/events/countdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";
import { getEventTimingState } from "@/lib/events/countdown";

const STARTS_AT = "2026-06-27T13:00:00Z";
const ENDS_AT = "2026-06-28T01:00:00Z";

describe("getEventTimingState", () => {
test("counts down to the exact start instant", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-27T00:00:00Z"),
);

expect(state).toEqual({
status: "countdown",
days: 0,
hours: 13,
minutes: 0,
seconds: 0,
label: "Today",
clock: "13:00:00",
});
});

test("shows countdown one minute before start", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-27T12:59:00Z"),
);

expect(state).toEqual({
status: "countdown",
days: 0,
hours: 0,
minutes: 1,
seconds: 0,
label: "Today",
clock: "00:01:00",
});
});

test("returns live status at the exact start instant", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-27T13:00:00Z"),
);

expect(state).toEqual({ status: "live", label: "Happening now" });
});

test("returns live status during the event", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-27T18:00:00Z"),
);

expect(state).toEqual({ status: "live", label: "Happening now" });
});

test("returns ended status at the exact end instant", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-28T01:00:00Z"),
);

expect(state).toEqual({ status: "ended", label: "Event ended" });
});

test("returns ended status after the event", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-28T05:00:00Z"),
);

expect(state).toEqual({ status: "ended", label: "Event ended" });
});

test("shows days label for multi-day countdown", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-24T13:00:00Z"),
);

expect(state?.status).toBe("countdown");
if (state?.status === "countdown") {
expect(state.days).toBe(3);
expect(state.label).toBe("3 days left");
}
});

test("shows singular day label", () => {
const state = getEventTimingState(
STARTS_AT,
ENDS_AT,
Date.parse("2026-06-26T13:00:00Z"),
);

expect(state?.status).toBe("countdown");
if (state?.status === "countdown") {
expect(state.days).toBe(1);
expect(state.label).toBe("1 day left");
}
});
});
43 changes: 23 additions & 20 deletions src/lib/events/countdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ type EventCountdownState = {
clock: string;
};

type EventLiveState = {
label: "Happening now" | "Event ended";
};

export type EventTimingState =
| (EventCountdownState & { status: "countdown" })
| (EventLiveState & { status: "live" | "ended" });

const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
const MINUTE_MS = 60_000;
Expand All @@ -16,30 +24,24 @@ function padTime(value: number): string {
return String(value).padStart(2, "0");
}

/** Calendar date key (YYYY-MM-DD) in UTC for the given instant. */
export function getUtcDateKey(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");

return `${year}-${month}-${day}`;
}

/** Midnight UTC on the event's calendar day. Event `date` fields are UTC calendar dates. */
function getEventTargetTime(date: string): number {
return new Date(`${date}T00:00:00Z`).getTime();
}

export function getEventCountdownState(
date: string,
export function getEventTimingState(
startsAt: string,
endsAt: string,
now: number,
): EventCountdownState | null {
const diff = getEventTargetTime(date) - now;
): EventTimingState | null {
const startMs = new Date(startsAt).getTime();
const endMs = new Date(endsAt).getTime();

if (diff <= 0) {
return null;
if (now >= endMs) {
return { status: "ended", label: "Event ended" };
}

if (now >= startMs) {
return { status: "live", label: "Happening now" };
}

const diff = startMs - now;

const days = Math.floor(diff / DAY_MS);
const hours = Math.floor((diff % DAY_MS) / HOUR_MS);
const minutes = Math.floor((diff % HOUR_MS) / MINUTE_MS);
Expand All @@ -55,6 +57,7 @@ export function getEventCountdownState(
}

return {
status: "countdown",
days,
hours,
minutes,
Expand Down
Loading
Loading