Add Readme and code
This commit is contained in:
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user