diff --git a/app/admin/publishNotice/PublishForm.tsx b/app/admin/publishNotice/PublishForm.tsx index 0de801e8..8d16c9f1 100644 --- a/app/admin/publishNotice/PublishForm.tsx +++ b/app/admin/publishNotice/PublishForm.tsx @@ -200,9 +200,22 @@ export default function NoticeboardForm() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (formData.eventTime && formData.eventEndTime) { + const start = new Date(formData.eventTime); + const end = new Date(formData.eventEndTime); + if (end < start) { + toast.error("Event end time cannot be before start time"); + return; + } + } + try { const payload = { - ...formData, + title: formData.title, + description: formData.description, + entity: formData.type, + location: formData.location, + body: formData.body, eventEndTime: formData.eventEndTime ? new Date(formData.eventEndTime).toISOString() : null, @@ -239,6 +252,7 @@ export default function NoticeboardForm() { function isoToDatetimeLocal(iso: string) { const date = new Date(iso); + if (isNaN(date.getTime()) || date.getFullYear() < 1970) return ""; const pad = (n: number) => String(n).padStart(2, "0"); @@ -296,9 +310,22 @@ export default function NoticeboardForm() { e.preventDefault(); if (!noticeId) return; + if (formData.eventTime && formData.eventEndTime) { + const start = new Date(formData.eventTime); + const end = new Date(formData.eventEndTime); + if (end < start) { + toast.error("Event end time cannot be before start time"); + return; + } + } + try { const payload = { - ...formData, + title: formData.title, + description: formData.description, + entity: formData.type, + location: formData.location, + body: formData.body, eventEndTime: formData.eventEndTime ? new Date(formData.eventEndTime).toISOString() : null, @@ -342,13 +369,13 @@ export default function NoticeboardForm() {
- {["title", "description"].map((field) => ( + {[{ name: "title", maxLength: 50 }, { name: "description", maxLength: undefined }].map(({ name: field, maxLength }) => (
diff --git a/app/components/location/EditLocationModal.tsx b/app/components/location/EditLocationModal.tsx index b999a2f9..c95fda52 100644 --- a/app/components/location/EditLocationModal.tsx +++ b/app/components/location/EditLocationModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useHistoryBack } from "@/app/hooks/use-history-back"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -71,6 +72,9 @@ export function EditLocationModal({ const [isSubmitting, setIsSubmitting] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); + // Close modal on phone back button instead of navigating away + useHistoryBack(isOpen, () => setIsOpen(false)); + const [formData, setFormData] = useState({ name: "", description: "", diff --git a/app/components/location/ReviewDrawer.tsx b/app/components/location/ReviewDrawer.tsx index 2fa934cf..d3feeff4 100644 --- a/app/components/location/ReviewDrawer.tsx +++ b/app/components/location/ReviewDrawer.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useHistoryBack } from "@/app/hooks/use-history-back"; import { Button } from "@/components/ui/button"; import { Drawer, @@ -43,6 +44,9 @@ export function ReviewDrawer({ const [isSubmitting, setIsSubmitting] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); + // Close drawer on phone back button instead of navigating away + useHistoryBack(isOpen, () => setIsOpen(false)); + const handleSubmit = async () => { if (!rating) { toast.error("Please select a rating."); diff --git a/app/hooks/use-history-back.ts b/app/hooks/use-history-back.ts new file mode 100644 index 00000000..faed32de --- /dev/null +++ b/app/hooks/use-history-back.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; + +/** + * Intercepts the browser/phone back button to close a drawer/dialog + * instead of navigating away. Essential for PWA UX on mobile. + * + * When `isOpen` becomes true, a dummy history entry is pushed. + * Pressing back pops that entry and calls `onClose` instead of navigating. + */ +export function useHistoryBack(isOpen: boolean, onClose: () => void) { + // Store onClose in a ref so the effect doesn't re-run when the + // parent passes an unstable (inline) callback reference. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const didPushRef = useRef(false); + + useEffect(() => { + if (isOpen) { + // Push a dummy state so that "back" stays on the same page + window.history.pushState({ drawerOpen: true }, ""); + didPushRef.current = true; + + const handlePopState = () => { + // Back was pressed — close the drawer instead of navigating + didPushRef.current = false; + onCloseRef.current(); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + // If the drawer was closed programmatically (not via back button), + // clean up the dummy history entry we pushed + if (didPushRef.current) { + didPushRef.current = false; + window.history.back(); + } + }; + } + }, [isOpen]); // Only re-run when open state actually changes +} diff --git a/calendar/components/dialogs/add-event-dialog.tsx b/calendar/components/dialogs/add-event-dialog.tsx index ca0acc78..127397c2 100644 --- a/calendar/components/dialogs/add-event-dialog.tsx +++ b/calendar/components/dialogs/add-event-dialog.tsx @@ -108,7 +108,7 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { Title - + @@ -126,7 +126,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="Select a date" data-invalid={fieldState.invalid} /> @@ -143,7 +146,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { Start Time - + { + field.onChange(time); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} hourCycle={12} data-invalid={fieldState.invalid} /> @@ -161,7 +167,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="Select a date" data-invalid={fieldState.invalid} /> @@ -178,7 +187,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { End Time - + { + field.onChange(time); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} hourCycle={12} data-invalid={fieldState.invalid} /> @@ -256,7 +268,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="No end date (forever)" data-invalid={fieldState.invalid} /> diff --git a/calendar/components/dialogs/edit-event-dialog.tsx b/calendar/components/dialogs/edit-event-dialog.tsx index e46f818c..775009f9 100644 --- a/calendar/components/dialogs/edit-event-dialog.tsx +++ b/calendar/components/dialogs/edit-event-dialog.tsx @@ -288,7 +288,7 @@ export function EditEventDialog({ children, event }: IProps) { Title - + @@ -306,7 +306,10 @@ export function EditEventDialog({ children, event }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="Select a date" data-invalid={fieldState.invalid} /> @@ -323,7 +326,10 @@ export function EditEventDialog({ children, event }: IProps) { Start Time - + { + field.onChange(time); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} hourCycle={12} data-invalid={fieldState.invalid} /> @@ -341,7 +347,10 @@ export function EditEventDialog({ children, event }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="Select a date" data-invalid={fieldState.invalid} /> @@ -358,7 +367,10 @@ export function EditEventDialog({ children, event }: IProps) { End Time - + { + field.onChange(time); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} hourCycle={12} data-invalid={fieldState.invalid} /> @@ -438,7 +450,10 @@ export function EditEventDialog({ children, event }: IProps) { field.onChange(date as Date)} + onSelect={date => { + field.onChange(date as Date); + form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]); + }} placeholder="No end date (forever)" data-invalid={fieldState.invalid} /> diff --git a/calendar/requests.ts b/calendar/requests.ts index 47424d29..dcd51001 100644 --- a/calendar/requests.ts +++ b/calendar/requests.ts @@ -47,20 +47,17 @@ function noticeToEvent(notice: NoticeFromAPI, index: number): IEvent | null { //const start = new Date(notice.eventTime); //const end = new Date(notice.eventEndTime); - const cleanStartTime = notice.eventTime.replace(/(Z|[+-]\d{2}:?\d{2})$/, ''); - const cleanEndTime = notice.eventEndTime.replace(/(Z|[+-]\d{2}:?\d{2})$/, ''); - - const start = new Date(cleanStartTime); - const end = new Date(cleanEndTime); + const start = new Date(notice.eventTime); + const end = new Date(notice.eventEndTime || notice.eventTime); // Skip notices with invalid dates - if (isNaN(start.getTime())) { + if (isNaN(start.getTime()) || start.getFullYear() < 1970) { console.warn("Invalid date in notice:", notice.title, notice.eventTime); return null; } // If end date is invalid, default to start date + 1 hour - const validEnd = isNaN(end.getTime()) + const validEnd = isNaN(end.getTime()) || end.getFullYear() < 1970 ? new Date(start.getTime() + 60 * 60 * 1000) : end; diff --git a/calendar/schemas.ts b/calendar/schemas.ts index 6c6d7dc9..69ee119f 100644 --- a/calendar/schemas.ts +++ b/calendar/schemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const eventSchema = z.object({ entity: z.string().optional(), // Entity organizing the event - title: z.string().min(1, "Title is required"), + title: z.string().min(1, "Title is required").max(50, "Title must be 50 characters or less"), description: z.string().min(1, "Description is required"), startDate: z.date({ error: "Start date is required" }), startTime: z.object( diff --git a/calendar/schemas.user-event.ts b/calendar/schemas.user-event.ts index 552bebcd..ca7cfbef 100644 --- a/calendar/schemas.user-event.ts +++ b/calendar/schemas.user-event.ts @@ -6,7 +6,7 @@ import { z } from "zod"; * Kept separate so the original eventSchema is never modified. */ export const userEventSchema = z.object({ - title: z.string().min(1, "Title is required"), + title: z.string().min(1, "Title is required").max(50, "Title must be 50 characters or less"), description: z.string().default(""), startDate: z.date({ error: "Start date is required" }), startTime: z.object( diff --git a/components/AddLocationDrawer.tsx b/components/AddLocationDrawer.tsx index befcc2c6..140fdfec 100644 --- a/components/AddLocationDrawer.tsx +++ b/components/AddLocationDrawer.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useState } from "react"; +import { useHistoryBack } from "@/app/hooks/use-history-back"; + import { Drawer, DrawerContent, @@ -61,6 +63,9 @@ export default function AddLocationDrawer({ const [isSubmitting, setIsSubmitting] = useState(false); const isDesktop = useMediaQuery("(min-width: 768px)"); + // Close drawer on phone back button instead of navigating away + useHistoryBack(open, () => onOpenChange(false)); + // Auto-fill lat/lon + trigger open useEffect(() => { const lat = localStorage.getItem("selected_lat"); diff --git a/server/maps/handler.adminActions.go b/server/maps/handler.adminActions.go index c7e84715..9d3c1671 100644 --- a/server/maps/handler.adminActions.go +++ b/server/maps/handler.adminActions.go @@ -182,6 +182,11 @@ func addNotice(c *gin.Context) { return } + if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"}) + return + } + userID, exist := c.Get("userID") // means api requests must be authenticated if !exist { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) @@ -429,13 +434,18 @@ func editNotice(c *gin.Context) { return } - var input model.Notice + var input AddNoticeRequest if err := c.ShouldBindJSON(&input); err != nil { logrus.WithError(err).Warn("JSON binding failed") c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) return } + if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"}) + return + } + var notice model.Notice if err := connections.DB.Where("notice_id = ?", noticeID).First(¬ice).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/server/maps/handler.userEvents.go b/server/maps/handler.userEvents.go index 62cdd034..49921bb2 100644 --- a/server/maps/handler.userEvents.go +++ b/server/maps/handler.userEvents.go @@ -52,6 +52,11 @@ func createUserEvent(c *gin.Context) { return } + if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"}) + return + } + color := input.Color if color == "" { color = "blue" @@ -124,6 +129,11 @@ func updateUserEvent(c *gin.Context) { return } + if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"}) + return + } + event.Title = input.Title event.Description = input.Description event.EventTime = input.EventTime diff --git a/server/maps/request.model.go b/server/maps/request.model.go index 088a5051..981c617d 100644 --- a/server/maps/request.model.go +++ b/server/maps/request.model.go @@ -33,7 +33,7 @@ func (r AddLocationRequest) ToLocation(userID uuid.UUID) model.Location { } type AddNoticeRequest struct { - Title string `json:"title" binding:"required"` + Title string `json:"title" binding:"required,max=50"` Description string `json:"description" binding:"required"` Body string `json:"body"` CoverPic *uuid.UUID `json:"coverPic"` @@ -67,7 +67,7 @@ type FlagActionRequest struct { } type AddUserEventRequest struct { - Title string `json:"title" binding:"required"` + Title string `json:"title" binding:"required,max=50"` Description string `json:"description"` EventTime time.Time `json:"eventTime" binding:"required"` EventEndTime time.Time `json:"eventEndTime" binding:"required"`