mirror of
https://github.com/logos-co/logos-web.git
synced 2026-08-27 12:11:14 +00:00
- Added tech-networking-thumb.webp to the nav-overlay/figma directory. - Added tech-overview-thumb.webp to the nav-overlay/figma directory. - Added tech-storage-thumb.webp to the nav-overlay/figma directory.
312 lines
9.1 KiB
TypeScript
312 lines
9.1 KiB
TypeScript
// Build-time source for the active-circles snapshot.
|
|
//
|
|
// This module performs the live API fetches and all derivation. It is invoked
|
|
// ONLY by `scripts/sync-active-circles.ts`, which writes the result to the
|
|
// committed snapshot JSON. Runtime pages never import this file — they read the
|
|
// snapshot via `active-circles.ts`.
|
|
|
|
import {
|
|
ACTIVE_CIRCLES_DAYS,
|
|
type ActiveCircleEvent,
|
|
type ActiveCircleMarker,
|
|
type ActiveCircleStat,
|
|
type ActiveCircleUpcomingEvent,
|
|
type ActiveCirclesSnapshot,
|
|
} from './active-circles-shared'
|
|
import { getLatestEventsByCity, type CircleEvent } from './circle-events'
|
|
import {
|
|
BI_GRAPHQL_API_URL,
|
|
CIRCLES_GRAPHQL_RESPONSE_KEY,
|
|
LOGOS_GRAPHQL_API_URL,
|
|
} from './logos-data-api'
|
|
import { logger } from './logger'
|
|
|
|
type GraphqlResponse<TData> = {
|
|
data?: TData
|
|
}
|
|
|
|
async function postGraphql<TData>(
|
|
url: string,
|
|
query: string
|
|
): Promise<TData | null> {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query }),
|
|
cache: 'no-store',
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`GraphQL request failed: ${response.status}`)
|
|
}
|
|
|
|
const payload = (await response.json()) as GraphqlResponse<TData>
|
|
return payload.data ?? null
|
|
} catch (error) {
|
|
logger.error('Failed to fetch active circles data', { error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isoDaysAgo(now: Date, days: number): string {
|
|
const date = new Date(now)
|
|
date.setDate(date.getDate() - days)
|
|
return date.toISOString().split('T')[0] ?? ''
|
|
}
|
|
|
|
function getCirclesGraphqlQuery(dateString: string) {
|
|
return `query CountDistinctCities {
|
|
${CIRCLES_GRAPHQL_RESPONSE_KEY}(where: { end_at: { _gte: "${dateString}" } }) {
|
|
aggregate {
|
|
count(distinct: true, columns: location_city)
|
|
}
|
|
}
|
|
}`
|
|
}
|
|
|
|
async function fetchDistinctActiveCityCount(
|
|
activeSinceDate: string
|
|
): Promise<number | null> {
|
|
type ResponseData = {
|
|
[CIRCLES_GRAPHQL_RESPONSE_KEY]?: {
|
|
aggregate?: {
|
|
count?: number
|
|
}
|
|
}
|
|
}
|
|
|
|
const data = await postGraphql<ResponseData>(
|
|
BI_GRAPHQL_API_URL,
|
|
getCirclesGraphqlQuery(activeSinceDate)
|
|
)
|
|
|
|
return data?.[CIRCLES_GRAPHQL_RESPONSE_KEY]?.aggregate?.count ?? null
|
|
}
|
|
|
|
async function fetchCircleEvents(): Promise<ActiveCircleEvent[]> {
|
|
type ResponseData = {
|
|
stg_external_circle_circle_event?: ActiveCircleEvent[]
|
|
}
|
|
|
|
const query = `query CircleEvents {
|
|
stg_external_circle_circle_event {
|
|
event_id
|
|
event_name
|
|
event_url
|
|
geo_latitude
|
|
geo_longitude
|
|
location_city
|
|
location_country
|
|
end_at
|
|
start_at
|
|
}
|
|
}`
|
|
|
|
const data = await postGraphql<ResponseData>(LOGOS_GRAPHQL_API_URL, query)
|
|
const events = data?.stg_external_circle_circle_event ?? []
|
|
|
|
return [...events].sort((a, b) => {
|
|
return new Date(b.start_at).getTime() - new Date(a.start_at).getTime()
|
|
})
|
|
}
|
|
|
|
function getEventsSince(
|
|
events: ActiveCircleEvent[],
|
|
activeSinceDate: string
|
|
): ActiveCircleEvent[] {
|
|
const activeSince = Date.parse(activeSinceDate)
|
|
|
|
if (Number.isNaN(activeSince)) {
|
|
return events
|
|
}
|
|
|
|
return events.filter((event) => {
|
|
const eventDate = Date.parse(event.end_at ?? event.start_at ?? '')
|
|
return !Number.isNaN(eventDate) && eventDate >= activeSince
|
|
})
|
|
}
|
|
|
|
function toActiveCircleMarker(
|
|
event: ActiveCircleEvent
|
|
): ActiveCircleMarker | null {
|
|
const city = event.location_city?.trim()
|
|
const country = event.location_country?.trim()
|
|
if (!city || !country) return null
|
|
|
|
const lat = Number(event.geo_latitude)
|
|
const lng = Number(event.geo_longitude)
|
|
if (Number.isNaN(lat) || Number.isNaN(lng)) return null
|
|
|
|
return {
|
|
id: event.event_id,
|
|
name: event.event_name,
|
|
city,
|
|
country,
|
|
lat,
|
|
lng,
|
|
eventUrl: event.event_url?.trim() || null,
|
|
}
|
|
}
|
|
|
|
function byCityCountry(a: ActiveCircleMarker, b: ActiveCircleMarker): number {
|
|
return `${a.city}, ${a.country}`.localeCompare(`${b.city}, ${b.country}`)
|
|
}
|
|
|
|
function startOfUtcDayMs(now: Date): number {
|
|
const date = new Date(now)
|
|
date.setUTCHours(0, 0, 0, 0)
|
|
return date.getTime()
|
|
}
|
|
|
|
// The events API returns timezone-naive timestamps (e.g. "2026-06-06T11:00:00").
|
|
// Parsing those with `new Date(...)` would use the runtime's local zone, making
|
|
// the result depend on the build machine. Pin naive timestamps to UTC so the
|
|
// wall-clock is interpreted deterministically everywhere.
|
|
function parseEventInstantMs(iso: string): number {
|
|
const hasZone = /([zZ])|([+-]\d{2}:?\d{2})$/.test(iso)
|
|
return Date.parse(hasZone ? iso : `${iso}Z`)
|
|
}
|
|
|
|
function toUpcomingEvent(event: ActiveCircleEvent): ActiveCircleUpcomingEvent {
|
|
return {
|
|
id: event.event_id,
|
|
name: event.event_name,
|
|
city: event.location_city?.trim() ?? '',
|
|
country: event.location_country?.trim() ?? '',
|
|
startAt: event.start_at,
|
|
endAt: event.end_at ?? null,
|
|
eventUrl: event.event_url?.trim() || null,
|
|
coverUrl: null,
|
|
}
|
|
}
|
|
|
|
const LUMA_CDN_HOST = 'images.lumacdn.com'
|
|
const LUMA_COVER_WIDTH = 246
|
|
const LUMA_FETCH_TIMEOUT_MS = 10_000
|
|
|
|
/**
|
|
* Builds a resized Luma CDN URL from a raw cover URL so cards load a small
|
|
* square image instead of the full-resolution original. Falls back to the raw
|
|
* URL for anything not served from the known Luma CDN host.
|
|
*/
|
|
function toResizedLumaCover(rawCoverUrl: string): string {
|
|
try {
|
|
const url = new URL(rawCoverUrl)
|
|
if (url.hostname !== LUMA_CDN_HOST) return rawCoverUrl
|
|
|
|
const path = url.pathname.replace(/^\/+/, '')
|
|
const transform = `format=auto,fit=cover,quality=80,width=${LUMA_COVER_WIDTH},height=${LUMA_COVER_WIDTH}`
|
|
return `https://${LUMA_CDN_HOST}/cdn-cgi/image/${transform}/${path}`
|
|
} catch {
|
|
return rawCoverUrl
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scrapes the cover image from a public Luma event page. Luma embeds the event
|
|
* payload (including `cover_url`) in the server-rendered HTML, so no API key is
|
|
* required. Returns null on any failure so the UI falls back to a default image.
|
|
*/
|
|
async function fetchLumaCoverUrl(eventUrl: string): Promise<string | null> {
|
|
try {
|
|
const url = new URL(eventUrl)
|
|
if (!/(^|\.)luma\.com$/.test(url.hostname)) return null
|
|
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), LUMA_FETCH_TIMEOUT_MS)
|
|
|
|
try {
|
|
const response = await fetch(eventUrl, {
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (logos-web active-circles sync)' },
|
|
cache: 'no-store',
|
|
signal: controller.signal,
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`Luma page request failed: ${response.status}`)
|
|
}
|
|
|
|
const html = await response.text()
|
|
const match = html.match(/"cover_url":"(https:\/\/[^"\\]+)"/)
|
|
const rawCoverUrl = match?.[1]
|
|
return rawCoverUrl ? toResizedLumaCover(rawCoverUrl) : null
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to fetch Luma cover image', { eventUrl, error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Enriches upcoming events with their Luma cover image, in parallel. */
|
|
async function withCoverImages(
|
|
events: ActiveCircleUpcomingEvent[]
|
|
): Promise<ActiveCircleUpcomingEvent[]> {
|
|
return Promise.all(
|
|
events.map(async (event) => {
|
|
if (!event.eventUrl) return event
|
|
const coverUrl = await fetchLumaCoverUrl(event.eventUrl)
|
|
return coverUrl ? { ...event, coverUrl } : event
|
|
})
|
|
)
|
|
}
|
|
|
|
/** Events whose start date is today (UTC) or later, sorted soonest-first. */
|
|
function getUpcomingEvents(
|
|
events: ActiveCircleEvent[],
|
|
now: Date
|
|
): ActiveCircleUpcomingEvent[] {
|
|
const startOfToday = startOfUtcDayMs(now)
|
|
|
|
return events
|
|
.filter((event) => {
|
|
const start = parseEventInstantMs(event.start_at ?? '')
|
|
return !Number.isNaN(start) && start >= startOfToday
|
|
})
|
|
.sort((a, b) => a.start_at.localeCompare(b.start_at))
|
|
.map(toUpcomingEvent)
|
|
}
|
|
|
|
/**
|
|
* Fetches live data and derives the full active-circles snapshot. The `circles`
|
|
* array (deduped, one per active city) is the single source consumed by both
|
|
* the `/circles` map and the `/active-circles` list at runtime.
|
|
*/
|
|
export async function buildActiveCirclesSnapshot(
|
|
now: Date
|
|
): Promise<ActiveCirclesSnapshot> {
|
|
const activeSinceDate = isoDaysAgo(now, ACTIVE_CIRCLES_DAYS)
|
|
const [distinctActiveCities, events] = await Promise.all([
|
|
fetchDistinctActiveCityCount(activeSinceDate),
|
|
fetchCircleEvents(),
|
|
])
|
|
|
|
const activeEvents = getEventsSince(events, activeSinceDate)
|
|
const circles = getLatestEventsByCity(activeEvents as CircleEvent[])
|
|
.map(toActiveCircleMarker)
|
|
.filter((marker): marker is ActiveCircleMarker => marker !== null)
|
|
.sort(byCityCountry)
|
|
|
|
const upcomingEvents = await withCoverImages(getUpcomingEvents(events, now))
|
|
|
|
const distinctCountries = new Set(
|
|
events.map((event) => event.location_country?.trim()).filter(Boolean)
|
|
).size
|
|
|
|
const stats: ActiveCircleStat[] = [
|
|
{ label: 'Total Circle Events', value: events.length },
|
|
{ label: 'Distinct Cities', value: distinctActiveCities },
|
|
{ label: 'Distinct Countries', value: distinctCountries },
|
|
{ label: 'Upcoming Events', value: upcomingEvents.length },
|
|
]
|
|
|
|
return {
|
|
generatedAt: now.toISOString(),
|
|
activeSinceDate,
|
|
circles,
|
|
upcomingEvents,
|
|
stats,
|
|
}
|
|
}
|