Add Readme and code

This commit is contained in:
2026-06-10 08:31:59 +02:00
commit d3b839e35b
2 changed files with 497 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
# Group Dates
A tiny self-hosted web app for scheduling get-togethers with your friend group.
You circle dates on a calendar, optionally add a time per day, and share one
link. Friends open the same calendar, circle the days that work for them, and
the best date floats to the top. No accounts, no sign-up — the link is the key.
Built with **Go standard library only**: no external dependencies, one static
binary, polls stored in a plain JSON file.
## Run it
Requires Go 1.22+.
```sh
go build -o groupdates .
./groupdates
```
Open http://localhost:8080 — that's the page for creating a poll.
þ
Flags:
| Flag | Default | Meaning |
|---------|---------|----------------------------------|
| `-addr` | `:8080` | Listen address |
| `-data` | `data` | Directory for `polls.json` |
To let friends reach it, run it on a machine they can access (a small VPS, a
Raspberry Pi behind a port forward, etc.) and put it behind HTTPS — e.g. a
Caddy or nginx reverse proxy. The share links use whatever host your friends
open the page on, so no configuration is needed.
## How it works
1. **Create a poll** — give it a title, click every date you want to offer on
the calendar (only today and future dates are clickable), and optionally
set a time for each date in the list below the calendar.
2. **Share** — after creating you get two links:
- **Share link** (`/p/<id>`) — send this to the group.
- **Admin link** (`/p/<id>?admin=<token>`) — keep this. It's the only way
to close or delete the poll.
3. **Friends answer** — they open the share link, see the same calendar with
the offered days highlighted, circle the ones that work, enter their name,
and send. Answering again with the same name (case-insensitive) replaces
the earlier answer.
4. **Close or delete** — polls can never be edited after creation. From the
admin link you can *close* the poll (answers freeze, results stay visible)
or *delete* it entirely.
The poll page refreshes results every 30 seconds while open.
## API (if you're curious)
| Method | Path | What it does |
|----------|------------------------|---------------------------------------|
| `POST` | `/api/polls` | Create a poll, returns id + admin token |
| `GET` | `/api/polls/{id}` | Poll data (admin token never included) |
| `POST` | `/api/polls/{id}/votes`| Submit / replace an answer |
| `POST` | `/api/polls/{id}/close`| Close (admin token required) |
| `DELETE` | `/api/polls/{id}` | Delete (admin token required) |
Admin token goes in the `X-Admin-Token` header or `?admin=` query parameter.
## Storage
Everything lives in `<data-dir>/polls.json`, written atomically on every
change. Back it up by copying that one file. Deleting a poll removes it from
the file immediately.
## Project layout
```
main.go server, storage, API handlers (stdlib only)
static/index.html create-a-poll page
static/poll.html voting + results page
static/calendar.js the shared calendar widget both pages use
static/create.js create-page logic
static/poll.js poll-page logic
static/style.css styles
```
Static files are embedded into the binary with `go:embed`, so the compiled
`groupdates` binary is all you need to deploy.
+413
View File
@@ -0,0 +1,413 @@
package main
import (
"crypto/rand"
"embed"
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
//go:embed static
var staticFS embed.FS
// ---------- Models ----------
type Option struct {
ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
Time string `json:"time,omitempty"` // HH:MM, optional
}
type Vote struct {
Name string `json:"name"`
OptionIDs []string `json:"optionIds"`
CreatedAt time.Time `json:"createdAt"`
}
type Poll struct {
ID string `json:"id"`
AdminToken string `json:"-"` // never serialized to clients
Title string `json:"title"`
Description string `json:"description,omitempty"`
Options []Option `json:"options"`
Votes []Vote `json:"votes"`
Closed bool `json:"closed"`
CreatedAt time.Time `json:"createdAt"`
}
// pollFile is the on-disk representation (includes the admin token).
type pollFile struct {
Poll
AdminToken string `json:"adminToken"`
}
// ---------- Store ----------
type Store struct {
mu sync.Mutex
path string
polls map[string]*Poll
}
func NewStore(path string) (*Store, error) {
s := &Store{path: path, polls: map[string]*Poll{}}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
return nil, err
}
var list []pollFile
if err := json.Unmarshal(data, &list); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
for i := range list {
p := list[i].Poll
p.AdminToken = list[i].AdminToken
s.polls[p.ID] = &p
}
return s, nil
}
// persist writes all polls to disk. Caller must hold s.mu.
func (s *Store) persist() error {
list := make([]pollFile, 0, len(s.polls))
for _, p := range s.polls {
list = append(list, pollFile{Poll: *p, AdminToken: p.AdminToken})
}
sort.Slice(list, func(i, j int) bool { return list[i].CreatedAt.Before(list[j].CreatedAt) })
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *Store) Create(p *Poll) error {
s.mu.Lock()
defer s.mu.Unlock()
s.polls[p.ID] = p
return s.persist()
}
func (s *Store) Get(id string) (*Poll, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.polls[id]
if !ok {
return nil, false
}
cp := *p // shallow copy is fine for read-only use
return &cp, true
}
// Update runs fn on the poll under lock and persists the result.
func (s *Store) Update(id string, fn func(*Poll) error) error {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.polls[id]
if !ok {
return errNotFound
}
if err := fn(p); err != nil {
return err
}
return s.persist()
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.polls[id]; !ok {
return errNotFound
}
delete(s.polls, id)
return s.persist()
}
var errNotFound = errors.New("not found")
// ---------- Helpers ----------
const idAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func randomID(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
out := make([]byte, n)
for i, v := range b {
out[i] = idAlphabet[int(v)%len(idAlphabet)]
}
return string(out)
}
var (
dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
timeRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func adminToken(r *http.Request) string {
if t := r.Header.Get("X-Admin-Token"); t != "" {
return t
}
return r.URL.Query().Get("admin")
}
// ---------- Handlers ----------
type server struct {
store *Store
}
type createPollRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Options []struct {
Date string `json:"date"`
Time string `json:"time"`
} `json:"options"`
}
func (sv *server) createPoll(w http.ResponseWriter, r *http.Request) {
var req createPollRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body.")
return
}
req.Title = strings.TrimSpace(req.Title)
req.Description = strings.TrimSpace(req.Description)
if req.Title == "" {
writeError(w, http.StatusBadRequest, "A title is required.")
return
}
if len(req.Title) > 120 || len(req.Description) > 600 {
writeError(w, http.StatusBadRequest, "Title or description is too long.")
return
}
if len(req.Options) == 0 {
writeError(w, http.StatusBadRequest, "Select at least one date.")
return
}
if len(req.Options) > 60 {
writeError(w, http.StatusBadRequest, "Too many dates (max 60).")
return
}
seen := map[string]bool{}
options := make([]Option, 0, len(req.Options))
for _, o := range req.Options {
if !dateRe.MatchString(o.Date) {
writeError(w, http.StatusBadRequest, "Invalid date: "+o.Date)
return
}
if _, err := time.Parse("2006-01-02", o.Date); err != nil {
writeError(w, http.StatusBadRequest, "Invalid date: "+o.Date)
return
}
if o.Time != "" && !timeRe.MatchString(o.Time) {
writeError(w, http.StatusBadRequest, "Invalid time for "+o.Date+" (use HH:MM).")
return
}
key := o.Date + "T" + o.Time
if seen[key] {
continue
}
seen[key] = true
options = append(options, Option{ID: randomID(8), Date: o.Date, Time: o.Time})
}
sort.Slice(options, func(i, j int) bool {
if options[i].Date != options[j].Date {
return options[i].Date < options[j].Date
}
return options[i].Time < options[j].Time
})
p := &Poll{
ID: randomID(10),
AdminToken: randomID(24),
Title: req.Title,
Description: req.Description,
Options: options,
Votes: []Vote{},
CreatedAt: time.Now().UTC(),
}
if err := sv.store.Create(p); err != nil {
log.Printf("create poll: %v", err)
writeError(w, http.StatusInternalServerError, "Could not save the poll.")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"id": p.ID, "adminToken": p.AdminToken})
}
func (sv *server) getPoll(w http.ResponseWriter, r *http.Request) {
p, ok := sv.store.Get(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "Poll not found.")
return
}
resp := struct {
*Poll
IsAdmin bool `json:"isAdmin"`
}{p, adminToken(r) == p.AdminToken}
writeJSON(w, http.StatusOK, resp)
}
type voteRequest struct {
Name string `json:"name"`
OptionIDs []string `json:"optionIds"`
}
func (sv *server) vote(w http.ResponseWriter, r *http.Request) {
var req voteRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body.")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
writeError(w, http.StatusBadRequest, "Enter your name so friends know who answered.")
return
}
if len(req.Name) > 60 {
writeError(w, http.StatusBadRequest, "Name is too long.")
return
}
err := sv.store.Update(r.PathValue("id"), func(p *Poll) error {
if p.Closed {
return errors.New("This poll is closed.")
}
valid := map[string]bool{}
for _, o := range p.Options {
valid[o.ID] = true
}
ids := []string{}
seen := map[string]bool{}
for _, id := range req.OptionIDs {
if valid[id] && !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
v := Vote{Name: req.Name, OptionIDs: ids, CreatedAt: time.Now().UTC()}
// Same name (case-insensitive) replaces the earlier answer.
for i := range p.Votes {
if strings.EqualFold(p.Votes[i].Name, req.Name) {
p.Votes[i] = v
return nil
}
}
p.Votes = append(p.Votes, v)
return nil
})
if err != nil {
if errors.Is(err, errNotFound) {
writeError(w, http.StatusNotFound, "Poll not found.")
} else {
writeError(w, http.StatusConflict, err.Error())
}
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (sv *server) withAdmin(fn func(http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p, ok := sv.store.Get(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "Poll not found.")
return
}
if adminToken(r) != p.AdminToken {
writeError(w, http.StatusForbidden, "Only the poll creator can do this. Use your admin link.")
return
}
fn(w, r)
}
}
func (sv *server) closePoll(w http.ResponseWriter, r *http.Request) {
err := sv.store.Update(r.PathValue("id"), func(p *Poll) error {
p.Closed = true
return nil
})
if err != nil {
writeError(w, http.StatusNotFound, "Poll not found.")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (sv *server) deletePoll(w http.ResponseWriter, r *http.Request) {
if err := sv.store.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "Poll not found.")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---------- main ----------
func main() {
addr := flag.String("addr", ":8080", "listen address")
dataDir := flag.String("data", "data", "directory for poll storage")
flag.Parse()
if err := os.MkdirAll(*dataDir, 0o755); err != nil {
log.Fatal(err)
}
store, err := NewStore(filepath.Join(*dataDir, "polls.json"))
if err != nil {
log.Fatal(err)
}
sv := &server{store: store}
static, _ := fs.Sub(staticFS, "static")
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, static, "index.html")
})
mux.HandleFunc("GET /p/{id}", func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, static, "poll.html")
})
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(static)))
mux.HandleFunc("POST /api/polls", sv.createPoll)
mux.HandleFunc("GET /api/polls/{id}", sv.getPoll)
mux.HandleFunc("POST /api/polls/{id}/votes", sv.vote)
mux.HandleFunc("POST /api/polls/{id}/close", sv.withAdmin(sv.closePoll))
mux.HandleFunc("DELETE /api/polls/{id}", sv.withAdmin(sv.deletePoll))
log.Printf("Group Dates running on http://localhost%s (data in %s)", *addr, *dataDir)
log.Fatal(http.ListenAndServe(*addr, mux))
}