Files
daily-timer/internal/timer/timer.go
Mikhail Kiselev 41bc35fd43 feat: add Jira URL integration and relay functionality
- Implemented OpenBrowserURL function to open Jira links
- Updated components to utilize the new Jira URL feature
- Added relay server to manage real-time URL updates
- Set default Jira URL in settings if not specified
2026-04-03 03:31:00 +03:00

569 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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()
if t.currentSpeakerID != 0 {
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()
meetingElapsed := now.Sub(t.meetingStartTime)
if t.currentSpeakerID == 0 {
t.mu.Unlock()
t.emit(EventTick)
continue
}
speakerElapsed := now.Sub(t.speakerStartTime)
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)
}