Compare commits
5 Commits
791af99662
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e850d3b00 | |||
| 95fde663ca | |||
| 98692359db | |||
| e4e555cac3 | |||
| 2b63370873 |
@@ -25,6 +25,10 @@ RUN mkdir -p /app/data && chown -R app:app /app
|
|||||||
EXPOSE 8002
|
EXPOSE 8002
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8002
|
ENV PORT=8002
|
||||||
|
ENV MAX_ACTIVITY_LOG_ENTRIES=400
|
||||||
|
ENV ADMIN_LOG_BROADCAST_LIMIT=200
|
||||||
|
ENV STALE_ROOM_CLEANUP_INTERVAL=5m
|
||||||
|
ENV STALE_ROOM_TTL=30m
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
|
|||||||
109
README.md
109
README.md
@@ -40,9 +40,100 @@ Enterprise-style Scrum Poker application using Go, Gin, and SSE for real-time ro
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
- `HOST`: server host/interface to bind (default all interfaces, equivalent to `0.0.0.0`)
|
### Quick Reference
|
||||||
- `PORT`: server port (default `8002`)
|
|
||||||
- `DATA_PATH`: directory for room JSON files (default `./data`)
|
- `HOST` (default: `0.0.0.0`): network interface/address the server binds to.
|
||||||
|
- `PORT` (default: `8002`): HTTP port the server listens on.
|
||||||
|
- `DATA_PATH` (default: `./data`): folder where room JSON files are stored.
|
||||||
|
- `MAX_ACTIVITY_LOG_ENTRIES` (default: `400`): max activity entries retained per room.
|
||||||
|
- `ADMIN_LOG_BROADCAST_LIMIT` (default: `200`): max recent log entries sent to admins over SSE.
|
||||||
|
- `STALE_ROOM_CLEANUP_INTERVAL` (default: `5m`): how often stale-room cleanup runs.
|
||||||
|
- `STALE_ROOM_TTL` (default: `30m`): empty rooms older than this are deleted.
|
||||||
|
|
||||||
|
### Variable Guide With Use Cases
|
||||||
|
|
||||||
|
`HOST`
|
||||||
|
- What it controls: where the web server is reachable from.
|
||||||
|
- Use case: local-only dev on one machine.
|
||||||
|
```bash
|
||||||
|
HOST=localhost PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: allow access from LAN/Docker/k8s ingress.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`PORT`
|
||||||
|
- What it controls: server port.
|
||||||
|
- Use case: avoid conflicts with another app already on `8002`.
|
||||||
|
```bash
|
||||||
|
PORT=9000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: common reverse-proxy port mapping.
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=8080 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`DATA_PATH`
|
||||||
|
- What it controls: disk location for persisted rooms.
|
||||||
|
- Use case: keep data in a dedicated local directory.
|
||||||
|
```bash
|
||||||
|
DATA_PATH=./tmp/rooms go run ./src
|
||||||
|
```
|
||||||
|
- Use case: persistent mount in containers.
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 -e DATA_PATH=/app/data -v $(pwd)/data:/app/data scrum-solitare
|
||||||
|
```
|
||||||
|
|
||||||
|
`MAX_ACTIVITY_LOG_ENTRIES`
|
||||||
|
- What it controls: per-room in-memory/disk activity history cap.
|
||||||
|
- Use case: retain more history for auditing/debugging.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=1000 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce memory/storage in lightweight deployments.
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=100 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`ADMIN_LOG_BROADCAST_LIMIT`
|
||||||
|
- What it controls: maximum recent activity entries included in admin SSE payloads.
|
||||||
|
- Use case: richer admin timeline in active rooms.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=400 go run ./src
|
||||||
|
```
|
||||||
|
- Use case: smaller SSE payloads for lower bandwidth.
|
||||||
|
```bash
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=50 go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL`
|
||||||
|
- What it controls: scheduler frequency for checking stale empty rooms.
|
||||||
|
- Use case: aggressive cleanup in ephemeral environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=1m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: reduce cleanup churn in stable environments.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=15m go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
`STALE_ROOM_TTL`
|
||||||
|
- What it controls: time since last room activity before an empty room is deleted.
|
||||||
|
- Use case: auto-prune quickly for temporary team rooms.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=10m go run ./src
|
||||||
|
```
|
||||||
|
- Use case: keep empty rooms around longer between sessions.
|
||||||
|
```bash
|
||||||
|
STALE_ROOM_TTL=2h go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duration Format Notes
|
||||||
|
|
||||||
|
`STALE_ROOM_CLEANUP_INTERVAL` and `STALE_ROOM_TTL` use Go duration syntax:
|
||||||
|
- valid examples: `30s`, `5m`, `45m`, `2h`
|
||||||
|
- invalid examples: `5` (missing unit), `five minutes`
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
@@ -53,13 +144,23 @@ go run ./src
|
|||||||
|
|
||||||
Open `http://localhost:8002`.
|
Open `http://localhost:8002`.
|
||||||
|
|
||||||
To bind explicitly:
|
Host binding examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
HOST=localhost PORT=8002 go run ./src
|
HOST=localhost PORT=8002 go run ./src
|
||||||
HOST=0.0.0.0 PORT=8002 go run ./src
|
HOST=0.0.0.0 PORT=8002 go run ./src
|
||||||
```
|
```
|
||||||
|
|
||||||
|
State tuning example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAX_ACTIVITY_LOG_ENTRIES=600 \
|
||||||
|
ADMIN_LOG_BROADCAST_LIMIT=300 \
|
||||||
|
STALE_ROOM_CLEANUP_INTERVAL=2m \
|
||||||
|
STALE_ROOM_TTL=45m \
|
||||||
|
go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string
|
Host string
|
||||||
Port string
|
Port string
|
||||||
DataPath string
|
DataPath string
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func envIntOrDefault(name string, fallback int) int {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDurationOrDefault(name string, fallback time.Duration) time.Duration {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -24,9 +60,18 @@ func Load() Config {
|
|||||||
dataPath = "./data"
|
dataPath = "./data"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxActivityLogEntries := envIntOrDefault("MAX_ACTIVITY_LOG_ENTRIES", 400)
|
||||||
|
adminLogBroadcastLimit := envIntOrDefault("ADMIN_LOG_BROADCAST_LIMIT", 200)
|
||||||
|
staleRoomCleanupInterval := envDurationOrDefault("STALE_ROOM_CLEANUP_INTERVAL", 5*time.Minute)
|
||||||
|
staleRoomTTL := envDurationOrDefault("STALE_ROOM_TTL", 30*time.Minute)
|
||||||
|
|
||||||
return Config{
|
return Config{
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
DataPath: dataPath,
|
DataPath: dataPath,
|
||||||
|
MaxActivityLogEntries: maxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: adminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: staleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: staleRoomTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
manager, err := state.NewManager(cfg.DataPath)
|
manager, err := state.NewManager(cfg.DataPath, state.ManagerOptions{
|
||||||
|
MaxActivityLogEntries: cfg.MaxActivityLogEntries,
|
||||||
|
AdminLogBroadcastLimit: cfg.AdminLogBroadcastLimit,
|
||||||
|
StaleRoomCleanupInterval: cfg.StaleRoomCleanupInterval,
|
||||||
|
StaleRoomTTL: cfg.StaleRoomTTL,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize state manager: %v", err)
|
log.Fatalf("failed to initialize state manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,138 @@ package state
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxActivityLogEntries = 400
|
defaultMaxActivityLogEntries = 400
|
||||||
adminLogBroadcastLimit = 200
|
defaultAdminLogBroadcastLimit = 200
|
||||||
|
defaultStaleRoomCleanupInterval = 5 * time.Minute
|
||||||
|
defaultStaleRoomTTL = 30 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]*Room
|
rooms map[string]*Room
|
||||||
store *DiskStore
|
store *DiskStore
|
||||||
|
|
||||||
|
maxActivityLogEntries int
|
||||||
|
adminLogBroadcastLimit int
|
||||||
|
staleRoomCleanupInterval time.Duration
|
||||||
|
staleRoomTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(dataPath string) (*Manager, error) {
|
type ManagerOptions struct {
|
||||||
|
MaxActivityLogEntries int
|
||||||
|
AdminLogBroadcastLimit int
|
||||||
|
StaleRoomCleanupInterval time.Duration
|
||||||
|
StaleRoomTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeManagerOptions(opts ManagerOptions) ManagerOptions {
|
||||||
|
if opts.MaxActivityLogEntries <= 0 {
|
||||||
|
opts.MaxActivityLogEntries = defaultMaxActivityLogEntries
|
||||||
|
}
|
||||||
|
if opts.AdminLogBroadcastLimit <= 0 {
|
||||||
|
opts.AdminLogBroadcastLimit = defaultAdminLogBroadcastLimit
|
||||||
|
}
|
||||||
|
if opts.StaleRoomCleanupInterval <= 0 {
|
||||||
|
opts.StaleRoomCleanupInterval = defaultStaleRoomCleanupInterval
|
||||||
|
}
|
||||||
|
if opts.StaleRoomTTL <= 0 {
|
||||||
|
opts.StaleRoomTTL = defaultStaleRoomTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(dataPath string, opts ManagerOptions) (*Manager, error) {
|
||||||
store, err := NewDiskStore(dataPath)
|
store, err := NewDiskStore(dataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
normalizedOpts := normalizeManagerOptions(opts)
|
||||||
|
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
rooms: make(map[string]*Room),
|
rooms: make(map[string]*Room),
|
||||||
store: store,
|
store: store,
|
||||||
|
maxActivityLogEntries: normalizedOpts.MaxActivityLogEntries,
|
||||||
|
adminLogBroadcastLimit: normalizedOpts.AdminLogBroadcastLimit,
|
||||||
|
staleRoomCleanupInterval: normalizedOpts.StaleRoomCleanupInterval,
|
||||||
|
staleRoomTTL: normalizedOpts.StaleRoomTTL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
if loadErr := manager.loadFromDisk(); loadErr != nil {
|
||||||
return nil, loadErr
|
return nil, loadErr
|
||||||
}
|
}
|
||||||
|
manager.startCleanupLoop()
|
||||||
|
|
||||||
return manager, nil
|
return manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) startCleanupLoop() {
|
||||||
|
ticker := time.NewTicker(m.staleRoomCleanupInterval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
m.cleanupStaleRooms(nowUTC())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupStaleRooms(now time.Time) {
|
||||||
|
m.mu.RLock()
|
||||||
|
rooms := make([]*Room, 0, len(m.rooms))
|
||||||
|
for _, room := range m.rooms {
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, room := range rooms {
|
||||||
|
room.mu.Lock()
|
||||||
|
roomID := room.ID
|
||||||
|
hasConnected := hasConnectedParticipantsLocked(room)
|
||||||
|
recentlyActive := now.Sub(room.UpdatedAt) < m.staleRoomTTL
|
||||||
|
hasSubscribers := len(room.subscribers) > 0
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
if hasConnected || recentlyActive || hasSubscribers {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
current, ok := m.rooms[roomID]
|
||||||
|
if !ok || current != room {
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.mu.Lock()
|
||||||
|
if hasConnectedParticipantsLocked(current) || now.Sub(current.UpdatedAt) < m.staleRoomTTL || len(current.subscribers) > 0 {
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(m.rooms, roomID)
|
||||||
|
current.mu.Unlock()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.store.Delete(roomID); err != nil {
|
||||||
|
log.Printf("failed to delete stale room %s: %v", roomID, err)
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.rooms[roomID]; !exists {
|
||||||
|
m.rooms[roomID] = room
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) {
|
||||||
roomName := normalizeName(input.RoomName, 80)
|
roomName := normalizeName(input.RoomName, 80)
|
||||||
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
creatorUsername := normalizeName(input.CreatorUsername, 32)
|
||||||
@@ -683,8 +781,8 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by
|
|||||||
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
state.Links.AdminLink = "/room/" + room.ID + "?adminToken=" + room.AdminToken
|
||||||
|
|
||||||
start := 0
|
start := 0
|
||||||
if len(room.ActivityLog) > adminLogBroadcastLimit {
|
if len(room.ActivityLog) > m.adminLogBroadcastLimit {
|
||||||
start = len(room.ActivityLog) - adminLogBroadcastLimit
|
start = len(room.ActivityLog) - m.adminLogBroadcastLimit
|
||||||
}
|
}
|
||||||
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start)
|
state.AdminLogs = make([]PublicActivityLogEntry, 0, len(room.ActivityLog)-start)
|
||||||
for _, item := range room.ActivityLog[start:] {
|
for _, item := range room.ActivityLog[start:] {
|
||||||
@@ -734,8 +832,8 @@ func (m *Manager) appendActivityLogLocked(room *Room, format string, args ...any
|
|||||||
Message: fmt.Sprintf(format, args...),
|
Message: fmt.Sprintf(format, args...),
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(room.ActivityLog) > maxActivityLogEntries {
|
if len(room.ActivityLog) > m.maxActivityLogEntries {
|
||||||
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-maxActivityLogEntries:]...)
|
room.ActivityLog = append([]ActivityLogEntry(nil), room.ActivityLog[len(room.ActivityLog)-m.maxActivityLogEntries:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,3 +842,13 @@ func (m *Manager) disconnectParticipantLocked(room *Room, participant *Participa
|
|||||||
participant.UpdatedAt = nowUTC()
|
participant.UpdatedAt = nowUTC()
|
||||||
room.UpdatedAt = nowUTC()
|
room.UpdatedAt = nowUTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasConnectedParticipantsLocked(room *Room) bool {
|
||||||
|
for _, participant := range room.Participants {
|
||||||
|
if participant.Connected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,3 +75,20 @@ func (ds *DiskStore) LoadAll() ([]persistedRoom, error) {
|
|||||||
|
|
||||||
return rooms, nil
|
return rooms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ds *DiskStore) Delete(roomID string) error {
|
||||||
|
ds.mu.Lock()
|
||||||
|
defer ds.mu.Unlock()
|
||||||
|
|
||||||
|
finalPath := filepath.Join(ds.dataPath, roomID+".json")
|
||||||
|
if err := os.Remove(finalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := finalPath + ".tmp"
|
||||||
|
if err := os.Remove(tmpPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,7 +86,20 @@
|
|||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="room-password">Room password (optional)</label>
|
<label for="room-password">Room password (optional)</label>
|
||||||
<input type="password" id="room-password" name="password" maxlength="64" placeholder="Optional password">
|
<input
|
||||||
|
type="password"
|
||||||
|
id="room-password"
|
||||||
|
name="password"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="Optional password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
data-bwignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,17 @@ body.is-dragging-window .ui-tool-title-bar {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-panel-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-window > .window-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.participants-window {
|
.participants-window {
|
||||||
min-height: 24rem;
|
min-height: 24rem;
|
||||||
}
|
}
|
||||||
@@ -421,12 +432,13 @@ body.is-dragging-window .ui-tool-title-bar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.participants-scroll {
|
.participants-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 13rem;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ function parseNumericVote(value) {
|
|||||||
|
|
||||||
function calculateSummary(state) {
|
function calculateSummary(state) {
|
||||||
const rows = new Map();
|
const rows = new Map();
|
||||||
const numericVotes = [];
|
const scoreVotes = [];
|
||||||
|
const cardIndexByValue = new Map(state.cards.map((cardValue, index) => [cardValue, index]));
|
||||||
|
let hasNumericVote = false;
|
||||||
|
|
||||||
state.participants.forEach((participant) => {
|
state.participants.forEach((participant) => {
|
||||||
if (!participant.connected) {
|
if (!participant.connected) {
|
||||||
@@ -221,26 +223,42 @@ function calculateSummary(state) {
|
|||||||
|
|
||||||
const numeric = parseNumericVote(participant.voteValue);
|
const numeric = parseNumericVote(participant.voteValue);
|
||||||
if (numeric !== null) {
|
if (numeric !== null) {
|
||||||
numericVotes.push(numeric);
|
hasNumericVote = true;
|
||||||
|
scoreVotes.push(numeric);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardIndexByValue.has(participant.voteValue)) {
|
||||||
|
scoreVotes.push(cardIndexByValue.get(participant.voteValue));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let average = null;
|
let average = null;
|
||||||
if (numericVotes.length > 0) {
|
if (scoreVotes.length > 0) {
|
||||||
average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length;
|
average = scoreVotes.reduce((acc, value) => acc + value, 0) / scoreVotes.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckNumeric = state.cards
|
let averageCard = null;
|
||||||
.map(parseNumericVote)
|
|
||||||
.filter((value) => value !== null)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
let recommended = null;
|
let recommended = null;
|
||||||
if (average !== null && deckNumeric.length > 0) {
|
if (!hasNumericVote && average !== null && state.cards.length > 0) {
|
||||||
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
const roundedIndex = Math.min(
|
||||||
|
state.cards.length - 1,
|
||||||
|
Math.max(0, Math.round(average)),
|
||||||
|
);
|
||||||
|
averageCard = state.cards[roundedIndex];
|
||||||
|
recommended = state.cards[roundedIndex];
|
||||||
|
} else {
|
||||||
|
const deckNumeric = state.cards
|
||||||
|
.map(parseNumericVote)
|
||||||
|
.filter((value) => value !== null)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (average !== null && deckNumeric.length > 0) {
|
||||||
|
recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, average, recommended };
|
return { rows, average, recommended, averageCard };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(state) {
|
function renderSummary(state) {
|
||||||
@@ -251,7 +269,7 @@ function renderSummary(state) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, average, recommended } = calculateSummary(state);
|
const { rows, average, recommended, averageCard } = calculateSummary(state);
|
||||||
summaryBody.innerHTML = '';
|
summaryBody.innerHTML = '';
|
||||||
|
|
||||||
if (rows.size === 0) {
|
if (rows.size === 0) {
|
||||||
@@ -274,7 +292,11 @@ function renderSummary(state) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
|
if (averageCard !== null) {
|
||||||
|
summaryAverage.textContent = `Average: ${averageCard}`;
|
||||||
|
} else {
|
||||||
|
summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`;
|
||||||
|
}
|
||||||
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
|
summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +422,34 @@ function updateShareLink() {
|
|||||||
shareLinkInput.value = raw ? `${window.location.origin}${raw}` : '';
|
shareLinkInput.value = raw ? `${window.location.origin}${raw}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackCopyFromShareInput() {
|
||||||
|
shareLinkInput.focus();
|
||||||
|
shareLinkInput.select();
|
||||||
|
shareLinkInput.setSelectionRange(0, shareLinkInput.value.length);
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAndCopyShareLink() {
|
||||||
|
if (!shareLinkInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareLinkInput.focus();
|
||||||
|
shareLinkInput.select();
|
||||||
|
shareLinkInput.setSelectionRange(0, shareLinkInput.value.length);
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !window.isSecureContext) {
|
||||||
|
fallbackCopyFromShareInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareLinkInput.value);
|
||||||
|
} catch (_err) {
|
||||||
|
fallbackCopyFromShareInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
@@ -502,6 +552,9 @@ async function changeName() {
|
|||||||
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
revealBtn.addEventListener('click', () => adminAction('reveal'));
|
||||||
resetBtn.addEventListener('click', () => adminAction('reset'));
|
resetBtn.addEventListener('click', () => adminAction('reset'));
|
||||||
shareAdminToggle.addEventListener('change', updateShareLink);
|
shareAdminToggle.addEventListener('change', updateShareLink);
|
||||||
|
shareLinkInput.addEventListener('click', () => {
|
||||||
|
void selectAndCopyShareLink();
|
||||||
|
});
|
||||||
changeNameBtn.addEventListener('click', () => {
|
changeNameBtn.addEventListener('click', () => {
|
||||||
void changeName();
|
void changeName();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwn(obj, key) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||||
|
}
|
||||||
|
|
||||||
function parseJSON(value, fallback) {
|
function parseJSON(value, fallback) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -153,7 +157,58 @@
|
|||||||
saveWindowLayouts();
|
saveWindowLayouts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureWindowLayout(def) {
|
function measureLayoutFromContent(def) {
|
||||||
|
const windowEl = def.el;
|
||||||
|
const fallback = def.defaultLayout;
|
||||||
|
const wasHidden = windowEl.classList.contains('hidden');
|
||||||
|
const previousStyles = {
|
||||||
|
visibility: windowEl.style.visibility,
|
||||||
|
left: windowEl.style.left,
|
||||||
|
top: windowEl.style.top,
|
||||||
|
width: windowEl.style.width,
|
||||||
|
height: windowEl.style.height,
|
||||||
|
right: windowEl.style.right,
|
||||||
|
bottom: windowEl.style.bottom,
|
||||||
|
transform: windowEl.style.transform,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wasHidden) {
|
||||||
|
windowEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
windowEl.style.visibility = 'hidden';
|
||||||
|
windowEl.style.left = '-10000px';
|
||||||
|
windowEl.style.top = '-10000px';
|
||||||
|
windowEl.style.width = 'max-content';
|
||||||
|
windowEl.style.height = 'auto';
|
||||||
|
windowEl.style.right = 'auto';
|
||||||
|
windowEl.style.bottom = 'auto';
|
||||||
|
windowEl.style.transform = 'none';
|
||||||
|
|
||||||
|
const rect = windowEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
windowEl.style.visibility = previousStyles.visibility;
|
||||||
|
windowEl.style.left = previousStyles.left;
|
||||||
|
windowEl.style.top = previousStyles.top;
|
||||||
|
windowEl.style.width = previousStyles.width;
|
||||||
|
windowEl.style.height = previousStyles.height;
|
||||||
|
windowEl.style.right = previousStyles.right;
|
||||||
|
windowEl.style.bottom = previousStyles.bottom;
|
||||||
|
windowEl.style.transform = previousStyles.transform;
|
||||||
|
|
||||||
|
if (wasHidden) {
|
||||||
|
windowEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeLayout({
|
||||||
|
left: fallback.left,
|
||||||
|
top: fallback.top,
|
||||||
|
width: Number.isFinite(rect.width) && rect.width > 0 ? Math.ceil(rect.width) : fallback.width,
|
||||||
|
height: Number.isFinite(rect.height) && rect.height > 0 ? Math.ceil(rect.height) : fallback.height,
|
||||||
|
}, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowLayout(def, options = {}) {
|
||||||
if (isMobileViewport()) {
|
if (isMobileViewport()) {
|
||||||
def.el.style.right = 'auto';
|
def.el.style.right = 'auto';
|
||||||
def.el.style.bottom = 'auto';
|
def.el.style.bottom = 'auto';
|
||||||
@@ -161,7 +216,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeLayout(windowLayouts[def.id], def.defaultLayout);
|
const hasSavedLayout = hasOwn(options, 'hasSavedLayout')
|
||||||
|
? options.hasSavedLayout
|
||||||
|
: hasOwn(windowLayouts, def.id);
|
||||||
|
const normalized = hasSavedLayout
|
||||||
|
? normalizeLayout(windowLayouts[def.id], def.defaultLayout)
|
||||||
|
: measureLayoutFromContent(def);
|
||||||
const clamped = clampLayoutToViewport(normalized);
|
const clamped = clampLayoutToViewport(normalized);
|
||||||
applyLayout(def.el, clamped);
|
applyLayout(def.el, clamped);
|
||||||
windowLayouts[def.id] = clamped;
|
windowLayouts[def.id] = clamped;
|
||||||
@@ -284,11 +344,20 @@
|
|||||||
|
|
||||||
function initWindowLayouts() {
|
function initWindowLayouts() {
|
||||||
windowLayouts = loadWindowLayouts();
|
windowLayouts = loadWindowLayouts();
|
||||||
|
let shouldPersistSeededLayouts = false;
|
||||||
|
|
||||||
windowDefs.forEach((def) => {
|
windowDefs.forEach((def) => {
|
||||||
ensureWindowLayout(def);
|
const hasSavedLayout = hasOwn(windowLayouts, def.id);
|
||||||
|
ensureWindowLayout(def, { hasSavedLayout });
|
||||||
|
if (!hasSavedLayout) {
|
||||||
|
shouldPersistSeededLayouts = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldPersistSeededLayouts) {
|
||||||
|
saveWindowLayouts();
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
if (isMobileViewport()) {
|
if (isMobileViewport()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user