package main import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "time" "unicode/utf8" ) // ---- Helpers ---- func randHex(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b) } func msNow() int64 { return time.Now().UnixMilli() } // voteThreshold returns the number of votes required to trigger pending_removal // for a non-#1 entry. = min(3, ceil(n/2)), minimum 1. func voteThreshold(n int) int { half := (n + 1) / 2 if half > 3 { return 3 } if half < 1 { return 1 } return half } func sanitize(s string, maxRunes int) string { runes := []rune(s) if len(runes) > maxRunes { runes = runes[:maxRunes] } out := make([]rune, 0, len(runes)) for _, r := range runes { if r != 0 && utf8.ValidRune(r) { out = append(out, r) } } return string(out) } // ---- Data model ---- type Entry struct { ID string Number int Name string JoinedAt int64 // Unix ms Status string // "active" | "pending_removal" PendingBy []int PendingAt *int64 // Unix ms token string cancelFn context.CancelFunc } // pub returns the public (broadcast-safe) view of an entry. type entryPub struct { ID string `json:"id"` Number int `json:"number"` Name string `json:"name,omitempty"` JoinedAt int64 `json:"joinedAt"` Status string `json:"status"` PendingBy []int `json:"pendingBy,omitempty"` PendingAt *int64 `json:"pendingAt,omitempty"` } func (e *Entry) pub() entryPub { return entryPub{ ID: e.ID, Number: e.Number, Name: e.Name, JoinedAt: e.JoinedAt, Status: e.Status, PendingBy: e.PendingBy, PendingAt: e.PendingAt, } } // ---- Queue state ---- type QueueState struct { Name string // display name, set at creation Entries []*Entry Counter int CompletedSlots []float64 // seconds; only self-removals, not voted-out entries } // stateJSON serialises the full public state for broadcast. func (q *QueueState) stateJSON() []byte { views := make([]entryPub, len(q.Entries)) for i, e := range q.Entries { views[i] = e.pub() } b, _ := json.Marshal(map[string]interface{}{ "type": "state", "name": q.Name, "entries": views, "counter": q.Counter, "avgDuration": q.avgDuration(), }) return b } // avgDuration returns the average slot duration in seconds, or nil if <3 samples. func (q *QueueState) avgDuration() interface{} { if len(q.CompletedSlots) < 3 { return nil } var sum float64 for _, d := range q.CompletedSlots { sum += d } return sum / float64(len(q.CompletedSlots)) } func (q *QueueState) findByToken(token string) *Entry { for _, e := range q.Entries { if e.token == token { return e } } return nil } func (q *QueueState) findByID(id string) *Entry { for _, e := range q.Entries { if e.ID == id { return e } } return nil } func (q *QueueState) removeEntry(id string) { for i, e := range q.Entries { if e.ID == id { q.Entries = append(q.Entries[:i], q.Entries[i+1:]...) return } } } // findVoteTarget returns the Number of the entry the given voter is targeting, // or 0 if they have no active vote. func (q *QueueState) findVoteTarget(voterNumber int) int { for _, e := range q.Entries { for _, n := range e.PendingBy { if n == voterNumber { return e.Number } } } return 0 } // ---- Mutations ---- // join creates a new entry and returns it with its secret token. func (q *QueueState) join(name string) (*Entry, string) { name = sanitize(name, 50) token := randHex(16) q.Counter++ e := &Entry{ ID: randHex(8), Number: q.Counter, Name: name, JoinedAt: msNow(), Status: "active", token: token, } q.Entries = append(q.Entries, e) return e, token } // selfRemove removes the entry owned by token, recording the slot duration. func (q *QueueState) selfRemove(token string) bool { e := q.findByToken(token) if e == nil { return false } dur := float64(msNow()-e.JoinedAt) / 1000.0 q.CompletedSlots = append(q.CompletedSlots, dur) if e.cancelFn != nil { e.cancelFn() } removedNum := e.Number q.removeEntry(e.ID) q.cleanupVotes(removedNum) return true } // startPending transitions an entry into pending_removal and launches the 60s timer. // When the timer fires it sends the entry ID to timerC. func (q *QueueState) startPending(e *Entry, timerC chan<- string) { now := msNow() e.Status = "pending_removal" e.PendingAt = &now ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) e.cancelFn = cancel id := e.ID go func() { <-ctx.Done() cancel() if ctx.Err() == context.DeadlineExceeded { select { case timerC <- id: default: } } }() } // markDone marks the #1 entry as pending_removal on behalf of byNumber. // Returns false if the queue shifted or entry is already pending. func (q *QueueState) markDone(byNumber int, entryID string, timerC chan<- string) bool { if len(q.Entries) == 0 { return false } target := q.Entries[0] if target.ID != entryID { return false // queue shifted since the client clicked } if target.Status == "pending_removal" { return false } target.PendingBy = []int{byNumber} q.startPending(target, timerC) return true } // voteRemove adds a vote against a non-#1 entry. // Returns "ok", "already_voting", or "noop". func (q *QueueState) voteRemove(voterNumber int, targetID string, timerC chan<- string) string { target := q.findByID(targetID) if target == nil { return "noop" } // Cannot vote on own entry if target.Number == voterNumber { return "noop" } // Cannot vote on #1 (use markDone instead) if len(q.Entries) > 0 && q.Entries[0].ID == targetID { return "noop" } // One active vote per user across the whole queue if existing := q.findVoteTarget(voterNumber); existing != 0 { return "already_voting" } target.PendingBy = append(target.PendingBy, voterNumber) if len(target.PendingBy) >= voteThreshold(len(q.Entries)) { q.startPending(target, timerC) } return "ok" } // restore cancels pending_removal for the entry owned by token. func (q *QueueState) restore(token string) bool { e := q.findByToken(token) if e == nil || e.Status != "pending_removal" { return false } if e.cancelFn != nil { e.cancelFn() e.cancelFn = nil } e.Status = "active" e.PendingAt = nil e.PendingBy = nil return true } // timerRemove is called when the 60s timer fires for an entry. func (q *QueueState) timerRemove(entryID string) bool { e := q.findByID(entryID) if e == nil || e.Status != "pending_removal" { return false // already restored } removedNum := e.Number q.removeEntry(entryID) q.cleanupVotes(removedNum) return true } // cleanupVotes removes a departed entry's number from all PendingBy lists. // For entries in pending_removal with remaining votes below threshold, restores them. // Called AFTER the entry has been removed from q.Entries so threshold uses updated count. func (q *QueueState) cleanupVotes(removedNumber int) { thresh := voteThreshold(len(q.Entries)) for _, e := range q.Entries { for i, n := range e.PendingBy { if n == removedNumber { e.PendingBy = append(e.PendingBy[:i], e.PendingBy[i+1:]...) // Restore only if votes remain but are now below threshold (Case B). // If PendingBy is empty after removal (Case A marker left), let the // 60s timer run its course — the action was already taken. if e.Status == "pending_removal" && len(e.PendingBy) > 0 && len(e.PendingBy) < thresh { if e.cancelFn != nil { e.cancelFn() e.cancelFn = nil } e.Status = "active" e.PendingAt = nil e.PendingBy = nil } break } } } }