From 66ab5d7fffb7d86d0145cf63c66259f69d389fa4 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 9 Oct 2026 15:52:03 +0530 Subject: [PATCH] feat(event-page): countdown pill on the event banner Shows the time left until the start on the event's public page, worded like the dashboard pill (63d 5h 12m). Under an hour it adds seconds. Changed characters roll into place, and a short blur crossfade carries the switch to Live. The server leaves the pill out once the event ends. base.css now carries the build version in its URL. It had a 12 hour cache, so a returning visitor could get the new script with the old stylesheet and see the pill unstyled. Co-Authored-By: Claude Opus 5.5 --- buzz/public/js/event_countdown.ts | 105 +++++++++++++++++++++++ buzz/public/js/event_page.bundle.ts | 2 + buzz/public/js/time_left.test.ts | 29 +++++++ buzz/public/js/time_left.ts | 16 ++++ buzz/public/themes/base.css | 119 ++++++++++++++++++++++++++ buzz/templates/event_page/layout.html | 2 +- buzz/www/event/index.html | 19 ++-- buzz/www/event/index.py | 17 +++- buzz/www/event/meta.py | 17 ++-- buzz/www/event/test_event_render.py | 16 ++++ 10 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 buzz/public/js/event_countdown.ts create mode 100644 buzz/public/js/time_left.test.ts create mode 100644 buzz/public/js/time_left.ts diff --git a/buzz/public/js/event_countdown.ts b/buzz/public/js/event_countdown.ts new file mode 100644 index 00000000..7f441f1d --- /dev/null +++ b/buzz/public/js/event_countdown.ts @@ -0,0 +1,105 @@ +import { formatTimeLeft } from "./time_left" + +const SECOND = 1000 +const BLURRED = { opacity: 0, filter: "blur(4px)" } +const SHARP = { opacity: 1, filter: "blur(0)" } +const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)" +const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)") + +// Each changed character rolls down into place; inline-grid slots collapse a plain space. +class RollingText { + constructor(private element: HTMLElement) {} + + set(value: string) { + const text = value.replaceAll(" ", "\u00a0") + const slots = [...this.element.children] as HTMLElement[] + if (slots.length !== text.length) { + this.element.replaceChildren(...[...text].map((character) => this.slot(character))) + return + } + ;[...text].forEach((character, index) => this.roll(slots[index], character)) + } + + private slot(character: string): HTMLElement { + const slot = document.createElement("span") + slot.className = "roll-slot" + slot.dataset.character = character + slot.append(this.glyph(character)) + return slot + } + + private glyph(character: string, className = ""): HTMLElement { + const glyph = document.createElement("span") + glyph.className = className + glyph.textContent = character + return glyph + } + + private roll(slot: HTMLElement, character: string) { + if (slot.dataset.character === character) return + slot.dataset.character = character + const outgoing = slot.lastElementChild as HTMLElement + outgoing.className = "roll-out" + // Reduced motion turns the roll off, and with it animationend. + if (reduceMotion.matches) outgoing.remove() + else outgoing.addEventListener("animationend", () => outgoing.remove(), { once: true }) + slot.append(this.glyph(character, "roll-in")) + } +} + +// The pill on an event's banner: time left until the start on the viewer's own clock, then +// Live until the end. The server leaves it out once the event is over. +export class EventCountdown extends HTMLElement { + private timer = 0 + private label = "" + private value?: RollingText + + connectedCallback() { + const value = this.querySelector(".countdown-value") + if (!value) return + this.value = new RollingText(value) + this.label = this.querySelector(".countdown-upcoming")?.firstChild?.textContent?.trim() || "" + this.tick() + this.hidden = false + } + + disconnectedCallback() { + clearTimeout(this.timer) + } + + private tick = () => { + if (!this.render(Date.now())) return + this.timer = window.setTimeout(this.tick, SECOND - (Date.now() % SECOND)) + } + + /** Returns false once the event has ended and the pill is gone. */ + private render(now: number): boolean { + const startsAt = Date.parse(this.getAttribute("starts-at") || "") + const endsAt = Date.parse(this.getAttribute("ends-at") || "") + if (now >= endsAt) { + this.remove() + return false + } + const isLive = now >= startsAt + this.setLive(isLive) + if (isLive) { + this.removeAttribute("aria-label") + return true + } + const timeLeft = formatTimeLeft(startsAt - now) + this.value?.set(timeLeft) + this.setAttribute("aria-label", `${this.label} ${timeLeft}`) + return true + } + + // A short blur crossfade carries the switch to Live; the first render, still hidden, just sets it. + private setLive(isLive: boolean) { + if (isLive === this.hasAttribute("data-live")) return + const swap = () => this.toggleAttribute("data-live", isLive) + if (this.hidden || reduceMotion.matches) return swap() + this.animate([SHARP, BLURRED], { duration: 120, easing: EASE_OUT }).finished.then(() => { + swap() + this.animate([BLURRED, SHARP], { duration: 200, easing: EASE_OUT }) + }) + } +} diff --git a/buzz/public/js/event_page.bundle.ts b/buzz/public/js/event_page.bundle.ts index d91e779d..17717fe1 100644 --- a/buzz/public/js/event_page.bundle.ts +++ b/buzz/public/js/event_page.bundle.ts @@ -1,4 +1,5 @@ import { bannerPattern } from "./event_banner" +import { EventCountdown } from "./event_countdown" // The key frappe-ui's useColorScheme keeps the dashboard's choice under, so both share one. // Also read by the inline script in the layout's , which applies it before first paint. @@ -27,6 +28,7 @@ class EventBanner extends HTMLElement { } if (!customElements.get("event-banner")) customElements.define("event-banner", EventBanner) +if (!customElements.get("event-countdown")) customElements.define("event-countdown", EventCountdown) function setMode(toggle: HTMLElement, mode: string) { document.documentElement.dataset.mode = mode diff --git a/buzz/public/js/time_left.test.ts b/buzz/public/js/time_left.test.ts new file mode 100644 index 00000000..d209cb79 --- /dev/null +++ b/buzz/public/js/time_left.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { formatTimeLeft } from "./time_left.ts" + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +test("shows days, hours and minutes", () => { + assert.equal(formatTimeLeft(63 * DAY + 5 * HOUR + 12 * MINUTE + 40 * SECOND), "63d 5h 12m") +}) + +test("keeps an empty hour between days and minutes", () => { + assert.equal(formatTimeLeft(2 * DAY + 7 * MINUTE), "2d 0h 7m") +}) + +test("drops days under a day", () => { + assert.equal(formatTimeLeft(5 * HOUR + 12 * MINUTE + 40 * SECOND), "5h 12m") +}) + +test("switches to minutes and seconds under an hour", () => { + assert.equal(formatTimeLeft(12 * MINUTE + 8 * SECOND), "12m 08s") +}) + +test("floors at zero once the start has passed", () => { + assert.equal(formatTimeLeft(-5 * SECOND), "0m 00s") +}) diff --git a/buzz/public/js/time_left.ts b/buzz/public/js/time_left.ts new file mode 100644 index 00000000..f353fb83 --- /dev/null +++ b/buzz/public/js/time_left.ts @@ -0,0 +1,16 @@ +const SECONDS_IN = { day: 86400, hour: 3600, minute: 60 } + +/** + * Time left as the dashboard's countdown pill words it: "14d 5h 55m", with the leading + * empty units dropped. Under an hour, seconds join so the pill keeps visibly ticking. + */ +export function formatTimeLeft(milliseconds: number): string { + const total = Math.max(0, Math.floor(milliseconds / 1000)) + const days = Math.floor(total / SECONDS_IN.day) + const hours = Math.floor((total % SECONDS_IN.day) / SECONDS_IN.hour) + const minutes = Math.floor((total % SECONDS_IN.hour) / SECONDS_IN.minute) + const seconds = String(total % SECONDS_IN.minute).padStart(2, "0") + if (days) return `${days}d ${hours}h ${minutes}m` + if (hours) return `${hours}h ${minutes}m` + return `${minutes}m ${seconds}s` +} diff --git a/buzz/public/themes/base.css b/buzz/public/themes/base.css index 33ccc775..010e97aa 100644 --- a/buzz/public/themes/base.css +++ b/buzz/public/themes/base.css @@ -261,6 +261,118 @@ body:has(.login-frame:not([data-ready])) { border-radius: var(--radius-banner); } +/* Countdown pill, straddling the banner's bottom edge like the dashboard's */ + +.event-banner-frame { + position: relative; +} + +.countdown-pill { + position: absolute; + bottom: 0; + left: 50%; + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border-radius: 999px; + background: var(--surface-raised); + box-shadow: + 0 0 0 1px var(--border), + 0 4px 12px rgb(0 0 0 / 0.12); + color: var(--ink-muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + white-space: nowrap; + transform: translate(-50%, 50%); + animation: countdown-in 250ms var(--ease-out) both; +} + +.countdown-pill[hidden] { + display: none; +} + +.countdown-pill:not([data-live]) :is(.live-dot, .countdown-live), +.countdown-pill[data-live] .countdown-upcoming { + display: none; +} + +.countdown-value { + display: inline-flex; + color: var(--ink-strong); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.roll-slot { + display: inline-grid; + overflow: hidden; +} + +.roll-slot > span { + grid-area: 1 / 1; +} + +.roll-in { + animation: roll-in 300ms var(--ease-out) both; +} + +.roll-out { + animation: roll-out 300ms var(--ease-out) both; +} + +.live-dot { + position: relative; + display: inline-flex; + width: 8px; + height: 8px; +} + +.live-dot::before, +.live-dot::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 999px; + background: #16a34a; +} + +.live-dot::before { + opacity: 0.75; + animation: live-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; +} + +@keyframes countdown-in { + from { + opacity: 0; + transform: translate(-50%, 50%) scale(0.9); + } +} + +@keyframes roll-in { + from { + opacity: 0; + transform: translateY(-70%); + } +} + +@keyframes roll-out { + to { + opacity: 0; + transform: translateY(70%); + } +} + +@keyframes live-ping { + 75%, + 100% { + opacity: 0; + transform: scale(2); + } +} + .event-intro { display: flex; align-items: center; @@ -1160,6 +1272,13 @@ summary:focus-visible { } @media (prefers-reduced-motion: reduce) { + .countdown-pill, + .roll-in, + .roll-out, + .live-dot::before { + animation: none; + } + .btn:not([aria-disabled]):active, .es-button:active, .event-card:active, diff --git a/buzz/templates/event_page/layout.html b/buzz/templates/event_page/layout.html index 0e145806..329564bc 100644 --- a/buzz/templates/event_page/layout.html +++ b/buzz/templates/event_page/layout.html @@ -21,7 +21,7 @@ - + diff --git a/buzz/www/event/index.html b/buzz/www/event/index.html index b2b9991f..235fde44 100644 --- a/buzz/www/event/index.html +++ b/buzz/www/event/index.html @@ -40,11 +40,20 @@

{{ _("Links") }}

{% endmacro %}
- {% if event.banner_image %} - - {% else %} - - {% endif %} +
+ {% if event.banner_image %} + + {% else %} + + {% endif %} + {% if countdown %} + + {% endif %} +
diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index f80a2940..20415695 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -1,3 +1,5 @@ +from datetime import datetime +from datetime import time as clock_time from functools import cached_property from itertools import groupby from urllib.parse import urlparse @@ -22,7 +24,7 @@ from buzz.utils import datetime_in_time_zone, format_gmt_offset, get_time_zone_label from buzz.www.event.date_range import RANGE_SEPARATOR, EventDateRange, format_time from buzz.www.event.link_icons import LINK_ICON_PATHS -from buzz.www.event.meta import EventMeta +from buzz.www.event.meta import EventMeta, event_time_zone from buzz.www.event.venue_map import venue_map_url from buzz.www.site_header import apply_site_context @@ -122,10 +124,23 @@ def as_context(self) -> dict: "online_label": OnlineMeeting(self.event).label, "register_url": registration_link(self.event), "registrations_closed": are_registrations_closed(self.event), + "countdown": self.countdown(), } meta = EventMeta(self.event, self.page, context) return context | {"meta": meta.as_dict(), "structured_data": meta.structured_data()} + def countdown(self) -> dict | None: + # An event with no closing time runs to the end of its last day; past that, no countdown. + # Whole seconds: JavaScript only promises to parse milliseconds. + zone = event_time_zone(self.event) + start_time = get_time(self.event.start_time) if self.event.start_time else clock_time.min + end_time = get_time(self.event.end_time) if self.event.end_time else clock_time(23, 59, 59) + starts_at = datetime.combine(getdate(self.event.start_date), start_time, zone) + ends_at = datetime.combine(getdate(self.event.end_date or self.event.start_date), end_time, zone) + if ends_at <= datetime.now(zone): + return None + return {"starts_at": starts_at.isoformat(), "ends_at": ends_at.isoformat()} + def tabs(self) -> list[dict]: sections = [ ("about", _("About"), True), diff --git a/buzz/www/event/meta.py b/buzz/www/event/meta.py index 7eb39f1e..c2a68275 100644 --- a/buzz/www/event/meta.py +++ b/buzz/www/event/meta.py @@ -9,6 +9,14 @@ IMAGE_FIELDS = ("meta_image", "og_image", "banner_image", "card_image") +def event_time_zone(event) -> ZoneInfo: + # time_zone is free text; a bad value must not take the page down + try: + return ZoneInfo(event.time_zone or get_system_timezone()) + except (ZoneInfoNotFoundError, ValueError): + return ZoneInfo(get_system_timezone()) + + def plain_text(html: str | None) -> str: return BeautifulSoup(html or "", "html.parser").get_text(" ") @@ -67,14 +75,7 @@ def structured_data(self) -> dict | None: def event_datetime(self, date, time) -> str: if not time: return getdate(date).isoformat() - return datetime.combine(getdate(date), get_time(time), self.time_zone()).isoformat() - - def time_zone(self) -> ZoneInfo: - # time_zone is free text; a bad value must not take the page down - try: - return ZoneInfo(self.event.time_zone or get_system_timezone()) - except (ZoneInfoNotFoundError, ValueError): - return ZoneInfo(get_system_timezone()) + return datetime.combine(getdate(date), get_time(time), event_time_zone(self.event)).isoformat() def end_datetime(self) -> str | None: if not self.event.end_time: diff --git a/buzz/www/event/test_event_render.py b/buzz/www/event/test_event_render.py index 2966ea2a..88678196 100644 --- a/buzz/www/event/test_event_render.py +++ b/buzz/www/event/test_event_render.py @@ -91,6 +91,22 @@ def test_online_event_names_the_platform_but_never_shows_the_link(self): self.set_event({"meeting_link": "https://notzoom.us.example/j/1"}) self.assertEqual(EventPage(self.route).as_context()["online_label"], "Online") + def test_upcoming_event_renders_a_countdown(self): + self.set_event({"time_zone": "Asia/Kolkata", "start_date": "2099-01-05", "start_time": "09:30:00"}) + html = render(self.route) + self.assertIn('starts-at="2099-01-05T09:30:00+05:30"', html) + + def test_ended_event_has_no_countdown(self): + self.set_event({"start_date": "2020-01-05", "end_date": "2020-01-05"}) + self.assertNotIn("