- 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
569 lines
13 KiB
Go
569 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
|
||
|
||
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)
|
||
}
|