From 88e1c606cc0423d93a6f4683fa50ed11f11bc48e Mon Sep 17 00:00:00 2001 From: pengkiang Date: Thu, 2 Apr 2026 11:13:13 +0800 Subject: [PATCH 1/6] UI: Fix all mobile accessibility issues --- components/booking/Bookings.tsx | 15 ++- components/event/Events.tsx | 23 ++++ components/event/MonthView.tsx | 4 +- components/event/WeekView.tsx | 137 ++++++++++----------- components/student-group/IgModal.tsx | 4 +- components/student-group/StudentGroups.tsx | 44 ++++++- components/ui/calendar.tsx | 2 +- components/ui/checkbox.tsx | 2 +- components/ui/mobilecalendar.tsx | 135 ++++++++++++++++++++ 9 files changed, 283 insertions(+), 83 deletions(-) create mode 100644 components/ui/mobilecalendar.tsx diff --git a/components/booking/Bookings.tsx b/components/booking/Bookings.tsx index 86bcdae..b856e7d 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/mobilecalendar'; 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..b27ef79 100644 --- a/components/event/Events.tsx +++ b/components/event/Events.tsx @@ -477,6 +477,29 @@ export default function Events({ events, userOrgs }: EventsProps) {
+
+ + + +
{/* Calendar View */} {viewMode === 'MONTH' ? ( diff --git a/components/event/MonthView.tsx b/components/event/MonthView.tsx index f3a46b2..469ce34 100644 --- a/components/event/MonthView.tsx +++ b/components/event/MonthView.tsx @@ -116,14 +116,14 @@ export default function MonthView({
- + {event.eventName}
))} {hasMoreEvents && ( + + {monthLabel} + + + + +
+ {getWeek(offset).map((day, i) => ( + + ))} +
+ + ); +} From 96c9ec5155cd1e087f7550adee8a71388e8a3716 Mon Sep 17 00:00:00 2001 From: pengkiang Date: Thu, 2 Apr 2026 13:35:38 +0800 Subject: [PATCH 2/6] Add swiping to events calendar page --- components/event/Events.tsx | 44 +++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/components/event/Events.tsx b/components/event/Events.tsx index b27ef79..ed896ab 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'; @@ -274,6 +274,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 +321,6 @@ export default function Events({ events, userOrgs }: EventsProps) {
)} {/* Sidebar */} - {/* TODO: How do mobile people select dates? */}
{/* View Toggle */} {/* Main Content */} -
+
{/* Header */}
-
+
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/event/Events.tsx b/components/event/Events.tsx index ed896ab..6d39be9 100644 --- a/components/event/Events.tsx +++ b/components/event/Events.tsx @@ -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() }, @@ -334,6 +336,7 @@ export default function Events({ events, userOrgs }: EventsProps) { [val.toLowerCase()]: currentDate.toLocaleString('en-sg', { month: 'long', year: 'numeric', + timeZone: SGT, }), } : { date: currentDate.toDateString() }, diff --git a/components/event/MonthView.tsx b/components/event/MonthView.tsx index 469ce34..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; diff --git a/components/event/WeekView.tsx b/components/event/WeekView.tsx index 5472b36..8a6cbff 100644 --- a/components/event/WeekView.tsx +++ b/components/event/WeekView.tsx @@ -8,6 +8,7 @@ import { import { ClockIcon, MapPinIcon, UserIcon } from 'lucide-react'; import { getCategoryBgColor } from '@/lib/formOptions'; +import { toSGT } from '@/lib/utils/client/time'; import type { EventView } from '@/lib/utils/server/event'; interface WeekViewProps { @@ -17,7 +18,7 @@ interface WeekViewProps { handleEventClick: (event: EventView) => void; } -const today = new Date(); +const today = toSGT(new Date()); export default function WeekView({ currentDate, @@ -99,8 +100,8 @@ export default function WeekView({
- {format(event.start, 'p')} -{' '} - {format(event.end, 'p')} + {format(toSGT(event.start), 'p')} -{' '} + {format(toSGT(event.end), 'p')}
{event.booking && ( diff --git a/components/ui/mobile-calendar.tsx b/components/ui/mobile-calendar.tsx index 92a945d..c36cbef 100644 --- a/components/ui/mobile-calendar.tsx +++ b/components/ui/mobile-calendar.tsx @@ -12,6 +12,7 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; +import { SGT } from '@/lib/utils/client/time'; const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -44,6 +45,7 @@ export function MobileWeekStrip({ const monthLabel = mid.toLocaleString('default', { month: 'long', year: 'numeric', + timeZone: SGT, }); return ( 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 86ce1bd..b8df59f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format:check": "prettier --check ." }, "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)) From 9410f7e11d45310502a29e7c3a33f61e5cc8bbd8 Mon Sep 17 00:00:00 2001 From: pengkiang Date: Fri, 3 Apr 2026 17:20:34 +0800 Subject: [PATCH 6/6] debug: fix ig page bug --- components/student-group/StudentGroups.tsx | 38 ++++++++++++---------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/components/student-group/StudentGroups.tsx b/components/student-group/StudentGroups.tsx index 934546c..afd5c72 100644 --- a/components/student-group/StudentGroups.tsx +++ b/components/student-group/StudentGroups.tsx @@ -179,24 +179,26 @@ export default function StudentGroups({ orgs }: StudentGroupsProps) { />
- {EVENT_CATEGORIES.map((category) => ( -
- - handleCategoryChange(category.name, checked as boolean) - } - className={category.bgColor} - /> - -
- ))} +
+ {EVENT_CATEGORIES.map((category) => ( +
+ + handleCategoryChange(category.name, checked as boolean) + } + className={category.bgColor} + /> + +
+ ))} +