Event Hosting - Enabling Community Potlucks
From Async to Real-Time
Posts let neighbors share food asynchronously - "I have leftovers, pickup anytime." But real community happens in person, at specific times.
Community potlucks. Volunteer shifts at food banks. Cooking classes. These require:
- Date/time coordination
- RSVP tracking with capacity limits
- Sign-up sheets (who's bringing what?)
- Location management
- Reminders and updates
On November 8th, I started building Phase 3: the event hosting system.
The Event Data Model
Events are complex. Here's the full schema:
// src/lib/schema.ts - Event tables
export const events = pgTable("events", {
id: text("id").primaryKey(),
postId: text("post_id").references(() => posts.id, { onDelete: 'cascade' }),
hostId: text("host_id").notNull().references(() => user.id),
title: text("title").notNull(),
description: text("description").notNull(),
eventType: text("event_type").notNull(), // 'potluck' | 'volunteer' | 'cooking_class'
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time"),
location: text("location").notNull(),
locationCoords: json("location_coords").$type<{ lat: number; lng: number }>(),
isPublicLocation: boolean("is_public_location").default(true),
capacity: integer("capacity"),
rsvpCount: integer("rsvp_count").default(0),
waitlistCount: integer("waitlist_count").default(0),
recurrenceId: text("recurrence_id").references(() => eventRecurrence.id),
status: text("status").default("active"), // 'active' | 'cancelled' | 'completed'
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const eventRsvps = pgTable("event_rsvps", {
id: text("id").primaryKey(),
eventId: text("event_id").notNull().references(() => events.id, { onDelete: 'cascade' }),
userId: text("user_id").notNull().references(() => user.id),
status: text("status").notNull(), // 'attending' | 'waitlisted' | 'declined'
guestCount: integer("guest_count").default(1),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (table) => [
unique().on(table.eventId, table.userId),
]);
export const signUpSlots = pgTable("sign_up_slots", {
id: text("id").primaryKey(),
eventId: text("event_id").notNull().references(() => events.id, { onDelete: 'cascade' }),
slotName: text("slot_name").notNull(), // "Salad", "Main Dish", "Dessert"
description: text("description"),
maxClaims: integer("max_claims").default(1),
claimCount: integer("claim_count").default(0),
sortOrder: integer("sort_order").default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const signUpClaims = pgTable("sign_up_claims", {
id: text("id").primaryKey(),
slotId: text("slot_id").notNull().references(() => signUpSlots.id, { onDelete: 'cascade' }),
userId: text("user_id").notNull().references(() => user.id),
details: text("details"), // "Caesar salad with homemade dressing"
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => [
unique().on(table.slotId, table.userId),
]);
export const eventRecurrence = pgTable("event_recurrence", {
id: text("id").primaryKey(),
frequency: text("frequency").notNull(), // 'daily' | 'weekly' | 'monthly'
dayOfWeek: integer("day_of_week"), // 0-6 for weekly
dayOfMonth: integer("day_of_month"), // 1-31 for monthly
interval: integer("interval").default(1),
endsAt: timestamp("ends_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const eventAttendance = pgTable("event_attendance", {
id: text("id").primaryKey(),
eventId: text("event_id").notNull().references(() => events.id, { onDelete: 'cascade' }),
userId: text("user_id").notNull().references(() => user.id),
checkedInAt: timestamp("checked_in_at").notNull(),
notes: text("notes"),
}, (table) => [
unique().on(table.eventId, table.userId),
]);
This is comprehensive - supporting everything from simple potlucks to complex recurring volunteer shifts.
Key Design Decisions
1. Link to Posts
Every event creates a post (kind="event"). This:
- Shows events in the community feed
- Enables commenting on events
- Reuses karma/helpful marks
- Maintains feed chronology
2. Capacity + Waitlist
Events can have limited capacity. When full, additional RSVPs go to waitlist. This prevents over-crowding while maintaining interest.
3. Sign-Up Sheets
Potlucks need coordination: who's bringing salad, dessert, drinks? Sign-up slots solve this with:
- Named slots ("Salad", "Main Dish")
- Max claims per slot (2 people can bring salad)
- Optional details ("Caesar salad with croutons")
4. Recurring Events
Weekly volunteer shifts or monthly community dinners need recurrence patterns. The eventRecurrence table handles this separately, allowing one-off edits without breaking the series.
The Event Creation Flow
Creating events required a multi-step wizard:
// src/components/events/event-creation-wizard.tsx
export function EventCreationWizard({ onComplete }: Props) {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Partial<EventFormData>>({});
const steps = [
{ id: 1, label: "Basic Info", component: EventBasicInfoStep },
{ id: 2, label: "Date & Time", component: EventDateTimeStep },
{ id: 3, label: "Location", component: EventLocationStep },
{ id: 4, label: "Capacity", component: EventCapacityStep },
{ id: 5, label: "Sign-Up Sheet", component: EventSignUpSheetStep },
];
const handleNext = (stepData: Partial<EventFormData>) => {
setFormData((prev) => ({ ...prev, ...stepData }));
if (step < steps.length) {
setStep(step + 1);
} else {
handleSubmit({ ...formData, ...stepData });
}
};
const handleSubmit = async (data: EventFormData) => {
const response = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
toast.error('Failed to create event');
return;
}
const { id } = await response.json();
onComplete(id);
};
const CurrentStepComponent = steps[step - 1].component;
return (
<div className="space-y-6">
<ProgressIndicator currentStep={step} totalSteps={steps.length} />
<CurrentStepComponent
initialData={formData}
onNext={handleNext}
onBack={() => setStep(step - 1)}
/>
</div>
);
}
Each step collected specific information:
Step 1: Basic Info
- Event title
- Description
- Event type (potluck, volunteer, cooking class)
Step 2: Date & Time
- Start date/time
- End time
- Recurrence pattern (optional)
Step 3: Location
- Address or description
- Public vs private location toggle
- Map picker for coordinates
Step 4: Capacity
- Max attendees
- Enable waitlist?
- RSVP deadline
Step 5: Sign-Up Sheet
- Add slots (Salad, Main, Dessert)
- Set max claims per slot
- Reorder slots
This wizard UX was later replaced with a modal (Part 11), but the multi-step concept validated well.
The RSVP System
RSVPs required careful capacity management:
// src/lib/event-queries.ts
export async function createRsvp(params: {
eventId: string;
userId: string;
guestCount: number;
notes?: string;
}) {
const { eventId, userId, guestCount, notes } = params;
// Get event with current RSVP count
const event = await db
.select()
.from(events)
.where(eq(events.id, eventId))
.limit(1);
if (!event[0]) {
throw new Error('Event not found');
}
// Check capacity
const availableSpots = event[0].capacity
? event[0].capacity - event[0].rsvpCount
: Infinity;
const status = guestCount <= availableSpots ? 'attending' : 'waitlisted';
// Create RSVP
const rsvpId = crypto.randomUUID();
await db.insert(eventRsvps).values({
id: rsvpId,
eventId,
userId,
status,
guestCount,
notes,
});
// Update event counters
await db
.update(events)
.set({
rsvpCount: status === 'attending'
? sql`${events.rsvpCount} + ${guestCount}`
: events.rsvpCount,
waitlistCount: status === 'waitlisted'
? sql`${events.waitlistCount} + ${guestCount}`
: events.waitlistCount,
})
.where(eq(events.id, eventId));
return { id: rsvpId, status };
}
This handled:
- Capacity checks
- Automatic waitlisting when full
- Guest count tracking
- Optimistic counter updates
The Sign-Up Sheet UI
Potlucks needed a visual sign-up interface:
// src/components/events/signup-sheet.tsx
export function SignUpSheet({ eventId }: Props) {
const [slots, setSlots] = useState<SignUpSlot[]>([]);
useEffect(() => {
fetch(`/api/events/${eventId}/slots`)
.then((res) => res.json())
.then((data) => setSlots(data.slots));
}, [eventId]);
const handleClaimSlot = async (slotId: string, details: string) => {
const response = await fetch(`/api/events/${eventId}/slots/${slotId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ details }),
});
if (!response.ok) {
toast.error('Failed to claim slot');
return;
}
// Refresh slots
const updated = await fetch(`/api/events/${eventId}/slots`).then((r) => r.json());
setSlots(updated.slots);
toast.success('Slot claimed!');
};
return (
<div className="space-y-4">
<h3 className="font-semibold text-lg">What to Bring</h3>
{slots.map((slot) => (
<Card key={slot.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{slot.slotName}</CardTitle>
<Badge variant="outline">
{slot.claimCount} / {slot.maxClaims} claimed
</Badge>
</div>
{slot.description && (
<CardDescription>{slot.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{slot.claims.map((claim) => (
<div key={claim.id} className="flex items-center gap-2 mb-2">
<Avatar className="h-6 w-6">
<AvatarImage src={claim.user.image} />
<AvatarFallback>{claim.user.name[0]}</AvatarFallback>
</Avatar>
<span className="text-sm">{claim.user.name}</span>
{claim.details && (
<span className="text-sm text-muted-foreground">- {claim.details}</span>
)}
</div>
))}
{slot.claimCount < slot.maxClaims && (
<ClaimSlotDialog
slotName={slot.slotName}
onClaim={(details) => handleClaimSlot(slot.id, details)}
/>
)}
</CardContent>
</Card>
))}
</div>
);
}
This UI made coordination visual - you could see who was bringing what, avoiding duplicate salads.
The Calendar View
Events needed discoverable beyond the feed. Enter the calendar:
// src/app/community/events/calendar/page.tsx
export default async function EventsCalendarPage({
searchParams,
}: {
searchParams: Promise<{ month?: string; type?: string }>;
}) {
const params = await searchParams;
const month = params.month || format(new Date(), 'yyyy-MM');
const eventType = params.type;
const [year, monthNum] = month.split('-').map(Number);
const startDate = new Date(year, monthNum - 1, 1);
const endDate = new Date(year, monthNum, 0, 23, 59, 59);
let query = db
.select()
.from(events)
.where(
and(
gte(events.startTime, startDate),
lte(events.startTime, endDate),
eq(events.status, 'active')
)
)
.orderBy(events.startTime);
if (eventType) {
query = query.where(eq(events.eventType, eventType));
}
const eventsData = await query;
return <EventsCalendarClient events={eventsData} currentMonth={month} />;
}
The client component rendered a traditional month grid with event markers.
The Map Integration
Events with coordinates appeared on the map:
// src/app/map/pageClient.tsx
{events.map((event) => (
<Marker
key={event.id}
longitude={event.locationCoords.lng}
latitude={event.locationCoords.lat}
onClick={() => setSelectedEvent(event.id)}
>
<Calendar className="text-blue-500 h-8 w-8" />
</Marker>
))}
Clicking an event marker showed a popup with:
- Event title and time
- RSVP count / capacity
- Quick RSVP button
- Link to full details
This unified discovery - food banks, events, and posts all on one map.
What Went Right
-
Comprehensive Schema: Planning tables upfront avoided painful migrations later
-
Capacity Management: Waitlist system prevented over-booking elegantly
-
Sign-Up Sheets: Solved potluck coordination without external tools
-
Map Integration: Events felt integrated, not bolted-on
-
Post Linkage: Showing events in feed increased visibility
What I'd Do Differently
Mistake 1: No Edit Flow
Once created, events couldn't be edited. This required creating a duplicate or manual database fixes.
Mistake 2: No Cancellation Notifications
Hosts could cancel events, but attendees weren't notified. Email/push notifications were deferred too long.
Mistake 3: Complex Wizard
The 5-step wizard tested well but felt heavy. A single-page form with sections would have been faster.
What I Learned
-
Events Are Complex: RSVPs, capacity, sign-ups, recurrence - each adds multiplicative complexity
-
Denormalization Matters More:
rsvpCountandclaimCountwere essential for performance -
UX Iteration Is Key: The wizard worked but needed refinement based on usage
-
Integration Beats Silos: Events on the map and in the feed felt cohesive
Up Next
In Part 7, I'll cover the CopilotKit migration - upgrading the AI chat system with better streaming, tool renderers, and generative UI.
Key Commits:
5a0cbe4- Implement Phase 3A event hosting backend (MVP foundation)93cc136- Implement Phase 3B event hosting UI (creation wizard + detail pages)2dc7f6d- Complete Phase 3B - event-focused community page redesign
Related Files:
src/lib/schema.ts- Event tablessrc/lib/event-queries.ts- Event data layersrc/app/api/events/route.ts- Events CRUD APIsrc/components/events/event-creation-wizard.tsx- Creation wizard
Jordan Hindo
Full-stack Developer & AI Engineer building in public. Exploring the future of agentic coding and AI-generated assets.
Get in touch