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