564 lines
13 KiB
Go
564 lines
13 KiB
Go
package timer
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"daily-timer/internal/models"
|
|
)
|
|
|
|
type EventType string
|
|
|
|
const (
|
|
EventTick EventType = "tick"
|
|
EventSpeakerWarning EventType = "speaker_warning"
|
|
EventSpeakerTimeUp EventType = "speaker_timeup"
|
|
EventMeetingWarning EventType = "meeting_warning"
|
|
EventMeetingTimeUp EventType = "meeting_timeup"
|
|
EventSpeakerChanged EventType = "speaker_changed"
|
|
EventMeetingEnded EventType = "meeting_ended"
|
|
)
|
|
|
|
type Event struct {
|
|
Type EventType `json:"type"`
|
|
State models.TimerState `json:"state"`
|
|
}
|
|
|
|
type Timer struct {
|
|
mu sync.RWMutex
|
|
|
|
running bool
|
|
paused bool
|
|
|
|
meetingStartTime time.Time
|
|
meetingElapsed time.Duration
|
|
meetingLimit time.Duration
|
|
|
|
speakerStartTime time.Time
|
|
speakerElapsed time.Duration
|
|
speakerLimit time.Duration
|
|
|
|
currentSpeakerID uint
|
|
currentSpeaker string
|
|
speakingOrder int
|
|
queue []models.QueuedSpeaker
|
|
allSpeakers []models.SpeakerInfo
|
|
|
|
warningThreshold time.Duration
|
|
speakerWarned bool
|
|
speakerTimeUpEmitted bool
|
|
meetingWarned bool
|
|
|
|
eventCh chan Event
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
pausedAt time.Time
|
|
}
|
|
|
|
func New(meetingLimitSec, warningThresholdSec int) *Timer {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Timer{
|
|
meetingLimit: time.Duration(meetingLimitSec) * time.Second,
|
|
warningThreshold: time.Duration(warningThresholdSec) * time.Second,
|
|
eventCh: make(chan Event, 100),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
func (t *Timer) Events() <-chan Event {
|
|
return t.eventCh
|
|
}
|
|
|
|
func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.queue = speakers
|
|
|
|
// Initialize allSpeakers with pending status
|
|
t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
|
|
for i, s := range speakers {
|
|
t.allSpeakers[i] = models.SpeakerInfo{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
TimeLimit: s.TimeLimit,
|
|
Order: i + 1,
|
|
Status: models.SpeakerStatusPending,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Timer) Start() {
|
|
t.mu.Lock()
|
|
if t.running {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
t.running = true
|
|
t.paused = false
|
|
t.meetingStartTime = now
|
|
t.meetingElapsed = 0
|
|
t.speakingOrder = 0
|
|
t.speakerWarned = false
|
|
t.meetingWarned = false
|
|
|
|
if len(t.queue) > 0 {
|
|
t.startNextSpeaker(now, 0)
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
go t.tick()
|
|
}
|
|
|
|
func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
|
|
if len(t.queue) == 0 {
|
|
return
|
|
}
|
|
|
|
// Mark previous speaker as done and save their time spent
|
|
if t.currentSpeakerID != 0 {
|
|
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
|
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
|
|
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
|
t.allSpeakers[i].TimeSpent = timeSpent
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
speaker := t.queue[0]
|
|
t.queue = t.queue[1:]
|
|
t.currentSpeakerID = speaker.ID
|
|
t.currentSpeaker = speaker.Name
|
|
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
|
t.speakerStartTime = now.Add(-offset)
|
|
t.speakerElapsed = offset
|
|
t.speakingOrder++
|
|
t.speakerWarned = false
|
|
t.speakerTimeUpEmitted = false
|
|
|
|
// Mark current speaker as speaking
|
|
t.updateSpeakerStatus(speaker.ID, models.SpeakerStatusSpeaking)
|
|
}
|
|
|
|
func (t *Timer) updateSpeakerStatus(id uint, status models.SpeakerStatus) {
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == id {
|
|
t.allSpeakers[i].Status = status
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Timer) moveSpeakerToEnd(id uint) {
|
|
var speaker models.SpeakerInfo
|
|
idx := -1
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == id {
|
|
speaker = t.allSpeakers[i]
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx >= 0 {
|
|
// Remove from current position
|
|
t.allSpeakers = append(t.allSpeakers[:idx], t.allSpeakers[idx+1:]...)
|
|
// Add to end
|
|
t.allSpeakers = append(t.allSpeakers, speaker)
|
|
// Update order numbers
|
|
for i := range t.allSpeakers {
|
|
t.allSpeakers[i].Order = i + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Timer) NextSpeaker() {
|
|
t.mu.Lock()
|
|
|
|
if !t.running {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
if !t.paused {
|
|
t.speakerElapsed = now.Sub(t.speakerStartTime)
|
|
}
|
|
|
|
var eventType EventType
|
|
if len(t.queue) > 0 {
|
|
t.startNextSpeaker(now, 0)
|
|
eventType = EventSpeakerChanged
|
|
} else {
|
|
t.running = false
|
|
t.paused = false
|
|
eventType = EventMeetingEnded
|
|
}
|
|
t.mu.Unlock()
|
|
t.emit(eventType)
|
|
}
|
|
|
|
func (t *Timer) SkipSpeaker() {
|
|
t.mu.Lock()
|
|
|
|
if !t.running || t.currentSpeakerID == 0 {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Mark current speaker as skipped in allSpeakers
|
|
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusSkipped)
|
|
|
|
skipped := models.QueuedSpeaker{
|
|
ID: t.currentSpeakerID,
|
|
Name: t.currentSpeaker,
|
|
TimeLimit: int(t.speakerLimit.Seconds()),
|
|
}
|
|
t.queue = append(t.queue, skipped)
|
|
|
|
// Move skipped speaker to end of allSpeakers list
|
|
t.moveSpeakerToEnd(t.currentSpeakerID)
|
|
|
|
now := time.Now()
|
|
if len(t.queue) > 1 {
|
|
t.startNextSpeaker(now, 0)
|
|
t.mu.Unlock()
|
|
t.emit(EventSpeakerChanged)
|
|
} else {
|
|
// Only skipped speaker left - they need to speak now
|
|
t.startNextSpeaker(now, 0)
|
|
t.mu.Unlock()
|
|
t.emit(EventSpeakerChanged)
|
|
}
|
|
}
|
|
|
|
func (t *Timer) RemoveFromQueue(speakerID uint) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if !t.running {
|
|
return
|
|
}
|
|
|
|
// Don't remove current speaker - use SkipSpeaker for that
|
|
if speakerID == t.currentSpeakerID {
|
|
return
|
|
}
|
|
|
|
// Find speaker info before removing
|
|
var speakerInfo models.QueuedSpeaker
|
|
for _, s := range t.queue {
|
|
if s.ID == speakerID {
|
|
speakerInfo = s
|
|
break
|
|
}
|
|
}
|
|
|
|
// Remove from current position in queue
|
|
for i, s := range t.queue {
|
|
if s.ID == speakerID {
|
|
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add to end of queue so they can speak later
|
|
if speakerInfo.ID != 0 {
|
|
t.queue = append(t.queue, speakerInfo)
|
|
}
|
|
|
|
// Mark as skipped in allSpeakers and move to end
|
|
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
|
t.moveSpeakerToEnd(speakerID)
|
|
}
|
|
|
|
// SwitchToSpeaker moves the specified speaker to front of queue and starts them
|
|
// If speaker is already done, resumes their timer from accumulated time
|
|
func (t *Timer) SwitchToSpeaker(speakerID uint) {
|
|
t.mu.Lock()
|
|
|
|
if !t.running {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// First, find speaker in allSpeakers to get their info and status
|
|
var speakerInfo *models.SpeakerInfo
|
|
var speakerInfoIdx int
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == speakerID {
|
|
speakerInfo = &t.allSpeakers[i]
|
|
speakerInfoIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if speakerInfo == nil {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Don't switch to currently speaking speaker
|
|
if speakerInfo.Status == models.SpeakerStatusSpeaking {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// Calculate offset for resuming (0 for pending/skipped, timeSpent for done)
|
|
var offset time.Duration
|
|
if speakerInfo.Status == models.SpeakerStatusDone {
|
|
offset = time.Duration(speakerInfo.TimeSpent) * time.Second
|
|
}
|
|
|
|
// Save current speaker time
|
|
now := time.Now()
|
|
if t.currentSpeakerID != 0 {
|
|
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
|
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
|
|
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
|
t.allSpeakers[i].TimeSpent = timeSpent
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find speaker in queue (pending/skipped) or create new entry (done)
|
|
foundIdx := -1
|
|
for i, s := range t.queue {
|
|
if s.ID == speakerID {
|
|
foundIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Create QueuedSpeaker from SpeakerInfo
|
|
queuedSpeaker := models.QueuedSpeaker{
|
|
ID: speakerInfo.ID,
|
|
Name: speakerInfo.Name,
|
|
TimeLimit: speakerInfo.TimeLimit,
|
|
Order: speakerInfo.Order,
|
|
}
|
|
|
|
if foundIdx >= 0 {
|
|
// Remove from current position in queue
|
|
t.queue = append(t.queue[:foundIdx], t.queue[foundIdx+1:]...)
|
|
}
|
|
// Insert at front of queue
|
|
t.queue = append([]models.QueuedSpeaker{queuedSpeaker}, t.queue...)
|
|
|
|
// Move the selected speaker in allSpeakers to position after last done/speaking
|
|
insertPos := 0
|
|
for i, s := range t.allSpeakers {
|
|
if s.Status == models.SpeakerStatusDone || s.Status == models.SpeakerStatusSpeaking {
|
|
insertPos = i + 1
|
|
}
|
|
}
|
|
|
|
if speakerInfoIdx >= 0 && speakerInfoIdx != insertPos {
|
|
// Save speaker info before removing
|
|
savedInfo := *speakerInfo
|
|
// Remove from current position
|
|
t.allSpeakers = append(t.allSpeakers[:speakerInfoIdx], t.allSpeakers[speakerInfoIdx+1:]...)
|
|
// Adjust insert position if needed
|
|
if speakerInfoIdx < insertPos {
|
|
insertPos--
|
|
}
|
|
// Insert at new position
|
|
t.allSpeakers = append(t.allSpeakers[:insertPos], append([]models.SpeakerInfo{savedInfo}, t.allSpeakers[insertPos:]...)...)
|
|
// Update order numbers
|
|
for i := range t.allSpeakers {
|
|
t.allSpeakers[i].Order = i + 1
|
|
}
|
|
}
|
|
|
|
// Start this speaker with offset (0 for new speakers, accumulated time for done)
|
|
t.startNextSpeaker(now, offset)
|
|
t.mu.Unlock()
|
|
t.emit(EventSpeakerChanged)
|
|
}
|
|
|
|
func (t *Timer) Pause() {
|
|
t.mu.Lock()
|
|
|
|
if !t.running || t.paused {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
t.paused = true
|
|
t.pausedAt = now
|
|
t.speakerElapsed = now.Sub(t.speakerStartTime)
|
|
t.meetingElapsed = now.Sub(t.meetingStartTime)
|
|
t.mu.Unlock()
|
|
t.emit(EventTick)
|
|
}
|
|
|
|
func (t *Timer) Resume() {
|
|
t.mu.Lock()
|
|
|
|
if !t.running || !t.paused {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
pauseDuration := time.Since(t.pausedAt)
|
|
t.speakerStartTime = t.speakerStartTime.Add(pauseDuration)
|
|
t.meetingStartTime = t.meetingStartTime.Add(pauseDuration)
|
|
t.paused = false
|
|
t.mu.Unlock()
|
|
t.emit(EventTick)
|
|
}
|
|
|
|
func (t *Timer) Stop() {
|
|
t.mu.Lock()
|
|
|
|
if !t.running {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
// Mark current speaker as done and save their time spent
|
|
if t.currentSpeakerID != 0 {
|
|
now := time.Now()
|
|
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
|
for i := range t.allSpeakers {
|
|
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
|
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
|
t.allSpeakers[i].TimeSpent = timeSpent
|
|
break
|
|
}
|
|
}
|
|
}
|
|
t.running = false
|
|
t.paused = false
|
|
t.mu.Unlock()
|
|
t.emit(EventMeetingEnded)
|
|
}
|
|
|
|
func (t *Timer) GetState() models.TimerState {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
return t.buildState()
|
|
}
|
|
|
|
func (t *Timer) buildState() models.TimerState {
|
|
speakerElapsed := t.speakerElapsed
|
|
meetingElapsed := t.meetingElapsed
|
|
|
|
if t.running && !t.paused {
|
|
now := time.Now()
|
|
speakerElapsed = now.Sub(t.speakerStartTime)
|
|
meetingElapsed = now.Sub(t.meetingStartTime)
|
|
}
|
|
|
|
speakerOvertime := speakerElapsed > t.speakerLimit
|
|
meetingOvertime := meetingElapsed > t.meetingLimit
|
|
warning := !speakerOvertime && (t.speakerLimit-speakerElapsed) <= t.warningThreshold
|
|
|
|
// Copy allSpeakers to avoid data race and calculate total speakers time
|
|
allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
|
|
copy(allSpeakers, t.allSpeakers)
|
|
|
|
totalSpeakersTime := 0
|
|
for _, s := range t.allSpeakers {
|
|
totalSpeakersTime += s.TimeLimit
|
|
}
|
|
|
|
return models.TimerState{
|
|
Running: t.running,
|
|
Paused: t.paused,
|
|
CurrentSpeakerID: t.currentSpeakerID,
|
|
CurrentSpeaker: t.currentSpeaker,
|
|
SpeakerElapsed: int(speakerElapsed.Seconds()),
|
|
SpeakerLimit: int(t.speakerLimit.Seconds()),
|
|
MeetingElapsed: int(meetingElapsed.Seconds()),
|
|
MeetingLimit: int(t.meetingLimit.Seconds()),
|
|
SpeakerOvertime: speakerOvertime,
|
|
MeetingOvertime: meetingOvertime,
|
|
Warning: warning,
|
|
WarningSeconds: int(t.warningThreshold.Seconds()),
|
|
TotalSpeakersTime: totalSpeakersTime,
|
|
SpeakingOrder: t.speakingOrder,
|
|
TotalSpeakers: len(t.allSpeakers),
|
|
RemainingQueue: t.queue,
|
|
AllSpeakers: allSpeakers,
|
|
}
|
|
}
|
|
|
|
func (t *Timer) tick() {
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-t.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
t.mu.Lock()
|
|
if !t.running {
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
if t.paused {
|
|
t.mu.Unlock()
|
|
continue
|
|
}
|
|
|
|
now := time.Now()
|
|
speakerElapsed := now.Sub(t.speakerStartTime)
|
|
meetingElapsed := now.Sub(t.meetingStartTime)
|
|
|
|
remaining := t.speakerLimit - speakerElapsed
|
|
if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
|
|
t.speakerWarned = true
|
|
t.mu.Unlock()
|
|
t.emit(EventSpeakerWarning)
|
|
continue
|
|
}
|
|
|
|
if !t.speakerTimeUpEmitted && speakerElapsed >= t.speakerLimit {
|
|
t.speakerTimeUpEmitted = true
|
|
t.mu.Unlock()
|
|
t.emit(EventSpeakerTimeUp)
|
|
continue
|
|
}
|
|
|
|
meetingRemaining := t.meetingLimit - meetingElapsed
|
|
if !t.meetingWarned && meetingRemaining <= t.warningThreshold && meetingRemaining > 0 {
|
|
t.meetingWarned = true
|
|
t.mu.Unlock()
|
|
t.emit(EventMeetingWarning)
|
|
continue
|
|
}
|
|
|
|
t.mu.Unlock()
|
|
t.emit(EventTick)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Timer) emit(eventType EventType) {
|
|
t.mu.RLock()
|
|
state := t.buildState()
|
|
t.mu.RUnlock()
|
|
|
|
select {
|
|
case t.eventCh <- Event{Type: eventType, State: state}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (t *Timer) Close() {
|
|
t.cancel()
|
|
close(t.eventCh)
|
|
}
|