From a897b2d21eed4533dd6468a7a2fb4bfe9ba0def0 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 12 Sep 2025 23:46:04 +0200 Subject: [PATCH] Add exceptions + viz --- api/src/database.types.ts | 3 + sql/17_availabilities_table.sql | 7 + ui/src/components/AvailabilityCard.tsx | 96 ---- .../components/AvailabilityVisualization.tsx | 127 +++++ ui/src/hooks/availabilities.ts | 55 ++- ui/src/pages/availabilities.tsx | 446 ++++++++++++------ ui/src/types/database.types.ts | 3 + ui/src/ui-library/date-field.tsx | 57 ++- ui/src/ui-library/date-picker.tsx | 65 +-- ui/src/ui-library/icons/outline/calendar.tsx | 28 ++ xtablo-expo/lib/database.types.ts | 3 + 11 files changed, 592 insertions(+), 298 deletions(-) create mode 100644 ui/src/components/AvailabilityVisualization.tsx create mode 100644 ui/src/ui-library/icons/outline/calendar.tsx diff --git a/api/src/database.types.ts b/api/src/database.types.ts index e0b0a58..f10b08f 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -18,6 +18,7 @@ export type Database = { Row: { availability_data: Json created_at: string + exceptions: Json | null id: number updated_at: string user_id: string @@ -25,6 +26,7 @@ export type Database = { Insert: { availability_data?: Json created_at?: string + exceptions?: Json | null id?: number updated_at?: string user_id: string @@ -32,6 +34,7 @@ export type Database = { Update: { availability_data?: Json created_at?: string + exceptions?: Json | null id?: number updated_at?: string user_id?: string diff --git a/sql/17_availabilities_table.sql b/sql/17_availabilities_table.sql index 8f3a12a..2636279 100644 --- a/sql/17_availabilities_table.sql +++ b/sql/17_availabilities_table.sql @@ -67,3 +67,10 @@ ALTER TABLE availabilities RENAME COLUMN availabilities TO availability_data; -- Update the comment for the renamed column COMMENT ON COLUMN availabilities.availability_data IS 'JSONB object containing availability settings for each day (0-6, where 0 is Monday). Each day has enabled status and time ranges.'; + +-- Add exceptions column for date-specific availability overrides +ALTER TABLE availabilities ADD COLUMN exceptions JSONB DEFAULT '[]'; + +-- Add comment for the exceptions column +COMMENT ON COLUMN availabilities.exceptions IS + 'JSONB object containing date-specific availability exceptions that override regular availability settings. Keys are ISO date strings (YYYY-MM-DD), values contain exception type and optional time ranges.'; diff --git a/ui/src/components/AvailabilityCard.tsx b/ui/src/components/AvailabilityCard.tsx index 52247a5..f069e3b 100644 --- a/ui/src/components/AvailabilityCard.tsx +++ b/ui/src/components/AvailabilityCard.tsx @@ -29,7 +29,6 @@ interface AvailabilityCardProps { ) => void; } -const MINUTES_IN_DAY = 24 * 60; const MAX_END_TIME_MINUTES = 23 * 60 + 30; // 23:30 const DAYS_OF_WEEK_DISPLAY = [ @@ -55,96 +54,6 @@ function minutesToTime(minutes: number): string { .padStart(2, "0")}`; } -// TimeRangeSlider component for visualizing time ranges -function TimeRangeSlider({ timeRanges }: { timeRanges: TimeRange[] }) { - const sortedRanges = [...timeRanges].sort( - (a, b) => timeToMinutes(a.start) - timeToMinutes(b.start) - ); - - // Generate hour markers (every 2 hours from 6 AM to 10 PM) - const hourMarkers = []; - for (let hour = 6; hour <= 22; hour += 2) { - hourMarkers.push({ - time: `${hour.toString().padStart(2, "0")}:00`, - minutes: hour * 60, - label: `${hour}h`, - }); - } - - const getPositionPercentage = (minutes: number) => { - // Map 0-1440 minutes (24 hours) to 0-100% - return (minutes / MINUTES_IN_DAY) * 100; - }; - - return ( -
-
- {/* Hour markers - subtle dots */} - {hourMarkers.map((marker) => ( -
- ))} - - {/* Time range bubbles */} - {sortedRanges.map((range, index) => { - const startMinutes = timeToMinutes(range.start); - const endMinutes = timeToMinutes(range.end); - const leftPercent = getPositionPercentage(startMinutes); - const widthPercent = getPositionPercentage(endMinutes - startMinutes); - - return ( -
- {/* Inner glow effect */} -
- - {/* Bubble highlight */} -
-
- ); - })} - - {/* Start and end time markers - elegant */} -
-
- - 0h - -
-
-
- - 24h - -
-
- - {/* Hour labels - refined */} -
- {hourMarkers.map((marker) => ( -
- {marker.label} -
- ))} -
-
- ); -} - export function AvailabilityCard({ day, enabled, @@ -378,11 +287,6 @@ export function AvailabilityCard({ )}
- - {/* Time Slider Visualization */} - {enabled && timeRanges.length > 0 && ( - - )}
); } diff --git a/ui/src/components/AvailabilityVisualization.tsx b/ui/src/components/AvailabilityVisualization.tsx new file mode 100644 index 0000000..ce1ef46 --- /dev/null +++ b/ui/src/components/AvailabilityVisualization.tsx @@ -0,0 +1,127 @@ +import { WeeklyAvailability } from "@ui/hooks/availabilities"; +import { Text } from "@ui/ui-library/text"; + +// Check if a time slot is available for a given day +const isTimeSlotAvailable = ( + day: number, + timeSlot: string, + draftAvailabilities: WeeklyAvailability +): boolean => { + const dayAvailability = draftAvailabilities[day]; + if (!dayAvailability.enabled) return false; + + const slotMinutes = timeToMinutes(timeSlot); + return dayAvailability.timeRanges.some((range) => { + const startMinutes = timeToMinutes(range.start); + const endMinutes = timeToMinutes(range.end); + return slotMinutes >= startMinutes && slotMinutes < endMinutes; + }); +}; + +// Helper function to convert time string to minutes +const timeToMinutes = (time: string): number => { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +}; + +// Helper function to convert minutes to time string +const minutesToTime = (minutes: number): string => { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours.toString().padStart(2, "0")}:${mins + .toString() + .padStart(2, "0")}`; +}; + +// Generate time slots for visualization based on duration +const generateTimeSlots = (intervalMinutes: number = 30) => { + const slots = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += intervalMinutes) { + slots.push(minutesToTime(hour * 60 + minute)); + } + } + return slots; +}; + +const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; +const DAYS_OF_WEEK_DISPLAY = [ + "Lundi", + "Mardi", + "Mercredi", + "Jeudi", + "Vendredi", + "Samedi", + "Dimanche", +]; + +export const AvailabilityVisualization = ({ + draftAvailabilities, + slotDurationMinutes = 30, +}: { + draftAvailabilities: WeeklyAvailability; + slotDurationMinutes?: number; +}) => { + const timeSlots = generateTimeSlots(slotDurationMinutes); + return ( +
+ {/* Weekly Calendar Header */} +
+
+ + Heure + +
+ {DAYS_OF_WEEK.map((day) => ( +
+ + {DAYS_OF_WEEK_DISPLAY[day]} + +
+ ))} +
+ + {/* Time Slots */} +
+ {timeSlots.map((timeSlot) => { + const timeMinutes = timeToMinutes(timeSlot); + const hour = Math.floor(timeMinutes / 60); + // Only show hours from 6 AM to 11 PM + if (hour < 6 || hour > 23) return null; + + return ( +
+
+ + {timeSlot} + +
+ {DAYS_OF_WEEK.map((day) => ( +
+ {isTimeSlotAvailable(day, timeSlot, draftAvailabilities) ? ( +
+
+
+ ) : ( +
+
+
+ )} +
+ ))} +
+ ); + })} +
+
+ ); +}; diff --git a/ui/src/hooks/availabilities.ts b/ui/src/hooks/availabilities.ts index 0022c9a..ed34d15 100644 --- a/ui/src/hooks/availabilities.ts +++ b/ui/src/hooks/availabilities.ts @@ -3,6 +3,7 @@ import { queryClient } from "@ui/lib/api"; import { supabase } from "@ui/hooks/auth"; import { useSession } from "@ui/contexts/SessionContext"; import { useEffect, useState } from "react"; +import { Database } from "@ui/types/database.types"; export type TimeRange = { start: string; @@ -18,6 +19,18 @@ export type WeeklyAvailability = { [key: number]: DayAvailability; }; +export type Exception = { + date: string; +} & ( + | { + type: "hours"; + hours: TimeRange; + } + | { + type: "day"; + } +); + const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce( @@ -34,7 +47,9 @@ export const DEFAULT_AVAILABILITIES: WeeklyAvailability = DAYS_OF_WEEK.reduce( export function useAvailabilities() { const { session } = useSession(); - const { data: availabilities, isLoading } = useQuery({ + const { data: availabilities, isLoading } = useQuery< + Database["public"]["Tables"]["availabilities"]["Row"] + >({ queryKey: ["availabilities"], queryFn: async () => { const { data, error } = await supabase @@ -43,20 +58,35 @@ export function useAvailabilities() { .eq("user_id", session?.user.id) .limit(1); if (error) throw error; - return data?.[0].availability_data as WeeklyAvailability; + return data?.[0] as Database["public"]["Tables"]["availabilities"]["Row"]; }, enabled: !!session?.user.id, }); - console.log("availabilities", availabilities); - - const { mutate: updateAvailabilities, isPending: isUpdating } = useMutation({ - mutationFn: async (optionalAvailabilities: WeeklyAvailability) => { - const newAvailabilities = - optionalAvailabilities || DEFAULT_AVAILABILITIES; + const { mutate: updateAvailabilities } = useMutation< + void, + Error, + { + updatedAvailabilities: WeeklyAvailability; + newException?: Exception | null; + } + >({ + mutationFn: async ({ + updatedAvailabilities, + newException = null, + }: { + updatedAvailabilities: WeeklyAvailability; + newException?: Exception | null; + }) => { + const newAvailabilities = updatedAvailabilities || DEFAULT_AVAILABILITIES; + const newExceptions = [ + ...((availabilities?.exceptions as Exception[] | null) || []), + newException || [], + ]; const { error } = await supabase.from("availabilities").upsert( { availability_data: newAvailabilities, + exceptions: newExceptions, user_id: session?.user.id, }, { @@ -74,16 +104,17 @@ export function useAvailabilities() { useState(null); useEffect(() => { - if (availabilities) { - setDraftAvailabilities(availabilities); + if (availabilities?.availability_data) { + setDraftAvailabilities( + availabilities.availability_data as WeeklyAvailability + ); } - }, [availabilities]); + }, [availabilities?.availability_data]); return { isLoading, updateAvailabilities, draftAvailabilities: draftAvailabilities || DEFAULT_AVAILABILITIES, setDraftAvailabilities, - isUpdating, }; } diff --git a/ui/src/pages/availabilities.tsx b/ui/src/pages/availabilities.tsx index f910299..623b801 100644 --- a/ui/src/pages/availabilities.tsx +++ b/ui/src/pages/availabilities.tsx @@ -1,15 +1,22 @@ import { Strong, Text } from "@ui/ui-library/text"; import { AvailabilityCard } from "@ui/components/AvailabilityCard"; import { Button } from "@ui/ui-library/button"; -import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import { DEFAULT_AVAILABILITIES, + Exception, useAvailabilities, WeeklyAvailability, } from "@ui/hooks/availabilities"; import { toast } from "@ui/ui-library/toast/toast-queue"; import { useState } from "react"; import { Checkbox } from "@ui/ui-library/checkbox"; +import { PlusIcon } from "@ui/ui-library/icons"; +import { AvailabilityVisualization } from "@ui/components/AvailabilityVisualization"; +import { SaveIcon } from "lucide-react"; +import { DatePicker, DatePickerInput } from "@ui/ui-library/date-picker"; +import { Label } from "@ui/ui-library/field"; +import { DateValue, getLocalTimeZone, today } from "@internationalized/date"; +import { RadioGroup, Radios, Radio } from "@ui/ui-library/radio-group"; // Custom Modal Component interface CustomModalProps { @@ -81,13 +88,12 @@ interface TimeRange { } export function AvailabilitiesPage() { - const { - updateAvailabilities, - isUpdating, - draftAvailabilities, - setDraftAvailabilities, - } = useAvailabilities(); + const { updateAvailabilities, draftAvailabilities, setDraftAvailabilities } = + useAvailabilities(); + const [activeTab, setActiveTab] = useState<"reglages" | "visualisation">( + "reglages" + ); const [copyModalOpen, setCopyModalOpen] = useState(false); const [sourceDayData, setSourceDayData] = useState<{ day: number; @@ -95,6 +101,10 @@ export function AvailabilitiesPage() { timeRanges: TimeRange[]; } | null>(null); const [selectedDays, setSelectedDays] = useState([]); + const [exceptionModalOpen, setExceptionModalOpen] = useState(false); + const [exceptionType, setExceptionType] = useState<"day" | "hours">("day"); + const [exceptionDate, setExceptionDate] = useState(null); + const [exceptionHours, setExceptionHours] = useState(null); const handleCopyToOtherDays = ( sourceDay: number, @@ -135,145 +145,195 @@ export function AvailabilitiesPage() {

Disponibilités

- Définissez vos horaires de disponibilité pour chaque jour de la - semaine + {activeTab === "reglages" + ? "Définissez vos horaires de disponibilité pour chaque jour de la semaine" + : "Visualisez votre planning hebdomadaire"}
-
- - -
+ {activeTab === "reglages" && ( +
+ + + + +
+ )} +
+ + {/* Tab Navigation */} +
+ +
-
-
-
- {DAYS_OF_WEEK.map((day) => ( -
-
- { - setDraftAvailabilities({ - ...draftAvailabilities, - [day]: { - ...draftAvailabilities[day], - enabled, - }, - }); - }} - timeRanges={draftAvailabilities[day].timeRanges} - onTimeRangesChange={(ranges) => { - setDraftAvailabilities({ - ...draftAvailabilities, - [day]: { - ...draftAvailabilities[day], - timeRanges: ranges, - }, - }); - }} - onCopyToOtherDays={handleCopyToOtherDays} - /> + {activeTab === "reglages" && ( +
+
+
+ {DAYS_OF_WEEK.map((day) => ( +
+
+ { + setDraftAvailabilities({ + ...draftAvailabilities, + [day]: { + ...draftAvailabilities[day], + enabled, + }, + }); + }} + timeRanges={draftAvailabilities[day].timeRanges} + onTimeRangesChange={(ranges) => { + setDraftAvailabilities({ + ...draftAvailabilities, + [day]: { + ...draftAvailabilities[day], + timeRanges: ranges, + }, + }); + }} + onCopyToOtherDays={handleCopyToOtherDays} + /> +
+ ))} +
+
+ +
+
+
+

Fuseau horaire

+ + Vos disponibilités sont affichées dans votre fuseau horaire + local. +
- ))} -
-
-
-
-
-

Fuseau horaire

- - Vos disponibilités sont affichées dans votre fuseau horaire - local. - -
+
+ Votre fuseau horaire + + {Intl.DateTimeFormat().resolvedOptions().timeZone} + + + {new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}{" "} + - Heure locale + +
-
- Votre fuseau horaire - - {Intl.DateTimeFormat().resolvedOptions().timeZone} - - - {new Date().toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })}{" "} - - Heure locale - -
- -
- Information - - Les créneaux horaires seront automatiquement convertis dans le - fuseau horaire de vos clients lorsqu'ils consulteront vos - disponibilités. - +
+ Information + + Les créneaux horaires seront automatiquement convertis dans + le fuseau horaire de vos clients lorsqu'ils + consulteront vos disponibilités. + +
-
-
+ )} -
- {isUpdating && } - + {activeTab === "visualisation" && ( + + )}
{/* Copy Modal */} @@ -326,6 +386,126 @@ export function AvailabilitiesPage() {
+ + {/* Exception Modal */} + setExceptionModalOpen(false)} + title="Ajouter une exception" + > +
+ + Définissez une exception pour une date spécifique qui remplacera vos + disponibilités habituelles. + + +
+ { + if (value === null) { + return; + } + setExceptionDate(value.toDate(getLocalTimeZone())); + }} + > + + + + +
+ { + setExceptionType(value as "day" | "hours"); + }} + > + + + Indisponible toute la journée + Horaires personnalisés + + +
+ + {/* Custom Time Ranges (shown when custom is selected) */} +
+ +
+ ) => { + setExceptionHours({ + start: value.target.value, + end: exceptionHours?.end || "17:00", + }); + }} + /> + à + ) => { + setExceptionHours({ + start: exceptionHours?.start || "09:00", + end: value.target.value, + }); + }} + /> +
+
+
+ +
+ + +
+
+
); } diff --git a/ui/src/types/database.types.ts b/ui/src/types/database.types.ts index e0b0a58..f10b08f 100644 --- a/ui/src/types/database.types.ts +++ b/ui/src/types/database.types.ts @@ -18,6 +18,7 @@ export type Database = { Row: { availability_data: Json created_at: string + exceptions: Json | null id: number updated_at: string user_id: string @@ -25,6 +26,7 @@ export type Database = { Insert: { availability_data?: Json created_at?: string + exceptions?: Json | null id?: number updated_at?: string user_id: string @@ -32,6 +34,7 @@ export type Database = { Update: { availability_data?: Json created_at?: string + exceptions?: Json | null id?: number updated_at?: string user_id?: string diff --git a/ui/src/ui-library/date-field.tsx b/ui/src/ui-library/date-field.tsx index 318597d..f2a6bc0 100644 --- a/ui/src/ui-library/date-field.tsx +++ b/ui/src/ui-library/date-field.tsx @@ -6,9 +6,9 @@ import { DateSegment, DateValue, composeRenderProps, -} from 'react-aria-components'; -import { inputField } from './utils'; -import { twMerge } from 'tailwind-merge'; +} from "react-aria-components"; +import { inputField } from "./utils"; +import { twMerge } from "tailwind-merge"; export interface DateFieldProps extends RACDateFieldProps {} @@ -24,37 +24,44 @@ export function DateField(props: DateFieldProps) { inputField, // RAC does not set disable to date field when it is disable // So we have to style disable state for none input - isDisabled && '[&>:not(input)]:opacity-50', - className, + isDisabled && "[&>:not(input)]:opacity-50", + className ); - }, + } )} /> ); } -export type DateInputProps = Omit; +export type DateInputProps = Omit; export function DateInput(props: DateInputProps) { return ( - twMerge( - 'group flex w-full items-center rounded-md border border-input bg-transparent', - - '[&:has([data-disabled=true])]:opacity-50', - '[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50', - 'dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/10', - 'block min-w-[150px]', - 'text-base/6 sm:text-sm/6', - 'px-3', - 'py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]', - renderProps.isInvalid && 'border-destructive', - renderProps.isFocusWithin && 'border-ring ring-1 ring-ring', - className, - ), + className={composeRenderProps( + props.className, + (className, { isInvalid, isFocusWithin, isHovered, isDisabled }) => + twMerge( + "group flex min-w-[150px] items-center", + "w-full rounded-md text-base/6 shadow-sm outline-none sm:text-sm/6 dark:shadow-none", + "px-2.5 py-2.5 sm:py-1.5", + "ring ring-zinc-950/10 dark:ring-white/10", + !isFocusWithin && + !isDisabled && + !isInvalid && + isHovered && [ + "[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-zinc-950/20", + "dark:[&:not(:has([data-ui=date-segment][aria-readonly]))]:ring-white/20", + ], + "[&:has([data-disabled=true])]:opacity-50", + "[&:has([data-ui=date-segment][aria-readonly])]:bg-zinc-50", + "dark:[&:has([data-ui=date-segment][aria-readonly])]:bg-white/5", + isInvalid && "ring-red-600 dark:ring-red-600", + isFocusWithin ? "ring-ring dark:ring-ring ring-2" : "", + className + ) )} > {(segment) => ( @@ -62,9 +69,9 @@ export function DateInput(props: DateInputProps) { data-ui="date-segment" segment={segment} className={twMerge( - 'inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0', - 'data-placeholder:italic data-placeholder:text-muted', - 'focus:bg-accent focus:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--color-accent)_calc((49.44_-_l)_*_infinity)_0_0)]', + "inline rounded-sm px-0.5 caret-transparent outline-0 data-[type=literal]:px-0", + "data-placeholder:text-muted data-placeholder:italic", + "focus:bg-accent focus:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)] focus:data-placeholder:text-[lch(from_var(--accent)_calc((49.44_-_l)_*_infinity)_0_0)]" )} /> )} diff --git a/ui/src/ui-library/date-picker.tsx b/ui/src/ui-library/date-picker.tsx index 1bc8e6e..c0ab080 100644 --- a/ui/src/ui-library/date-picker.tsx +++ b/ui/src/ui-library/date-picker.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; import { DatePicker as RACDatePicker, DatePickerProps as RACDatePickerProps, @@ -7,15 +7,15 @@ import { useLocale, Group, composeRenderProps, -} from 'react-aria-components'; -import { Button } from './button'; -import { Calendar, YearRange } from './calendar'; -import { DateInput, DateInputProps } from './date-field'; -import { Dialog } from './dialog'; -import { Popover } from './popover'; -import { inputField } from './utils'; -import { twMerge } from 'tailwind-merge'; -import { CalendarIcon } from './icons'; +} from "react-aria-components"; +import { Button } from "./button"; +import { Calendar, YearRange } from "./calendar"; +import { DateInput, DateInputProps } from "./date-field"; +import { Dialog } from "./dialog"; +import { Popover } from "./popover"; +import { inputField } from "./utils"; +import { twMerge } from "tailwind-merge"; +import { CalendarIcon } from "./icons/outline/calendar"; export interface DatePickerProps extends RACDatePickerProps {} @@ -41,22 +41,21 @@ export function DatePickerInput({ data-ui="control" {...props} className={[ - 'group', - 'grid w-auto min-w-52', - 'grid-cols-[1fr_calc(theme(size.5)+20px)]', - 'sm:grid-cols-[1fr_calc(theme(size.4)+20px)]', - ].join(' ')} + "group", + "grid w-auto min-w-52", + "grid-cols-[1fr_auto]", + ].join(" ")} > twMerge( - 'col-span-full', - 'row-start-1', - 'sm:pe-9', - 'pe-10', - className, - ), + "col-span-full", + "row-start-1", + "sm:pe-8", + "pe-9", + className + ) )} /> - + @@ -101,12 +102,12 @@ export function DatePickerButton({