From 055df2800fab1401c45d0305a1d691680458791d Mon Sep 17 00:00:00 2001 From: AgustinSV Date: Tue, 9 Jun 2026 15:07:18 -0400 Subject: [PATCH 1/5] feat: added recurring event functionality and corresponding new recurring form options --- drizzle/0031_concerned_firebrand.sql | 5 + drizzle/meta/0031_snapshot.json | 818 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + .../recurring-event/RecurringEventsClient.tsx | 82 +- .../RecurringEventsClient.tsx.bak | 547 ++++++++++++ .../create/RecurringEventCreationForm.tsx | 288 +++++- .../dashboard/recurring-event/page.tsx | 10 + src/app/api/cron/recurring-events/route.ts | 75 +- src/app/api/recurring-events/route.ts | 64 +- src/db/schema/recurring-events.ts | 15 + 10 files changed, 1876 insertions(+), 35 deletions(-) create mode 100644 drizzle/0031_concerned_firebrand.sql create mode 100644 drizzle/meta/0031_snapshot.json create mode 100644 src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx.bak diff --git a/drizzle/0031_concerned_firebrand.sql b/drizzle/0031_concerned_firebrand.sql new file mode 100644 index 0000000..e85e9d9 --- /dev/null +++ b/drizzle/0031_concerned_firebrand.sql @@ -0,0 +1,5 @@ +ALTER TABLE "recurring_events" ADD COLUMN "days_of_week" integer[];--> statement-breakpoint +ALTER TABLE "recurring_events" ADD COLUMN "weekdays_only" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "recurring_events" ADD COLUMN "monthly_type" varchar(32);--> statement-breakpoint +ALTER TABLE "recurring_events" ADD COLUMN "monthly_nth" integer;--> statement-breakpoint +ALTER TABLE "recurring_events" ADD COLUMN "monthly_weekday" integer; \ No newline at end of file diff --git a/drizzle/meta/0031_snapshot.json b/drizzle/meta/0031_snapshot.json new file mode 100644 index 0000000..4aab01a --- /dev/null +++ b/drizzle/meta/0031_snapshot.json @@ -0,0 +1,818 @@ +{ + "id": "29605750-6871-4fee-b5e1-7b1567593eab", + "prevId": "3fdc7c7b-6ef1-45c8-a64e-7f65d0c56fc3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_attendee": { + "name": "event_attendee", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attended": { + "name": "attended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "event_attendee_event_id_events_id_fk": { + "name": "event_attendee_event_id_events_id_fk", + "tableFrom": "event_attendee", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_attendee_user_id_user_id_fk": { + "name": "event_attendee_user_id_user_id_fk", + "tableFrom": "event_attendee", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_attendee_event_id_user_id_pk": { + "name": "event_attendee_event_id_user_id_pk", + "columns": [ + "event_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "registered_users": { + "name": "registered_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recurring_event_id": { + "name": "recurring_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "events_location_id_locations_id_fk": { + "name": "events_location_id_locations_id_fk", + "tableFrom": "events", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_recurring_event_id_recurring_events_id_fk": { + "name": "events_recurring_event_id_recurring_events_id_fk", + "tableFrom": "events", + "tableTo": "recurring_events", + "columnsFrom": [ + "recurring_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.locations": { + "name": "locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "street_line": { + "name": "street_line", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latitude": { + "name": "latitude", + "type": "numeric(9, 6)", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric(9, 6)", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recurring_events": { + "name": "recurring_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "days_of_week": { + "name": "days_of_week", + "type": "integer[]", + "primaryKey": false, + "notNull": false + }, + "weekdays_only": { + "name": "weekdays_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "monthly_type": { + "name": "monthly_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "monthly_nth": { + "name": "monthly_nth", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "monthly_weekday": { + "name": "monthly_weekday", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recurring_events_location_id_locations_id_fk": { + "name": "recurring_events_location_id_locations_id_fk", + "tableFrom": "recurring_events", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_info": { + "name": "user_info", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "address_line_1": { + "name": "address_line_1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line_2": { + "name": "address_line_2", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "birth_month": { + "name": "birth_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birth_day": { + "name": "birth_day", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "birth_year": { + "name": "birth_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "preferred_neighborhood": { + "name": "preferred_neighborhood", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "hours_volunteered": { + "name": "hours_volunteered", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "referral_source": { + "name": "referral_source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "medical_notes": { + "name": "medical_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_registration_reminder": { + "name": "email_registration_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_unregistration_reminder": { + "name": "email_unregistration_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_day_of_reminder": { + "name": "email_day_of_reminder", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_info_user_id_user_id_fk": { + "name": "user_info_user_id_user_id_fk", + "tableFrom": "user_info", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "info_filled": { + "name": "info_filled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "onboarded": { + "name": "onboarded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_location_id_locations_id_fk": { + "name": "user_location_id_locations_id_fk", + "tableFrom": "user", + "tableTo": "locations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3af7957..fbc6093 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1777478903612, "tag": "0030_cooing_human_torch", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1781031312651, + "tag": "0031_concerned_firebrand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx index 9714705..7bc2922 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx @@ -45,6 +45,11 @@ type RecurringEventRow = { id: string; title: string; frequency: string; + daysOfWeek: number[] | null; + weekdaysOnly: boolean; + monthlyType: string | null; + monthlyNth: number | null; + monthlyWeekday: number | null; startDate: string; endDate: string | null; active: boolean; @@ -57,13 +62,72 @@ type Props = { showLocationFilter?: boolean; }; -const FREQUENCY_LABELS: Record = { - daily: "Daily", - weekly: "Weekly", - biweekly: "Every 2 weeks", - monthly: "Monthly", +const SHORT_DAY = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; +const FULL_DAY = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; +const NTH_LABEL: Record = { + 1: "1st", + 2: "2nd", + 3: "3rd", + 4: "4th", + [-1]: "Last", }; +function ordinal(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]); +} + +function formatRecurrence(p: RecurringEventRow): string { + switch (p.frequency) { + case "daily": + return p.weekdaysOnly ? "Weekdays (Mon\u2013Fri)" : "Every day"; + + case "weekly": { + if (p.daysOfWeek && p.daysOfWeek.length > 0) { + const days = [...p.daysOfWeek] + .sort((a, b) => a - b) + .map((d) => SHORT_DAY[d]) + .join(", "); + return `Weekly on ${days}`; + } + return "Weekly"; + } + + case "biweekly": { + if (p.daysOfWeek && p.daysOfWeek.length > 0) { + return `Every other ${FULL_DAY[p.daysOfWeek[0]]}`; + } + return "Every 2 weeks"; + } + + case "monthly": { + if ( + p.monthlyType === "nth-weekday" && + p.monthlyNth !== null && + p.monthlyWeekday !== null + ) { + const nth = NTH_LABEL[p.monthlyNth] ?? String(p.monthlyNth); + return `Monthly \u2014 ${nth} ${FULL_DAY[p.monthlyWeekday]}`; + } + // day-of-month (derive from startDate) + const day = new Date(p.startDate + "T00:00:00Z").getUTCDate(); + return `Monthly on the ${ordinal(day)}`; + } + + default: + return p.frequency; + } +} + const headerCellSx = (accentColor: string): SxProps => ({ fontWeight: 700, fontSize: "0.7rem", @@ -349,8 +413,6 @@ export default function RecurringEventsClient({ return patterns.filter((p) => { if (!showStopped && !p.active) return false; if (location && p.locationName !== location) return false; - // Date range overlap: pattern overlaps [dateFrom, dateTo] if - // pattern.startDate <= dateTo AND (pattern.endDate is null OR pattern.endDate >= dateFrom) if (dateTo && p.startDate > dateTo) return false; if (dateFrom && p.endDate && p.endDate < dateFrom) return false; return true; @@ -466,17 +528,17 @@ export default function RecurringEventsClient({ - {FREQUENCY_LABELS[pattern.frequency] ?? pattern.frequency} + {formatRecurrence(pattern)} - {pattern.locationName ?? "—"} + {pattern.locationName ?? "\u2014"} - {formatDate(pattern.startDate)} —{" "} + {formatDate(pattern.startDate)} {"\u2014"}{" "} {pattern.endDate ? formatDate(pattern.endDate) : "No end"} diff --git a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx.bak b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx.bak new file mode 100644 index 0000000..0796a9f --- /dev/null +++ b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx.bak @@ -0,0 +1,547 @@ +"use client"; + +import AutoModeIcon from "@mui/icons-material/AutoMode"; +import BlockIcon from "@mui/icons-material/Block"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import { + alpha, + Badge, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + FormControlLabel, + IconButton, + InputLabel, + MenuItem, + Paper, + Popover, + Select, + Stack, + type SxProps, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import * as React from "react"; + +import { useSnackbar } from "@/providers/snackbar-provider"; + +type RecurringEventRow = { + id: string; + title: string; + frequency: string; + daysOfWeek: number[] | null; + weekdaysOnly: boolean; + monthlyType: string | null; + monthlyNth: number | null; + monthlyWeekday: number | null; + startDate: string; + endDate: string | null; + active: boolean; + locationName: string | null; +}; + +type Props = { + patterns: RecurringEventRow[]; + accentColor: string; + showLocationFilter?: boolean; +}; + +const SHORT_DAY = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; +const FULL_DAY = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; +const NTH_LABEL: Record = { + 1: "1st", + 2: "2nd", + 3: "3rd", + 4: "4th", + [-1]: "Last", +}; + +function ordinal(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]); +} + +function formatRecurrence(p: RecurringEventRow): string { + switch (p.frequency) { + case "daily": + return p.weekdaysOnly ? "Weekdays (Mon–Fri)" : "Every day"; + + case "weekly": { + if (p.daysOfWeek && p.daysOfWeek.length > 0) { + const days = [...p.daysOfWeek] + .sort((a, b) => a - b) + .map((d) => SHORT_DAY[d]) + .join(", "); + return `Weekly on ${days}`; + } + return "Weekly"; + } + + case "biweekly": { + if (p.daysOfWeek && p.daysOfWeek.length > 0) { + return `Every other ${FULL_DAY[p.daysOfWeek[0]]}`; + } + return "Every 2 weeks"; + } + + case "monthly": { + if ( + p.monthlyType === "nth-weekday" && + p.monthlyNth !== null && + p.monthlyWeekday !== null + ) { + const nth = NTH_LABEL[p.monthlyNth] ?? String(p.monthlyNth); + return `Monthly — ${nth} ${FULL_DAY[p.monthlyWeekday]}`; + } + // day-of-month (derive from startDate) + const day = new Date(p.startDate + "T00:00:00Z").getUTCDate(); + return `Monthly on the ${ordinal(day)}`; + } + + default: + return p.frequency; + } +} + +const headerCellSx = (accentColor: string): SxProps => ({ + fontWeight: 700, + fontSize: "0.7rem", + letterSpacing: 0.9, + textTransform: "uppercase" as const, + color: alpha(accentColor, 0.7), + bgcolor: alpha(accentColor, 0.04), + borderBottom: "1px solid", + borderBottomColor: alpha(accentColor, 0.12), + py: 1.5, + whiteSpace: "nowrap" as const, +}); + +function formatDate(dateStr: string): string { + const [year, month, day] = dateStr.split("-").map(Number); + return new Date(year, month - 1, day).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// ── Filter popover ──────────────────────────────────────────────────────────── + +type FilterPopoverProps = { + accentColor: string; + locationOptions: string[]; + location: string; + setLocation: (v: string) => void; + dateFrom: string; + setDateFrom: (v: string) => void; + dateTo: string; + setDateTo: (v: string) => void; + showStopped: boolean; + setShowStopped: (v: boolean) => void; + activeFilterCount: number; + onClear: () => void; + showLocationFilter: boolean; +}; + +function FilterPopover({ + accentColor, + locationOptions, + location, + setLocation, + dateFrom, + setDateFrom, + dateTo, + setDateTo, + showStopped, + setShowStopped, + activeFilterCount, + onClear, + showLocationFilter, +}: FilterPopoverProps): React.ReactElement { + const [anchorEl, setAnchorEl] = React.useState( + null, + ); + + return ( + <> + + + + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + + + Filters + + + {showLocationFilter && ( + + + Location + + + + )} + + setDateFrom(e.target.value)} + InputLabelProps={{ shrink: true }} + inputProps={{ max: dateTo || undefined }} + /> + + setDateTo(e.target.value)} + InputLabelProps={{ shrink: true }} + inputProps={{ min: dateFrom || undefined }} + /> + + setShowStopped(e.target.checked)} + sx={{ + color: alpha(accentColor, 0.5), + "&.Mui-checked": { color: accentColor }, + }} + /> + } + label={ + Show stopped templates + } + /> + + {activeFilterCount > 0 && ( + + )} + + + + ); +} + +// ── Stop button ─────────────────────────────────────────────────────────────── + +function StopPatternButton({ + pattern, +}: { + pattern: RecurringEventRow; +}): React.ReactElement { + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const router = useRouter(); + const { showSnackbar } = useSnackbar(); + + async function handleConfirm(): Promise { + setLoading(true); + try { + const res = await fetch("/api/recurring-events", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: pattern.id }), + }); + + if (!res.ok) { + showSnackbar("Failed to stop recurring event.", "error"); + return; + } + + showSnackbar(`"${pattern.title}" stopped.`, "success"); + router.refresh(); + } finally { + setLoading(false); + setOpen(false); + } + } + + return ( + <> + + setOpen(true)} + sx={{ color: "text.secondary", "&:hover": { color: "error.main" } }} + > + + + + + setOpen(false)} + maxWidth="xs" + fullWidth + > + + Stop recurring event? + + + + {pattern.title} will stop generating new events. + All previously created events and their data will be kept. + + + + + + + + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export default function RecurringEventsClient({ + patterns, + accentColor, + showLocationFilter = true, +}: Props): React.ReactElement { + const [location, setLocation] = React.useState(""); + const [dateFrom, setDateFrom] = React.useState(""); + const [dateTo, setDateTo] = React.useState(""); + const [showStopped, setShowStopped] = React.useState(false); + + const locationOptions = React.useMemo(() => { + const seen = new Set(); + const result: string[] = []; + for (const p of patterns) { + if (p.locationName && !seen.has(p.locationName)) { + seen.add(p.locationName); + result.push(p.locationName); + } + } + return result.sort(); + }, [patterns]); + + const filtered = React.useMemo(() => { + return patterns.filter((p) => { + if (!showStopped && !p.active) return false; + if (location && p.locationName !== location) return false; + // Date range overlap: pattern overlaps [dateFrom, dateTo] if + // pattern.startDate <= dateTo AND (pattern.endDate is null OR pattern.endDate >= dateFrom) + if (dateTo && p.startDate > dateTo) return false; + if (dateFrom && p.endDate && p.endDate < dateFrom) return false; + return true; + }); + }, [patterns, showStopped, location, dateFrom, dateTo]); + + const activeFilterCount = [ + showLocationFilter ? location : "", + dateFrom || dateTo, + ].filter(Boolean).length; + + function handleClear(): void { + setLocation(""); + setDateFrom(""); + setDateTo(""); + } + + return ( + <> + + + Recurring Templates + + + Patterns that automatically generate events each day they occur. + + + + + + + + + + + + + + {["Title", "Frequency", "Location", "Dates", "Status"].map( + (heading) => ( + + {heading} + + ), + )} + + + + + {filtered.length > 0 ? ( + filtered.map((pattern) => ( + + + + {pattern.title} + + + + + {formatRecurrence(pattern)} + + + + + {pattern.locationName ?? "—"} + + + + + {formatDate(pattern.startDate)} —{" "} + {pattern.endDate ? formatDate(pattern.endDate) : "No end"} + + + \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx index 410fcb3..eb6d04f 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx @@ -8,8 +8,13 @@ import { FormHelperText, InputLabel, MenuItem, + Radio, + RadioGroup, Select, TextField, + ToggleButton, + ToggleButtonGroup, + Typography, } from "@mui/material"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; @@ -21,6 +26,49 @@ import * as React from "react"; import FormLayout from "@/components/layout/FormLayout"; import { useSnackbar } from "@/providers/snackbar-provider"; +// ── Constants ───────────────────────────────────────────────────────────────── + +const DAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; +const DAY_FULL = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +const NTH_OPTIONS = [ + { value: 1, label: "1st" }, + { value: 2, label: "2nd" }, + { value: 3, label: "3rd" }, + { value: 4, label: "4th" }, + { value: -1, label: "Last" }, +]; + +const FREQUENCY_OPTIONS = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "biweekly", label: "Every 2 weeks" }, + { value: "monthly", label: "Monthly" }, +]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ordinal(n: number): string { + const s = ["th", "st", "nd", "rd"]; + const v = n % 100; + return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]); +} + +function dayOfMonthFromDate(dateStr: string): number | null { + if (!dateStr) return null; + return new Date(dateStr + "T00:00:00Z").getUTCDate(); +} + +// ── Types ───────────────────────────────────────────────────────────────────── + type Location = { id: string; name: string; @@ -30,7 +78,7 @@ type Location = { postalCode: string; }; -type RecurringEventFormState = { +type FormState = { title: string; frequency: string; startDate: string; @@ -43,28 +91,24 @@ type RecurringEventFormState = { }; type FormErrors = Partial< - Record + Record >; -const FREQUENCY_OPTIONS = [ - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "biweekly", label: "Every 2 weeks" }, - { value: "monthly", label: "Monthly" }, -]; - type Props = { managerLocationId: string | null; managerLocationName: string | null; }; +// ── Component ───────────────────────────────────────────────────────────────── + export default function RecurringEventCreationForm({ managerLocationId, managerLocationName, }: Props): React.ReactElement { const isManager = managerLocationId !== null; - const [form, setForm] = React.useState({ + // ── Core form state ───────────────────────────────────────────────────────── + const [form, setForm] = React.useState({ title: "", frequency: "", startDate: "", @@ -80,9 +124,31 @@ export default function RecurringEventCreationForm({ const [locationOptions, setLocationOptions] = React.useState([]); const [errors, setErrors] = React.useState({}); + // ── Recurrence sub-state ──────────────────────────────────────────────────── + // weekly: which days (multi-select) + const [weeklyDays, setWeeklyDays] = React.useState([]); + // biweekly: which single day + const [biweeklyDay, setBiweeklyDay] = React.useState(1); // Mon default + // daily: weekdays only? + const [weekdaysOnly, setWeekdaysOnly] = React.useState(false); + // monthly: 'day-of-month' or 'nth-weekday' + const [monthlyType, setMonthlyType] = React.useState< + "day-of-month" | "nth-weekday" + >("day-of-month"); + const [monthlyNth, setMonthlyNth] = React.useState(1); + const [monthlyWeekday, setMonthlyWeekday] = React.useState(1); // Mon + const router = useRouter(); const { showSnackbar } = useSnackbar(); + // When switching to biweekly (or while on it and changing startDate), + // default the selected day to whatever day the start date falls on. + React.useEffect(() => { + if (form.frequency === "biweekly" && form.startDate) { + setBiweeklyDay(new Date(form.startDate + "T00:00:00Z").getUTCDay()); + } + }, [form.frequency, form.startDate]); + React.useEffect(() => { if (!isManager) { void fetch("/api/locations") @@ -91,6 +157,7 @@ export default function RecurringEventCreationForm({ } }, [isManager]); + // ── Handlers ───────────────────────────────────────────────────────────────── function handleChange( e: React.ChangeEvent, ): void { @@ -118,11 +185,19 @@ export default function RecurringEventCreationForm({ if (!unlimitedCapacity && !form.capacity) newErrors.capacity = "Enter a capacity or check Unlimited capacity"; + if (form.frequency === "weekly" && weeklyDays.length === 0) + newErrors.daysOfWeek = "Select at least one day"; + if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } + // Build recurrence payload + let daysOfWeek: number[] | undefined; + if (form.frequency === "weekly") daysOfWeek = weeklyDays; + if (form.frequency === "biweekly") daysOfWeek = [biweeklyDay]; + const res = await fetch("/api/recurring-events", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -137,6 +212,18 @@ export default function RecurringEventCreationForm({ unlimitedCapacity, locationId: form.locationId, description: form.description, + daysOfWeek, + weekdaysOnly: form.frequency === "daily" ? weekdaysOnly : undefined, + monthlyType: + form.frequency === "monthly" ? monthlyType : undefined, + monthlyNth: + form.frequency === "monthly" && monthlyType === "nth-weekday" + ? monthlyNth + : undefined, + monthlyWeekday: + form.frequency === "monthly" && monthlyType === "nth-weekday" + ? monthlyWeekday + : undefined, }), }); @@ -152,6 +239,10 @@ export default function RecurringEventCreationForm({ router.push("/dashboard/recurring-event"); } + // ── Derived values ──────────────────────────────────────────────────────────── + const monthlyDayOfMonth = dayOfMonthFromDate(form.startDate); + + // ── Render ──────────────────────────────────────────────────────────────────── return ( )} + {/* ── Frequency ── */} Frequency setMonthlyNth(Number(e.target.value))} + > + {NTH_OPTIONS.map((o) => ( + + {o.label} + + ))} + + + + + Weekday + + + + + {`The ${NTH_OPTIONS.find((o) => o.value === monthlyNth)?.label ?? ""} ${DAY_FULL[monthlyWeekday]} of every month`} + + + )} + + )} + + {/* ── Dates ── */} + {/* ── Times ── */} + {/* ── Capacity ── */} = 1 && d <= 5; + } return true; } case "weekly": { - return start.getUTCDay() === target.getUTCDay(); + // Support multiple days; fall back to start date's day for legacy records. + const days = + pattern.daysOfWeek && pattern.daysOfWeek.length > 0 + ? pattern.daysOfWeek + : [start.getUTCDay()]; + return days.includes(target.getUTCDay()); } case "biweekly": { - if (start.getUTCDay() !== target.getUTCDay()) return false; + const selectedDay = + pattern.daysOfWeek && pattern.daysOfWeek.length > 0 + ? pattern.daysOfWeek[0] + : start.getUTCDay(); + + if (target.getUTCDay() !== selectedDay) return false; + + // Anchor the "every other week" cadence on the first occurrence of + // selectedDay on or after startDate. + const daysUntilFirst = (selectedDay - start.getUTCDay() + 7) % 7; + const firstOccurrence = new Date(start); + firstOccurrence.setUTCDate(start.getUTCDate() + daysUntilFirst); + + if (target < firstOccurrence) return false; + const diffWeeks = Math.round( - (target.getTime() - start.getTime()) / (7 * 24 * 60 * 60 * 1000), + (target.getTime() - firstOccurrence.getTime()) / + (7 * 24 * 60 * 60 * 1000), ); return diffWeeks % 2 === 0; } case "monthly": { - return start.getUTCDate() === target.getUTCDate(); + if ( + pattern.monthlyType === "nth-weekday" && + pattern.monthlyNth !== null && + pattern.monthlyWeekday !== null + ) { + if (target.getUTCDay() !== pattern.monthlyWeekday) return false; + + if (pattern.monthlyNth === -1) { + // "Last" occurrence: the next 7 days spill into the following month. + const nextWeek = new Date(target); + nextWeek.setUTCDate(target.getUTCDate() + 7); + return nextWeek.getUTCMonth() !== target.getUTCMonth(); + } + + // nth occurrence within the month (1-4). + return Math.ceil(target.getUTCDate() / 7) === pattern.monthlyNth; + } + + // day-of-month: use startDate's day. Months that lack this day are + // naturally skipped because their dates never equal it (e.g. Feb has + // no 31st, so a pattern starting on the 31st is skipped in Feb). + return target.getUTCDate() === start.getUTCDate(); } default: { @@ -70,7 +121,7 @@ export async function POST(request: Request): Promise { let created = 0; for (const pattern of patterns) { - if (!occursOnDate(pattern.frequency, pattern.startDate, today)) continue; + if (!occursOnDate(pattern, today)) continue; const existing = await db .select({ id: events.id }) diff --git a/src/app/api/recurring-events/route.ts b/src/app/api/recurring-events/route.ts index 8855304..f0ad01e 100644 --- a/src/app/api/recurring-events/route.ts +++ b/src/app/api/recurring-events/route.ts @@ -5,6 +5,7 @@ import db from "@/db"; import { recurringEvents } from "@/db/schema/recurring-events"; const VALID_FREQUENCIES = ["daily", "weekly", "biweekly", "monthly"] as const; +const VALID_MONTHLY_TYPES = ["day-of-month", "nth-weekday"] as const; export async function POST(req: Request): Promise { try { @@ -21,8 +22,31 @@ export async function POST(req: Request): Promise { frequency, startDate, endDate, - } = body; + // recurrence options + daysOfWeek, + weekdaysOnly, + monthlyType, + monthlyNth, + monthlyWeekday, + } = body as { + title: string; + startTime: string; + endTime: string; + capacity: number | null; + unlimitedCapacity: boolean; + locationId: string; + description: string; + frequency: string; + startDate: string; + endDate?: string; + daysOfWeek?: number[]; + weekdaysOnly?: boolean; + monthlyType?: string; + monthlyNth?: number; + monthlyWeekday?: number; + }; + // ── Required field validation ──────────────────────────────────────────── if ( !title || !startTime || @@ -63,6 +87,39 @@ export async function POST(req: Request): Promise { ); } + // ── Recurrence-specific validation ─────────────────────────────────────── + if ( + (frequency === "weekly" || frequency === "biweekly") && + (!daysOfWeek || daysOfWeek.length === 0) + ) { + return NextResponse.json( + { error: "Select at least one day for weekly / biweekly events" }, + { status: 400 }, + ); + } + + if ( + frequency === "monthly" && + monthlyType === "nth-weekday" && + (monthlyNth === undefined || monthlyNth === null || monthlyWeekday === undefined || monthlyWeekday === null) + ) { + return NextResponse.json( + { error: "Monthly nth-weekday requires both nth and weekday" }, + { status: 400 }, + ); + } + + if ( + monthlyType !== undefined && + !VALID_MONTHLY_TYPES.includes(monthlyType as (typeof VALID_MONTHLY_TYPES)[number]) + ) { + return NextResponse.json( + { error: "Invalid monthlyType" }, + { status: 400 }, + ); + } + + // ── Persist ─────────────────────────────────────────────────────────────── await db.insert(recurringEvents).values({ title, startTime, @@ -73,6 +130,11 @@ export async function POST(req: Request): Promise { frequency, startDate, endDate: endDate || null, + daysOfWeek: daysOfWeek ?? null, + weekdaysOnly: weekdaysOnly ?? false, + monthlyType: monthlyType ?? null, + monthlyNth: monthlyNth ?? null, + monthlyWeekday: monthlyWeekday ?? null, }); return NextResponse.json({ ok: true }, { status: 201 }); diff --git a/src/db/schema/recurring-events.ts b/src/db/schema/recurring-events.ts index b17f295..76821c9 100644 --- a/src/db/schema/recurring-events.ts +++ b/src/db/schema/recurring-events.ts @@ -29,6 +29,21 @@ export const recurringEvents = pgTable("recurring_events", { // 'daily' | 'weekly' | 'biweekly' | 'monthly' frequency: varchar("frequency", { length: 32 }).notNull(), + // weekly / biweekly: day indices [0=Sun … 6=Sat]. null = derive from startDate (legacy). + daysOfWeek: integer("days_of_week").array(), + + // daily: if true, only Mon–Fri + weekdaysOnly: boolean("weekdays_only").default(false).notNull(), + + // monthly: 'day-of-month' | 'nth-weekday'. null = 'day-of-month' (legacy). + monthlyType: varchar("monthly_type", { length: 32 }), + + // monthly nth-weekday: which occurrence (1–4 or -1 for last) + monthlyNth: integer("monthly_nth"), + + // monthly nth-weekday: day of week (0=Sun … 6=Sat) + monthlyWeekday: integer("monthly_weekday"), + startDate: date("start_date", { mode: "string" }).notNull(), endDate: date("end_date", { mode: "string" }), From 54aa4a46bac8d6e6845139860bce75f5d9d70231 Mon Sep 17 00:00:00 2001 From: AgustinSV Date: Tue, 9 Jun 2026 15:34:08 -0400 Subject: [PATCH 2/5] fix: resovle build errors --- src/app/api/recurring-events/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/recurring-events/route.ts b/src/app/api/recurring-events/route.ts index f0ad01e..d7442ec 100644 --- a/src/app/api/recurring-events/route.ts +++ b/src/app/api/recurring-events/route.ts @@ -62,7 +62,7 @@ export async function POST(req: Request): Promise { ); } - if (!VALID_FREQUENCIES.includes(frequency)) { + if (!VALID_FREQUENCIES.includes(frequency as (typeof VALID_FREQUENCIES)[number])) { return NextResponse.json({ error: "Invalid frequency" }, { status: 400 }); } From ec89ab933de08e90597162d4dd0853c93d5757c5 Mon Sep 17 00:00:00 2001 From: AgustinSV Date: Tue, 9 Jun 2026 15:39:47 -0400 Subject: [PATCH 3/5] fix: resolved eslint errors --- .../recurring-event/RecurringEventsClient.tsx | 6 ++-- .../create/RecurringEventCreationForm.tsx | 35 +++++++++++++------ src/app/api/recurring-events/route.ts | 15 ++++++-- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx index 7bc2922..9cd396e 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx @@ -88,8 +88,9 @@ function ordinal(n: number): string { function formatRecurrence(p: RecurringEventRow): string { switch (p.frequency) { - case "daily": + case "daily": { return p.weekdaysOnly ? "Weekdays (Mon\u2013Fri)" : "Every day"; + } case "weekly": { if (p.daysOfWeek && p.daysOfWeek.length > 0) { @@ -123,8 +124,9 @@ function formatRecurrence(p: RecurringEventRow): string { return `Monthly on the ${ordinal(day)}`; } - default: + default: { return p.frequency; + } } } diff --git a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx index eb6d04f..556ed33 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx @@ -214,8 +214,7 @@ export default function RecurringEventCreationForm({ description: form.description, daysOfWeek, weekdaysOnly: form.frequency === "daily" ? weekdaysOnly : undefined, - monthlyType: - form.frequency === "monthly" ? monthlyType : undefined, + monthlyType: form.frequency === "monthly" ? monthlyType : undefined, monthlyNth: form.frequency === "monthly" && monthlyType === "nth-weekday" ? monthlyNth @@ -383,7 +382,12 @@ export default function RecurringEventCreationForm({ key={idx} value={idx} aria-label={DAY_FULL[idx]} - sx={{ width: 44, height: 44, p: 0, borderRadius: "50% !important" }} + sx={{ + width: 44, + height: 44, + p: 0, + borderRadius: "50% !important", + }} > {label} @@ -417,13 +421,22 @@ export default function RecurringEventCreationForm({ key={idx} value={idx} aria-label={DAY_FULL[idx]} - sx={{ width: 44, height: 44, p: 0, borderRadius: "50% !important" }} + sx={{ + width: 44, + height: 44, + p: 0, + borderRadius: "50% !important", + }} > {label} ))} - + The every-other-week cadence is anchored to your start date. @@ -449,10 +462,13 @@ export default function RecurringEventCreationForm({ color="text.secondary" sx={{ ml: 4, mt: -0.5, mb: 0.5, display: "block" }} > - Recurs on the{" "} - {ordinal(monthlyDayOfMonth)} of each month. + Recurs on the {ordinal(monthlyDayOfMonth)} of + each month. {monthlyDayOfMonth >= 29 && ( - <> Months without a {ordinal(monthlyDayOfMonth)} are skipped. + <> + {" "} + Months without a {ordinal(monthlyDayOfMonth)} are skipped. + )} )} @@ -534,8 +550,7 @@ export default function RecurringEventCreationForm({ InputLabelProps={{ shrink: true }} error={!!errors.endDate} helperText={ - errors.endDate ?? - "Events are created on the end date (inclusive)." + errors.endDate ?? "Events are created on the end date (inclusive)." } /> diff --git a/src/app/api/recurring-events/route.ts b/src/app/api/recurring-events/route.ts index d7442ec..e22532e 100644 --- a/src/app/api/recurring-events/route.ts +++ b/src/app/api/recurring-events/route.ts @@ -62,7 +62,11 @@ export async function POST(req: Request): Promise { ); } - if (!VALID_FREQUENCIES.includes(frequency as (typeof VALID_FREQUENCIES)[number])) { + if ( + !VALID_FREQUENCIES.includes( + frequency as (typeof VALID_FREQUENCIES)[number], + ) + ) { return NextResponse.json({ error: "Invalid frequency" }, { status: 400 }); } @@ -101,7 +105,10 @@ export async function POST(req: Request): Promise { if ( frequency === "monthly" && monthlyType === "nth-weekday" && - (monthlyNth === undefined || monthlyNth === null || monthlyWeekday === undefined || monthlyWeekday === null) + (monthlyNth === undefined || + monthlyNth === null || + monthlyWeekday === undefined || + monthlyWeekday === null) ) { return NextResponse.json( { error: "Monthly nth-weekday requires both nth and weekday" }, @@ -111,7 +118,9 @@ export async function POST(req: Request): Promise { if ( monthlyType !== undefined && - !VALID_MONTHLY_TYPES.includes(monthlyType as (typeof VALID_MONTHLY_TYPES)[number]) + !VALID_MONTHLY_TYPES.includes( + monthlyType as (typeof VALID_MONTHLY_TYPES)[number], + ) ) { return NextResponse.json( { error: "Invalid monthlyType" }, From 2150234af5ef33d6acc0e103989862857c5117ea Mon Sep 17 00:00:00 2001 From: AgustinSV Date: Tue, 9 Jun 2026 15:55:42 -0400 Subject: [PATCH 4/5] feat: recurring template form editing capabilities added --- .../recurring-event/RecurringEventsClient.tsx | 26 ++- .../recurring-event/[id]/edit/page.tsx | 71 ++++++++ .../create/RecurringEventCreationForm.tsx | 163 +++++++++++++++--- .../dashboard/recurring-event/page.tsx | 4 + src/app/api/recurring-events/route.ts | 48 ++++++ 5 files changed, 281 insertions(+), 31 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx diff --git a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx index 9cd396e..44a2ad5 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx @@ -2,6 +2,7 @@ import AutoModeIcon from "@mui/icons-material/AutoMode"; import BlockIcon from "@mui/icons-material/Block"; +import EditIcon from "@mui/icons-material/Edit"; import FilterListIcon from "@mui/icons-material/FilterList"; import { alpha, @@ -44,6 +45,8 @@ import { useSnackbar } from "@/providers/snackbar-provider"; type RecurringEventRow = { id: string; title: string; + description: string; + capacity: number | null; frequency: string; daysOfWeek: number[] | null; weekdaysOnly: boolean; @@ -507,7 +510,7 @@ export default function RecurringEventsClient({ ), )} @@ -553,9 +556,26 @@ export default function RecurringEventsClient({ /> - {pattern.active && } + + + + + + + {pattern.active && ( + + )} + )) diff --git a/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx b/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx new file mode 100644 index 0000000..d4539b9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx @@ -0,0 +1,71 @@ +import { eq } from "drizzle-orm"; +import { notFound, redirect } from "next/navigation"; + +import RecurringEventCreationForm from "@/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm"; +import PageContainer from "@/components/layout/PageContainer"; +import db from "@/db"; +import { locations } from "@/db/schema/locations"; +import { recurringEvents } from "@/db/schema/recurring-events"; +import { auth } from "@/lib/auth"; + +export default async function EditRecurringEventPage({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const session = await auth(); + const role = session?.user?.role; + + if (role !== "admin" && role !== "manager") { + redirect("/dashboard"); + } + + const { id } = await params; + + const [pattern] = await db + .select() + .from(recurringEvents) + .where(eq(recurringEvents.id, id)); + + if (!pattern) notFound(); + + let managerLocationId: string | null = null; + let managerLocationName: string | null = null; + + if (role === "manager") { + const locationId = session?.user?.locationId ?? null; + managerLocationId = locationId; + if (locationId) { + const loc = await db.query.locations.findFirst({ + where: eq(locations.id, locationId), + }); + managerLocationName = loc?.name ?? null; + } + } + + return ( + + + + ); +} diff --git a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx index 556ed33..46218aa 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/create/RecurringEventCreationForm.tsx @@ -1,5 +1,6 @@ "use client"; +import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; import { Box, Checkbox, @@ -94,9 +95,28 @@ type FormErrors = Partial< Record >; +type InitialValues = { + id: string; + title: string; + description: string; + capacity: number | null; + frequency: string; + startDate: string; + endDate: string | null; + startTime: string; + endTime: string; + locationId: string; + daysOfWeek: number[] | null; + weekdaysOnly: boolean; + monthlyType: string | null; + monthlyNth: number | null; + monthlyWeekday: number | null; +}; + type Props = { managerLocationId: string | null; managerLocationName: string | null; + initialValues?: InitialValues; }; // ── Component ───────────────────────────────────────────────────────────────── @@ -104,39 +124,57 @@ type Props = { export default function RecurringEventCreationForm({ managerLocationId, managerLocationName, + initialValues, }: Props): React.ReactElement { const isManager = managerLocationId !== null; + const isEditMode = initialValues !== undefined; // ── Core form state ───────────────────────────────────────────────────────── const [form, setForm] = React.useState({ - title: "", - frequency: "", - startDate: "", - endDate: "", - startTime: "", - endTime: "", - capacity: "", - locationId: managerLocationId ?? "", - description: "", + title: initialValues?.title ?? "", + frequency: initialValues?.frequency ?? "", + startDate: initialValues?.startDate ?? "", + endDate: initialValues?.endDate ?? "", + startTime: initialValues?.startTime ?? "", + endTime: initialValues?.endTime ?? "", + capacity: + initialValues?.capacity == null ? "" : String(initialValues.capacity), + locationId: initialValues?.locationId ?? managerLocationId ?? "", + description: initialValues?.description ?? "", }); - const [unlimitedCapacity, setUnlimitedCapacity] = React.useState(false); + const [unlimitedCapacity, setUnlimitedCapacity] = React.useState( + isEditMode ? initialValues!.capacity === null : false, + ); const [locationOptions, setLocationOptions] = React.useState([]); const [errors, setErrors] = React.useState({}); // ── Recurrence sub-state ──────────────────────────────────────────────────── // weekly: which days (multi-select) - const [weeklyDays, setWeeklyDays] = React.useState([]); + const [weeklyDays, setWeeklyDays] = React.useState( + initialValues?.daysOfWeek ?? [], + ); // biweekly: which single day - const [biweeklyDay, setBiweeklyDay] = React.useState(1); // Mon default + const [biweeklyDay, setBiweeklyDay] = React.useState( + initialValues?.daysOfWeek?.[0] ?? 1, + ); // daily: weekdays only? - const [weekdaysOnly, setWeekdaysOnly] = React.useState(false); + const [weekdaysOnly, setWeekdaysOnly] = React.useState( + initialValues?.weekdaysOnly ?? false, + ); // monthly: 'day-of-month' or 'nth-weekday' const [monthlyType, setMonthlyType] = React.useState< "day-of-month" | "nth-weekday" - >("day-of-month"); - const [monthlyNth, setMonthlyNth] = React.useState(1); - const [monthlyWeekday, setMonthlyWeekday] = React.useState(1); // Mon + >( + (initialValues?.monthlyType as "day-of-month" | "nth-weekday") ?? + "day-of-month", + ); + const [monthlyNth, setMonthlyNth] = React.useState( + initialValues?.monthlyNth ?? 1, + ); + const [monthlyWeekday, setMonthlyWeekday] = React.useState( + initialValues?.monthlyWeekday ?? 1, + ); const router = useRouter(); const { showSnackbar } = useSnackbar(); @@ -193,6 +231,33 @@ export default function RecurringEventCreationForm({ return; } + // ── Edit mode: only update mutable fields ────────────────────────────── + if (isEditMode) { + const res = await fetch("/api/recurring-events", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: initialValues!.id, + title: form.title, + description: form.description, + capacity: unlimitedCapacity ? null : Number(form.capacity), + endDate: form.endDate || null, + }), + }); + + if (!res.ok) { + showSnackbar( + "Failed to update recurring event. Please try again.", + "error", + ); + return; + } + + showSnackbar("Recurring event updated successfully!", "success"); + router.push("/dashboard/recurring-event"); + return; + } + // Build recurrence payload let daysOfWeek: number[] | undefined; if (form.frequency === "weekly") daysOfWeek = weeklyDays; @@ -244,9 +309,9 @@ export default function RecurringEventCreationForm({ // ── Render ──────────────────────────────────────────────────────────────────── return ( + {isEditMode && ( + + + + Frequency, schedule, and location cannot be changed after creation. + Changes here apply to future instances only. + + + )} + {isManager ? ( ) : ( - + Location setWeekdaysOnly(e.target.checked)} /> } @@ -369,7 +462,7 @@ export default function RecurringEventCreationForm({ { - if (newDays.length > 0) { + if (!isEditMode && newDays.length > 0) { setWeeklyDays(newDays); setErrors((prev) => ({ ...prev, daysOfWeek: undefined })); } @@ -411,7 +504,7 @@ export default function RecurringEventCreationForm({ exclusive value={biweeklyDay} onChange={(_, day: number | null) => { - if (day !== null) setBiweeklyDay(day); + if (!isEditMode && day !== null) setBiweeklyDay(day); }} aria-label="day of week" sx={{ flexWrap: "wrap", gap: 0.5 }} @@ -447,9 +540,12 @@ export default function RecurringEventCreationForm({ - setMonthlyType(e.target.value as "day-of-month" | "nth-weekday") - } + onChange={(e) => { + if (!isEditMode) + setMonthlyType( + e.target.value as "day-of-month" | "nth-weekday", + ); + }} > - + Occurrence { setForm((prev) => ({ diff --git a/src/app/(dashboard)/dashboard/recurring-event/page.tsx b/src/app/(dashboard)/dashboard/recurring-event/page.tsx index 5a606b9..25b09f4 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/page.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/page.tsx @@ -12,6 +12,8 @@ import RecurringEventsClient from "./RecurringEventsClient"; type RecurringEventRow = { id: string; title: string; + description: string; + capacity: number | null; frequency: string; daysOfWeek: number[] | null; weekdaysOnly: boolean; @@ -31,6 +33,8 @@ async function getRecurringEvents( .select({ id: recurringEvents.id, title: recurringEvents.title, + description: recurringEvents.description, + capacity: recurringEvents.capacity, frequency: recurringEvents.frequency, daysOfWeek: recurringEvents.daysOfWeek, weekdaysOnly: recurringEvents.weekdaysOnly, diff --git a/src/app/api/recurring-events/route.ts b/src/app/api/recurring-events/route.ts index e22532e..bc510b6 100644 --- a/src/app/api/recurring-events/route.ts +++ b/src/app/api/recurring-events/route.ts @@ -155,6 +155,54 @@ export async function POST(req: Request): Promise { } } +export async function PUT(req: Request): Promise { + try { + const { id, title, description, capacity, endDate } = + (await req.json()) as { + id: string; + title?: string; + description?: string; + capacity?: number | null; + endDate?: string | null; + }; + + if (!id) { + return NextResponse.json({ error: "Missing id" }, { status: 400 }); + } + + if (title !== undefined && !title.trim()) { + return NextResponse.json( + { error: "Title cannot be empty" }, + { status: 400 }, + ); + } + + if (description !== undefined && !description.trim()) { + return NextResponse.json( + { error: "Description cannot be empty" }, + { status: 400 }, + ); + } + + await db + .update(recurringEvents) + .set({ + ...(title !== undefined && { title: title.trim() }), + ...(description !== undefined && { description: description.trim() }), + ...(capacity !== undefined && { capacity: capacity ?? null }), + ...(endDate !== undefined && { endDate: endDate || null }), + }) + .where(eq(recurringEvents.id, id)); + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + export async function PATCH(req: Request): Promise { try { const { id } = await req.json(); From 69511729cfab91bb4e6ab677926a4ca0da3c0e41 Mon Sep 17 00:00:00 2001 From: AgustinSV Date: Tue, 9 Jun 2026 16:17:45 -0400 Subject: [PATCH 5/5] feat: complete with recurring event functionality --- .../recurring-event/RecurringEventsClient.tsx | 30 ++++++++++--------- .../recurring-event/[id]/edit/page.tsx | 1 + 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx index 44a2ad5..a522291 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/RecurringEventsClient.tsx @@ -559,21 +559,23 @@ export default function RecurringEventsClient({ sx={{ width: 88, p: 0, pr: 1, textAlign: "right" }} > - - - - - {pattern.active && ( - + <> + + + + + + + )} diff --git a/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx b/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx index d4539b9..2d88f03 100644 --- a/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx +++ b/src/app/(dashboard)/dashboard/recurring-event/[id]/edit/page.tsx @@ -28,6 +28,7 @@ export default async function EditRecurringEventPage({ .where(eq(recurringEvents.id, id)); if (!pattern) notFound(); + if (!pattern.active) redirect("/dashboard/recurring-event"); let managerLocationId: string | null = null; let managerLocationName: string | null = null;