From 72b321d98a46b37119e64045f67700718263ceae Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:39:59 +0800 Subject: [PATCH 1/8] Update dependencies to use @acme/api version 0.2.0 and introduce social URL validation schema in the validators package. (#23) --- apps/api/package.json | 2 +- apps/map/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 63247c96..25f67450 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@acme/api": "workspace:*", + "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", "@acme/db": "workspace:^0.1.0", "@acme/env": "workspace:^0.1.0", diff --git a/apps/map/package.json b/apps/map/package.json index 9195e711..085d4790 100644 --- a/apps/map/package.json +++ b/apps/map/package.json @@ -21,7 +21,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@acme/api": "workspace:*", + "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", "@acme/db": "workspace:^0.1.0", "@acme/env": "workspace:^0.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fff4d0be..fe451619 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,7 @@ importers: apps/api: dependencies: '@acme/api': - specifier: workspace:* + specifier: workspace:^0.2.0 version: link:../../packages/api '@acme/auth': specifier: workspace:^0.1.0 @@ -310,7 +310,7 @@ importers: apps/map: dependencies: '@acme/api': - specifier: workspace:* + specifier: workspace:^0.2.0 version: link:../../packages/api '@acme/auth': specifier: workspace:^0.1.0 From 0ef1e02e416d852dfa28ede1421de34a1fcef565 Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:42:25 +0800 Subject: [PATCH 2/8] [MOUNT-57] Update modals and add mobile filter sheet (#21) * Update modals and add mobile filter sheet - Updated various modal components to improve layout and responsiveness, including adjustments to class names for better styling. - Introduced a new MobileFilterSheet component for mobile devices to manage filters more effectively. - Integrated MobileFilterSheet into AOsTable, AreasTable, and other relevant components to enhance user experience on smaller screens. - Adjusted filter handling logic to accommodate the new mobile filter interface. * Enhance admin layout with titles and z-index adjustments - Added a title prop to the Layout component for better page identification across various admin pages. - Updated the z-index for the header to ensure proper stacking context. - Adjusted padding for content areas to improve layout consistency. - Modified several admin pages to utilize the new title feature, enhancing user experience and accessibility. From 20b5f5e2605bee63762d73ee982b51a3451cff42 Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:42:44 +0800 Subject: [PATCH 3/8] Add scrollbars in the admin sidebar. (#19) From ebd1f8a41036ba1f291bac3add5582a76384d324 Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:43:16 +0800 Subject: [PATCH 4/8] Update event category handling in forms and modals (#18) - Introduced EVENT_CATEGORY_OPTIONS and EVENT_CATEGORY_LABEL_MAP constants for better management of event categories. - Updated LocationEventForm, AdminEventTypesModal, and AdminWorkoutsModal to utilize the new constants for displaying event category labels. - Improved label formatting to include category descriptions where applicable. From 207249e8deeaa6ef43af448dd28ef6b95760d25b Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:12:38 +0800 Subject: [PATCH 5/8] [MOUNT-46] Enhance event handling and introduce map event router (#12) * Enhance event handling and introduce map event router - Added support for invalidating map queries in AdminDeleteModal and AdminWorkoutsModal. - Updated AdminWorkoutsModal to include loading state and improved event creation/updating feedback. - Refactored event querying to use new map-specific endpoints, enhancing filtering capabilities. - Introduced a new map event router to handle event-related operations with improved structure and filtering options. * Updated the event router to include pagination and sorting capabilities for the `all` endpoint. --- packages/api/src/router/event.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/api/src/router/event.test.ts b/packages/api/src/router/event.test.ts index c45b7ed7..2a3588c5 100644 --- a/packages/api/src/router/event.test.ts +++ b/packages/api/src/router/event.test.ts @@ -242,6 +242,19 @@ describe("Event Router", () => { }); it("should return response shape without pre-existing data", async () => { + const client = createTestClient(); + const result = await client.map.event.all({ + pageIndex: 0, + pageSize: 50, + statuses: ["active"], + }); + + expect(result.events?.some((e) => e.id === created?.id)).toBe(true); + }); + }); + + describe("map.event.all", () => { + it("should return a list of events with filtering", async () => { const client = createTestClient(); const result = await client.map.event.all({ pageIndex: 0, From 8becd329d6a180b6ad395e45f9623dd0ab15c797 Mon Sep 17 00:00:00 2001 From: elsiedp <141648006+elsiedp@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:13:30 +0800 Subject: [PATCH 6/8] Enhance Google Map component by adding draggable marker and improving center handling logic (#2) --- apps/map/src/app/_components/map/google-map-simple.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/map/src/app/_components/map/google-map-simple.tsx b/apps/map/src/app/_components/map/google-map-simple.tsx index 2a1e739b..70457d2e 100644 --- a/apps/map/src/app/_components/map/google-map-simple.tsx +++ b/apps/map/src/app/_components/map/google-map-simple.tsx @@ -68,3 +68,4 @@ const ProvidedGoogleMapSimple = ({ ); }; + From 2cb80a026e614276cd91609b6755b2152008d830 Mon Sep 17 00:00:00 2001 From: elsiedp Date: Mon, 30 Mar 2026 16:43:18 +0800 Subject: [PATCH 7/8] Implement event tagging and instance management modals in admin interface - Introduced AdminEventTagsModal and AdminEventInstancesModal for managing event tags and instances. - Updated modal switcher to include new modals for event tag and instance management. - Enhanced AdminDeleteModal to support deletion of event tags and instances. - Added navigation links for event tags and instances in the admin sidebar. - Improved event handling in various components to accommodate new event status logic and updates. --- .../src/app/_components/map/event-chip.tsx | 26 +- .../map/filtered-map-results-provider.tsx | 154 +++- .../app/_components/map/google-map-simple.tsx | 1 - .../src/app/_components/map/group-marker.tsx | 37 +- .../map/mobile-nearby-locations-item.tsx | 9 +- .../_components/map/nearby-location-item.tsx | 1 + .../_components/map/selected-group-marker.tsx | 20 +- .../features-cluster-marker.tsx | 31 +- .../_components/modal/admin-delete-modal.tsx | 20 + .../modal/admin-event-instances-modal.tsx | 749 ++++++++++++++++++ .../modal/admin-event-tags-modal.tsx | 323 ++++++++ .../app/_components/modal/modal-switcher.tsx | 14 + .../workout/workout-details-content.tsx | 190 ++++- .../app/admin/_components/admin-nav-links.tsx | 14 + .../add-event-instance-button.tsx | 26 + .../event-instances/event-instances-table.tsx | 277 +++++++ .../src/app/admin/event-instances/page.tsx | 29 + .../admin/event-tags/add-event-tag-button.tsx | 26 + .../event-tags/create-event-tag-form.tsx | 219 +++++ .../app/admin/event-tags/event-tags-table.tsx | 216 +++++ apps/map/src/app/admin/event-tags/page.tsx | 27 + apps/map/src/utils/map-status-colors.ts | 112 +++ apps/map/src/utils/store/modal.ts | 10 + apps/map/src/utils/types.ts | 5 + .../api/src/router/event-instance.test.ts | 2 +- packages/api/src/router/event-instance.ts | 35 +- packages/api/src/router/event-tag.ts | 2 + packages/api/src/router/event.test.ts | 4 +- packages/api/src/router/map/location.ts | 119 ++- packages/shared/src/app/constants.ts | 8 + packages/validators/src/index.ts | 2 + 31 files changed, 2624 insertions(+), 84 deletions(-) create mode 100644 apps/map/src/app/_components/modal/admin-event-instances-modal.tsx create mode 100644 apps/map/src/app/_components/modal/admin-event-tags-modal.tsx create mode 100644 apps/map/src/app/admin/event-instances/add-event-instance-button.tsx create mode 100644 apps/map/src/app/admin/event-instances/event-instances-table.tsx create mode 100644 apps/map/src/app/admin/event-instances/page.tsx create mode 100644 apps/map/src/app/admin/event-tags/add-event-tag-button.tsx create mode 100644 apps/map/src/app/admin/event-tags/create-event-tag-form.tsx create mode 100644 apps/map/src/app/admin/event-tags/event-tags-table.tsx create mode 100644 apps/map/src/app/admin/event-tags/page.tsx create mode 100644 apps/map/src/utils/map-status-colors.ts diff --git a/apps/map/src/app/_components/map/event-chip.tsx b/apps/map/src/app/_components/map/event-chip.tsx index 467cd71e..6fe4fb33 100644 --- a/apps/map/src/app/_components/map/event-chip.tsx +++ b/apps/map/src/app/_components/map/event-chip.tsx @@ -9,7 +9,9 @@ import { useCallback } from "react"; import type { DayOfWeek } from "@acme/shared/app/enums"; import { cn } from "@acme/ui"; +import type { MapStatus } from "~/utils/types"; import { getWhenFromWorkout } from "~/utils/get-when-from-workout"; +import { getSelectedChipBg } from "~/utils/map-status-colors"; import { setView } from "~/utils/set-view"; import { setSelectedItem } from "~/utils/store/selected-item"; import BootSvgComponent from "../SVGs/boot-camp"; @@ -20,6 +22,7 @@ export const EventChip = (props: { variant?: "interactive" | "non-interactive"; size?: "small" | "medium" | "large"; selected?: boolean; + mapStatus?: MapStatus; onClick?: (e?: React.MouseEvent) => void; event: { name?: string; @@ -104,8 +107,7 @@ export const EventChip = (props: { "px-2 shadow", "cursor-pointer", { "pointer-events-none bg-muted": !isInteractive }, - { "bg-red-600": isInteractive && selected }, - { "bg-muted": isInteractive && !selected }, + isInteractive && selected && getSelectedChipBg(props.mapStatus ?? null), { "gap-1 py-[1px]": size === "small" }, { "gap-1 py-[2px]": size === "medium" }, { "gap-2 py-[3px]": size === "large" }, @@ -116,7 +118,7 @@ export const EventChip = (props: {
) : event.eventTypes.some((et) => et.name === "Ruck") ? ( ) : event.eventTypes.some((et) => et.name === "Run") ? ( ) : null}
diff --git a/apps/map/src/app/_components/map/filtered-map-results-provider.tsx b/apps/map/src/app/_components/map/filtered-map-results-provider.tsx index 49a254f2..6329600d 100644 --- a/apps/map/src/app/_components/map/filtered-map-results-provider.tsx +++ b/apps/map/src/app/_components/map/filtered-map-results-provider.tsx @@ -4,9 +4,10 @@ import type { ReactNode } from "react"; import { createContext, useContext, useMemo } from "react"; import { DEFAULT_CENTER } from "@acme/shared/app/constants"; +import type { DayOfWeek } from "@acme/shared/app/enums"; import { RERENDER_LOGS } from "@acme/shared/common/constants"; -import type { SparseF3Marker } from "~/utils/types"; +import type { MapStatus, SparseF3Marker } from "~/utils/types"; import { orpc, useQuery } from "~/orpc/react"; import { filterData } from "~/utils/filtered-data"; import { filterStore } from "~/utils/store/filter"; @@ -33,6 +34,55 @@ const FilteredMapResultsContext = createContext<{ allLocationMarkersWithLatLngAndFilterData: undefined, }); +const DAYS_OF_WEEK: DayOfWeek[] = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]; + +function dateToDayOfWeek(dateStr: string): DayOfWeek { + const d = new Date(dateStr + "T00:00:00"); + return DAYS_OF_WEEK[d.getUTCDay()] ?? "sunday"; +} + +function computeMapStatus( + event: { startDate: string | null; endDate: string | null; id: number }, + instanceLookup: Map< + number, + { seriesException: string | null; startDate: string }[] + >, +): MapStatus { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const thirtyDaysOut = new Date(now); + thirtyDaysOut.setDate(thirtyDaysOut.getDate() + 30); + + if (event.endDate) { + const end = new Date(event.endDate + "T00:00:00"); + const started = event.startDate + ? new Date(event.startDate + "T00:00:00") <= now + : true; + if (started && end >= now && end <= thirtyDaysOut) return "closing"; + } + + const instances = instanceLookup.get(event.id) ?? []; + if (instances.some((i) => i.seriesException === "closed")) return "closing"; + + if (event.startDate) { + const start = new Date(event.startDate + "T00:00:00"); + if (start > now && start <= thirtyDaysOut) return "deviation"; + } + + if (instances.some((i) => i.seriesException === "different-time")) + return "deviation"; + + return null; +} + export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { RERENDER_LOGS && console.log("FilteredMapResultsProvider rerender"); const nearbyLocationCenter = mapStore.use.nearbyLocationCenter(); @@ -42,6 +92,12 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { }), ); + const { data: upcomingInstancesData } = useQuery( + orpc.map.location.upcomingInstances.queryOptions({ + input: undefined, + }), + ); + const filters = filterStore.useBoundStore(); /** @@ -50,6 +106,27 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { const allLocationMarkersWithLatLngAndFilterData = useMemo(() => { if (!mapEventAndLocationData) return undefined; + const instancesBySeriesId = new Map< + number, + { seriesException: string | null; startDate: string }[] + >(); + const standaloneInstances: NonNullable = []; + + if (upcomingInstancesData) { + for (const instance of upcomingInstancesData) { + if (instance.seriesId != null) { + const list = instancesBySeriesId.get(instance.seriesId) ?? []; + list.push({ + seriesException: instance.seriesException, + startDate: instance.startDate, + }); + instancesBySeriesId.set(instance.seriesId, list); + } else { + standaloneInstances.push(instance); + } + } + } + const allLocationMarkerFilterData = mapEventAndLocationData.map( (location) => { return { @@ -60,42 +137,71 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { lon: location[4], fullAddress: location[5], events: location[6].map((event) => { - return { + const eventObj = { id: event[0], name: event[1], dayOfWeek: event[2], startTime: event[3], eventTypes: event[4], + startDate: event[5], + endDate: event[6], + }; + return { + ...eventObj, + mapStatus: computeMapStatus(eventObj, instancesBySeriesId), }; }), }; }, ); - const locationIdToLatLng = allLocationMarkerFilterData.reduce( - (acc, location) => { - acc[location.id] = location; - return acc; - }, - {} as Record< - number, - { - lat: number | null; - lon: number | null; - fullAddress: string | null; - } - >, - ); + const locationMap = new Map< + number, + (typeof allLocationMarkerFilterData)[number] + >(); + for (const loc of allLocationMarkerFilterData) { + locationMap.set(loc.id, loc); + } - return allLocationMarkerFilterData.map((location) => { - return { - ...location, - lat: locationIdToLatLng[location.id]?.lat ?? null, - lon: locationIdToLatLng[location.id]?.lon ?? null, - fullAddress: locationIdToLatLng[location.id]?.fullAddress ?? null, + for (const instance of standaloneInstances) { + if ( + instance.locationId == null || + instance.lat == null || + instance.lon == null + ) + continue; + const dayOfWeek = dateToDayOfWeek(instance.startDate); + const instanceEvent = { + id: -instance.id, + name: instance.name, + dayOfWeek: dayOfWeek as DayOfWeek, + startTime: instance.startTime, + eventTypes: instance.eventTypes, + startDate: instance.startDate, + endDate: null, + mapStatus: "highlight" as MapStatus, }; - }); - }, [mapEventAndLocationData]); + + const existing = locationMap.get(instance.locationId); + if (existing) { + existing.events.push(instanceEvent); + } else { + const newLocation = { + id: instance.locationId, + aoName: instance.aoName ?? "", + logo: instance.aoLogo, + lat: instance.lat, + lon: instance.lon, + fullAddress: instance.fullAddress, + events: [instanceEvent], + }; + locationMap.set(instance.locationId, newLocation); + allLocationMarkerFilterData.push(newLocation); + } + } + + return allLocationMarkerFilterData; + }, [mapEventAndLocationData, upcomingInstancesData]); /** * Filter the location markers by the filters diff --git a/apps/map/src/app/_components/map/google-map-simple.tsx b/apps/map/src/app/_components/map/google-map-simple.tsx index 70457d2e..2a1e739b 100644 --- a/apps/map/src/app/_components/map/google-map-simple.tsx +++ b/apps/map/src/app/_components/map/google-map-simple.tsx @@ -68,4 +68,3 @@ const ProvidedGoogleMapSimple = ({ ); }; - diff --git a/apps/map/src/app/_components/map/group-marker.tsx b/apps/map/src/app/_components/map/group-marker.tsx index ffad62fc..469a7188 100644 --- a/apps/map/src/app/_components/map/group-marker.tsx +++ b/apps/map/src/app/_components/map/group-marker.tsx @@ -10,6 +10,13 @@ import { dayOfWeekToShortDayOfWeek } from "@acme/shared/app/functions"; import { cn } from "@acme/ui"; import { groupMarkerClick } from "~/utils/actions/group-marker-click"; +import { + getDominantStatus, + getSelectedBg, + getSelectedBorder, + getStatusBase, + STATUS_BASE_DEFAULT, +} from "~/utils/map-status-colors"; import { appStore } from "~/utils/store/app"; import { mapStore } from "~/utils/store/map"; import { @@ -93,12 +100,15 @@ export const FeatureMarker = ({ [id], ); + const dominantStatus = events?.length ? getDominantStatus(events) : null; + return !events?.length ? null : !isClose ? ( { const eventId = events.find( (event) => event.id === selectedEventId, @@ -170,21 +180,24 @@ export const FeatureMarker = ({ }} className={cn( // min-h-[32.5px] so it doesn't collapse with no text - "min-h-[32.5px] flex-1 cursor-pointer bg-foreground py-2 text-center text-background", - "border-b-2 border-l-2 border-r-2 border-t-2 border-foreground ", + "min-h-[32.5px] flex-1 cursor-pointer py-2 text-center", + "border-b-2 border-l-2 border-r-2 border-t-2", `google-eventid-${event.id}`, + (event.mapStatus && getStatusBase(event.mapStatus)) ?? + STATUS_BASE_DEFAULT, { "rounded-r-full": isEnd, "rounded-l-full": isStart, - "border-red-600 dark:border-red-400": - isCurrentSelectedEvent || - (isCurrentSelectedLocation && noSelectedEvent), - "border-red-600 bg-red-600 font-bold dark:bg-red-400": - // On mobile we always use the background - (touchDevice && isCurrentSelectedEvent) || - isCurrentPanelEvent || - (isCurrentPanelLocation && noSelectedPanelEvent), }, + (isCurrentSelectedEvent || + (isCurrentSelectedLocation && noSelectedEvent)) && + getSelectedBorder(event.mapStatus), + ((touchDevice && isCurrentSelectedEvent) || + isCurrentPanelEvent || + (isCurrentPanelLocation && noSelectedPanelEvent)) && [ + getSelectedBg(event.mapStatus), + "font-bold", + ], )} > {dayText} @@ -198,9 +211,7 @@ export const FeatureMarker = ({ style={{ zIndex: 0 }} > event.name) .filter(onlyUnique) @@ -45,7 +49,9 @@ export const MobileNearbyLocationsItem = (props: { "p-2", "h-32", "border-2 border-foreground/10", - { "border-red-500": isSelected }, + isSelected && + ((dominantStatus && getSelectedBorder(dominantStatus)) ?? + "border-red-500"), )} onClick={() => { searchBarRef.current?.blur(); @@ -96,6 +102,7 @@ export const MobileNearbyLocationsItem = (props: { key={event.id} selected={false} size="small" + mapStatus={event.mapStatus} event={event} location={searchResult} /> diff --git a/apps/map/src/app/_components/map/nearby-location-item.tsx b/apps/map/src/app/_components/map/nearby-location-item.tsx index 22460f71..c924e98d 100644 --- a/apps/map/src/app/_components/map/nearby-location-item.tsx +++ b/apps/map/src/app/_components/map/nearby-location-item.tsx @@ -150,6 +150,7 @@ export const NearbyLocationItem = (props: { location={item} size="small" hideName + mapStatus={event.mapStatus} selected={event.id === eventId && item.id === locationId} onClick={(e) => { handleClick({ diff --git a/apps/map/src/app/_components/map/selected-group-marker.tsx b/apps/map/src/app/_components/map/selected-group-marker.tsx index b43a6288..cf2c4904 100644 --- a/apps/map/src/app/_components/map/selected-group-marker.tsx +++ b/apps/map/src/app/_components/map/selected-group-marker.tsx @@ -9,6 +9,11 @@ import { cn } from "@acme/ui"; import type { SparseF3Marker } from "~/utils/types"; import { groupMarkerClick } from "~/utils/actions/group-marker-click"; import { isTouchDevice } from "~/utils/is-touch-device"; +import { + getSelectedBg, + getStatusBase, + STATUS_BASE_DEFAULT, +} from "~/utils/map-status-colors"; import { selectedItemStore, setSelectedItem, @@ -80,18 +85,19 @@ export const MemoSelectedGroupMarker = memo( void groupMarkerClick({ locationId: id, eventId }); }} className={cn( - "flex-1 cursor-pointer border-b-2 border-t-2 border-foreground bg-foreground py-2 text-center text-background", - "border-l-2 border-r-2", + "flex-1 cursor-pointer py-2 text-center", + "border-b-2 border-l-2 border-r-2 border-t-2", `google-eventid-${event.id}`, + (event.mapStatus && getStatusBase(event.mapStatus)) ?? + STATUS_BASE_DEFAULT, { "rounded-r-full": isEnd, "rounded-l-full": isStart, - "border-red-600 font-bold dark:bg-red-400": - selectedIndex === markerIdx, - "bg-red-600": true, - // (!!panel || alwaysShowFillInsteadOfOutline) && - // ( selectedIndex === markerIdx || selectedIndex === -1 ), }, + selectedIndex === markerIdx && [ + getSelectedBg(event.mapStatus), + "font-bold", + ], )} > {dayText} diff --git a/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx b/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx index 995befa9..6f066b17 100644 --- a/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx +++ b/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx @@ -4,6 +4,14 @@ import { useAdvancedMarkerRef, } from "@vis.gl/react-google-maps"; +import { cn } from "@acme/ui"; + +import type { MapStatus } from "~/utils/types"; +import { + getClusterBg, + getClusterInner, + getClusterMid, +} from "~/utils/map-status-colors"; import { closePanel } from "~/utils/store/selected-item"; interface TreeClusterMarkerProps { @@ -15,6 +23,7 @@ interface TreeClusterMarkerProps { position: google.maps.LatLngLiteral; size: number; sizeAsText: string; + statusColor?: MapStatus; } export const FeaturesClusterMarker = ({ @@ -23,6 +32,7 @@ export const FeaturesClusterMarker = ({ sizeAsText, onMarkerClick, clusterId, + statusColor, }: TreeClusterMarkerProps) => { const [markerRef, marker] = useAdvancedMarkerRef(); const markerSize = Math.floor(36 + Math.sqrt(size) * 2); @@ -41,15 +51,26 @@ export const FeaturesClusterMarker = ({ closePanel(); return onMarkerClick?.(marker, clusterId); }} - className={ - "marker cluster flex items-center justify-center rounded-full bg-foreground/30" - } + className={cn( + "marker cluster flex items-center justify-center rounded-full", + statusColor ? getClusterBg(statusColor) : "bg-foreground/30", + )} style={{ width: markerSize, height: markerSize }} anchorPoint={AdvancedMarkerAnchorPoint.CENTER} > -
+
{sizeAsText} diff --git a/apps/map/src/app/_components/modal/admin-delete-modal.tsx b/apps/map/src/app/_components/modal/admin-delete-modal.tsx index 1b8882d4..e116d0b1 100644 --- a/apps/map/src/app/_components/modal/admin-delete-modal.tsx +++ b/apps/map/src/app/_components/modal/admin-delete-modal.tsx @@ -32,6 +32,8 @@ export default function AdminDeleteModal({ eventId?: number; locationId?: number; userId?: number; + eventTagId?: number; + eventInstanceId?: number; } | void>; switch (data.type) { @@ -54,6 +56,12 @@ export default function AdminDeleteModal({ case DeleteType.LOCATION: mutation = orpc.location.delete.call; break; + case DeleteType.EVENT_TAG: + mutation = orpc.eventTag.delete.call; + break; + case DeleteType.EVENT_INSTANCE: + mutation = orpc.eventInstance.delete.call; + break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${data.type}`); @@ -90,6 +98,14 @@ export default function AdminDeleteModal({ await invalidateQueries("location"); await invalidateQueries("map"); break; + case DeleteType.EVENT_TAG: + await invalidateQueries("eventTag"); + await invalidateQueries("map"); + break; + case DeleteType.EVENT_INSTANCE: + await invalidateQueries("eventInstance"); + await invalidateQueries("map"); + break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${data.type}`); @@ -171,6 +187,10 @@ const dataTypeToName = ( return "User"; case DeleteType.LOCATION: return "Location"; + case DeleteType.EVENT_TAG: + return "Event Tag"; + case DeleteType.EVENT_INSTANCE: + return "Event Instance"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${dataType}`); diff --git a/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx b/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx new file mode 100644 index 00000000..b6a38600 --- /dev/null +++ b/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx @@ -0,0 +1,749 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { z } from "zod"; + +import { Z_INDEX } from "@acme/shared/app/constants"; +import { SeriesException } from "@acme/shared/app/enums"; +import { + convertHH_mmToHHmm, + convertHHmmToHH_mm, +} from "@acme/shared/app/functions"; +import { safeParseInt } from "@acme/shared/common/functions"; +import { cn } from "@acme/ui"; +import { Button } from "@acme/ui/button"; +import { Checkbox } from "@acme/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@acme/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@acme/ui/form"; +import { Input } from "@acme/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@acme/ui/select"; +import { Spinner } from "@acme/ui/spinner"; +import { Textarea } from "@acme/ui/textarea"; +import { toast } from "@acme/ui/toast"; + +import gte from "lodash/gte"; +import { + invalidateQueries, + orpc, + ORPCError, + useMutation, + useQuery, +} from "~/orpc/react"; +import type { DataType } from "~/utils/store/modal"; +import { + closeModal, + DeleteType, + ModalType, + openModal, +} from "~/utils/store/modal"; +import { ControlledTimeInput } from "../time-input"; +import { VirtualizedCombobox } from "../virtualized-combobox"; + +const NONE = "__none__"; + +function seriesExceptionLabel(value: string): string { + switch (value) { + case "closed": + return "Closed"; + case "different-time": + return "Different time"; + case "miscellaneous": + return "Miscellaneous"; + default: + return value; + } +} + +const EventInstanceFormSchema = z + .object({ + regionId: z.number().optional(), + orgId: z + .number() + .refine((n) => n > 0, { message: "Select an organization" }), + locationId: z.number().nullable().optional(), + startDate: z.string().min(1, { message: "Start date is required" }), + endDate: z.string().nullable().optional(), + name: z.string().optional(), + description: z.string().nullish(), + startTime: z.string().nullable().optional(), + endTime: z.string().nullable().optional(), + eventTypeId: z.number().optional(), + eventTagId: z.number().optional(), + seriesId: z.number().nullable().optional(), + seriesException: z + .enum(["closed", "different-time", "miscellaneous"]) + .nullable() + .optional(), + isPrivate: z.boolean(), + }) + .superRefine((data, ctx) => { + if ( + data.startTime && + data.endTime && + data.startTime.length === 5 && + data.endTime.length === 5 + ) { + if (data.startTime > data.endTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End time must be after start time", + path: ["endTime"], + }); + } + } + if (data.startDate && data.endDate && data.endDate < data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End date must be on or after start date", + path: ["endDate"], + }); + } + }); + +type EventInstanceFormValues = z.infer; + +export default function AdminEventInstancesModal({ + data, +}: { + data: DataType[ModalType.ADMIN_EVENT_INSTANCES]; +}) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: regions } = useQuery( + orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }), + ); + const { data: aos } = useQuery( + orpc.org.all.queryOptions({ input: { orgTypes: ["ao"] } }), + ); + const { data: locations } = useQuery( + orpc.location.all.queryOptions({ input: { statuses: ["active"] } }), + ); + + const { data: instanceResponse, isLoading: isLoadingInstance } = useQuery( + orpc.eventInstance.byId.queryOptions({ + input: { id: data.id ?? -1 }, + enabled: gte(data.id, 0), + }), + ); + + const instance = instanceResponse ?? undefined; + const isEditing = !!instance; + const isLoading = gte(data.id, 0) && isLoadingInstance; + + const form = useForm({ + schema: EventInstanceFormSchema, + }); + + const formRegionId = form.watch("regionId"); + + const { data: eventTypes } = useQuery( + orpc.eventType.all.queryOptions({ + input: { + pageSize: 200, + orgIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const { data: eventTagsResponse } = useQuery( + orpc.eventTag.all.queryOptions({ + input: { + pageSize: 200, + statuses: ["active"], + orgIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const { data: eventsResponse } = useQuery( + orpc.event.all.queryOptions({ + input: { + pageSize: 500, + regionIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const sortedEventTypes = eventTypes?.eventTypes ?? []; + const sortedEventTags = eventTagsResponse?.eventTags ?? []; + const events = eventsResponse?.events ?? []; + + useEffect(() => { + console.log("instance", instance); + + form.reset({ + regionId: instance?.org?.parentId ?? undefined, + orgId: instance?.orgId ?? 0, + locationId: instance?.locationId ?? null, + startDate: instance?.startDate ?? "", + endDate: instance?.endDate ?? null, + name: instance?.name ?? "", + description: instance?.description ?? "", + startTime: + instance?.startTime?.length === 4 + ? convertHHmmToHH_mm(instance.startTime) + : null, + endTime: + instance?.endTime?.length === 4 + ? convertHHmmToHH_mm(instance.endTime) + : null, + eventTypeId: instance?.eventTypes?.[0]?.eventTypeId, + eventTagId: instance?.eventTags?.[0]?.eventTagId, + seriesId: instance?.seriesId ?? null, + seriesException: instance?.seriesException ?? null, + isPrivate: instance?.isPrivate ?? false, + }); + }, [form, instance]); + + const crupdateEventInstance = useMutation( + orpc.eventInstance.crupdate.mutationOptions({ + onSuccess: async () => { + await invalidateQueries("eventInstance"); + closeModal(); + toast.success( + isEditing + ? "Successfully updated event instance" + : "Successfully created event instance", + ); + router.refresh(); + }, + onError: (err) => { + toast.error( + err instanceof ORPCError && err?.code === "UNAUTHORIZED" + ? err.message ?? + "You are not authorized to create or update event instances" + : "Failed to save event instance", + ); + }, + }), + ); + + const onSubmit = async (formData: EventInstanceFormValues) => { + setIsSubmitting(true); + try { + const startTime = + formData.startTime && formData.startTime.length === 5 + ? convertHH_mmToHHmm(formData.startTime) + : undefined; + const endTime = + formData.endTime && formData.endTime.length === 5 + ? convertHH_mmToHHmm(formData.endTime) + : undefined; + + const trimmedName = formData.name?.trim(); + const descTrim = formData.description?.trim(); + await crupdateEventInstance.mutateAsync({ + ...(isEditing && data.id != null ? { id: data.id } : {}), + orgId: formData.orgId, + startDate: formData.startDate, + endDate: formData.endDate, + locationId: formData.locationId ?? null, + name: trimmedName ? trimmedName : undefined, + description: descTrim == null || descTrim === "" ? null : descTrim, + startTime: startTime ? startTime : undefined, + endTime: endTime ? endTime : undefined, + ...(formData.eventTypeId ? { eventTypeId: formData.eventTypeId } : {}), + ...(formData.eventTagId ? { eventTagId: formData.eventTagId } : {}), + seriesId: formData.seriesId ?? null, + seriesException: formData.seriesException ?? null, + isPrivate: formData.isPrivate, + }); + } catch { + // handled in onError + } finally { + setIsSubmitting(false); + } + }; + + return ( + closeModal()}> + + + + {isEditing ? "Edit" : "Add"} Event Instance + + + + {isLoading ? ( +
+ +
+ ) : ( +
+ + ( + + Region + + ({ + value: region.id.toString(), + label: region.name, + })) ?? [] + } + searchPlaceholder="Select a region" + onSelect={(value) => { + const orgId = safeParseInt(value as string); + if (orgId == null) { + toast.error("Invalid region"); + return; + } + field.onChange(orgId); + form.setValue("orgId", 0); + form.setValue("locationId", null); + form.setValue("eventTypeId", undefined); + form.setValue("eventTagId", undefined); + form.setValue("seriesId", null); + }} + isMulti={false} + /> + + + + )} + /> + { + const filteredAOs = aos?.orgs.filter( + (ao) => ao.parentId === form.watch("regionId"), + ); + return ( + + AO + + + + ); + }} + /> + ( + + Start date + + + + + + )} + /> + ( + + End date (optional) + + + + + + )} + /> +
+ + +
+ { + const regionId = form.getValues("regionId"); + const filteredLocations = locations?.locations.filter( + (location) => location.regionId === regionId, + ); + return ( + + Location (optional) + + + + ); + }} + /> + ( + + Event type (optional) + + + + )} + /> + ( + + Event tag (optional) + + + + )} + /> + ( + + Series (optional) + + + + )} + /> + ( + + Series exception (optional) + + + + )} + /> + ( + + + field.onChange(v === true)} + /> + + Private + + )} + /> + ( + + Name (optional) + + + + + + )} + /> + ( + + Description (optional) + +