diff --git a/apps/backend/src/schema/schema.ts b/apps/backend/src/schema/schema.ts index a74ce23..9043923 100644 --- a/apps/backend/src/schema/schema.ts +++ b/apps/backend/src/schema/schema.ts @@ -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' diff --git a/apps/frontend/src/components/NearbyVenues.tsx b/apps/frontend/src/components/NearbyVenues.tsx index e5a3f8c..8c1eb61 100644 --- a/apps/frontend/src/components/NearbyVenues.tsx +++ b/apps/frontend/src/components/NearbyVenues.tsx @@ -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 { @@ -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 ( <> @@ -47,7 +54,7 @@ function VenueRow({ venue }: { venue: SearchResult }) { )} {venue.openingHours && venue.openingHours.length > 0 && ( - {formatOpeningHours(venue.openingHours)} + {formatOpeningHours(venue.openingHours, timeZone)} )} {venue.latestPriceSighting && ( @@ -69,6 +76,8 @@ export default function NearbyVenues({ maxDistanceKm = 10, onRefresh, }: NearbyVenuesProps) { + const timeZone = useUserTimeZone() + return (
@@ -131,11 +140,11 @@ export default function NearbyVenues({ params={{ cityId: venue.cityId, venueId: venue.id }} className={rowClassName} > - + ) : (
- +
)} diff --git a/apps/frontend/src/components/VenuePrices.tsx b/apps/frontend/src/components/VenuePrices.tsx new file mode 100644 index 0000000..11cab0d --- /dev/null +++ b/apps/frontend/src/components/VenuePrices.tsx @@ -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 ( +
+

+ Prices +

+ + {sortedPrices.length === 0 ? ( +

+ No prices reported yet. +

+ ) : ( +
    + {sortedPrices.map((sighting, index) => { + const limitation = formatPriceLimitation(sighting, timeZone) + + return ( +
  • + + {formatPrice(sighting)} + + {limitation && ( + + {limitation} + + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/apps/frontend/src/components/ViewVenue.tsx b/apps/frontend/src/components/ViewVenue.tsx index 0df70c7..efa6128 100644 --- a/apps/frontend/src/components/ViewVenue.tsx +++ b/apps/frontend/src/components/ViewVenue.tsx @@ -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 (
@@ -50,8 +53,8 @@ export function ViewVenue({ venue }: { venue: SearchResult }) { {formatOpeningDays(openingHours.days)} - {formatOpeningTime(openingHours.opening)} -{' '} - {formatOpeningTime(openingHours.closing)} + {formatOpeningTime(openingHours.opening, timeZone)} -{' '} + {formatOpeningTime(openingHours.closing, timeZone)} ))} diff --git a/apps/frontend/src/components/__tests__/NearbyVenues.test.tsx b/apps/frontend/src/components/__tests__/NearbyVenues.test.tsx index e634824..49da582 100644 --- a/apps/frontend/src/components/__tests__/NearbyVenues.test.tsx +++ b/apps/frontend/src/components/__tests__/NearbyVenues.test.tsx @@ -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 function renderNearbyVenues(overrides: Partial = {}) { diff --git a/apps/frontend/src/components/__tests__/VenuePrices.test.tsx b/apps/frontend/src/components/__tests__/VenuePrices.test.tsx new file mode 100644 index 0000000..b6ca84b --- /dev/null +++ b/apps/frontend/src/components/__tests__/VenuePrices.test.tsx @@ -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( + , + ) + + 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() + + expect(screen.getByText('No prices reported yet.')).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/components/__tests__/ViewVenue.test.tsx b/apps/frontend/src/components/__tests__/ViewVenue.test.tsx index 5db8770..00ca30e 100644 --- a/apps/frontend/src/components/__tests__/ViewVenue.test.tsx +++ b/apps/frontend/src/components/__tests__/ViewVenue.test.tsx @@ -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() diff --git a/apps/frontend/src/lib/__tests__/format-opening-hours.test.ts b/apps/frontend/src/lib/__tests__/format-opening-hours.test.ts new file mode 100644 index 0000000..cc0fe61 --- /dev/null +++ b/apps/frontend/src/lib/__tests__/format-opening-hours.test.ts @@ -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', + ) + }) +}) diff --git a/apps/frontend/src/lib/format-opening-hours.ts b/apps/frontend/src/lib/format-opening-hours.ts index 34dac6d..7ed032e 100644 --- a/apps/frontend/src/lib/format-opening-hours.ts +++ b/apps/frontend/src/lib/format-opening-hours.ts @@ -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() + +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 { @@ -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(' · ') } diff --git a/apps/frontend/src/lib/format-price.ts b/apps/frontend/src/lib/format-price.ts index 46e16a1..436f5b7 100644 --- a/apps/frontend/src/lib/format-price.ts +++ b/apps/frontend/src/lib/format-price.ts @@ -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` @@ -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 +} diff --git a/apps/frontend/src/lib/time-zone.ts b/apps/frontend/src/lib/time-zone.ts new file mode 100644 index 0000000..bf6c82f --- /dev/null +++ b/apps/frontend/src/lib/time-zone.ts @@ -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' diff --git a/apps/frontend/src/lib/use-user-time-zone.ts b/apps/frontend/src/lib/use-user-time-zone.ts new file mode 100644 index 0000000..71b1e29 --- /dev/null +++ b/apps/frontend/src/lib/use-user-time-zone.ts @@ -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 +} diff --git a/apps/frontend/src/routes/$cityId/venues/$venueId.tsx b/apps/frontend/src/routes/$cityId/venues/$venueId.tsx index a56ab2e..73b136a 100644 --- a/apps/frontend/src/routes/$cityId/venues/$venueId.tsx +++ b/apps/frontend/src/routes/$cityId/venues/$venueId.tsx @@ -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')({ @@ -75,6 +76,8 @@ function VenuePage() {
+ +