diff --git a/components/DailyViewModal.tsx b/components/DailyViewModal.tsx index 6465f49..bfd2d18 100644 --- a/components/DailyViewModal.tsx +++ b/components/DailyViewModal.tsx @@ -11,6 +11,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { getCategoryBgColor } from '@/lib/formOptions'; +import { toSGT } from '@/lib/utils/client/time'; import type { EventView } from '@/lib/utils/server/event'; interface DailyViewModalProps { @@ -36,7 +37,7 @@ export default function DailyViewModal({ > - {format(date, 'd MMMM').toUpperCase()} EVENTS + {format(toSGT(date), 'd MMMM').toUpperCase()} EVENTS {/* Events list */} @@ -65,7 +66,8 @@ export default function DailyViewModal({
- {format(event.start, 'p')} - {format(event.end, 'p')} + {format(toSGT(event.start), 'p')} -{' '} + {format(toSGT(event.end), 'p')}
diff --git a/components/EventModal.tsx b/components/EventModal.tsx index 2496f2f..d92117a 100644 --- a/components/EventModal.tsx +++ b/components/EventModal.tsx @@ -49,17 +49,9 @@ import { SelectValue, } from '@/components/ui/select'; import { NewEventSchema } from '@/lib/schema/event'; -import { dateTimeFormatter } from '@/lib/utils/client/time'; +import { dateTimeFormatter, formatTime, toSGT } from '@/lib/utils/client/time'; import type { EventView } from '@/lib/utils/server/event'; -const formatTime = (d: Date) => - d.toLocaleTimeString('en-SG', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: 'Asia/Singapore', - }); - interface EventModalProps { form: UseFormReturn>; selectedEvent: EventView | null; @@ -195,12 +187,13 @@ export default function EventModal({ selected={field.value} onSelect={(selectedDate) => { if (selectedDate) { - field.value.setFullYear( + const updated = toSGT(field.value); + updated.setFullYear( selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), ); - field.onChange(field.value); + field.onChange(new Date(updated)); } setSelectStartDayOpen(false); }} @@ -213,11 +206,15 @@ export default function EventModal({ type='time' value={formatTime(field.value)} onChange={(e) => { + if (!e.target.value) return; const [hours, minutes] = e.target.value .split(':') .map(Number); - field.value.setHours(hours, minutes); - field.onChange(field.value); + if (Number.isNaN(hours) || Number.isNaN(minutes)) + return; + const updated = toSGT(field.value); + updated.setHours(hours, minutes, 0, 0); + field.onChange(new Date(updated)); }} className='bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none' step={1800} @@ -254,12 +251,13 @@ export default function EventModal({ selected={field.value} onSelect={(selectedDate) => { if (selectedDate) { - field.value.setFullYear( + const updated = toSGT(field.value); + updated.setFullYear( selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), ); - field.onChange(field.value); + field.onChange(new Date(updated)); setSelectEndDayOpen(false); } }} @@ -272,11 +270,15 @@ export default function EventModal({ type='time' value={formatTime(field.value)} onChange={(e) => { + if (!e.target.value) return; const [hours, minutes] = e.target.value .split(':') .map(Number); - field.value.setHours(hours, minutes); - field.onChange(field.value); + if (Number.isNaN(hours) || Number.isNaN(minutes)) + return; + const updated = toSGT(field.value); + updated.setHours(hours, minutes, 0, 0); + field.onChange(new Date(updated)); }} className='bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none' step={1800} diff --git a/components/booking/BookingCard.tsx b/components/booking/BookingCard.tsx index e93b7dd..869b39f 100644 --- a/components/booking/BookingCard.tsx +++ b/components/booking/BookingCard.tsx @@ -1,6 +1,7 @@ import { format } from 'date-fns'; import { CalendarIcon, ClockIcon, MapPinIcon, UserIcon } from 'lucide-react'; +import { toSGT } from '@/lib/utils/client/time'; import type { BookingView } from '@/lib/utils/server/booking'; interface BookingCardProps { @@ -30,10 +31,10 @@ const BookingCard: React.FC = ({ booking }) => {
{/* TODO: Align both start and end timings vertically */}
- Start: {format(booking.start, 'd MMMM p')} + Start: {format(toSGT(booking.start), 'd MMMM p')}
- End: {format(booking.end, 'd MMMM p')} + End: {format(toSGT(booking.end), 'd MMMM p')}
diff --git a/components/booking/BookingModal.tsx b/components/booking/BookingModal.tsx index 37ab347..1ffcbcf 100644 --- a/components/booking/BookingModal.tsx +++ b/components/booking/BookingModal.tsx @@ -52,18 +52,10 @@ import { SelectValue, } from '@/components/ui/select'; import { NewBookingClientSchema } from '@/lib/schema/booking'; -import { dateTimeFormatter } from '@/lib/utils/client/time'; +import { dateTimeFormatter, formatTime, toSGT } from '@/lib/utils/client/time'; import type { BookingView } from '@/lib/utils/server/booking'; import type { VenueView } from '@/lib/utils/server/venue'; -const formatTime = (d: Date) => - d.toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: 'Asia/Singapore', - }); - interface BookingModalProps { form: UseFormReturn>; selectedBooking: BookingView | null; @@ -156,7 +148,7 @@ export default function BookingModal({ } > - + @@ -193,7 +185,7 @@ export default function BookingModal({ } > - + @@ -242,12 +234,13 @@ export default function BookingModal({ selected={field.value} onSelect={(selectedDate) => { if (selectedDate) { - field.value.setFullYear( + const updated = toSGT(field.value); + updated.setFullYear( selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), ); - field.onChange(field.value); + field.onChange(new Date(updated)); } setSelectStartDayOpen(false); }} @@ -260,11 +253,15 @@ export default function BookingModal({ type='time' value={formatTime(field.value)} onChange={(e) => { + if (!e.target.value) return; const [hours, minutes] = e.target.value .split(':') .map(Number); - field.value.setHours(hours, minutes); - field.onChange(field.value); + if (Number.isNaN(hours) || Number.isNaN(minutes)) + return; + const updated = toSGT(field.value); + updated.setHours(hours, minutes, 0, 0); + field.onChange(new Date(updated)); }} className='bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none' step={1800} @@ -301,12 +298,13 @@ export default function BookingModal({ selected={field.value} onSelect={(selectedDate) => { if (selectedDate) { - field.value.setFullYear( + const updated = toSGT(field.value); + updated.setFullYear( selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), ); - field.onChange(field.value); + field.onChange(new Date(updated)); setSelectEndDayOpen(false); } }} @@ -319,11 +317,15 @@ export default function BookingModal({ type='time' value={formatTime(field.value)} onChange={(e) => { + if (!e.target.value) return; const [hours, minutes] = e.target.value .split(':') .map(Number); - field.value.setHours(hours, minutes); - field.onChange(field.value); + if (Number.isNaN(hours) || Number.isNaN(minutes)) + return; + const updated = toSGT(field.value); + updated.setHours(hours, minutes, 0, 0); + field.onChange(new Date(updated)); }} className='bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none' step={1800} diff --git a/components/booking/Bookings.tsx b/components/booking/Bookings.tsx index 86bcdae..5545b29 100644 --- a/components/booking/Bookings.tsx +++ b/components/booking/Bookings.tsx @@ -20,6 +20,7 @@ import { z } from 'zod/v4'; import BookingModal from '@/components/booking/BookingModal'; import { Calendar } from '@/components/ui/calendar'; +import { MobileWeekStrip } from '@/components/ui/mobile-calendar'; import { Spinner } from '@/components/ui/spinner'; import { createBooking, @@ -197,6 +198,12 @@ export default function Bookings({ handle(formData); }; + const handleDateChange = (newDate: Date) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('date', newDate.toDateString()); + window.history.pushState(null, '', `?${params.toString()}`); + }; + const handleDeleteBooking = (bookingId: number) => { if (!isAuthenticated) { toast.error('Please login to delete bookings!'); @@ -253,16 +260,16 @@ export default function Bookings({ )} {/* Calendar - Hidden on mobile */} - {/* TODO: How do mobile people select dates? */} + + + {/* Desktop sidebar calendar - hidden on mobile */}
{ if (newDate) { - const params = new URLSearchParams(searchParams.toString()); - params.set('date', newDate.toDateString()); - window.history.pushState(null, '', `?${params.toString()}`); + handleDateChange(newDate); } }} className='sticky top-19 w-full rounded-md' diff --git a/components/event/Events.tsx b/components/event/Events.tsx index 3c34c22..6d39be9 100644 --- a/components/event/Events.tsx +++ b/components/event/Events.tsx @@ -17,7 +17,7 @@ import { } from 'date-fns'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; -import { useActionState, useMemo, useState } from 'react'; +import { useActionState, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod/v4'; @@ -37,7 +37,7 @@ import { EVENT_CATEGORIES } from '@/lib/formOptions'; import { useAuth } from '@/lib/hooks/useAuth'; import { NewEventSchema } from '@/lib/schema/event'; import { cn } from '@/lib/utils'; -import { getNext30Minutes } from '@/lib/utils/client/time'; +import { getNext30Minutes, SGT, toSGT } from '@/lib/utils/client/time'; import type { EventView } from '@/lib/utils/server/event'; import CalendarGrid from './CalendarGrid'; @@ -65,7 +65,7 @@ export default function Events({ events, userOrgs }: EventsProps) { const [viewMode, setViewMode] = useState<'MONTH' | 'WEEK'>('MONTH'); const currentDate = useMemo(() => { - let date = new Date(); + let date = toSGT(new Date()); try { let searchParamsDate: number | undefined = undefined; const searchParamsMonth = searchParams.get('month'); @@ -75,7 +75,7 @@ export default function Events({ events, userOrgs }: EventsProps) { if (searchParamsWeek) searchParamsDate = Date.parse(searchParamsWeek); if (searchParamsDate !== undefined && !isNaN(searchParamsDate)) - date = new Date(searchParamsDate); + date = toSGT(new Date(searchParamsDate)); } catch {} return date; }, [searchParams]); @@ -232,6 +232,7 @@ export default function Events({ events, userOrgs }: EventsProps) { [viewMode.toLowerCase()]: newDate.toLocaleString('en-sg', { month: 'long', year: 'numeric', + timeZone: SGT, }), } : { date: newDate.toDateString() }, @@ -250,6 +251,7 @@ export default function Events({ events, userOrgs }: EventsProps) { [viewMode.toLowerCase()]: newDate.toLocaleString('en-sg', { month: 'long', year: 'numeric', + timeZone: SGT, }), } : { date: newDate.toDateString() }, @@ -274,6 +276,43 @@ export default function Events({ events, userOrgs }: EventsProps) { form.setValue('endTime', event.end); }; + const calendarRef = useRef(null); + const touchStartX = useRef(0); + const touchEndX = useRef(0); + + useEffect(() => { + const handleTouchStart = (e: TouchEvent) => { + touchStartX.current = e.changedTouches[0].screenX; + }; + + const handleTouchEnd = (e: TouchEvent) => { + touchEndX.current = e.changedTouches[0].screenX; + const deltaX = touchEndX.current - touchStartX.current; + + if (Math.abs(deltaX) > 50) { + // swipe threshold + if (deltaX > 0) { + handlePrevious(); // swipe right → go previous + } else { + handleNext(); // swipe left → go next + } + } + }; + + const calendarEl = calendarRef.current; + if (calendarEl) { + calendarEl.addEventListener('touchstart', handleTouchStart); + calendarEl.addEventListener('touchend', handleTouchEnd); + } + + return () => { + if (calendarEl) { + calendarEl.removeEventListener('touchstart', handleTouchStart); + calendarEl.removeEventListener('touchend', handleTouchEnd); + } + }; + }, [currentDate, viewMode]); + return (
{(createEventPending || editEventPending || deleteEventPending) && ( @@ -284,7 +323,6 @@ export default function Events({ events, userOrgs }: EventsProps) {
)} {/* Sidebar */} - {/* TODO: How do mobile people select dates? */}
{/* View Toggle */} {/* Main Content */} -
+
{/* Header */}
-
+
+
+ + + +
{/* Calendar View */} {viewMode === 'MONTH' ? ( diff --git a/components/event/MonthView.tsx b/components/event/MonthView.tsx index f3a46b2..e8df315 100644 --- a/components/event/MonthView.tsx +++ b/components/event/MonthView.tsx @@ -2,11 +2,12 @@ import { isSameDay } from 'date-fns'; import { Fragment } from 'react'; import { getCategoryBgColor } from '@/lib/formOptions'; +import { toSGT } from '@/lib/utils/client/time'; import type { EventView } from '@/lib/utils/server/event'; const DAY_OF_WEEKS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; -const today = new Date(); +const today = toSGT(new Date()); interface MonthViewProps { currentDate: Date; @@ -116,14 +117,14 @@ export default function MonthView({
- + {event.eventName}
))} {hasMoreEvents && ( + + {monthLabel} + + +
+ +
+ {getWeek(offset).map((day, i) => ( + + ))} +
+
+ ); +} diff --git a/lib/utils/client/index.ts b/lib/utils/client/index.ts index aac11cd..e1ed7b6 100644 --- a/lib/utils/client/index.ts +++ b/lib/utils/client/index.ts @@ -1,3 +1,5 @@ +import { toSGT } from '@/lib/utils/client/time'; + export function timeToIndex(time: string): number { // Parse time string like "8:00am", "2:30pm", etc. const match = time.match(/^(\d{1,2}):(\d{2}) (am|pm)$/i); @@ -18,9 +20,8 @@ export function timeToIndex(time: string): number { } export function dateToHalfHourIndex(date: Date): number { - const hour = date.getHours(); - const minute = date.getMinutes(); - return hour * 2 + (minute >= 30 ? 1 : 0); + const sgt = toSGT(date); + return sgt.getHours() * 2 + (sgt.getMinutes() >= 30 ? 1 : 0); } export function getTimeSpanInHalfHours(startDate: Date, endDate: Date): number { diff --git a/lib/utils/client/time.ts b/lib/utils/client/time.ts index dededd5..b53f99c 100644 --- a/lib/utils/client/time.ts +++ b/lib/utils/client/time.ts @@ -1,9 +1,26 @@ +import { TZDate } from '@date-fns/tz'; import { roundToNearestMinutes } from 'date-fns'; -export const dateTimeFormatter = Intl.DateTimeFormat('en-SG'); +export const SGT = 'Asia/Singapore'; + +/** Convert any Date to a timezone-aware SGT TZDate. */ +export const toSGT = (date: Date) => new TZDate(date, SGT); + +export const dateTimeFormatter = Intl.DateTimeFormat('en-SG', { + timeZone: SGT, +}); + +export const timeFormatter = Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: SGT, +}); + +export const formatTime = (d: Date) => timeFormatter.format(d); export const getNext30Minutes = () => { - return roundToNearestMinutes(new Date(), { + return roundToNearestMinutes(new TZDate(new Date(), SGT), { nearestTo: 30, roundingMethod: 'ceil', }); diff --git a/package.json b/package.json index 39d0a2c..4ed5aa6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@date-fns/tz": "^1.4.1", "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 318be77..e9d1d93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@date-fns/tz': + specifier: ^1.4.1 + version: 1.4.1 '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))