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
105 changes: 105 additions & 0 deletions buzz/public/js/event_countdown.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(".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 })
})
}
}
2 changes: 2 additions & 0 deletions buzz/public/js/event_page.bundle.ts
Original file line number Diff line number Diff line change
@@ -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 <head>, which applies it before first paint.
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions buzz/public/js/time_left.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
16 changes: 16 additions & 0 deletions buzz/public/js/time_left.ts
Original file line number Diff line number Diff line change
@@ -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`
}
119 changes: 119 additions & 0 deletions buzz/public/themes/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion buzz/templates/event_page/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=Newsreader:wght@400&display=swap">
<link rel="stylesheet" href="{{ bundled_asset('espresso.bundle.css') }}">
<link rel="stylesheet" href="/assets/buzz/themes/base.css">
<link rel="stylesheet" href="/assets/buzz/themes/base.css?v={{ build_version }}">
<link rel="stylesheet" href="/assets/buzz/islands/icons.css">
<style>{{ theme_css }}</style>
<script src="{{ bundled_asset('event_page.bundle.ts') }}" defer></script>
Expand Down
19 changes: 14 additions & 5 deletions buzz/www/event/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,20 @@ <h2 class="label">{{ _("Links") }}</h2>
{% endmacro %}

<main class="event-content">
{% if event.banner_image %}
<img class="event-banner" src="{{ event.banner_image }}" alt="">
{% else %}
<event-banner class="event-banner" seed="{{ event.title }}"></event-banner>
{% endif %}
<div class="event-banner-frame">
{% if event.banner_image %}
<img class="event-banner" src="{{ event.banner_image }}" alt="">
{% else %}
<event-banner class="event-banner" seed="{{ event.title }}"></event-banner>
{% endif %}
{% if countdown %}
<event-countdown class="countdown-pill" role="timer" starts-at="{{ countdown.starts_at }}" ends-at="{{ countdown.ends_at }}" hidden>
<span class="live-dot" aria-hidden="true"></span>
<span class="countdown-live">{{ _("Live") }}</span>
<span class="countdown-upcoming">{{ _("Starting in") }} <span class="countdown-value" aria-hidden="true"></span></span>
</event-countdown>
{% endif %}
</div>

<div class="event-intro">
<div class="event-heading">
Expand Down
17 changes: 16 additions & 1 deletion buzz/www/event/index.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading