feat: initial daily-timer implementation
This commit is contained in:
428
internal/timer/timer.go
Normal file
428
internal/timer/timer.go
Normal file
@@ -0,0 +1,428 @@
|
||||
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)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
go t.tick()
|
||||
}
|
||||
|
||||
func (t *Timer) startNextSpeaker(now time.Time) {
|
||||
if len(t.queue) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark previous speaker as done (only if they were speaking, not skipped)
|
||||
if t.currentSpeakerID != 0 {
|
||||
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
|
||||
}
|
||||
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
|
||||
t.speakerElapsed = 0
|
||||
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)
|
||||
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)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
for i, s := range t.queue {
|
||||
if s.ID == speakerID {
|
||||
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as skipped in allSpeakers and move to end
|
||||
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
||||
t.moveSpeakerToEnd(speakerID)
|
||||
}
|
||||
|
||||
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 before stopping
|
||||
if t.currentSpeakerID != 0 {
|
||||
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone)
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user