From 7a331d241ab7adeae2aa7691073427542c0c4da7 Mon Sep 17 00:00:00 2001 From: Devin Willis Date: Wed, 26 Aug 2026 14:31:24 -0400 Subject: [PATCH] Add live countdown, Silver Q venue section, real address, OneTouch favicon --- public/assets/founders-break.ics | 4 +- public/css/style.css | 88 ++++++++++++++++++++++ public/images/favicon.svg | 29 ++++++-- public/js/main.js | 123 ++++++++++++++++++++++++++++++- site.config.js | 32 ++++++-- views/index.ejs | 83 ++++++++++++++++++--- 6 files changed, 329 insertions(+), 30 deletions(-) diff --git a/public/assets/founders-break.ics b/public/assets/founders-break.ics index 1d7c08a..662eb9d 100644 --- a/public/assets/founders-break.ics +++ b/public/assets/founders-break.ics @@ -28,9 +28,9 @@ DTSTART;TZID=America/New_York:20260827T190000 DTEND;TZID=America/New_York:20260827T210000 RRULE:FREQ=WEEKLY;BYDAY=TH SUMMARY:The Founder's Break -LOCATION:Silver Q\, Gainesville\, FL +LOCATION:Silver Q Billiards & Sports Bar\, 225 W University Ave\, Gainesville\, FL 32601 URL:https://foundersbreak.onetouch.studio -DESCRIPTION:UF startup pool social. Every Thursday\, 7 to 9 PM at Silver Q. +DESCRIPTION:UF startup pool social. Every Thursday\, 7 to 9 PM at Silver Q\, 225 W University Ave. Zero pool skills required. Free\, 18+. Bring your ideas and a valid ID.\n\n Hosted by OneTouch Studio. Details and RSVP: https://foundersbreak.onetouch. studio diff --git a/public/css/style.css b/public/css/style.css index 6617006..1de21d2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -161,6 +161,53 @@ a { color: var(--accent2); } background: linear-gradient(190deg, transparent 55%, rgba(6, 6, 11, 0.5)); } +/* ── Countdown ────────────────────────────────────────────── */ + +.countdown-band { max-width: var(--shell); margin: 0 auto; padding: 0 var(--gut) 4.5rem; } + +.countdown { + position: relative; isolation: isolate; overflow: hidden; + padding: 2.6rem 2rem; + border: 1px solid var(--border); border-radius: var(--r-surface); + background: + radial-gradient(90% 140% at 50% 0%, rgba(16, 185, 129, 0.12), transparent 68%), + var(--bg2); + text-align: center; +} + +.countdown-label { + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; + text-transform: uppercase; color: var(--accent2); +} + +.countdown-clock { + display: flex; justify-content: center; align-items: flex-start; + gap: clamp(1.2rem, 5vw, 3.4rem); + margin: 1.5rem 0 1.2rem; +} +.countdown-clock .unit { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; min-width: 3.4ch; } +.countdown-clock .num { + font-family: "Space Grotesk", sans-serif; font-weight: 700; + font-size: clamp(2.4rem, 7vw, 4rem); line-height: 1; + font-variant-numeric: tabular-nums; letter-spacing: -0.03em; +} +.countdown-clock .cap { + font-size: 0.7rem; font-weight: 600; letter-spacing: 0.13em; + text-transform: uppercase; color: var(--muted); +} +/* One-frame lift as a digit changes, so the clock reads as live rather than static. */ +.countdown-clock .num.tick { animation: tick 0.42s var(--ease); } +@keyframes tick { + 0% { transform: translateY(-0.14em); opacity: 0.45; } + 100% { transform: none; opacity: 1; } +} + +.countdown-when { color: var(--muted); font-size: 0.95rem; } + +.countdown.is-live { border-color: var(--border-hover); } +.countdown.is-live .countdown-label { color: var(--accent2); } +.countdown.is-live .countdown-when { color: var(--text); font-weight: 600; } + /* ── Details: display-value strip ─────────────────────────── */ .details { max-width: var(--shell); margin: 0 auto; padding: 0 var(--gut) 6rem; } @@ -248,6 +295,39 @@ a { color: var(--accent2); } .cell-text h3 { font-size: 1.16rem; font-weight: 600; margin-bottom: 0.65rem; } .cell-text p { color: var(--muted); font-size: 0.96rem; max-width: 52ch; } +/* ── Venue ────────────────────────────────────────────────── */ + +.venue { + max-width: var(--shell); margin: 0 auto; + padding: 0 var(--gut) 7rem; + display: grid; grid-template-columns: 0.85fr 1.15fr; gap: 4rem; align-items: center; +} +.venue-logo { width: auto; height: clamp(96px, 13vw, 132px); margin-bottom: 1.8rem; opacity: 0.95; } +.venue h2 { font-size: clamp(1.6rem, 3.2vw, 2.2rem); font-weight: 700; } +.venue p { margin-top: 1rem; color: var(--muted); max-width: 46ch; } + +.venue address { margin-top: 1.4rem; font-style: normal; } +.venue address a { + font-family: "Space Grotesk", sans-serif; font-weight: 600; + font-size: 1.12rem; line-height: 1.45; color: var(--text); text-decoration: none; + border-bottom: 1px solid var(--border-hover); +} +.venue address a:hover { color: var(--accent2); } + +.venue-meta { + display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; + margin-top: 1.4rem; font-size: 0.9rem; +} +.venue-meta a { color: var(--accent2); text-decoration: none; border-bottom: 1px solid var(--border-hover); } +.venue-meta a:hover { border-bottom-color: var(--accent2); } +.venue-meta span { color: var(--muted); } + +.venue-figure img { + width: 100%; aspect-ratio: 3 / 2; object-fit: cover; + border-radius: var(--r-surface); border: 1px solid var(--hairline); +} +.venue-figure figcaption { margin-top: 0.8rem; font-size: 0.82rem; color: var(--fine); } + /* ── RSVP ─────────────────────────────────────────────────── */ .rsvp { padding: 0 var(--gut) 7rem; } @@ -321,9 +401,17 @@ form { margin-top: 2rem; } .cell-wide { grid-column: span 2; } } +@media (max-width: 1000px) { + .venue { grid-template-columns: 1fr; gap: 2.4rem; } + .venue-figure { order: -1; } +} + @media (max-width: 760px) { :root { --gut: 1.25rem; } .nav-link { display: none; } + .countdown { padding: 2rem 1rem; } + .countdown-clock { gap: 0.9rem; } + .venue { padding-bottom: 5rem; } .detail-grid { grid-template-columns: 1fr 1fr; } .detail { padding: 1.4rem 1.1rem; } .detail:nth-child(odd) { border-left: 0; } diff --git a/public/images/favicon.svg b/public/images/favicon.svg index 06d8f1e..fca987e 100644 --- a/public/images/favicon.svg +++ b/public/images/favicon.svg @@ -1,9 +1,13 @@ - - - - - - + + + + + + + + + @@ -11,6 +15,17 @@ - + + + + + + + + + + + + diff --git a/public/js/main.js b/public/js/main.js index 809300a..f452d46 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,9 +1,10 @@ -/* The Founder's Break - nav state, scroll reveal, RSVP submit. +/* The Founder's Break - nav state, scroll reveal, live countdown, RSVP submit. No scroll listeners: nav state and reveal both ride IntersectionObserver. */ (function () { "use strict"; + var TZ = "America/New_York"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* ── Nav background once the page has moved off the top ── */ @@ -23,14 +24,130 @@ var io = new IntersectionObserver(function (entries, obs) { entries.forEach(function (entry, i) { if (!entry.isIntersecting) return; - var delay = Math.min(i, 4) * 70; - setTimeout(function () { entry.target.classList.add("in"); }, delay); + setTimeout(function () { entry.target.classList.add("in"); }, Math.min(i, 4) * 70); obs.unobserve(entry.target); }); }, { threshold: 0.15, rootMargin: "0px 0px -8% 0px" }); reveals.forEach(function (el) { io.observe(el); }); } + /* ── Live countdown ────────────────────────────────────── + Mirrors the rule in views/index.ejs so the clock keeps itself honest + without a reload: once an event ends, it rolls to the following week + on its own, skipping any cancelled date. */ + + var el = document.getElementById("countdown"); + if (el) startCountdown(el); + + function etParts(date) { + // "2026-08-27" plus the weekday index, both in Eastern time. + var ymd = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", + }).format(date); + var wd = new Intl.DateTimeFormat("en-US", { timeZone: TZ, weekday: "short" }).format(date); + var order = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + return { iso: ymd, day: order[wd] }; + } + + function etOffsetHours(iso) { + var label = new Intl.DateTimeFormat("en-US", { timeZone: TZ, timeZoneName: "shortOffset" }) + .format(new Date(iso + "T12:00:00Z")); + var m = label.match(/GMT([+-]?\d{1,2})/); + return m ? parseInt(m[1], 10) : -5; + } + + function instantFor(iso, hour) { + var p = iso.split("-").map(Number); + return new Date(Date.UTC(p[0], p[1] - 1, p[2], hour - etOffsetHours(iso), 0, 0)); + } + + function startCountdown(root) { + var weekday = Number(root.dataset.weekday); + var startHour = Number(root.dataset.startHour); + var endHour = Number(root.dataset.endHour); + var first = root.dataset.first; + var cancelled = (root.dataset.cancelled || "").split(",").filter(Boolean); + + var nums = {}; + root.querySelectorAll("[data-unit]").forEach(function (n) { nums[n.dataset.unit] = n; }); + var label = document.getElementById("countdown-label"); + var when = document.getElementById("countdown-when"); + var clock = document.getElementById("countdown-clock"); + + function nextOccurrence(now) { + var today = etParts(now); + var parts = today.iso.split("-").map(Number); + var cur = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])); + var firstParts = first.split("-").map(Number); + var firstDate = new Date(Date.UTC(firstParts[0], firstParts[1] - 1, firstParts[2])); + if (cur < firstDate) cur = firstDate; + + var iso = function (d) { return d.toISOString().slice(0, 10); }; + while (cur.getUTCDay() !== weekday) cur.setUTCDate(cur.getUTCDate() + 1); + // Only roll past today once the night is actually over. + if (iso(cur) === today.iso && now >= instantFor(today.iso, endHour)) { + cur.setUTCDate(cur.getUTCDate() + 7); + } + while (cancelled.indexOf(iso(cur)) !== -1) cur.setUTCDate(cur.getUTCDate() + 7); + return iso(cur); + } + + function render() { + var now = new Date(); + var iso = nextOccurrence(now); + var start = instantFor(iso, startHour); + var end = instantFor(iso, endHour); + + var pretty = new Intl.DateTimeFormat("en-US", { + timeZone: TZ, weekday: "long", month: "long", day: "numeric", + }).format(start); + + if (now >= start && now < end) { + root.classList.add("is-live"); + label.textContent = "Happening right now"; + clock.hidden = true; + when.textContent = "Until " + fmtTime(end) + " tonight. Come find us at the tables."; + return; + } + + root.classList.remove("is-live"); + clock.hidden = false; + var diff = Math.max(0, start - now); + var total = Math.floor(diff / 1000); + set("days", Math.floor(total / 86400)); + set("hours", Math.floor(total / 3600) % 24); + set("minutes", Math.floor(total / 60) % 60); + set("seconds", total % 60); + + label.textContent = total < 86400 ? "The Break is tonight" : "Until the next Break"; + when.textContent = pretty + ", " + fmtTime(start) + " at Silver Q"; + } + + function fmtTime(d) { + return new Intl.DateTimeFormat("en-US", { timeZone: TZ, hour: "numeric", minute: "2-digit" }).format(d); + } + + function set(unit, value) { + var node = nums[unit]; + if (!node) return; + var text = unit === "days" ? String(value) : String(value).padStart(2, "0"); + if (node.textContent === text) return; + node.textContent = text; + if (reduceMotion) return; + node.classList.remove("tick"); + void node.offsetWidth; // restart the animation + node.classList.add("tick"); + } + + render(); + var timer = setInterval(render, 1000); + // A backgrounded tab throttles the interval; catch up the moment it returns. + document.addEventListener("visibilitychange", function () { + if (!document.hidden) render(); + }); + window.addEventListener("pagehide", function () { clearInterval(timer); }); + } + /* ── RSVP ── */ var form = document.getElementById("rsvp-form"); if (!form) return; diff --git a/site.config.js b/site.config.js index aff3757..2e23931 100644 --- a/site.config.js +++ b/site.config.js @@ -2,14 +2,15 @@ // // The event recurs weekly, so nothing here is a one-off date. index.ejs computes // the next occurrence at render time from `weekday` + `firstEvent`, minus anything -// listed in `cancelled`. To skip a week (holiday, venue closed, whatever), add the -// ISO date to `cancelled` and redeploy. Do not hand-edit dates anywhere else. +// listed in `cancelled`, and public/js/main.js runs the same rule client-side so a +// page left open overnight still rolls itself forward. To skip a week, add the ISO +// date to `cancelled` and redeploy. Do not hand-edit dates anywhere else. module.exports = { name: "The Founder's Break", tagline: "UF Startup Pool Social", description: - "A weekly pool social for the people building things in Gainesville. Every Thursday at Silver Q.", + "A weekly pool social for the people building things in Gainesville. Every Thursday at Silver Q, 225 W University Ave.", // Schedule timezone: "America/New_York", @@ -17,15 +18,28 @@ module.exports = { firstEvent: "2026-08-27", startTime: "7:00 PM", endTime: "9:00 PM", + startHour24: 19, endHour24: 21, // after this hour on event day, the page rolls to next week cancelled: [ // "2026-11-26", // example: Thanksgiving ], - // Venue - venue: "Silver Q", - city: "Gainesville", - mapUrl: "https://www.google.com/maps/search/?api=1&query=Silver+Q+Gainesville+FL", + // Venue. Every field here is sourced, not guessed. Silver Q Billiards & Sports + // Bar, established 1992, is the host venue; it is not run by OneTouch Studio. + venue: { + name: "Silver Q", + fullName: "Silver Q Billiards & Sports Bar", + street: "225 W University Ave", + city: "Gainesville", + state: "FL", + zip: "32601", + note: "Downtown, about a mile from campus. Doors from 4:00 PM, we take over the tables at 7:00.", + established: "1992", + instagram: "silverqgnv", + instagramUrl: "https://www.instagram.com/silverqgnv/", + mapUrl: + "https://www.google.com/maps/search/?api=1&query=Silver+Q+Billiards+225+W+University+Ave+Gainesville+FL+32601", + }, entry: "Free, 18+", // Host @@ -45,6 +59,10 @@ module.exports = { "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/0b5cd98c-b31a-4d09-980f-d31da5873a53/break-shot.jpg", napkin: "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/eb54a5f7-a30e-4a7a-b3cb-0beb5eff43ff/napkin.jpg", + venue: + "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/458dab49-e942-4016-a744-127da6f9a754/silverq-venue.jpg", + venueLogo: + "https://nyc3.digitaloceanspaces.com/corben-sites/images/c23ea202-5442-41b4-9a1b-1586e81c50eb/5adbc5b1-ed83-48ae-b067-8ca01d0fde44/silverq-logo.png", }, expect: [ diff --git a/views/index.ejs b/views/index.ejs index 3fc23af..0e1e63d 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -2,9 +2,12 @@ /* ---- Next occurrence ------------------------------------------------- All arithmetic happens on a UTC Date that carries the *Eastern calendar date*, never a wall-clock instant. That keeps day-of-week and day-adding - correct across DST without needing a timezone library. */ + correct across DST without needing a timezone library. public/js/main.js + runs the same rule so the live countdown agrees with this render and can + roll itself over without a reload. */ const TZ = site.timezone || "America/New_York"; const now = new Date(); + const V = site.venue; const todayISO = new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", @@ -24,10 +27,10 @@ while (next.getUTCDay() !== site.weekday) next.setUTCDate(next.getUTCDate() + 1); if (isoOf(next) === todayISO && hourET >= (site.endHour24 || 21)) next.setUTCDate(next.getUTCDate() + 7); - const cancelled = site.cancelled || []; - let skippedWeek = null; // Keep the FIRST skipped date: with two cancellations in a row the notice // should name the soonest one people were expecting, not the last one. + const cancelled = site.cancelled || []; + let skippedWeek = null; while (cancelled.includes(isoOf(next))) { if (!skippedWeek) skippedWeek = isoOf(next); next.setUTCDate(next.getUTCDate() + 7); @@ -40,14 +43,23 @@ const nextShort = fmtDate({ weekday: "short", month: "short", day: "numeric" }); const weekdayName = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "long" }).format(next); + // Eastern UTC offset on the event date, so the countdown target is a real instant. + const offLabel = new Intl.DateTimeFormat("en-US", { timeZone: TZ, timeZoneName: "shortOffset" }) + .format(new Date(`${nextISO}T12:00:00Z`)); + const offHours = Number((offLabel.match(/GMT([+-]?\d{1,2})/) || [, "-5"])[1]); + const pad = (n) => String(n).padStart(2, "0"); + const offStr = `${offHours < 0 ? "-" : "+"}${pad(Math.abs(offHours))}:00`; + const startISO = `${nextISO}T${pad(site.startHour24)}:00:00${offStr}`; + const endISO = `${nextISO}T${pad(site.endHour24)}:00:00${offStr}`; + const heroLine = isTonight - ? `Tonight at ${site.venue}, ${site.startTime} to ${site.endTime}. Grab a cue and meet the people building things in ${site.city}.` - : `Every ${weekdayName} at ${site.venue}, ${site.startTime} to ${site.endTime}. The next one is ${nextLong}.`; + ? `Tonight at ${V.name}, ${site.startTime} to ${site.endTime}. Grab a cue and meet the people building things in ${V.city}.` + : `Every ${weekdayName} at ${V.name}, ${site.startTime} to ${site.endTime}. The next one is ${nextLong}.`; const details = [ { label: "When", value: `Every ${weekdayName}` }, { label: "Time", value: `${site.startTime} to ${site.endTime}` }, - { label: "Where", value: `${site.venue}, ${site.city}`, href: site.mapUrl }, + { label: "Where", value: `${V.name}, ${V.street}`, href: V.mapUrl }, { label: "Entry", value: site.entry }, ]; %> @@ -67,6 +79,7 @@ + @@ -83,12 +96,12 @@ "@type": "Event", name: site.name, description: site.description, - startDate: `${nextISO}T19:00:00-04:00`, - endDate: `${nextISO}T21:00:00-04:00`, + startDate: startISO, + endDate: endISO, eventSchedule: { "@type": "Schedule", repeatFrequency: "P1W", byDay: "https://schema.org/Thursday", startTime: "19:00", endTime: "21:00" }, eventStatus: "https://schema.org/EventScheduled", eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode", - location: { "@type": "Place", name: site.venue, address: { "@type": "PostalAddress", addressLocality: site.city, addressRegion: "FL", addressCountry: "US" } }, + location: { "@type": "Place", name: V.fullName, address: { "@type": "PostalAddress", streetAddress: V.street, addressLocality: V.city, addressRegion: V.state, postalCode: V.zip, addressCountry: "US" } }, organizer: { "@type": "Organization", name: site.host.name, url: site.host.url }, isAccessibleForFree: true, image: [site.images.hero], @@ -108,6 +121,7 @@ @@ -131,6 +145,31 @@ + +
+
+

+ <%= isTonight ? "The Break is tonight" : "Until the next Break" %> +

+
+
-days
+
-hours
+
-min
+
-sec
+
+

<%= nextLong %>, <%= site.startTime %> at <%= V.name %>

+
+
+
<% if (skippedWeek) { %> @@ -176,6 +215,28 @@
+ +
+
+ +

We play at <%= V.name %>.

+

<%= V.note %>

+
+ <%= V.street %>
<%= V.city %>, <%= V.state %> <%= V.zip %>
+
+

+ @<%= V.instagram %> + Pouring since <%= V.established %> +

+
+
+ The Silver Q storefront on West University Avenue at dusk. +
Look for the sign on West University Avenue.
+
+
+
@@ -213,7 +274,7 @@
@@ -229,7 +290,7 @@ Hosted by <%= site.host.name %> <%= site.host.email %> - <%= site.venue %>, <%= site.city %> + <%= V.name %>, <%= V.street %>