diff --git a/backend/samfundet/migrations/0006_venue_is_open_days.py b/backend/samfundet/migrations/0006_venue_is_open_days.py new file mode 100644 index 000000000..099932bce --- /dev/null +++ b/backend/samfundet/migrations/0006_venue_is_open_days.py @@ -0,0 +1,48 @@ +# Generated manually for is_open_* fields + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0005_medlemsinfo'), + ] + + operations = [ + migrations.AddField( + model_name='venue', + name='is_open_monday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_tuesday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_wednesday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_thursday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_friday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_saturday', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='venue', + name='is_open_sunday', + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/samfundet/models/general.py b/backend/samfundet/models/general.py index 073b212da..e1e981681 100644 --- a/backend/samfundet/models/general.py +++ b/backend/samfundet/models/general.py @@ -235,6 +235,15 @@ class Venue(CustomBaseModel): closing_saturday = models.TimeField(default=time(hour=20), blank=True, null=True) closing_sunday = models.TimeField(default=time(hour=20), blank=True, null=True) + # Is the venue open on this day? + is_open_monday = models.BooleanField(default=True) + is_open_tuesday = models.BooleanField(default=True) + is_open_wednesday = models.BooleanField(default=True) + is_open_thursday = models.BooleanField(default=True) + is_open_friday = models.BooleanField(default=True) + is_open_saturday = models.BooleanField(default=True) + is_open_sunday = models.BooleanField(default=True) + class Meta: verbose_name = 'Venue' verbose_name_plural = 'Venues' diff --git a/backend/samfundet/tests/test_views.py b/backend/samfundet/tests/test_views.py index 34b5838b9..9bf754871 100644 --- a/backend/samfundet/tests/test_views.py +++ b/backend/samfundet/tests/test_views.py @@ -282,18 +282,20 @@ def test_open_venues_filtering( 'slug': open_slug, f'opening_{day_of_week}': open_time, f'closing_{day_of_week}': close_time, + f'is_open_{day_of_week}': True, } self._create_venue(**open_venue_kwargs) - # 2. Venue that is explicitly closed (00:00 - 00:00) + # 2. Venue that is explicitly closed (is_open_* = False) closed_venue_kwargs = { 'slug': closed_slug, f'opening_{day_of_week}': zero_time, f'closing_{day_of_week}': zero_time, + f'is_open_{day_of_week}': False, } self._create_venue(**closed_venue_kwargs) - # 3. Venue open on a different day (should be treated as closed on the test day if explicitly set to 0:00-0:00) + # 3. Venue open on a different day (should be treated as closed on the test day) other_slug = 'other_day' other_day_kwargs = { 'slug': other_slug, @@ -301,6 +303,7 @@ def test_open_venues_filtering( 'closing_tuesday': close_time, f'opening_{day_of_week}': zero_time, f'closing_{day_of_week}': zero_time, + f'is_open_{day_of_week}': False, } self._create_venue(**other_day_kwargs) diff --git a/backend/samfundet/view/general_views.py b/backend/samfundet/view/general_views.py index 929aa22cb..26fb9ae93 100644 --- a/backend/samfundet/view/general_views.py +++ b/backend/samfundet/view/general_views.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from datetime import time, timedelta +from datetime import timedelta from itertools import chain from rest_framework import status @@ -18,7 +18,7 @@ from rest_framework.permissions import AllowAny from django.utils import timezone -from django.db.models import Q, QuerySet +from django.db.models import QuerySet from django.shortcuts import get_object_or_404 from root.constants import WebFeatures @@ -134,16 +134,10 @@ class VenueView(ModelViewSet): @action(detail=False, methods=['get']) def open_venues(self, request: Request) -> Response: + # Note: This 4-hour offset must match frontend getVenueDay() in utils.ts day_name = (timezone.now() - timedelta(hours=4)).strftime('%A').lower() - q = ~Q( - **{ - f'opening_{day_name}': time(0, 0, 0), - f'closing_{day_name}': time(0, 0, 0), - } - ) - - open_venues = Venue.objects.filter(q) + open_venues = Venue.objects.filter(**{f'is_open_{day_name}': True}) serializer = self.get_serializer(open_venues, many=True) return Response(serializer.data) diff --git a/frontend/src/Components/Checkbox/Checkbox.module.scss b/frontend/src/Components/Checkbox/Checkbox.module.scss index 1c09385db..e92a11072 100644 --- a/frontend/src/Components/Checkbox/Checkbox.module.scss +++ b/frontend/src/Components/Checkbox/Checkbox.module.scss @@ -1,5 +1,7 @@ /* stylelint-disable selector-max-compound-selectors */ @use 'src/constants' as *; + +@use 'src/mixins' as *; $checkmark: '\2714'; /* ASCII "check"-symbol. */ /* Label wraper i checkbox.tsx. */ @@ -31,7 +33,10 @@ $checkmark: '\2714'; /* ASCII "check"-symbol. */ margin-right: 10px; margin-left: 10px; flex-shrink: 0; - transition: background 0.15s, border-color 0.15s; /* Bakgrunn kommer gradvis inn. */ + + @include theme-dark { + border-color: $grey-0; + } } /* Her velges (og styles) det elementet med klassen .checkbox__box som ligger rett etter(ved bruk av +) elementet med klassen .checkbox__input, når det sistnevnte elementet er "checked". */ diff --git a/frontend/src/Components/InputTime/InputTime.module.scss b/frontend/src/Components/InputTime/InputTime.module.scss index 7a4d18f2e..6e1143de4 100644 --- a/frontend/src/Components/InputTime/InputTime.module.scss +++ b/frontend/src/Components/InputTime/InputTime.module.scss @@ -2,6 +2,27 @@ @use 'src/mixins' as *; +.inputTime_wrap { + border-radius: 0.375rem; + border: 1px solid $grey-35; + box-shadow: 0 1px 2px 0 $black-t10; + + @include theme-dark { + border-color: $grey-2; + } +} + +.inputTime_wrap:focus-within { + border-color: $grey-1; + box-shadow: 0 0 0 1px $grey-1; + outline: none; + + @include theme-dark { + border-color: $grey-35; + box-shadow: 0 0 0 1px $grey-35; + } +} + .inputTime { display: flex; align-items: left; @@ -13,9 +34,11 @@ box-sizing: border-box; padding: auto 5px; text-align: center; - border: 2px solid $grey-4; + border: none; + background-color: transparent; + @include theme-dark { - border-color: $grey-2; + color: $white; } } @@ -38,7 +61,11 @@ .number:focus { outline: 3px hidden var(--active); - box-shadow: 0 0 2px 2px #999999; +} + +.number:disabled { + opacity: 0.7; + cursor: not-allowed; } .error { diff --git a/frontend/src/Components/InputTime/InputTime.tsx b/frontend/src/Components/InputTime/InputTime.tsx index ca6d04613..0937bb425 100644 --- a/frontend/src/Components/InputTime/InputTime.tsx +++ b/frontend/src/Components/InputTime/InputTime.tsx @@ -11,7 +11,7 @@ type InputTimeProps = { error?: string; }; -export function InputTime({ onChange, onBlur, value, error }: InputTimeProps) { +export function InputTime({ className, disabled, onChange, onBlur, value, error }: InputTimeProps) { const [hour, setHour] = useState(''); const [minute, setMinute] = useState(''); @@ -56,13 +56,14 @@ export function InputTime({ onChange, onBlur, value, error }: InputTimeProps) { } return ( -
+
@@ -72,6 +73,7 @@ export function InputTime({ onChange, onBlur, value, error }: InputTimeProps) { className={classNames(styles.number, error && styles.error)} name="minute" value={minute} + disabled={disabled} onChange={handleChange} onBlur={handleBlur} /> diff --git a/frontend/src/Components/OpeningHours/OpeningHours.stories.tsx b/frontend/src/Components/OpeningHours/OpeningHours.stories.tsx index 4a3a13132..ac8433492 100644 --- a/frontend/src/Components/OpeningHours/OpeningHours.stories.tsx +++ b/frontend/src/Components/OpeningHours/OpeningHours.stories.tsx @@ -9,7 +9,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Basic: Story = { args: { @@ -20,75 +20,84 @@ export const Basic: Story = { name: 'Daglighallen', opening_monday: '08:00:00', closing_monday: '20:00:00', + is_open_monday: true, opening_tuesday: '08:00:00', closing_tuesday: '20:00:00', + is_open_tuesday: true, opening_wednesday: '08:00:00', closing_wednesday: '20:00:00', + is_open_wednesday: true, opening_thursday: '08:00:00', closing_thursday: '20:00:00', + is_open_thursday: true, opening_friday: '08:00:00', closing_friday: '20:00:00', + is_open_friday: true, opening_saturday: '08:00:00', closing_saturday: '20:00:00', + is_open_saturday: true, opening_sunday: '08:00:00', closing_sunday: '20:00:00', + is_open_sunday: true, }, { id: 2, slug: 'edgar', name: 'Edgar', - opening_monday: '08:00:00', - closing_monday: '20:00:00', - opening_tuesday: '08:00:00', - closing_tuesday: '20:00:00', - opening_wednesday: '08:00:00', - closing_wednesday: '20:00:00', - opening_thursday: '08:00:00', - closing_thursday: '20:00:00', - opening_friday: '08:00:00', - closing_friday: '20:00:00', - opening_saturday: '08:00:00', - closing_saturday: '20:00:00', - opening_sunday: '08:00:00', - closing_sunday: '20:00:00', + opening_monday: '10:00:00', + closing_monday: '22:00:00', + is_open_monday: true, + opening_tuesday: '10:00:00', + closing_tuesday: '22:00:00', + is_open_tuesday: true, + opening_wednesday: '10:00:00', + closing_wednesday: '22:00:00', + is_open_wednesday: true, + opening_thursday: '10:00:00', + closing_thursday: '22:00:00', + is_open_thursday: true, + opening_friday: '10:00:00', + closing_friday: '23:00:00', + is_open_friday: true, + opening_saturday: '10:00:00', + closing_saturday: '23:00:00', + is_open_saturday: true, + opening_sunday: '00:00:00', + closing_sunday: '00:00:00', + is_open_sunday: false, }, + ], + }, +}; + +export const WithClosedDays: Story = { + args: { + venues: [ { - id: 3, + id: 1, slug: 'klubben', name: 'Klubben', - opening_monday: '08:00:00', - closing_monday: '20:00:00', - opening_tuesday: '08:00:00', - closing_tuesday: '20:00:00', - opening_wednesday: '08:00:00', - closing_wednesday: '20:00:00', - opening_thursday: '08:00:00', - closing_thursday: '20:00:00', - opening_friday: '08:00:00', - closing_friday: '20:00:00', - opening_saturday: '08:00:00', - closing_saturday: '20:00:00', - opening_sunday: '08:00:00', - closing_sunday: '20:00:00', - }, - { - id: 4, - slug: 'storsalen', - name: 'Storsalen', - opening_monday: '08:00:00', - closing_monday: '20:00:00', - opening_tuesday: '08:00:00', - closing_tuesday: '20:00:00', - opening_wednesday: '08:00:00', - closing_wednesday: '20:00:00', - opening_thursday: '08:00:00', - closing_thursday: '20:00:00', - opening_friday: '08:00:00', - closing_friday: '20:00:00', - opening_saturday: '08:00:00', - closing_saturday: '20:00:00', - opening_sunday: '08:00:00', - closing_sunday: '20:00:00', + opening_monday: '00:00:00', + closing_monday: '00:00:00', + is_open_monday: false, + opening_tuesday: '00:00:00', + closing_tuesday: '00:00:00', + is_open_tuesday: false, + opening_wednesday: '18:00:00', + closing_wednesday: '02:00:00', + is_open_wednesday: true, + opening_thursday: '18:00:00', + closing_thursday: '02:00:00', + is_open_thursday: true, + opening_friday: '18:00:00', + closing_friday: '03:00:00', + is_open_friday: true, + opening_saturday: '18:00:00', + closing_saturday: '03:00:00', + is_open_saturday: true, + opening_sunday: '00:00:00', + closing_sunday: '00:00:00', + is_open_sunday: false, }, ], }, diff --git a/frontend/src/Components/OpeningHours/OpeningHours.tsx b/frontend/src/Components/OpeningHours/OpeningHours.tsx index 6c4e9b57e..d58c80ae8 100644 --- a/frontend/src/Components/OpeningHours/OpeningHours.tsx +++ b/frontend/src/Components/OpeningHours/OpeningHours.tsx @@ -4,6 +4,7 @@ import { Link } from '~/Components/Link/Link'; import { Text } from '~/Components/Text/Text'; import type { VenueDto } from '~/dto'; import { KEY } from '~/i18n/constants'; +import { getVenueScheduleISO } from '~/utils'; import styles from './OpeningHours.module.scss'; type OpeningHoursProps = { @@ -23,9 +24,6 @@ export function OpeningHours({ venues, isLoading, isError }: OpeningHoursProps) return {t(KEY.error_generic)}; } - const today = new Date().toISOString().split('T')[0]; - const day = new Date().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase(); - return (
@@ -33,8 +31,7 @@ export function OpeningHours({ venues, isLoading, isError }: OpeningHoursProps) {venues.map((venue) => { - const openingTime = venue[`opening_${day}` as keyof VenueDto] as string; - const closingTime = venue[`closing_${day}` as keyof VenueDto] as string; + const { startISO, endISO } = getVenueScheduleISO(venue); return ( ); diff --git a/frontend/src/Components/OpeningHours/OpeningHoursContainer.tsx b/frontend/src/Components/OpeningHours/OpeningHoursContainer.tsx index 4caba5795..781e3f6e7 100644 --- a/frontend/src/Components/OpeningHours/OpeningHoursContainer.tsx +++ b/frontend/src/Components/OpeningHours/OpeningHoursContainer.tsx @@ -1,18 +1,21 @@ import { useQuery } from '@tanstack/react-query'; -import { getOpenVenues } from '~/api'; -import type { VenueDto } from '~/dto'; +import { getVenues } from '~/api'; import { venueKeys } from '~/queryKeys'; +import { getVenueDay, getVenueDaySchedule } from '~/utils'; import { OpeningHours } from './OpeningHours'; export function OpeningHoursContainer() { const { - data: openVenues, + data: venues, isLoading, isError, - } = useQuery({ - queryKey: venueKeys.open(), - queryFn: getOpenVenues, + } = useQuery({ + queryKey: venueKeys.all, + queryFn: getVenues, }); + const day = getVenueDay(); + const openVenues = venues?.filter((v) => getVenueDaySchedule(v, day).isOpen); + return ; } diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss index 29e81b82c..9e3d39f37 100644 --- a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.module.scss @@ -2,10 +2,8 @@ @use 'src/constants' as *; -$header-border: #f1f1f1; -$header-border-dark: #000000; - $bg-dark: #272727; +$row-border-dark: #353535; .subtitle { display: inline-block; @@ -14,18 +12,18 @@ $bg-dark: #272727; } .venue_container { - @include flex-row; - flex-wrap: wrap; - gap: 1em; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr)); + gap: 1.25rem; + align-items: start; } .venue_box { - border-radius: 0.4em; + container-type: inline-size; + @include rounded-light; background-color: $white; border: 1px solid $grey-4; - overflow: hidden; - min-width: 16em; - max-width: 20em; + padding: 20px; @include theme-dark { border-color: $black; @@ -35,44 +33,160 @@ $bg-dark: #272727; } .venue_header { - padding: 0.5em 0.75em; - border-bottom: 1px solid $header-border; + padding: 0.5em; font-size: 1.25em; font-weight: 700; color: $black; @include theme-dark { - border-color: $header-border-dark; color: white; } } .venue_content { - padding: 1em; + overflow-x: auto; } -.input_row { - @include flex-row; +.row_container { + display: flex; flex-direction: column; - justify-content: space-between; - width: 100%; - gap: 0.5em; + min-width: 100%; } +/* Base styles for both header and data rows */ +.day_row_header, .day_row { @include flex-row; justify-content: space-between; width: 100%; + border-bottom: 1px solid $grey-5; + padding: 0.75rem 1rem; + + @include theme-dark { + border-bottom-color: $row-border-dark; + } } +/* Common styles for row children */ .day_label { + width: 4rem; + flex-shrink: 0; + color: $black-2; + font-weight: 500; + + @include theme-dark { + color: $theme-dark-color; + } +} + +/* Styling for the Header Row */ +.day_row_header { + font-size: 0.75rem; + font-weight: 600; color: $grey-1; + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 2px solid $grey-4; + @include theme-dark { - color: $grey-4; + color: $grey-36; + border-bottom-color: $grey-0; + } + + .day_label { + font-weight: 600; + color: $grey-1; + + @include theme-dark { + color: $grey-36; + } } } .day_edit { - @include flex-row; - gap: 0.5em; + display: grid; + align-items: center; + grid-template-columns: 6rem 6rem 5rem; + column-gap: 1rem; +} + +.time_label { + font-weight: 600; + color: $grey-1; + + @include theme-dark { + color: $grey-36; + } +} + +.open_label, +.checkbox_wrapper { + width: 5rem; + text-align: center; +} + +.checkbox_wrapper { + @include flex-row-center; +} + +/* Styling for data rows */ +.day_row { + transition: background-color 0.3s ease; + + &:hover { + background-color: $grey-5; + + @include theme-dark { + background-color: $black-2; + } + } +} + +/* Faded/disabled row when venue is closed that day */ +.day_row.row_disabled { + opacity: 0.45; + background-color: $grey-5; + + @include theme-dark { + opacity: 0.35; + background-color: $black-2; + border-bottom-color: transparent; + } +} + +/* Size overrides for the admin venue box context */ +.time_input { + width: 6rem; + font-size: 1rem; + line-height: 1.5rem; + padding: 0.4rem 0.8rem; +} + +/* Compact layout when the box itself is narrow (mobile or 3-col desktop) */ +@container (max-width: 29rem) { + .day_row_header, + .day_row { + padding: 0.5rem 0.75rem; + } + + .day_label { + width: 3rem; + font-size: 0.875rem; + } + + .day_edit { + grid-template-columns: 4.5rem 4.5rem 2.5rem; + column-gap: 0.5rem; + } + + .open_label, + .checkbox_wrapper { + width: 2.5rem; + } + + .time_input { + width: 4.5rem; + font-size: 0.875rem; + padding: 0.3rem 0.4rem; + } } diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx index 43b8796ec..1f164304f 100644 --- a/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/OpeningHoursAdminPage.tsx @@ -1,142 +1,52 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { type ChangeEvent, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { InputTime } from '~/Components'; +import { OpeningHours } from '~/Components/OpeningHours'; import { getVenues, patchVenue } from '~/api'; import type { VenueDto } from '~/dto'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { venueKeys } from '~/queryKeys'; -import { ALL_DAYS } from '~/types'; -import { getDayKey, lowerCapitalize } from '~/utils'; +import { getVenueDay, getVenueDaySchedule, lowerCapitalize } from '~/utils'; import { AdminPage } from '../AdminPageLayout'; import styles from './OpeningHoursAdminPage.module.scss'; +import { VenueOpeningHoursBox } from './VenueOpeningHoursBox'; export function OpeningHoursAdminPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); - const [saveTimer, setSaveTimer] = useState>({}); useTitle(lowerCapitalize(`${t(KEY.common_edit)} ${t(KEY.common_opening_hours)}`)); - // Use React Query to fetch venues const { data: venues = [], isLoading } = useQuery({ queryKey: venueKeys.all, queryFn: getVenues, - // Sort venues by name for a stable order - select: (data) => [...data].sort((venueA, venueB) => venueA.name.localeCompare(venueB.name)), + select: (data) => [...data].sort((a, b) => a.name.localeCompare(b.name)), }); - // We need a reference to read changed state inside timeout - const venueRef = useRef(venues); - - // Use React Query mutation to update venues const updateVenueMutation = useMutation({ mutationFn: ({ slug, data }: { slug: string; data: Partial }) => patchVenue(slug, data), - // Update cache in place instead of invalidating - onSuccess: (updated: VenueDto, vars) => { + onMutate: async ({ slug, data }) => { + await queryClient.cancelQueries({ queryKey: venueKeys.all }); + const previousVenues = queryClient.getQueryData(venueKeys.all); queryClient.setQueryData(venueKeys.all, (oldVenues = []) => - oldVenues.map((venue) => (venue.slug === vars.slug ? { ...venue, ...vars.data } : venue)), + oldVenues.map((venue) => (venue.slug === slug ? { ...venue, ...data } : venue)), ); - - // Show success toast + return { previousVenues }; + }, + onSuccess: () => { toast.success(t(KEY.common_save_successful)); }, - onError: (error) => { - // Show error toast + onError: (error, _vars, context) => { + if (context?.previousVenues) { + queryClient.setQueryData(venueKeys.all, context.previousVenues); + } toast.error(t(KEY.common_something_went_wrong)); console.error('Error updating venue:', error); }, }); - // Update venueRef when venues data changes - venueRef.current = venues; - - // Save venue change using React Query mutation - function saveVenue(venue: VenueDto, field: keyof VenueDto, value: string) { - // Update optimistic state - const updatedVenues = venues.map((v) => (v.id === venue.id ? { ...v, [field]: value } : v)); - venueRef.current = updatedVenues; - - // Send field change to backend using mutation - updateVenueMutation.mutate({ - slug: venue.slug, - data: { [field]: value }, - }); - } - - // Update view model on field with optimistic updates - function handleOnChange(venue: VenueDto, field: keyof VenueDto) { - return (e: ChangeEvent) => { - const value = e.currentTarget.value; - - // Optimistically update the query cache - queryClient.setQueryData(venueKeys.all, (oldVenues: VenueDto[] | undefined) => { - if (!oldVenues) return []; - return oldVenues.map((v) => { - if (v.id === venue.id) { - return { ...v, [field]: value }; - } - return v; - }); - }); - - // Cancel old save timer if any - const timer_id = `${venue.id}_${field}`; - if (saveTimer[timer_id] !== undefined) { - clearTimeout(saveTimer[timer_id]); - } - - // Start a new save timer - const timer = setTimeout(() => { - saveVenue(venue, field, value); - }, 1000); - - // Store timeout to allow cancel - setSaveTimer({ - ...saveTimer, - [timer_id]: timer, - }); - }; - } - - /** - * Box for a single venue. - * Shows open/close times for each day. - * */ - function venueBox(venue: VenueDto) { - return ( -
-
{venue.name}
-
-
- {ALL_DAYS.map((day) => { - const openField: keyof VenueDto = `opening_${day}`; - const closeField: keyof VenueDto = `closing_${day}`; - // Edit tools - return ( -
-
{t(getDayKey(day))}
-
- handleOnChange(venue, openField)} - onBlur={(formattedTime) => saveVenue(venue, openField, formattedTime)} - /> -

-

- handleOnChange(venue, closeField)} - onBlur={(formattedTime) => saveVenue(venue, closeField, formattedTime)} - /> -
-
- ); - })} -
-
-
- ); + function handleSave(venue: VenueDto, field: keyof VenueDto, value: string | boolean) { + updateVenueMutation.mutate({ slug: venue.slug, data: { [field]: value } }); } const header = ( @@ -145,9 +55,17 @@ export function OpeningHoursAdminPage() { ); + const today = getVenueDay(); + const venuesOpenToday = venues.filter((v) => getVenueDaySchedule(v, today).isOpen); + return ( -
{venues.map((venue) => venueBox(venue))}
+ +
+ {venues.map((venue) => ( + + ))} +
); } diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.stories.tsx b/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.stories.tsx new file mode 100644 index 000000000..19278d72d --- /dev/null +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.stories.tsx @@ -0,0 +1,56 @@ +import { useArgs } from '@storybook/preview-api'; +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import type { VenueDto } from '~/dto'; +import { VenueOpeningHoursBox } from './VenueOpeningHoursBox'; + +const meta: Meta = { + title: 'PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox', + component: VenueOpeningHoursBox, + args: { + venue: { + id: 1, + slug: 'daglighallen', + name: 'Daglighallen', + opening_monday: '08:00:00', + closing_monday: '20:00:00', + is_open_monday: true, + opening_tuesday: '08:00:00', + closing_tuesday: '20:00:00', + is_open_tuesday: true, + opening_wednesday: '08:00:00', + closing_wednesday: '20:00:00', + is_open_wednesday: true, + opening_thursday: '08:00:00', + closing_thursday: '20:00:00', + is_open_thursday: true, + opening_friday: '08:00:00', + closing_friday: '20:00:00', + is_open_friday: true, + opening_saturday: '08:00:00', + closing_saturday: '20:00:00', + is_open_saturday: true, + opening_sunday: '00:00:00', + closing_sunday: '00:00:00', + is_open_sunday: false, + }, + onSave: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +function Interactive(args: React.ComponentProps) { + const [{ venue }, updateArgs] = useArgs<{ venue: VenueDto }>(); + return ( + updateArgs({ venue: { ...v, [field]: value } })} + /> + ); +} + +export const Basic: Story = { render: Interactive }; diff --git a/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.tsx b/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.tsx new file mode 100644 index 000000000..fce59495b --- /dev/null +++ b/frontend/src/PagesAdmin/OpeningHoursAdminPage/VenueOpeningHoursBox.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { InputTime } from '~/Components'; +import { Checkbox } from '~/Components/Checkbox'; +import type { VenueDto } from '~/dto'; +import { KEY } from '~/i18n/constants'; +import { ALL_DAYS } from '~/types'; +import { getDayKey, getVenueDaySchedule } from '~/utils'; +import styles from './OpeningHoursAdminPage.module.scss'; + +type Props = { + venue: VenueDto; + onSave: (venue: VenueDto, field: keyof VenueDto, value: string | boolean) => void; +}; + +export function VenueOpeningHoursBox({ venue, onSave }: Props) { + const { t } = useTranslation(); + + return ( +
+
{venue.name}
+
+
+
+
{t(KEY.common_day)}
+
+ {t(KEY.common_from)} + {t(KEY.common_to)} +
{t(KEY.common_open)}
+
+
+ + {ALL_DAYS.map((day) => { + const openField: keyof VenueDto = `opening_${day}`; + const closeField: keyof VenueDto = `closing_${day}`; + const isOpenField: keyof VenueDto = `is_open_${day}`; + const { opening, closing, isOpen } = getVenueDaySchedule(venue, day); + return ( +
+
{t(getDayKey(day))}
+
+ onSave(venue, openField, formattedTime)} + disabled={!isOpen} + /> + onSave(venue, closeField, formattedTime)} + disabled={!isOpen} + /> +
+ onSave(venue, isOpenField, !isOpen)} + /> +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 93fb5c1ea..2bc2d8288 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -188,12 +188,6 @@ export async function patchVenue(slug: string | number, venue: Partial return response.data; } -export async function getOpenVenues(): Promise { - const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__venues_open_venues; - const response = await axios.get(url, { withCredentials: true }); - return response.data; -} - export async function getPermissions(): Promise { const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__permissions_list; const response = await axios.get(url, { withCredentials: true }); diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 4ecd509da..a939e6e99 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -137,9 +137,15 @@ export type VenueDto = { closing_friday?: string; closing_saturday?: string; closing_sunday?: string; -}; -export type OpenVenuesDto = VenueDto[]; + is_open_monday: boolean; + is_open_tuesday: boolean; + is_open_wednesday: boolean; + is_open_thursday: boolean; + is_open_friday: boolean; + is_open_saturday: boolean; + is_open_sunday: boolean; +}; // ==================== // // Event // diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 052742a4f..cd9d104e4 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -20,6 +20,7 @@ export const KEY = { // ==================== // // Days: + common_day: 'common_day', common_day_monday: 'common_day_monday', common_day_tuesday: 'common_day_tuesday', common_day_wednesday: 'common_day_wednesday', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index e9ec04582..5024306af 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -6,6 +6,7 @@ export const nb = prepareTranslations({ // Common // // ==================== // // Days: + [KEY.common_day]: 'Dag', [KEY.common_day_monday]: 'Mandag', [KEY.common_day_tuesday]: 'Tirsdag', [KEY.common_day_wednesday]: 'Onsdag', @@ -64,7 +65,7 @@ export const nb = prepareTranslations({ [KEY.common_show]: 'Vis', [KEY.common_date]: 'Dato', [KEY.common_send]: 'Send', - [KEY.common_open]: 'Åpne', + [KEY.common_open]: 'Åpen?', [KEY.common_menu]: 'Meny', [KEY.common_name]: 'Navn', [KEY.common_next]: 'Neste', @@ -680,6 +681,7 @@ export const en = prepareTranslations({ // Common // // ==================== // // Days: + [KEY.common_day]: 'Day', [KEY.common_day_monday]: 'Monday', [KEY.common_day_tuesday]: 'Tuesday', [KEY.common_day_wednesday]: 'Wednesday', @@ -738,7 +740,7 @@ export const en = prepareTranslations({ [KEY.common_from]: 'From', [KEY.common_date]: 'Date', [KEY.common_send]: 'Send', - [KEY.common_open]: 'Open', + [KEY.common_open]: 'Open?', [KEY.common_edit]: 'Edit', [KEY.common_show]: 'Show', [KEY.common_table]: 'Table', diff --git a/frontend/src/queryKeys.ts b/frontend/src/queryKeys.ts index add88d9e5..86ccf3dad 100644 --- a/frontend/src/queryKeys.ts +++ b/frontend/src/queryKeys.ts @@ -80,7 +80,6 @@ export const venueKeys = { list: (filters: unknown[]) => [...venueKeys.lists(), { filters }] as const, details: () => [...venueKeys.all, 'detail'] as const, detail: (slug: string) => [...venueKeys.details(), slug] as const, - open: () => [...venueKeys.list(['open'])] as const, }; export const imageKeys = { diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 9e5c6f944..72107b036 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -6,7 +6,7 @@ import type { UseFormReturn } from 'react-hook-form'; import { toast } from 'react-toastify'; import type { z } from 'zod'; import { CURSOR_TRAIL_CLASS, THEME_KEY, type ThemeValue } from '~/constants'; -import type { UserDto } from '~/dto'; +import type { UserDto, VenueDto } from '~/dto'; import { KEY } from './i18n/constants'; import type { TranslationKeys } from './i18n/types'; import { @@ -182,6 +182,78 @@ export function isTruthy(value = ''): boolean { return !falsy.includes(value.toLowerCase()); } +export type DaySchedule = { + opening: string; + closing: string; + isOpen: boolean; +}; + +// 4-hour offset for venues open past midnight +export const VENUE_DAY_OFFSET_MS = 4 * 60 * 60 * 1000; + +/** + * Returns the current date shifted by the venue day offset. + * Venues can be open past midnight (e.g. Thursday 10:00–02:00), so we subtract + * 4 hours: 01:00 Friday still counts as Thursday in the venue schedule. + */ +export function getVenueDate(): Date { + return new Date(Date.now() - VENUE_DAY_OFFSET_MS); +} + +/** + * Returns the current "venue day" — the day whose schedule should be shown. + * This matches the backend's open_venues logic (timezone.now() - timedelta(hours=4)). + */ +export function getVenueDay(): Day { + return getVenueDate().toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase() as Day; +} + +/** + * Returns the opening hours schedule for a specific day from a venue. + * Centralises the per-day field access so the unsafe cast lives in one place. + */ +export function getVenueDaySchedule(venue: VenueDto, day: Day): DaySchedule { + return { + opening: venue[`opening_${day}`] as string, + closing: venue[`closing_${day}`] as string, + isOpen: venue[`is_open_${day}`] as boolean, + }; +} + +type VenueScheduleISO = { + startISO: string; + endISO: string; + isOpen: boolean; +}; + +/** + * Returns ISO date strings for a venue's schedule on the current venue day. + * Handles overnight hours (e.g., 22:00-02:00) by adding a day to the end time. + */ +export function getVenueScheduleISO(venue: VenueDto): VenueScheduleISO { + const day = getVenueDay(); + const { opening, closing, isOpen } = getVenueDaySchedule(venue, day); + + if (!isOpen || !opening || !closing) { + return { startISO: '', endISO: '', isOpen: false }; + } + + const startDate = getVenueDate(); + + // Check if closing is earlier than opening (indicates overnight to next day) + const [openHour, openMin] = opening.split(':').map(Number); + const [closeHour, closeMin] = closing.split(':').map(Number); + const isOvernight = closeHour < openHour || (closeHour === openHour && closeMin <= openMin); + + const endDate = isOvernight ? new Date(startDate.getTime() + 24 * 60 * 60 * 1000) : startDate; + + const dateToISO = (date: Date) => date.toISOString().split('T')[0]; + const startISO = `${dateToISO(startDate)}T${opening}`; + const endISO = `${dateToISO(endDate)}T${closing}`; + + return { startISO, endISO, isOpen }; +} + /** * Gets the translation key for a given day */
@@ -43,11 +40,7 @@ export function OpeningHours({ venues, isLoading, isError }: OpeningHoursProps) - +