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 (
|
@@ -43,11 +40,7 @@ export function OpeningHours({ venues, isLoading, isError }: OpeningHoursProps)
|
-
+
|
);
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
*/