improving-week-36 #1

Merged
jare2473 merged 41 commits from improving-week-36 into main 2025-09-04 10:49:05 +02:00
12 changed files with 573 additions and 14 deletions
Showing only changes of commit d0e70f4b19 - Show all commits

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes'; // move the routing and loading logic here
import { SettingsProvider } from './context/SettingsContext';
function App() {
return (
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
<SettingsProvider>
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
</SettingsProvider>
);
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Layout from './Layout';
import { RoomBooking } from './pages/RoomBooking';
import { NewBooking } from './pages/NewBooking';
import { CalendarSettings } from './pages/CalendarSettings';
import FullScreenLoader from './components/FullScreenLoader';
const AppRoutes = () => {
@@ -31,6 +32,7 @@ const AppRoutes = () => {
<Route path="/" element={<Layout />}>
<Route index element={<RoomBooking bookings={bookings} />} />
<Route path="new-booking" element={<NewBooking addBooking={addBooking} />} />
<Route path="calendar-settings" element={<CalendarSettings />} />
</Route>
</Routes>
</>

View File

@@ -3,11 +3,13 @@ import { DatePicker } from '../react-aria-starter/src/DatePicker';
import { today, getLocalTimeZone } from '@internationalized/date';
import { getFutureDate, isDateUnavailable } from '../utils/bookingUtils';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
export function BookingDatePicker() {
const booking = useBookingContext();
const minDate = today(getLocalTimeZone());
const maxDate = getFutureDate(14);
const { settings, getEffectiveToday } = useSettingsContext();
const minDate = getEffectiveToday();
const maxDate = minDate.add({ days: settings.bookingRangeDays });
const handlePreviousDay = () => {
const previousDay = booking.selectedDate.subtract({ days: 1 });
@@ -34,7 +36,7 @@ export function BookingDatePicker() {
firstDayOfWeek="mon"
minValue={minDate}
maxValue={maxDate}
isDateUnavailable={isDateUnavailable}
isDateUnavailable={(date) => isDateUnavailable(date, minDate, settings.bookingRangeDays)}
onPreviousClick={handlePreviousDay}
onNextClick={handleNextDay}
canNavigatePrevious={canNavigatePrevious}

View File

@@ -31,6 +31,7 @@ const Header = () => {
<div className={styles.menu}>
{/* Menu items */}
<Link onClick={handleClick} to="/">Lokalbokning</Link>
<Link onClick={handleClick} to="/calendar-settings">Calendar Settings</Link>
</div>
)}
</header>

View File

@@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { today, getLocalTimeZone, CalendarDate } from '@internationalized/date';
const SettingsContext = createContext();
export const useSettingsContext = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettingsContext must be used within a SettingsProvider');
}
return context;
};
export const SettingsProvider = ({ children }) => {
const [settings, setSettings] = useState(() => {
// Load settings from localStorage or use defaults
const saved = localStorage.getItem('calendarSettings');
if (saved) {
try {
const parsed = JSON.parse(saved);
return {
...parsed,
// Convert date strings back to DateValue objects
mockToday: parsed.mockToday ? new Date(parsed.mockToday) : null,
};
} catch (e) {
console.warn('Failed to parse saved settings:', e);
}
}
return {
// Use mock date if set, otherwise real today
mockToday: null,
// Days in the future users can book
bookingRangeDays: 14,
// Room availability percentage
roomAvailabilityChance: 0.7,
// Number of rooms
numberOfRooms: 5,
// Earliest booking time (in half-hour slots from 8:00)
earliestTimeSlot: 0, // 8:00
// Latest booking time
latestTimeSlot: 22, // 19:00 (last slot ending at 19:30)
};
});
// Save settings to localStorage whenever they change
useEffect(() => {
const toSave = {
...settings,
// Convert Date objects to strings for JSON serialization
mockToday: settings.mockToday ? settings.mockToday.toISOString() : null,
};
localStorage.setItem('calendarSettings', JSON.stringify(toSave));
}, [settings]);
// Get the effective "today" date (mock or real)
const getEffectiveToday = () => {
if (settings.mockToday) {
// Convert JavaScript Date to CalendarDate using the proper library function
const mockDate = settings.mockToday;
const year = mockDate.getFullYear();
const month = mockDate.getMonth() + 1; // JS months are 0-indexed
const day = mockDate.getDate();
return new CalendarDate(year, month, day);
}
return today(getLocalTimeZone());
};
const updateSettings = (newSettings) => {
setSettings(prev => ({ ...prev, ...newSettings }));
};
const resetSettings = () => {
setSettings({
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: 0.7,
numberOfRooms: 5,
earliestTimeSlot: 0,
latestTimeSlot: 22,
});
localStorage.removeItem('calendarSettings');
};
return (
<SettingsContext.Provider value={{
settings,
updateSettings,
resetSettings,
getEffectiveToday,
}}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -9,7 +9,7 @@ import {
import { DEFAULT_BOOKING_TITLE, PEOPLE } from '../constants/bookingConstants';
import { useDisabledOptions } from './useDisabledOptions';
export function useBookingState(addBooking) {
export function useBookingState(addBooking, initialDate = null) {
// State hooks - simplified back to useState for stability
const [timeSlotsByRoom, setTimeSlotsByRoom] = useState(generateInitialRooms());
const [currentRoom, setCurrentRoom] = useState(null);
@@ -18,7 +18,7 @@ export function useBookingState(addBooking) {
const [selectedEndIndex, setSelectedEndIndex] = useState(null);
const [selectedBookingLength, setSelectedBookingLength] = useState(0);
const [participant, setParticipant] = useState("Arjohn Emilsson");
const [selectedDate, setSelectedDate] = useState(today(getLocalTimeZone()));
const [selectedDate, setSelectedDate] = useState(initialDate || today(getLocalTimeZone()));
const [availableTimeSlots, setAvailableTimeSlots] = useState([]);
const [indeciesInHover, setIndeciesInHover] = useState([]);
const [title, setTitle] = useState("");

View File

@@ -0,0 +1,206 @@
import React, { useState } from 'react';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './CalendarSettings.module.css';
export function CalendarSettings() {
const { settings, updateSettings, resetSettings, getEffectiveToday } = useSettingsContext();
const [tempDate, setTempDate] = useState('');
const handleMockDateChange = (e) => {
const dateValue = e.target.value;
setTempDate(dateValue);
if (dateValue) {
updateSettings({ mockToday: new Date(dateValue) });
} else {
updateSettings({ mockToday: null });
}
};
const formatDateForInput = (date) => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getTimeFromSlot = (slot) => {
const totalMinutes = 8 * 60 + slot * 30;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
};
const effectiveToday = getEffectiveToday();
const isUsingMockDate = settings.mockToday !== null;
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Calendar Settings</h1>
<p>Configure calendar behavior for testing purposes</p>
</div>
<div className={styles.content}>
<div className={styles.section}>
<h2>Date Settings</h2>
<div className={styles.setting}>
<label htmlFor="mockDate">
<strong>Mock Today's Date</strong>
<span className={styles.description}>
Override the current date for testing. Leave empty to use real date.
</span>
</label>
<div className={styles.dateGroup}>
<input
id="mockDate"
type="date"
value={settings.mockToday ? formatDateForInput(settings.mockToday) : ''}
onChange={handleMockDateChange}
className={styles.dateInput}
/>
{isUsingMockDate && (
<button
onClick={() => {
updateSettings({ mockToday: null });
setTempDate('');
}}
className={styles.clearButton}
>
Use Real Date
</button>
)}
</div>
<div className={styles.currentStatus}>
Effective today: <strong>{effectiveToday.day}/{effectiveToday.month}/{effectiveToday.year}</strong>
{isUsingMockDate && <span className={styles.mockLabel}> (MOCK)</span>}
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingRange">
<strong>Booking Range (Days)</strong>
<span className={styles.description}>
How many days in the future users can book
</span>
</label>
<input
id="bookingRange"
type="number"
min="1"
max="365"
value={settings.bookingRangeDays}
onChange={(e) => updateSettings({ bookingRangeDays: parseInt(e.target.value) })}
className={styles.numberInput}
/>
<div className={styles.currentStatus}>
Latest bookable date: <strong>{effectiveToday.add({ days: settings.bookingRangeDays }).day}/{effectiveToday.add({ days: settings.bookingRangeDays }).month}/{effectiveToday.add({ days: settings.bookingRangeDays }).year}</strong>
</div>
</div>
</div>
<div className={styles.section}>
<h2>Room Settings</h2>
<div className={styles.setting}>
<label htmlFor="numberOfRooms">
<strong>Number of Rooms</strong>
<span className={styles.description}>
Total number of rooms available for booking
</span>
</label>
<input
id="numberOfRooms"
type="number"
min="1"
max="20"
value={settings.numberOfRooms}
onChange={(e) => updateSettings({ numberOfRooms: parseInt(e.target.value) })}
className={styles.numberInput}
/>
</div>
<div className={styles.setting}>
<label htmlFor="availabilityChance">
<strong>Room Availability %</strong>
<span className={styles.description}>
Percentage chance that a time slot is available (affects random generation)
</span>
</label>
<div className={styles.sliderGroup}>
<input
id="availabilityChance"
type="range"
min="0"
max="1"
step="0.1"
value={settings.roomAvailabilityChance}
onChange={(e) => updateSettings({ roomAvailabilityChance: parseFloat(e.target.value) })}
className={styles.slider}
/>
<span className={styles.sliderValue}>
{Math.round(settings.roomAvailabilityChance * 100)}%
</span>
</div>
</div>
</div>
<div className={styles.section}>
<h2>Time Slot Settings</h2>
<div className={styles.setting}>
<label htmlFor="earliestTime">
<strong>Earliest Booking Time</strong>
<span className={styles.description}>
First available time slot of the day
</span>
</label>
<select
id="earliestTime"
value={settings.earliestTimeSlot}
onChange={(e) => updateSettings({ earliestTimeSlot: parseInt(e.target.value) })}
className={styles.select}
>
{Array.from({ length: 23 }, (_, i) => (
<option key={i} value={i}>
{getTimeFromSlot(i)}
</option>
))}
</select>
</div>
<div className={styles.setting}>
<label htmlFor="latestTime">
<strong>Latest Booking Time</strong>
<span className={styles.description}>
Last available time slot of the day
</span>
</label>
<select
id="latestTime"
value={settings.latestTimeSlot}
onChange={(e) => updateSettings({ latestTimeSlot: parseInt(e.target.value) })}
className={styles.select}
>
{Array.from({ length: 23 }, (_, i) => (
<option key={i} value={i} disabled={i <= settings.earliestTimeSlot}>
{getTimeFromSlot(i)}
</option>
))}
</select>
</div>
</div>
<div className={styles.actions}>
<button onClick={resetSettings} className={styles.resetButton}>
Reset to Defaults
</button>
<div className={styles.info}>
Settings are automatically saved and will persist between sessions
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
margin-bottom: 2rem;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.header p {
color: #6b7280;
font-size: 1.1rem;
}
.content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 1.5rem;
border-bottom: 2px solid #f3f4f6;
padding-bottom: 0.5rem;
}
.setting {
margin-bottom: 1.5rem;
}
.setting:last-child {
margin-bottom: 0;
}
.setting label {
display: block;
margin-bottom: 0.5rem;
}
.setting label strong {
font-size: 1rem;
font-weight: 600;
color: #374151;
display: block;
}
.description {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
display: block;
}
.dateGroup {
display: flex;
gap: 0.75rem;
align-items: center;
}
.dateInput, .numberInput, .select {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background: white;
}
.dateInput:focus, .numberInput:focus, .select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.dateInput {
width: 200px;
}
.numberInput {
width: 120px;
}
.select {
width: 150px;
}
.clearButton {
padding: 0.5rem 1rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.clearButton:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.currentStatus {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #4b5563;
}
.mockLabel {
background: #fbbf24;
color: #92400e;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sliderGroup {
display: flex;
align-items: center;
gap: 1rem;
}
.slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: #e5e7eb;
outline: none;
cursor: pointer;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.sliderValue {
font-weight: 600;
color: #2563eb;
min-width: 40px;
text-align: center;
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.resetButton {
padding: 0.75rem 2rem;
background: #dc2626;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.resetButton:hover {
background: #b91c1c;
}
.info {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.section {
padding: 1rem;
}
.dateGroup {
flex-direction: column;
align-items: flex-start;
}
.sliderGroup {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -8,9 +8,11 @@ import { RoomSelectionField } from '../components/RoomSelectionField';
import { BookingLengthField } from '../components/BookingLengthField';
import { useBookingState } from '../hooks/useBookingState';
import { BookingProvider } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
export function NewBooking({ addBooking }) {
const booking = useBookingState(addBooking);
const { getEffectiveToday } = useSettingsContext();
const booking = useBookingState(addBooking, getEffectiveToday());
return (
<BookingProvider value={booking}>

View File

@@ -23,6 +23,10 @@
width: 2rem;
height: 2rem;
padding: 0;
&[data-disabled] {
display: none;
}
}
.react-aria-CalendarCell {
@@ -39,6 +43,10 @@
display: none;
}
&:hover:not([data-selected]):not([data-disabled]):not([data-unavailable]) {
background-color: #e9e9e9;
}
&[data-pressed] {
background: var(--gray-100);
}
@@ -64,6 +72,7 @@
&[data-unavailable] {
text-decoration: line-through;
color: var(--invalid-color);
color: rgb(203, 203, 203);
}
}

View File

@@ -134,6 +134,8 @@
.react-aria-Popover[data-trigger=DatePicker] {
max-width: unset;
transform: translateX(-50%);
left: 50% !important;
}
.react-aria-DatePicker {

View File

@@ -74,15 +74,14 @@ export const getLongestConsecutive = (allRooms) => {
return longest;
};
export const createDisabledRanges = () => {
const now = today(getLocalTimeZone());
export const createDisabledRanges = (effectiveToday, bookingRangeDays = 14) => {
return [
[now.add({ days: 14 }), now.add({ days: 9999 })],
[effectiveToday.add({ days: bookingRangeDays }), effectiveToday.add({ days: 9999 })],
];
};
export const isDateUnavailable = (date) => {
const disabledRanges = createDisabledRanges();
export const isDateUnavailable = (date, effectiveToday, bookingRangeDays = 14) => {
const disabledRanges = createDisabledRanges(effectiveToday, bookingRangeDays);
return disabledRanges.some((interval) =>
date.compare(interval[0]) >= 0 &&
date.compare(interval[1]) <= 0