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
31 changes: 25 additions & 6 deletions src/app/(frontend)/_components/PortalShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
import { Button } from '@/components/ui/button'
import type { ProductPageCopy } from '@/utilities/pageCopy'
import { toSafeURL } from '@/utilities/safeURL'
import { SessionDateTime } from './SessionDateTime'
import { VibeCheckButton } from './VibeCheckButton'

type PortalHomeProps = {
Expand Down Expand Up @@ -137,7 +138,12 @@ export const PortalPublicHome: React.FC<PortalHomeProps> = ({
</div>
{nextEvent ? (
<div className="mt-4">
<p className="portal-kicker">{formatDateTime(nextEvent.startsAt)}</p>
<SessionDateTime
className="portal-kicker"
dateStyle="medium"
endsAt={nextEvent.endsAt}
startsAt={nextEvent.startsAt}
/>
<h2 className="mt-2 portal-heading-sm">{nextEvent.title}</h2>
{nextEvent.locationLabel ? (
<p className="mt-2 text-sm text-muted-foreground">{nextEvent.locationLabel}</p>
Expand Down Expand Up @@ -206,7 +212,12 @@ export const PortalPublicHome: React.FC<PortalHomeProps> = ({
{upcomingEvents.length ? (
upcomingEvents.slice(0, 3).map((event) => (
<article className="portal-card" key={event.id}>
<p className="portal-kicker">{formatDateTime(event.startsAt)}</p>
<SessionDateTime
className="portal-kicker"
dateStyle="medium"
endsAt={event.endsAt}
startsAt={event.startsAt}
/>
<h3 className="mt-2 portal-heading-sm">{event.title}</h3>
{event.summary ? (
<p className="mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground">
Expand Down Expand Up @@ -489,9 +500,12 @@ export const PortalDashboard: React.FC<DashboardProps> = ({
<div className="portal-card text-sm">
<p className="font-bold text-foreground">Next session</p>
<p className="mt-2 text-muted-foreground">{nextEvent.title}</p>
<p className="mt-1 text-muted-foreground">
{formatDateTime(nextEvent.startsAt)}
</p>
<SessionDateTime
className="mt-1 block text-muted-foreground"
dateStyle="medium"
endsAt={nextEvent.endsAt}
startsAt={nextEvent.startsAt}
/>
{nextEvent.locationLabel ? (
<p className="mt-1 text-muted-foreground">{nextEvent.locationLabel}</p>
) : null}
Expand Down Expand Up @@ -592,7 +606,12 @@ export const PortalDashboard: React.FC<DashboardProps> = ({
<div className="space-y-4">
{upcomingEvents.slice(0, 3).map((event) => (
<article className="portal-card" key={event.id}>
<p className="portal-kicker">{formatDateTime(event.startsAt)}</p>
<SessionDateTime
className="portal-kicker"
dateStyle="medium"
endsAt={event.endsAt}
startsAt={event.startsAt}
/>
<h3 className="mt-2 font-bold text-foreground">{event.title}</h3>
{event.locationLabel ? (
<p className="mt-2 text-sm text-muted-foreground">{event.locationLabel}</p>
Expand Down
168 changes: 168 additions & 0 deletions src/app/(frontend)/_components/SessionDateTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
'use client'

import { useEffect, useState } from 'react'
import type { FC } from 'react'

type DateStyle = 'full' | 'long' | 'medium' | 'short'

type SessionDateTimeProps = {
className?: string
dateStyle?: DateStyle
endsAt?: string | null
startsAt?: string | null
}

type SessionDateBadgeProps = {
dateClassName?: string
dayClassName?: string
startsAt?: string | null
}

const fallbackTimeZone = 'UTC'

export const SessionDateTime: FC<SessionDateTimeProps> = ({
className,
dateStyle = 'long',
endsAt,
startsAt,
}) => {
const [timeZone, setTimeZone] = useState(fallbackTimeZone)
const label = formatSessionDateTime({ dateStyle, endsAt, startsAt, timeZone })

useEffect(() => {
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone

if (localTimeZone) setTimeZone(localTimeZone)
}, [])
Comment on lines +29 to +36

if (!startsAt || !label) return null

return (
<time className={className} dateTime={startsAt} suppressHydrationWarning>
{label}
</time>
)
}

export const SessionDateBadge: FC<SessionDateBadgeProps> = ({
dateClassName,
dayClassName,
startsAt,
}) => {
const [timeZone, setTimeZone] = useState(fallbackTimeZone)
const badge = formatSessionDateBadge(startsAt, timeZone)

useEffect(() => {
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone

if (localTimeZone) setTimeZone(localTimeZone)
}, [])
Comment on lines +52 to +59

if (!startsAt || !badge) return null

return (
<>
<p className={dayClassName} suppressHydrationWarning>
{badge.day}
</p>
<p className={dateClassName} suppressHydrationWarning>
{badge.date}
</p>
</>
)
}

const formatSessionDateTime = ({
dateStyle,
endsAt,
startsAt,
timeZone,
}: {
dateStyle: DateStyle
endsAt?: string | null
startsAt?: string | null
timeZone: string
}): string | null => {
if (!startsAt) return null

const start = toValidDate(startsAt)
const end = endsAt ? toValidDate(endsAt) : null

if (!start) return null

const timeZoneLabel = getTimeZoneLabel(start, timeZone)
const dateFormatter = new Intl.DateTimeFormat('en-US', {
dateStyle,
timeZone,
})
const timeFormatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
timeZone,
})
const startDate = dateFormatter.format(start)
const startTime = timeFormatter.format(start)

if (!end) return `${startDate}, ${startTime} ${timeZoneLabel}`

const endDate = dateFormatter.format(end)
const endTime = timeFormatter.format(end)
const range =
startDate === endDate
? `${startDate}, ${startTime}-${endTime}`
: `${startDate}, ${startTime}-${endDate}, ${endTime}`

return `${range} ${timeZoneLabel}`
}

const formatSessionDateBadge = (
startsAt: string | null | undefined,
timeZone: string,
): { date: string; day: string } | null => {
if (!startsAt) return null

const start = toValidDate(startsAt)

if (!start) return null

return {
date: new Intl.DateTimeFormat('en-US', {
day: '2-digit',
timeZone,
}).format(start),
day: new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
}).format(start),
}
}

const getTimeZoneLabel = (date: Date, timeZone: string): string => {
const label =
getTimeZoneName(date, timeZone, 'longGeneric') ||
getTimeZoneName(date, timeZone, 'long') ||
timeZone

return simplifyTimeZoneLabel(label)
}

const getTimeZoneName = (
date: Date,
timeZone: string,
timeZoneName: Intl.DateTimeFormatOptions['timeZoneName'],
): string | null =>
new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName,
})
.formatToParts(date)
.find((part) => part.type === 'timeZoneName')?.value || null

const simplifyTimeZoneLabel = (label: string): string =>
label.replace(/\s+(Standard|Daylight) Time$/, '').replace(/\s+Time$/, '')

const toValidDate = (value: string): Date | null => {
const date = new Date(value)

return Number.isNaN(date.getTime()) ? null : date
}
19 changes: 7 additions & 12 deletions src/app/(frontend)/events/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
import { createGoogleCalendarURL } from '@/utilities/calendarLinks'
import { getCurrentUser } from '@/utilities/getCurrentUser'
import { toSafeURL } from '@/utilities/safeURL'
import { SessionDateTime } from '../../_components/SessionDateTime'

export const dynamic = 'force-dynamic'

Expand All @@ -32,15 +33,6 @@ type Args = {
const relationDocs = <T extends { id: number }>(items?: (number | T)[] | null): T[] =>
items?.filter((item): item is T => item !== null && typeof item === 'object') || []

const formatDateTime = (date?: string | null) => {
if (!date) return null

return new Intl.DateTimeFormat('en', {
dateStyle: 'full',
timeStyle: 'short',
}).format(new Date(date))
}

const formatDate = (date?: string | null) => {
if (!date) return null

Expand Down Expand Up @@ -180,9 +172,12 @@ export default async function SessionDetailPage({ params: paramsPromise }: Args)

<aside className="border border-border bg-card/30 p-5">
<p className="portal-kicker">Session</p>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{formatDateTime(event.startsAt)}
</p>
<SessionDateTime
className="mt-3 block text-sm leading-6 text-muted-foreground"
dateStyle="full"
endsAt={event.endsAt}
startsAt={event.startsAt}
/>
{event.locationLabel ? (
<p className="mt-3 text-sm text-muted-foreground">{event.locationLabel}</p>
) : null}
Expand Down
32 changes: 13 additions & 19 deletions src/app/(frontend)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Event, Profile, Project, Thread } from '@/payload-types'
import { createGoogleCalendarURL } from '@/utilities/calendarLinks'
import { getCurrentUser } from '@/utilities/getCurrentUser'
import { toSafeURL } from '@/utilities/safeURL'
import { SessionDateBadge, SessionDateTime } from '../_components/SessionDateTime'
import {
getListPageValue,
getListQueryValue,
Expand All @@ -22,15 +23,6 @@ import {
export const dynamic = 'force-dynamic'
const EVENTS_PER_PAGE = 24

const formatDateTime = (date?: string | null) => {
if (!date) return null

return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(date))
}

const relationDocs = <T extends { id: number }>(items?: (number | T)[] | null): T[] =>
items?.filter((item): item is T => item !== null && typeof item === 'object') || []

Expand Down Expand Up @@ -85,7 +77,7 @@ export default async function EventsPage({ searchParams: searchParamsPromise }:
const past = events.docs.filter(
(event) => !isLiveEvent(event, now) && new Date(event.startsAt).getTime() < now,
)
upcoming.sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime())
upcoming.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime())
past.sort((a, b) => new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime())
const canManageSessions = canContributeContent(user)

Expand Down Expand Up @@ -209,15 +201,14 @@ const SessionRow: React.FC<{
? [speaker.displayName].filter(Boolean)
: []
const sessionType = event.sessionType || 'brownbag'
const startsAt = new Date(event.startsAt)
const day = new Intl.DateTimeFormat('en', { weekday: 'short' }).format(startsAt)
const date = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(startsAt)

return (
<article className="grid gap-4 border-b border-border py-4 sm:grid-cols-[4rem_1fr]">
<div className="flex items-baseline gap-2 sm:block">
<p className="font-mono text-xs uppercase text-muted-foreground">{day}</p>
<p className="font-display text-2xl font-bold leading-none text-foreground">{date}</p>
<SessionDateBadge
dateClassName="font-display text-2xl font-bold leading-none text-foreground"
dayClassName="font-mono text-xs uppercase text-muted-foreground"
startsAt={event.startsAt}
/>
</div>
<div
className={`rounded-sm border p-5 ${
Expand All @@ -237,9 +228,12 @@ const SessionRow: React.FC<{
: ''}
</span>
) : null}
<span className="text-sm text-muted-foreground">
{formatDateTime(event.startsAt)}
</span>
<SessionDateTime
className="text-sm text-muted-foreground"
dateStyle="medium"
endsAt={event.endsAt}
startsAt={event.startsAt}
/>
</div>
<h3 className="mt-3 portal-heading-sm">
<Link className="transition-colors hover:text-primary" href={`/events/${event.id}`}>
Expand Down
24 changes: 14 additions & 10 deletions src/app/(frontend)/projects/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
} from '@/payload-types'
import { getCurrentUser } from '@/utilities/getCurrentUser'
import { toSafeURL } from '@/utilities/safeURL'
import { SessionDateTime } from '../../_components/SessionDateTime'
import { getProfileIDForUser, isProjectStewardProfile } from '../formData'

export const dynamic = 'force-dynamic'
Expand All @@ -32,22 +33,22 @@ type Args = {
}>
}

const formatDateTime = (date?: string | null) => {
const formatDate = (date?: string | null) => {
if (!date) return null

return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(new Date(date))
}

const formatDate = (date?: string | null) => {
const formatDateTime = (date?: string | null) => {
if (!date) return null

return new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'short',
year: 'numeric',
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(date))
}

Expand Down Expand Up @@ -227,9 +228,12 @@ export default async function ProjectPage({ params: paramsPromise }: Args) {
{events.map((event) => (
<article className="portal-card" key={event.id}>
<h3 className="font-bold">{event.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">
{formatDateTime(event.startsAt)}
</p>
<SessionDateTime
className="mt-2 block text-sm text-muted-foreground"
dateStyle="medium"
endsAt={event.endsAt}
startsAt={event.startsAt}
/>
{event.locationLabel ? (
<p className="mt-1 text-sm text-muted-foreground">{event.locationLabel}</p>
) : null}
Expand Down
Loading