From 5377f6ffe475cac9a8ef2d9b7db25823458a2c8b Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 12 Oct 2026 09:31:27 +0530 Subject: [PATCH] feat(events): toggle for the banner countdown, off by default Adds a Show Event Countdown check on Buzz Event. The event page renders the countdown pill only when it is on. The event Details page now uses the sidebar layout, with quick actions: a link to the event page and a switch for the countdown. The switch saves at once, and the Details banner shows the same pill as the event page. The dashboard reuses the event page's event-countdown element, and its styles move from base.css to a shared countdown.css. get_event returns the countdown start and end, calculated by the same function the event page uses, so both show the same times. Co-Authored-By: Claude Opus 5.5 --- buzz/api/events/schemas.py | 8 ++ buzz/api/events/services.py | 3 + buzz/events/countdown.py | 20 +++ .../events/doctype/buzz_event/buzz_event.json | 10 +- buzz/events/doctype/buzz_event/buzz_event.py | 1 + buzz/public/themes/base.css | 111 +---------------- buzz/public/themes/countdown.css | 117 ++++++++++++++++++ buzz/templates/event_page/layout.html | 1 + buzz/www/event/index.py | 17 +-- buzz/www/event/test_event_render.py | 23 +++- dashboard/components.d.ts | 2 + .../dashboard/events/BannerCountdown.vue | 36 ++++++ .../dashboard/events/EventBanner.vue | 89 ++++++------- .../dashboard/events/EventDetailsActions.vue | 54 ++++++++ dashboard/src/data/events.ts | 3 + .../src/pages/manage/events/EventDetails.vue | 34 ++++- dashboard/src/types.ts | 3 + dashboard/vite.config.ts | 5 +- 18 files changed, 362 insertions(+), 175 deletions(-) create mode 100644 buzz/events/countdown.py create mode 100644 buzz/public/themes/countdown.css create mode 100644 dashboard/src/components/dashboard/events/BannerCountdown.vue create mode 100644 dashboard/src/components/dashboard/events/EventDetailsActions.vue diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 17bf78808..df7c8ee69 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -61,6 +61,11 @@ class EventExternalLink(APIResponse): url: str +class EventCountdown(APIResponse): + starts_at: str + ends_at: str + + class EventDetail(APIResponse): """One event, with everything the manage page edits or shows.""" @@ -82,6 +87,9 @@ class EventDetail(APIResponse): # The organiser's own link, or the one Zoom issued when the meeting was booked. meeting_link: str | None = None is_published: bool + show_countdown: bool = False + # None once the event is over. + countdown: EventCountdown | None = None primary_host: EventHostRef | None = None co_hosts: list[EventHostRef] = Field(default_factory=list) external_links: list[EventExternalLink] = Field(default_factory=list) diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index b2870a2fe..10fd6d2c1 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -35,6 +35,7 @@ ) from buzz.api.filters.conditions import ListConditions, event_questions, filter_field, question_fields from buzz.api.filters.schemas import FilterField +from buzz.events.countdown import event_countdown from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, BuzzEvent from buzz.permissions import has_team_access, my_teams from buzz.utils import is_app_installed @@ -143,6 +144,7 @@ def split_by_date(events: list[MyEvent]) -> tuple[list[MyEvent], list[MyEvent]]: "venue", "meeting_link", "is_published", + "show_countdown", ) @@ -165,6 +167,7 @@ def event_detail(event: str) -> EventDetail: "primary_host": primary_host_of(row.team), "co_hosts": co_hosts_of(event), "external_links": external_links_of(event), + "countdown": event_countdown(row), } ) diff --git a/buzz/events/countdown.py b/buzz/events/countdown.py new file mode 100644 index 000000000..22444ce64 --- /dev/null +++ b/buzz/events/countdown.py @@ -0,0 +1,20 @@ +from datetime import datetime +from datetime import time as clock_time + +from frappe.utils import get_time, getdate + +from buzz.www.event.meta import event_time_zone + + +def event_countdown(event) -> dict | None: + """Start and end of the banner countdown, or None once the event is over.""" + # An event with no closing time runs to the end of its last day. + # Whole seconds: JavaScript only promises to parse milliseconds. + zone = event_time_zone(event) + start_time = get_time(event.start_time) if event.start_time else clock_time.min + end_time = get_time(event.end_time) if event.end_time else clock_time(23, 59, 59) + starts_at = datetime.combine(getdate(event.start_date), start_time, zone) + ends_at = datetime.combine(getdate(event.end_date or 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()} diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 0ad067b64..546576634 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -36,6 +36,7 @@ "website_tab", "is_published", "is_featured", + "show_countdown", "route", "default_ticket_type", "column_break_ndtl", @@ -247,6 +248,13 @@ "label": "Featured", "permlevel": 1 }, + { + "default": "0", + "description": "Shows the time until the event starts on the event page banner.", + "fieldname": "show_countdown", + "fieldtype": "Check", + "label": "Show Event Countdown" + }, { "fieldname": "tab_2_tab", "fieldtype": "Tab Break", @@ -681,7 +689,7 @@ "link_fieldname": "event" } ], - "modified": "2026-10-02 12:00:00.000000", + "modified": "2026-10-12 12:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 8ed715557..66cd444c8 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -88,6 +88,7 @@ class BuzzEvent(Document): send_booking_confirmation_email: DF.Check send_ticket_email: DF.Check short_description: DF.SmallText | None + show_countdown: DF.Check show_sponsorship_section: DF.Check sponsor_deck_attachments: DF.Table[SponsorshipDeckItem] sponsor_deck_cc: DF.SmallText | None diff --git a/buzz/public/themes/base.css b/buzz/public/themes/base.css index 010e97aa3..59ce1dba2 100644 --- a/buzz/public/themes/base.css +++ b/buzz/public/themes/base.css @@ -261,118 +261,12 @@ body:has(.login-frame:not([data-ready])) { border-radius: var(--radius-banner); } -/* Countdown pill, straddling the banner's bottom edge like the dashboard's */ +/* Holds the countdown pill (countdown.css) on the banner's bottom edge */ .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; @@ -1272,9 +1166,6 @@ summary:focus-visible { } @media (prefers-reduced-motion: reduce) { - .countdown-pill, - .roll-in, - .roll-out, .live-dot::before { animation: none; } diff --git a/buzz/public/themes/countdown.css b/buzz/public/themes/countdown.css new file mode 100644 index 000000000..1d993cd88 --- /dev/null +++ b/buzz/public/themes/countdown.css @@ -0,0 +1,117 @@ +/* Countdown pill on an event banner, shared by the event page and the dashboard. + The banner's frame positions it; the host page supplies the colour tokens. */ + +.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); + } +} + +@media (prefers-reduced-motion: reduce) { + .countdown-pill, + .roll-in, + .roll-out, + .live-dot::before { + animation: none; + } +} diff --git a/buzz/templates/event_page/layout.html b/buzz/templates/event_page/layout.html index 329564bc9..b5c0a5765 100644 --- a/buzz/templates/event_page/layout.html +++ b/buzz/templates/event_page/layout.html @@ -21,6 +21,7 @@ + diff --git a/buzz/www/event/index.py b/buzz/www/event/index.py index 204156952..cd60d6722 100644 --- a/buzz/www/event/index.py +++ b/buzz/www/event/index.py @@ -1,5 +1,3 @@ -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 @@ -19,6 +17,7 @@ from buzz.api.booking.services import are_registrations_closed from buzz.api.events.services import co_hosts_of, primary_host_of, registration_link from buzz.emails import venue_map_url as venue_link +from buzz.events.countdown import event_countdown from buzz.events.doctype.sponsorship_tier.sponsorship_tier import default_price from buzz.events.online_meeting import OnlineMeeting from buzz.utils import datetime_in_time_zone, format_gmt_offset, get_time_zone_label @@ -124,23 +123,11 @@ 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(), + "countdown": event_countdown(self.event) if self.event.show_countdown else None, } 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/test_event_render.py b/buzz/www/event/test_event_render.py index 886781966..49c9324d7 100644 --- a/buzz/www/event/test_event_render.py +++ b/buzz/www/event/test_event_render.py @@ -92,17 +92,34 @@ def test_online_event_names_the_platform_but_never_shows_the_link(self): 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"}) + self.set_event( + { + "show_countdown": 1, + "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.set_event({"show_countdown": 1, "start_date": "2020-01-05", "end_date": "2020-01-05"}) + self.assertNotIn(" +import "@public/themes/countdown.css" +import { EventCountdown } from "@public/js/event_countdown" + +// The event page's own pill, so the banner here reads the same as the one guests see. +if (!customElements.get("event-countdown")) customElements.define("event-countdown", EventCountdown) + +defineProps<{ startsAt: string; endsAt: string }>() + + + + + diff --git a/dashboard/src/components/dashboard/events/EventBanner.vue b/dashboard/src/components/dashboard/events/EventBanner.vue index 0cc43bfc6..181f2f0a2 100644 --- a/dashboard/src/components/dashboard/events/EventBanner.vue +++ b/dashboard/src/components/dashboard/events/EventBanner.vue @@ -62,60 +62,65 @@ watch(image, () => { @success="(file: { file_url: string }) => (image = file.file_url)" > diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 4015ae4d7..e29a59130 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -346,6 +346,9 @@ export interface EventDetail { venue: EventVenueDetail | null meeting_link: string | null is_published: boolean + show_countdown: boolean + // Null once the event is over. + countdown: { starts_at: string; ends_at: string } | null primary_host: EventHostRef | null co_hosts: EventHostRef[] external_links: EventExternalLink[] diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 47e549110..820ca91c3 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -20,7 +20,10 @@ export default defineConfig({ outDir: "../buzz/public/dashboard", }, }), - vue(), + vue({ + // The event page's custom elements, shared with the dashboard. + template: { compilerOptions: { isCustomElement: (tag) => tag === "event-countdown" } }, + }), ], resolve: { alias: {