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
1 change: 0 additions & 1 deletion apps/backend/src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type {
OpeningHoursNote,
PriceSighting,
} from '@t09-project-2/db'
import type { OpeningHours, PriceSighting } from '@t09-project-2/db'
import { GraphQLError } from 'graphql'

import { builder } from '../builder.js'
Expand Down
17 changes: 13 additions & 4 deletions apps/frontend/src/components/NearbyVenues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'

import { formatOpeningHours } from '#/lib/format-opening-hours'
import { formatPrice } from '#/lib/format-price'
import { useUserTimeZone } from '#/lib/use-user-time-zone'
import type { SearchResult } from '#/types/search'

interface NearbyVenuesProps {
Expand All @@ -18,7 +19,13 @@ interface NearbyVenuesProps {
const rowClassName =
'block w-full px-1 py-3 text-left text-sm text-(--sea-ink) transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--lagoon-deep)'

function VenueRow({ venue }: { venue: SearchResult }) {
function VenueRow({
venue,
timeZone,
}: {
venue: SearchResult
timeZone: string
}) {
return (
<>
<span className="flex items-baseline justify-between gap-3">
Expand Down Expand Up @@ -47,7 +54,7 @@ function VenueRow({ venue }: { venue: SearchResult }) {
)}
{venue.openingHours && venue.openingHours.length > 0 && (
<span className="mt-0.5 block text-xs text-(--sea-ink-soft)">
{formatOpeningHours(venue.openingHours)}
{formatOpeningHours(venue.openingHours, timeZone)}
</span>
)}
{venue.latestPriceSighting && (
Expand All @@ -69,6 +76,8 @@ export default function NearbyVenues({
maxDistanceKm = 10,
onRefresh,
}: NearbyVenuesProps) {
const timeZone = useUserTimeZone()

return (
<section className="island-shell rise-in rounded-2xl p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
Expand Down Expand Up @@ -131,11 +140,11 @@ export default function NearbyVenues({
params={{ cityId: venue.cityId, venueId: venue.id }}
className={rowClassName}
>
<VenueRow venue={venue} />
<VenueRow venue={venue} timeZone={timeZone} />
</Link>
) : (
<div className={rowClassName}>
<VenueRow venue={venue} />
<VenueRow venue={venue} timeZone={timeZone} />
</div>
)}
</li>
Expand Down
62 changes: 62 additions & 0 deletions apps/frontend/src/components/VenuePrices.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { formatPrice, formatPriceLimitation } from '#/lib/format-price'
import { useUserTimeZone } from '#/lib/use-user-time-zone'
import type { VenuePriceSighting } from '#/types/venue'

function isLimited(sighting: VenuePriceSighting) {
return sighting.Limitations.length > 0
}

function sortPrices(prices: VenuePriceSighting[]) {
return [...prices].sort((a, b) => {
const aLimited = isLimited(a)
const bLimited = isLimited(b)

if (aLimited !== bLimited) return aLimited ? 1 : -1

return b.timeSighted.localeCompare(a.timeSighted)
})
}

export function VenuePrices({ prices }: { prices: VenuePriceSighting[] }) {
const timeZone = useUserTimeZone()
const sortedPrices = sortPrices(prices)

return (
<section
aria-labelledby="venue-prices-heading"
className="mt-6 rounded-2xl bg-(--surface-strong) p-5 text-(--sea-ink)"
>
<h2 id="venue-prices-heading" className="text-xl font-extrabold">
Prices
</h2>

{sortedPrices.length === 0 ? (
<p className="mt-3 text-sm text-(--sea-ink-soft)">
No prices reported yet.
</p>
) : (
<ul className="mt-3 striped-list">
{sortedPrices.map((sighting, index) => {
const limitation = formatPriceLimitation(sighting, timeZone)

return (
<li
key={`${sighting.size}-${sighting.sizeMl ?? ''}-${sighting.timeSighted}-${index}`}
className="flex flex-wrap items-baseline gap-2 py-3 first:pt-0"
>
<span className="text-sm font-semibold">
{formatPrice(sighting)}
</span>
{limitation && (
<span className="rounded-full bg-(--chip-bg) px-2 py-0.5 text-xs font-bold text-(--sea-ink-soft)">
{limitation}
</span>
)}
</li>
)
})}
</ul>
)}
</section>
)
}
7 changes: 5 additions & 2 deletions apps/frontend/src/components/ViewVenue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {
formatOpeningTime,
} from '#/lib/format-opening-hours'
import { formatPrice } from '#/lib/format-price'
import { useUserTimeZone } from '#/lib/use-user-time-zone'
import type { SearchResult } from '#/types/search'

export function ViewVenue({ venue }: { venue: SearchResult }) {
const timeZone = useUserTimeZone()

return (
<article className="rounded-2xl bg-(--surface-strong) p-5 text-(--sea-ink)">
<div className="flex items-start justify-between gap-3">
Expand Down Expand Up @@ -50,8 +53,8 @@ export function ViewVenue({ venue }: { venue: SearchResult }) {
{formatOpeningDays(openingHours.days)}
</span>
<span className="text-(--sea-ink-soft)">
{formatOpeningTime(openingHours.opening)} -{' '}
{formatOpeningTime(openingHours.closing)}
{formatOpeningTime(openingHours.opening, timeZone)} -{' '}
{formatOpeningTime(openingHours.closing, timeZone)}
</span>
</li>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { makeSearchResult } from '#/test/fixtures'
import { renderWithProviders } from '#/test/render'
import NearbyVenues from '../NearbyVenues'

vi.mock('#/lib/use-user-time-zone', () => ({
useUserTimeZone: () => 'UTC',
}))

type NearbyVenuesProps = ComponentProps<typeof NearbyVenues>

function renderNearbyVenues(overrides: Partial<NearbyVenuesProps> = {}) {
Expand Down
52 changes: 52 additions & 0 deletions apps/frontend/src/components/__tests__/VenuePrices.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'

import { makePriceSighting } from '#/test/fixtures'
import { VenuePrices } from '../VenuePrices'

vi.mock('#/lib/use-user-time-zone', () => ({
useUserTimeZone: () => 'UTC',
}))

describe('VenuePrices', () => {
it('renders every price with limitation labels', () => {
render(
<VenuePrices
prices={[
makePriceSighting({
price: 169,
size: 'HALF_LITER',
sizeMl: 500,
Limitations: [],
}),
makePriceSighting({
price: 129,
size: 'PINT',
sizeMl: 568,
Limitations: ['MEMBERSHIP'],
}),
makePriceSighting({
price: 139,
size: 'PINT',
sizeMl: 568,
Limitations: ['TIME'],
timeStart: '1970-01-01T15:00:00.000Z',
timeEnd: '1970-01-01T17:00:00.000Z',
}),
]}
/>,
)

expect(screen.getByText('NOK 169 · 500 ml')).toBeInTheDocument()
expect(screen.getByText('NOK 129 · 568 ml')).toBeInTheDocument()
expect(screen.getByText('NOK 139 · 568 ml')).toBeInTheDocument()
expect(screen.getByText('Member price')).toBeInTheDocument()
expect(screen.getByText('Happy hour 15:00–17:00')).toBeInTheDocument()
})

it('shows an empty state when no prices exist', () => {
render(<VenuePrices prices={[]} />)

expect(screen.getByText('No prices reported yet.')).toBeInTheDocument()
})
})
6 changes: 5 additions & 1 deletion apps/frontend/src/components/__tests__/ViewVenue.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'

import { makeOpeningHours, makeSearchResult } from '#/test/fixtures'
import { ViewVenue } from '../ViewVenue'

vi.mock('#/lib/use-user-time-zone', () => ({
useUserTimeZone: () => 'UTC',
}))

describe('ViewVenue', () => {
it('renders the venue details, rating, price and opening hours', () => {
render(<ViewVenue venue={makeSearchResult()} />)
Expand Down
25 changes: 25 additions & 0 deletions apps/frontend/src/lib/__tests__/format-opening-hours.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'

import { formatOpeningTime } from '../format-opening-hours'

describe('formatOpeningTime', () => {
it('converts the stored UTC time into the requested timezone', () => {
const iso = '1970-01-01T15:00:00.000Z'

expect(formatOpeningTime(iso, 'Europe/Oslo')).toBe('16:00')
expect(formatOpeningTime(iso, 'UTC')).toBe('15:00')
expect(formatOpeningTime(iso, 'America/New_York')).toBe('10:00')
})

it('renders midnight as 00:00 rather than 24:00', () => {
expect(formatOpeningTime('1970-01-01T23:00:00.000Z', 'Europe/Oslo')).toBe(
'00:00',
)
})

it('uses the offset that applies on the stored date', () => {
expect(formatOpeningTime('2026-07-01T14:00:00.000Z', 'Europe/Oslo')).toBe(
'16:00',
)
})
})
32 changes: 28 additions & 4 deletions apps/frontend/src/lib/format-opening-hours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,29 @@ function formatRun(run: Day[]): string {
// consecutive days into ranges and naming whole weekdays/weekends.
// Examples: MON–FRI → "Weekdays", SAT–SUN → "Weekends",
// MON–WED → "Monday–Wednesday", MON/WED/FRI → "Monday, Wednesday and Friday".
export function formatOpeningTime(iso: string): string {
return iso.slice(11, 16)
const timeFormatters = new Map<string, Intl.DateTimeFormat>()

function getTimeFormatter(timeZone: string) {
const existing = timeFormatters.get(timeZone)

if (existing) return existing

const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
timeFormatters.set(timeZone, formatter)

return formatter
}

export function formatOpeningTime(
iso: string,
timeZone = 'Europe/Oslo',
): string {
return getTimeFormatter(timeZone).format(new Date(iso))
}

export interface OpeningHoursEntry {
Expand All @@ -54,11 +75,14 @@ export interface OpeningHoursNoteEntry {
}

/** Compact one-line summary, e.g. "Weekdays 14:00–02:00 · Weekends 12:00–03:00". */
export function formatOpeningHours(entries: OpeningHoursEntry[]): string {
export function formatOpeningHours(
entries: OpeningHoursEntry[],
timeZone = 'Europe/Oslo',
): string {
return entries
.map(
(entry) =>
`${formatOpeningDays(entry.days)} ${formatOpeningTime(entry.opening)}–${formatOpeningTime(entry.closing)}`,
`${formatOpeningDays(entry.days)} ${formatOpeningTime(entry.opening, timeZone)}–${formatOpeningTime(entry.closing, timeZone)}`,
)
.join(' · ')
}
Expand Down
28 changes: 28 additions & 0 deletions apps/frontend/src/lib/format-price.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { SearchPriceSighting } from '../types/search'
import { formatOpeningTime } from './format-opening-hours'
import { DEFAULT_TIME_ZONE } from './time-zone'

function formatSize(sighting: SearchPriceSighting) {
if (sighting.sizeMl != null) return `${Math.round(sighting.sizeMl)} ml`
Expand All @@ -9,3 +11,29 @@ function formatSize(sighting: SearchPriceSighting) {
export function formatPrice(sighting: SearchPriceSighting) {
return `NOK ${sighting.price} · ${formatSize(sighting)}`
}

export function formatPriceLimitation(
sighting: SearchPriceSighting,
timeZone = DEFAULT_TIME_ZONE,
): string | null {
const labels: string[] = []

if (sighting.Limitations.includes('MEMBERSHIP')) {
labels.push('Member price')
}

if (sighting.Limitations.includes('TIME')) {
const start = sighting.timeStart
? formatOpeningTime(sighting.timeStart, timeZone)
: null
const end = sighting.timeEnd
? formatOpeningTime(sighting.timeEnd, timeZone)
: null

labels.push(
start && end ? `Happy hour ${start}–${end}` : 'Time-limited price',
)
}

return labels.length > 0 ? labels.join(' · ') : null
}
3 changes: 3 additions & 0 deletions apps/frontend/src/lib/time-zone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// All current venues are in Norway. Until the API exposes a venue timezone,
// use the Norwegian zone as the SSR/default display timezone.
export const DEFAULT_TIME_ZONE = 'Europe/Oslo'
22 changes: 22 additions & 0 deletions apps/frontend/src/lib/use-user-time-zone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'

import { DEFAULT_TIME_ZONE } from './time-zone'

/**
* Resolves the user's IANA timezone on the client. The first render (including
* SSR) uses the venue's default timezone so the server and client markup match;
* the effect then switches to the browser timezone.
*/
export function useUserTimeZone(fallback = DEFAULT_TIME_ZONE) {
const [timeZone, setTimeZone] = useState(fallback)

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

if (detected) {
setTimeZone(detected)
}
}, [])

return timeZone
}
3 changes: 3 additions & 0 deletions apps/frontend/src/routes/$cityId/venues/$venueId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AddReviewDialog from '#/components/AddReviewDialog'
import { venueToSearchResult } from '#/components/VenueCard'
import { ViewVenue } from '#/components/ViewVenue'
import ClientVenueMap from '#/components/ClientVenueMap'
import { VenuePrices } from '#/components/VenuePrices'
import { venuesQueryOptions } from '#/graphql/venues'

export const Route = createFileRoute('/$cityId/venues/$venueId')({
Expand Down Expand Up @@ -75,6 +76,8 @@ function VenuePage() {
</section>
</div>

<VenuePrices prices={current.pricesSighted ?? []} />

<section className="mt-6 rounded-2xl bg-(--surface-strong) p-5 text-(--sea-ink)">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-baseline gap-2">
Expand Down