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 hasSpeakers := len(t.queue) > 0 if hasSpeakers { t.startNextSpeaker(now, 0) } t.mu.Unlock() if hasSpeakers { t.emit(EventSpeakerChanged) } 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) }