Compare commits

..

6 Commits

Author SHA1 Message Date
9e850d3b00 README.md env vars 2026-03-07 02:09:44 +02:00
95fde663ca ENV Update 2026-03-07 01:19:37 +02:00
98692359db Cleanup Job 2026-03-07 01:16:39 +02:00
e4e555cac3 Update 2026-03-07 01:12:36 +02:00
2b63370873 Updated password fields for autocomplete ignoring 2026-03-07 01:07:43 +02:00
791af99662 Updates 2026-03-07 00:52:47 +02:00
11 changed files with 470 additions and 93 deletions

View File

@@ -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
View File

@@ -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:

View File

@@ -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,
} }
} }

View File

@@ -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)
} }

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
@@ -188,17 +201,17 @@
aria-modal="false" aria-modal="false"
aria-labelledby="theme-tool-title" aria-labelledby="theme-tool-title"
data-ui-window data-ui-window
data-window-title="ThemePicker.exe" data-window-title="Theme.exe"
data-window-rights="all" data-window-rights="all"
data-window-order="10" data-window-order="10"
data-window-default-left="16" data-window-default-left="16"
data-window-default-top="88" data-window-default-top="88"
data-window-default-width="390" data-window-default-width="390"
data-window-default-height="250" data-window-default-height="320"
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}' data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}'
> >
<div class="title-bar ui-tool-title-bar" data-role="drag-handle"> <div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="theme-tool-title">ThemePicker.exe</span> <span id="theme-tool-title">Theme.exe</span>
<div class="title-bar-controls"> <div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button> <button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
</div> </div>
@@ -219,32 +232,7 @@
<span>No Theme</span> <span>No Theme</span>
</button> </button>
</div> </div>
</div> <p class="tool-copy">Set the display mode.</p>
</section>
<section
id="mode-tool-window"
class="window ui-tool-window hidden"
role="dialog"
aria-modal="false"
aria-labelledby="mode-tool-title"
data-ui-window
data-window-title="DisplayMode.exe"
data-window-rights="all"
data-window-order="20"
data-window-default-left="424"
data-window-default-top="88"
data-window-default-width="340"
data-window-default-height="190"
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png","modern":"/static/img/Windows Icons - PNG/desk.cpl_14_100-0.png","none":"/static/img/Windows Icons - PNG/timedate.cpl_14_200-6.png","default":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png"}'
>
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="mode-tool-title">DisplayMode.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy" id="mode-status-text">Current mode: Light</p> <p class="tool-copy" id="mode-status-text">Current mode: Light</p>
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action"> <button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt=""> <img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">

View File

@@ -162,17 +162,17 @@
aria-modal="false" aria-modal="false"
aria-labelledby="theme-tool-title" aria-labelledby="theme-tool-title"
data-ui-window data-ui-window
data-window-title="ThemePicker.exe" data-window-title="Theme.exe"
data-window-rights="all" data-window-rights="all"
data-window-order="10" data-window-order="10"
data-window-default-left="16" data-window-default-left="16"
data-window-default-top="88" data-window-default-top="88"
data-window-default-width="390" data-window-default-width="390"
data-window-default-height="250" data-window-default-height="320"
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}' data-window-icons='{"win98":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png","modern":"/static/img/Windows Icons - PNG/msconfig.exe_14_128-0.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/main.cpl_14_109-1.png"}'
> >
<div class="title-bar ui-tool-title-bar" data-role="drag-handle"> <div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="theme-tool-title">ThemePicker.exe</span> <span id="theme-tool-title">Theme.exe</span>
<div class="title-bar-controls"> <div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="theme-tool-window">×</button> <button type="button" data-role="close-window" data-target="theme-tool-window">×</button>
</div> </div>
@@ -193,32 +193,7 @@
<span>No Theme</span> <span>No Theme</span>
</button> </button>
</div> </div>
</div> <p class="tool-copy">Set the display mode.</p>
</section>
<section
id="mode-tool-window"
class="window ui-tool-window hidden"
role="dialog"
aria-modal="false"
aria-labelledby="mode-tool-title"
data-ui-window
data-window-title="DisplayMode.exe"
data-window-rights="all"
data-window-order="20"
data-window-default-left="424"
data-window-default-top="88"
data-window-default-width="340"
data-window-default-height="190"
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png","modern":"/static/img/Windows Icons - PNG/desk.cpl_14_100-0.png","none":"/static/img/Windows Icons - PNG/timedate.cpl_14_200-6.png","default":"/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png"}'
>
<div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="mode-tool-title">DisplayMode.exe</span>
<div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="mode-tool-window">×</button>
</div>
</div>
<div class="window-content">
<p class="tool-copy" id="mode-status-text">Current mode: Light</p> <p class="tool-copy" id="mode-status-text">Current mode: Light</p>
<button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action"> <button class="btn mode-toggle-btn" type="button" data-role="mode-toggle-action">
<img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt=""> <img class="taskbar-icon" data-role="mode-icon" src="/static/img/Windows Icons - PNG/desk.cpl_14_40-0.png" alt="">
@@ -234,7 +209,7 @@
aria-modal="false" aria-modal="false"
aria-labelledby="terminal-title" aria-labelledby="terminal-title"
data-ui-window data-ui-window
data-window-title="RoomTerminal.exe" data-window-title="Terminal.exe"
data-window-rights="admin" data-window-rights="admin"
data-window-order="30" data-window-order="30"
data-window-default-left="780" data-window-default-left="780"
@@ -244,7 +219,7 @@
data-window-icons='{"win98":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png","modern":"/static/img/Windows Icons - PNG/taskmgr.exe_14_137.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png"}' data-window-icons='{"win98":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png","modern":"/static/img/Windows Icons - PNG/taskmgr.exe_14_137.png","none":"/static/img/Windows Icons - PNG/taskmgr.exe_14_118-1.png","default":"/static/img/Windows Icons - PNG/taskmgr.exe_14_107-1.png"}'
> >
<div class="title-bar ui-tool-title-bar" data-role="drag-handle"> <div class="title-bar ui-tool-title-bar" data-role="drag-handle">
<span id="terminal-title">RoomTerminal.exe</span> <span id="terminal-title">Terminal.exe</span>
<div class="title-bar-controls"> <div class="title-bar-controls">
<button type="button" data-role="close-window" data-target="terminal-tool-window">×</button> <button type="button" data-role="close-window" data-target="terminal-tool-window">×</button>
</div> </div>

View File

@@ -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;
} }

View File

@@ -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();
}); });

View File

@@ -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;