-
Notifications
You must be signed in to change notification settings - Fork 8
feat(events): support scheduling public events in advance #429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
012fa83
2e7ba52
8fb9c82
508142f
d3f0fd4
8d1fc99
55a2c7e
0b56cca
ef3c489
6f3ffaa
e7f8021
e32df6d
d3b3ba9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| confirm-modules-purge=false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,7 +40,10 @@ run_all: | |
| run: | ||
| cd server/src && go install # Install first so that we keep cached build objects around. | ||
|
|
||
| set -a && . server/debug.env && set +a && \ | ||
| set -a && \ | ||
| . server/debug.env && \ | ||
| { [ -f .env ] && . ./.env || true; } && \ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you say how to populate .env in the README, or make a template env file and just mention it in the README? probably say this somewhere: "This API key can be found in GCP under the dev environment."
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — added a note under the Google Places section of the README on populating a root (posted by claude)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is that actually where the key you used is? I tried it and got this error: Google Maps JavaScript API error: ApiNotActivatedMapError Would be great to mention which project/key in the readme. I'd like to test it out so pls lmk :) |
||
| set +a && \ | ||
| cd server/src && \ | ||
| PORT=$(PORT) go run main.go | ||
|
|
||
|
|
@@ -63,9 +66,9 @@ dev_db: | |
| # Note: PNPM must be installed separately for each version of NPM used, since it is installed within each NPM installation. | ||
| # Note: `go tool` cannot yet be used to install golang-migrate: https://github.com/golang-migrate/migrate/issues/1232 | ||
| deps: | ||
| . $(NVM_SCRIPT) && nvm i 22 && npm i -g pnpm@$(PNPM_VERSION) && pnpm i | ||
| . $(NVM_SCRIPT) && nvm i 22 && npm i -g pnpm@$(PNPM_VERSION) && pnpm i --config.confirmModulesPurge=false | ||
| . $(NVM_SCRIPT) && cd frontend && nvm i $(VUE_FRONTEND_NODE_VERSION) && npm i --legacy-peer-deps | ||
| . $(NVM_SCRIPT) && cd frontend-v2 && nvm i $(REACT_FRONTEND_NODE_VERSION) && npm i -g pnpm@$(PNPM_VERSION) && pnpm i | ||
| . $(NVM_SCRIPT) && cd frontend-v2 && nvm i $(REACT_FRONTEND_NODE_VERSION) && npm i -g pnpm@$(PNPM_VERSION) && pnpm i --config.confirmModulesPurge=false | ||
| cd pkg && go mod download | ||
| cd server/src && go mod download | ||
| cd cli && go mod download | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| confirm-modules-purge=false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| 'use client' | ||
|
|
||
| import Link from 'next/link' | ||
| import { useQuery } from '@tanstack/react-query' | ||
| import { format, parseISO } from 'date-fns' | ||
| import { | ||
| CheckCircle2, | ||
| Users, | ||
| Pencil, | ||
| CalendarPlus, | ||
| Home, | ||
| Clock, | ||
| MapPin, | ||
| Globe, | ||
| } from 'lucide-react' | ||
| import { API_PATH, apiClient } from '@/lib/api' | ||
| import { Button } from '@/components/ui/button' | ||
| import { formatEventTimeRange } from '@/lib/time' | ||
|
|
||
| export function EventConfirmation({ eventId }: { eventId: number }) { | ||
| // Hydrated by the server page (same query key as the event form), so this | ||
| // resolves immediately on first render. | ||
| const { data: event } = useQuery({ | ||
| queryKey: [API_PATH.EVENT_GET, String(eventId)], | ||
| queryFn: ({ signal }) => apiClient.getEvent(eventId, signal), | ||
| }) | ||
|
|
||
| const timeRange = event ? formatEventTimeRange(event) : '' | ||
| const locationLabel = | ||
| event?.location?.name || event?.location?.formatted_address | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-6"> | ||
| <div className="flex flex-col items-center gap-3 text-center"> | ||
| <CheckCircle2 className="h-12 w-12 text-green-600" /> | ||
| <div className="flex flex-col gap-1"> | ||
| <h2 className="text-xl font-semibold">Event created</h2> | ||
| <p className="text-sm text-muted-foreground"> | ||
| Your event is scheduled. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| {event && ( | ||
| <div className="flex flex-col gap-2 rounded-xl border bg-card p-5 shadow-sm"> | ||
| <p className="text-base font-semibold">{event.event_name}</p> | ||
| <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-muted-foreground"> | ||
| <span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium"> | ||
| {event.event_type} | ||
| </span> | ||
| <span>{format(parseISO(event.event_date), 'PPP')}</span> | ||
| {timeRange && ( | ||
| <span className="inline-flex items-center gap-1"> | ||
| <Clock className="h-3.5 w-3.5" /> | ||
| {timeRange} | ||
| </span> | ||
| )} | ||
| {event.is_online ? ( | ||
| <span className="inline-flex items-center gap-1"> | ||
| <Globe className="h-3.5 w-3.5" /> | ||
| Online | ||
| </span> | ||
| ) : ( | ||
| locationLabel && ( | ||
| <span className="inline-flex items-center gap-1"> | ||
| <MapPin className="h-3.5 w-3.5" /> | ||
| {locationLabel} | ||
| </span> | ||
| ) | ||
| )} | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="flex flex-col gap-2"> | ||
| <Button asChild> | ||
| <Link href={`/events/${eventId}`}> | ||
| <Users className="h-4 w-4" /> | ||
| Take attendance now | ||
| </Link> | ||
| </Button> | ||
| <Button asChild variant="outline"> | ||
| <Link href={`/events/${eventId}?expanded=1`}> | ||
| <Pencil className="h-4 w-4" /> | ||
| Edit event | ||
| </Link> | ||
| </Button> | ||
| <Button asChild variant="outline"> | ||
| <Link href="/events/new"> | ||
| <CalendarPlus className="h-4 w-4" /> | ||
| Create another event | ||
| </Link> | ||
| </Button> | ||
| <Button asChild variant="ghost"> | ||
| <Link href="/home"> | ||
| <Home className="h-4 w-4" /> | ||
| Done | ||
| </Link> | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { | ||
| dehydrate, | ||
| HydrationBoundary, | ||
| QueryClient, | ||
| } from '@tanstack/react-query' | ||
| import { notFound } from 'next/navigation' | ||
| import { ContentWrapper } from '@/app/content-wrapper' | ||
| import { EventConfirmation } from './event-confirmation' | ||
| import { API_PATH, ApiClient } from '@/lib/api' | ||
| import { getCookies } from '@/lib/auth' | ||
| import { redirectForHttpError } from '@/lib/server-auth' | ||
|
|
||
| // Shown right after a scheduled (public) event is created. Attendance happens | ||
| // later, at the event, so we confirm the event is on the schedule and offer the | ||
| // natural next steps instead of dropping the user on the attendance page. | ||
| export default async function EventConfirmationPage({ | ||
| params, | ||
| }: { | ||
| params: Promise<{ id: string }> | ||
| }) { | ||
| const { id } = await params | ||
| const eventId = parseInt(id) | ||
| if (Number.isNaN(eventId)) { | ||
| notFound() | ||
| } | ||
|
|
||
| const apiClient = new ApiClient(await getCookies()) | ||
| const queryClient = new QueryClient() | ||
|
|
||
| await redirectForHttpError(() => | ||
| // Intentionally use fetchQuery instead of prefetchQuery; see redirectForHttpError for details. | ||
| queryClient.fetchQuery({ | ||
| queryKey: [API_PATH.EVENT_GET, String(eventId)], | ||
| queryFn: ({ signal }) => apiClient.getEvent(eventId, signal), | ||
| }), | ||
| ) | ||
|
|
||
| return ( | ||
| <ContentWrapper size="sm" className="gap-8"> | ||
| <HydrationBoundary state={dehydrate(queryClient)}> | ||
| <EventConfirmation eventId={eventId} /> | ||
| </HydrationBoundary> | ||
| </ContentWrapper> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { z } from 'zod' | ||
|
|
||
| export const EVENT_TYPES = [ | ||
| 'Action', | ||
| 'Campaign Action', | ||
| 'Community', | ||
| 'Frontline Surveillance', | ||
| 'Meeting', | ||
| 'Outreach', | ||
| 'Animal Care', | ||
| 'Training', | ||
| ] as const | ||
|
|
||
| export const DEFAULT_FIELD_COUNT = 5 | ||
| export const MIN_EMPTY_FIELDS = 1 | ||
|
|
||
| export type EventFormMode = 'event' | 'connection' | ||
|
|
||
| // Zod schema for form validation. | ||
| export const attendeeSchema = z.object({ | ||
| name: z.string().refine( | ||
| (name) => { | ||
| const trimmed = name.trim() | ||
| // Empty is ok, as it will be filtered out. | ||
| if (trimmed === '') return true | ||
| // Must have at least first and last name (contains a space). | ||
| return trimmed.indexOf(' ') !== -1 | ||
| }, | ||
| { | ||
| message: 'First & last name are required', | ||
| }, | ||
| ), | ||
| }) | ||
|
|
||
| export const formSchema = z | ||
| .object({ | ||
| eventName: z.string().min(1, 'Event name is required'), | ||
| eventType: z.string().min(1, 'Event type is required'), | ||
| eventDate: z.string().min(1, 'Event date is required'), | ||
| suppressSurvey: z.boolean(), | ||
| attendees: z.array(attendeeSchema), | ||
| // Upcoming-event fields. Kept permissive here (empty string / false is | ||
| // valid) so the plain attendance form still passes; the stricter per-mode | ||
| // rules — a start time and a location for public events — are enforced by | ||
| // the refinements below. | ||
| isPublic: z.boolean(), | ||
| isOnline: z.boolean(), | ||
| description: z.string(), | ||
| startTime: z.string(), | ||
| endTime: z.string(), | ||
| timezone: z.string(), | ||
| googlePlaceId: z.string(), | ||
| locationName: z.string(), | ||
| formattedAddress: z.string(), | ||
| lat: z.number().optional(), | ||
| lng: z.number().optional(), | ||
| // When true, the location is entered by hand (free-text name + optional | ||
| // coordinates) instead of picked from Google Places — for spots that aren't | ||
| // a clean Place, like an intersection. Stored on the event, not deduped. | ||
| manualLocation: z.boolean(), | ||
| }) | ||
| // TODO: events that cross midnight (end before start, e.g. an overnight | ||
| // vigil) can't be expressed yet — leave the end time blank for now. If this | ||
| // becomes a real need, add an explicit "end date" rather than inferring it. | ||
| .refine((v) => !(v.startTime && v.endTime) || v.endTime >= v.startTime, { | ||
| message: 'End time must be after start time', | ||
| path: ['endTime'], | ||
| }) | ||
| // Publicly listed events are scheduled in advance, so a start time is required. | ||
| .refine((v) => !v.isPublic || Boolean(v.startTime), { | ||
| message: 'Start time is required for public events', | ||
| path: ['startTime'], | ||
| }) | ||
| // In-person public events need a location; online ones don't. The Google | ||
| // path requires a picked place; the manual path requires a typed name. | ||
| .refine( | ||
| (v) => | ||
| !v.isPublic || v.isOnline || v.manualLocation || Boolean(v.googlePlaceId), | ||
| { | ||
| message: 'Location is required for in-person public events', | ||
| path: ['formattedAddress'], | ||
| }, | ||
| ) | ||
| .refine( | ||
| (v) => | ||
| !v.isPublic || | ||
| v.isOnline || | ||
| !v.manualLocation || | ||
| v.locationName.trim() !== '', | ||
| { | ||
| message: 'Location is required for in-person public events', | ||
| path: ['locationName'], | ||
| }, | ||
| ) | ||
| // Manual coordinates, when provided, must be valid. | ||
| .refine((v) => v.lat === undefined || (v.lat >= -90 && v.lat <= 90), { | ||
| message: 'Latitude must be between -90 and 90', | ||
| path: ['lat'], | ||
| }) | ||
| .refine((v) => v.lng === undefined || (v.lng >= -180 && v.lng <= 180), { | ||
| message: 'Longitude must be between -180 and 180', | ||
| path: ['lng'], | ||
| }) | ||
|
|
||
| export type FormValues = z.infer<typeof formSchema> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should launch.json be updated too? 🙏:)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left this one: the Go Server debug config loads a single
envFile(server/debug.env), and the new.envis optional (only needed for the Places key locally). Pointing the debugger at a.envmost people won't have would break 'Go Server' for them. If you want Places in the debugger, easiest is to dropGOOGLE_PLACES_API_KEYinto your localserver/debug.env. I documented the.envroute in the README.(posted by claude)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so far makefile and launch.json behavior have been kept in sync and i'd like to keep it that way if possible, so we don't have situations where things work depending on how they're launched. perhaps we could have 'make deps' create the file if it's missing so it can be added to launch.json too?