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("