Add RSVP support and Open Graph meta tags

People can now RSVP without claiming a slot. OG tags show
event title, date/time/location, and headcount in link previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 10:18:36 -04:00
parent 2b08f81c8d
commit d51e7fe867
9 changed files with 238 additions and 5 deletions
+8
View File
@@ -27,6 +27,14 @@ type Event struct {
CreatedAt time.Time
}
type Rsvp struct {
ID int64
EventID int64
Name string
Note string
CreatedAt time.Time
}
type Slot struct {
ID int64
EventID int64
+14
View File
@@ -51,3 +51,17 @@ DELETE FROM claims WHERE id = ?;
-- name: CountClaimsBySlot :one
SELECT COUNT(*) FROM claims WHERE slot_id = ?;
-- name: ListRsvps :many
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
-- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note)
VALUES (?, ?, ?)
RETURNING *;
-- name: DeleteRsvp :exec
DELETE FROM rsvps WHERE id = ?;
-- name: CountRsvps :one
SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
+78
View File
@@ -20,6 +20,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
return count, err
}
const countRsvps = `-- name: CountRsvps :one
SELECT COUNT(*) FROM rsvps WHERE event_id = ?
`
func (q *Queries) CountRsvps(ctx context.Context, eventID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countRsvps, eventID)
var count int64
err := row.Scan(&count)
return count, err
}
const createClaim = `-- name: CreateClaim :one
INSERT INTO claims (slot_id, name, note)
VALUES (?, ?, ?)
@@ -83,6 +94,31 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
return i, err
}
const createRsvp = `-- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note)
VALUES (?, ?, ?)
RETURNING id, event_id, name, note, created_at
`
type CreateRsvpParams struct {
EventID int64
Name string
Note string
}
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
row := q.db.QueryRowContext(ctx, createRsvp, arg.EventID, arg.Name, arg.Note)
var i Rsvp
err := row.Scan(
&i.ID,
&i.EventID,
&i.Name,
&i.Note,
&i.CreatedAt,
)
return i, err
}
const createSlot = `-- name: CreateSlot :one
INSERT INTO slots (event_id, name, emoji, max_claims, sort_order)
VALUES (?, ?, ?, ?, ?)
@@ -135,6 +171,15 @@ func (q *Queries) DeleteEvent(ctx context.Context, id int64) error {
return err
}
const deleteRsvp = `-- name: DeleteRsvp :exec
DELETE FROM rsvps WHERE id = ?
`
func (q *Queries) DeleteRsvp(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteRsvp, id)
return err
}
const deleteSlot = `-- name: DeleteSlot :exec
DELETE FROM slots WHERE id = ?
`
@@ -271,6 +316,39 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim,
return items, nil
}
const listRsvps = `-- name: ListRsvps :many
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
`
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
rows, err := q.db.QueryContext(ctx, listRsvps, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Rsvp
for rows.Next() {
var i Rsvp
if err := rows.Scan(
&i.ID,
&i.EventID,
&i.Name,
&i.Note,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSlots = `-- name: ListSlots :many
SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE event_id = ? ORDER BY sort_order, id
`
+65
View File
@@ -96,6 +96,7 @@ type SlotView struct {
type EventPageData struct {
Event db.Event
Slots []SlotView
Rsvps []db.Rsvp
TotalGoing int64
IsAdmin bool
}
@@ -143,9 +144,16 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
})
}
rsvps, err := s.q.ListRsvps(r.Context(), event.ID)
if err != nil {
return nil, err
}
totalGoing += int64(len(rsvps))
return &EventPageData{
Event: event,
Slots: slotViews,
Rsvps: rsvps,
TotalGoing: totalGoing,
IsAdmin: isAdmin,
}, nil
@@ -262,6 +270,63 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
}
// --- RSVP ---
func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
r.ParseForm()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
note := strings.TrimSpace(r.FormValue("note"))
event, err := s.q.GetEventBySlug(r.Context(), slug)
if err != nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note,
})
if err != nil {
log.Printf("create rsvp: %v", err)
http.Error(w, "Failed", http.StatusInternalServerError)
return
}
s.notify(slug)
data, err := s.loadEventPage(r, slug, false)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
}
func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
if err != nil {
http.Error(w, "Invalid RSVP", http.StatusBadRequest)
return
}
s.q.DeleteRsvp(r.Context(), rsvpID)
s.notify(slug)
data, err := s.loadEventPage(r, slug, false)
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
}
// --- SSE ---
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
+2
View File
@@ -146,6 +146,8 @@ func main() {
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
r.Post("/e/{slug}/claim", srv.handleClaim)
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
// SSE
r.Get("/e/{slug}/sse", srv.handleSSE)
+9
View File
@@ -26,5 +26,14 @@ CREATE TABLE IF NOT EXISTS claims (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rsvps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_slots_event ON slots(event_id);
CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_event ON rsvps(event_id);
+34 -5
View File
@@ -2,6 +2,15 @@
{{define "title"}}{{.Event.Title}} — bbq{{end}}
{{define "meta"}}
<meta property="og:title" content="{{.Event.Title}}">
<meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.Event.Title}}">
<meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
{{end}}
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
{{define "content"}}
@@ -15,8 +24,6 @@
</div>
</div>
<div class="section-label">What's needed</div>
<div id="slots-container"
hx-ext="sse"
sse-connect="/e/{{.Event.Slug}}/sse"
@@ -25,10 +32,31 @@
{{template "slots-inner" .}}
</div>
<div class="section-label">Add your name</div>
<div class="section-label" style="margin-top:40px">Sign up</div>
<div class="claim-form-wrapper">
<div class="form-title">I'll bring something &#8594;</div>
<div class="form-title">I'm coming &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/rsvp"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required>
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" name="note" placeholder="e.g. +1, arriving late, etc.">
</div>
<button class="btn-submit" type="submit">Count me in &#8599;</button>
</form>
</div>
{{if .Slots}}
<div class="section-label" style="margin-top:40px">I'll bring something</div>
<div class="claim-form-wrapper">
<div class="form-title">Claim a slot &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/claim"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
@@ -49,9 +77,10 @@
<label>Note (optional)</label>
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
</div>
<button class="btn-submit" type="submit">Count me in &#8599;</button>
<button class="btn-submit" type="submit">Claim &#8599;</button>
</form>
</div>
{{end}}
{{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Add slot</div>
+7
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}picnic{{end}}</title>
{{block "meta" .}}{{end}}
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,700;12..96,800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
@@ -319,6 +320,12 @@
background: var(--yellow);
border-color: var(--border);
}
.rsvp-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 40px;
}
.btn-danger {
background: none;
border: none;
+21
View File
@@ -1,4 +1,6 @@
{{define "slots-inner"}}
<div class="section-label">What's needed</div>
<div class="slots-grid">
{{range .Slots}}
<div class="slot-card{{if .IsFull}} full{{end}}">
<div class="slot-info">
@@ -28,6 +30,25 @@
</div>
</div>
{{end}}
</div>
<div class="section-label">Going ({{len .Rsvps}})</div>
<div class="rsvp-list">
{{if .Rsvps}}
{{range .Rsvps}}
<span class="claim-chip">
{{.Name}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.ID}}"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-confirm="Remove {{.Name}}?"
title="Remove">&#215;</button>
</span>
{{end}}
{{else}}
<span class="nobody">no one yet</span>
{{end}}
</div>
{{end}}
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}