feat: initial daily-timer implementation
This commit is contained in:
481
internal/app/app.go
Normal file
481
internal/app/app.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"daily-timer/internal/models"
|
||||
"daily-timer/internal/storage"
|
||||
"daily-timer/internal/timer"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
store *storage.Storage
|
||||
timer *timer.Timer
|
||||
session *models.MeetingSession
|
||||
currentLogs map[uint]*models.ParticipantLog
|
||||
}
|
||||
|
||||
func New(store *storage.Storage) *App {
|
||||
return &App{
|
||||
store: store,
|
||||
currentLogs: make(map[uint]*models.ParticipantLog),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
func (a *App) OnDomReady(ctx context.Context) {
|
||||
runtime.WindowShow(ctx)
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
if a.timer != nil {
|
||||
a.timer.Close()
|
||||
}
|
||||
if a.store != nil {
|
||||
_ = a.store.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Participants
|
||||
|
||||
func (a *App) GetParticipants() ([]models.Participant, error) {
|
||||
return a.store.GetParticipants()
|
||||
}
|
||||
|
||||
func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.Participant, error) {
|
||||
participants, _ := a.store.GetAllParticipants()
|
||||
order := len(participants)
|
||||
|
||||
p := &models.Participant{
|
||||
Name: name,
|
||||
Email: email,
|
||||
TimeLimit: timeLimit,
|
||||
Order: order,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := a.store.CreateParticipant(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int) error {
|
||||
p := &models.Participant{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Email: email,
|
||||
TimeLimit: timeLimit,
|
||||
}
|
||||
return a.store.UpdateParticipant(p)
|
||||
}
|
||||
|
||||
func (a *App) DeleteParticipant(id uint) error {
|
||||
return a.store.DeleteParticipant(id)
|
||||
}
|
||||
|
||||
func (a *App) ReorderParticipants(ids []uint) error {
|
||||
return a.store.ReorderParticipants(ids)
|
||||
}
|
||||
|
||||
// Meeting
|
||||
|
||||
func (a *App) GetMeeting() (*models.Meeting, error) {
|
||||
return a.store.GetMeeting()
|
||||
}
|
||||
|
||||
func (a *App) UpdateMeeting(name string, timeLimit int) error {
|
||||
meeting, err := a.store.GetMeeting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meeting.Name = name
|
||||
meeting.TimeLimit = timeLimit
|
||||
return a.store.UpdateMeeting(meeting)
|
||||
}
|
||||
|
||||
// Timer Controls
|
||||
|
||||
func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) error {
|
||||
meeting, err := a.store.GetMeeting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settings, _ := a.store.GetSettings()
|
||||
warningThreshold := 30
|
||||
if settings != nil {
|
||||
warningThreshold = settings.WarningThreshold
|
||||
}
|
||||
|
||||
session, err := a.store.CreateSession(meeting.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.session = session
|
||||
|
||||
for participantID, present := range attendance {
|
||||
_ = a.store.SetAttendance(session.ID, participantID, present, false)
|
||||
}
|
||||
|
||||
participants, _ := a.store.GetParticipants()
|
||||
participantMap := make(map[uint]models.Participant)
|
||||
for _, p := range participants {
|
||||
participantMap[p.ID] = p
|
||||
}
|
||||
|
||||
queue := make([]models.QueuedSpeaker, 0, len(participantOrder))
|
||||
for i, id := range participantOrder {
|
||||
if p, ok := participantMap[id]; ok {
|
||||
if present, ok := attendance[id]; ok && present {
|
||||
queue = append(queue, models.QueuedSpeaker{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
TimeLimit: p.TimeLimit,
|
||||
Order: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.timer = timer.New(meeting.TimeLimit, warningThreshold)
|
||||
a.timer.SetQueue(queue)
|
||||
a.currentLogs = make(map[uint]*models.ParticipantLog)
|
||||
go a.handleTimerEvents()
|
||||
a.timer.Start()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) handleTimerEvents() {
|
||||
for event := range a.timer.Events() {
|
||||
switch event.Type {
|
||||
case timer.EventTick:
|
||||
runtime.EventsEmit(a.ctx, "timer:tick", event.State)
|
||||
case timer.EventSpeakerWarning:
|
||||
runtime.EventsEmit(a.ctx, "timer:speaker_warning", event.State)
|
||||
case timer.EventSpeakerTimeUp:
|
||||
runtime.EventsEmit(a.ctx, "timer:speaker_timeup", event.State)
|
||||
case timer.EventMeetingWarning:
|
||||
runtime.EventsEmit(a.ctx, "timer:meeting_warning", event.State)
|
||||
case timer.EventMeetingTimeUp:
|
||||
runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
|
||||
case timer.EventSpeakerChanged:
|
||||
a.saveSpeakerLog(event.State)
|
||||
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
||||
case timer.EventMeetingEnded:
|
||||
a.saveSpeakerLog(event.State)
|
||||
a.endMeetingSession(event.State)
|
||||
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) saveSpeakerLog(state models.TimerState) {
|
||||
if a.session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for id, log := range a.currentLogs {
|
||||
if log.EndedAt == nil {
|
||||
now := time.Now()
|
||||
log.EndedAt = &now
|
||||
log.Duration = int(now.Sub(log.StartedAt).Seconds())
|
||||
|
||||
participants, _ := a.store.GetParticipants()
|
||||
for _, p := range participants {
|
||||
if p.ID == id {
|
||||
log.Overtime = log.Duration > p.TimeLimit
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = a.store.UpdateParticipantLog(log)
|
||||
}
|
||||
}
|
||||
|
||||
if state.CurrentSpeakerID > 0 {
|
||||
log := &models.ParticipantLog{
|
||||
SessionID: a.session.ID,
|
||||
ParticipantID: state.CurrentSpeakerID,
|
||||
StartedAt: time.Now(),
|
||||
Order: state.SpeakingOrder,
|
||||
}
|
||||
_ = a.store.CreateParticipantLog(log)
|
||||
a.currentLogs[state.CurrentSpeakerID] = log
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) endMeetingSession(state models.TimerState) {
|
||||
if a.session == nil {
|
||||
return
|
||||
}
|
||||
_ = a.store.EndSession(a.session.ID, state.MeetingElapsed)
|
||||
a.session = nil
|
||||
a.currentLogs = make(map[uint]*models.ParticipantLog)
|
||||
}
|
||||
|
||||
func (a *App) NextSpeaker() {
|
||||
if a.timer != nil {
|
||||
a.timer.NextSpeaker()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) SkipSpeaker() {
|
||||
if a.timer != nil {
|
||||
a.timer.SkipSpeaker()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RemoveFromQueue(speakerID uint) {
|
||||
if a.timer != nil {
|
||||
a.timer.RemoveFromQueue(speakerID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PauseMeeting() {
|
||||
if a.timer != nil {
|
||||
a.timer.Pause()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ResumeMeeting() {
|
||||
if a.timer != nil {
|
||||
a.timer.Resume()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) StopMeeting() {
|
||||
if a.timer != nil {
|
||||
a.timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetTimerState() *models.TimerState {
|
||||
if a.timer == nil {
|
||||
return nil
|
||||
}
|
||||
state := a.timer.GetState()
|
||||
return &state
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
func (a *App) GetSettings() (*models.Settings, error) {
|
||||
return a.store.GetSettings()
|
||||
}
|
||||
|
||||
func (a *App) UpdateSettings(settings *models.Settings) error {
|
||||
return a.store.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
// History & Statistics
|
||||
|
||||
func (a *App) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
|
||||
return a.store.GetSessions(limit, offset)
|
||||
}
|
||||
|
||||
func (a *App) GetSession(id uint) (*models.MeetingSession, error) {
|
||||
return a.store.GetSession(id)
|
||||
}
|
||||
|
||||
func (a *App) DeleteSession(id uint) error {
|
||||
return a.store.DeleteSession(id)
|
||||
}
|
||||
|
||||
func (a *App) DeleteAllSessions() error {
|
||||
return a.store.DeleteAllSessions()
|
||||
}
|
||||
|
||||
func (a *App) GetStatistics(fromStr, toStr string) (*models.AggregatedStats, error) {
|
||||
from, err := time.Parse("2006-01-02", fromStr)
|
||||
if err != nil {
|
||||
from = time.Now().AddDate(0, -1, 0)
|
||||
}
|
||||
|
||||
to, err := time.Parse("2006-01-02", toStr)
|
||||
if err != nil {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
return a.store.GetAggregatedStats(from, to)
|
||||
}
|
||||
|
||||
func (a *App) ExportData(fromStr, toStr string) (string, error) {
|
||||
from, _ := time.Parse("2006-01-02", fromStr)
|
||||
to, _ := time.Parse("2006-01-02", toStr)
|
||||
|
||||
if from.IsZero() {
|
||||
from = time.Now().AddDate(-1, 0, 0)
|
||||
}
|
||||
if to.IsZero() {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
participants, err := a.store.GetAllParticipants()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sessions, err := a.store.GetSessions(1000, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
meeting, _ := a.store.GetMeeting()
|
||||
sessionStats := make([]models.SessionStats, 0, len(sessions))
|
||||
|
||||
for _, s := range sessions {
|
||||
if s.StartedAt.Before(from) || s.StartedAt.After(to) {
|
||||
continue
|
||||
}
|
||||
|
||||
stats := models.SessionStats{
|
||||
SessionID: s.ID,
|
||||
Date: s.StartedAt.Format("2006-01-02 15:04"),
|
||||
TotalDuration: s.TotalDuration,
|
||||
MeetingLimit: meeting.TimeLimit,
|
||||
Overtime: s.TotalDuration > meeting.TimeLimit,
|
||||
}
|
||||
|
||||
for _, log := range s.ParticipantLogs {
|
||||
stats.ParticipantStats = append(stats.ParticipantStats, models.ParticipantStats{
|
||||
ParticipantID: log.ParticipantID,
|
||||
Name: log.Participant.Name,
|
||||
Duration: log.Duration,
|
||||
TimeLimit: log.Participant.TimeLimit,
|
||||
Overtime: log.Overtime,
|
||||
Skipped: log.Skipped,
|
||||
SpeakingOrder: log.Order,
|
||||
})
|
||||
if log.Overtime {
|
||||
stats.OvertimeCount++
|
||||
}
|
||||
if log.Skipped {
|
||||
stats.SkippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
for _, att := range s.Attendance {
|
||||
if att.Present {
|
||||
stats.PresentCount++
|
||||
} else {
|
||||
stats.AbsentCount++
|
||||
}
|
||||
}
|
||||
stats.ParticipantCount = stats.PresentCount + stats.AbsentCount
|
||||
|
||||
sessionStats = append(sessionStats, stats)
|
||||
}
|
||||
|
||||
aggStats, _ := a.store.GetAggregatedStats(from, to)
|
||||
|
||||
exportData := models.ExportData{
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
Participants: participants,
|
||||
Sessions: sessionStats,
|
||||
Statistics: *aggStats,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(exportData, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("daily-timer-export-%s.json", time.Now().Format("2006-01-02"))
|
||||
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
DefaultFilename: filename,
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "JSON Files", Pattern: "*.json"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if savePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(savePath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return savePath, nil
|
||||
}
|
||||
|
||||
func (a *App) ExportCSV(fromStr, toStr string) (string, error) {
|
||||
from, _ := time.Parse("2006-01-02", fromStr)
|
||||
to, _ := time.Parse("2006-01-02", toStr)
|
||||
|
||||
if from.IsZero() {
|
||||
from = time.Now().AddDate(-1, 0, 0)
|
||||
}
|
||||
if to.IsZero() {
|
||||
to = time.Now()
|
||||
}
|
||||
|
||||
sessions, err := a.store.GetSessions(1000, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
csv := "Date,Participant,Duration (s),Time Limit (s),Overtime,Skipped,Speaking Order\n"
|
||||
|
||||
for _, s := range sessions {
|
||||
if s.StartedAt.Before(from) || s.StartedAt.After(to) {
|
||||
continue
|
||||
}
|
||||
date := s.StartedAt.Format("2006-01-02")
|
||||
|
||||
for _, log := range s.ParticipantLogs {
|
||||
csv += fmt.Sprintf("%s,%s,%d,%d,%t,%t,%d\n",
|
||||
date,
|
||||
log.Participant.Name,
|
||||
log.Duration,
|
||||
log.Participant.TimeLimit,
|
||||
log.Overtime,
|
||||
log.Skipped,
|
||||
log.Order,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("daily-timer-export-%s.csv", time.Now().Format("2006-01-02"))
|
||||
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
DefaultFilename: filename,
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "CSV Files", Pattern: "*.csv"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if savePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(savePath, []byte(csv), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return savePath, nil
|
||||
}
|
||||
|
||||
// Sound
|
||||
|
||||
func (a *App) GetSoundsDir() string {
|
||||
configDir, _ := os.UserConfigDir()
|
||||
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
|
||||
_ = os.MkdirAll(soundsDir, 0755)
|
||||
return soundsDir
|
||||
}
|
||||
72
internal/models/models.go
Normal file
72
internal/models/models.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Participant struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Email string `json:"email,omitempty"`
|
||||
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
|
||||
Order int `json:"order" gorm:"default:0"`
|
||||
Active bool `json:"active" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Meeting struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null;default:Daily Standup"`
|
||||
TimeLimit int `json:"timeLimit" gorm:"default:3600"` // total meeting limit in seconds (1 hour)
|
||||
Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type MeetingSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
MeetingID uint `json:"meetingId" gorm:"not null"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
TotalDuration int `json:"totalDuration"` // seconds
|
||||
Completed bool `json:"completed" gorm:"default:false"`
|
||||
ParticipantLogs []ParticipantLog `json:"participantLogs,omitempty" gorm:"foreignKey:SessionID"`
|
||||
Attendance []SessionAttendance `json:"attendance,omitempty" gorm:"foreignKey:SessionID"`
|
||||
}
|
||||
|
||||
type ParticipantLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SessionID uint `json:"sessionId" gorm:"not null"`
|
||||
ParticipantID uint `json:"participantId" gorm:"not null"`
|
||||
Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||
Duration int `json:"duration"` // seconds
|
||||
Skipped bool `json:"skipped" gorm:"default:false"`
|
||||
Overtime bool `json:"overtime" gorm:"default:false"`
|
||||
Order int `json:"order"` // speaking order in session
|
||||
}
|
||||
|
||||
type SessionAttendance struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
SessionID uint `json:"sessionId" gorm:"not null"`
|
||||
ParticipantID uint `json:"participantId" gorm:"not null"`
|
||||
Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
|
||||
Present bool `json:"present" gorm:"default:true"`
|
||||
JoinedLate bool `json:"joinedLate" gorm:"default:false"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
DefaultParticipantTime int `json:"defaultParticipantTime" gorm:"default:120"` // seconds
|
||||
DefaultMeetingTime int `json:"defaultMeetingTime" gorm:"default:900"` // seconds
|
||||
SoundEnabled bool `json:"soundEnabled" gorm:"default:true"`
|
||||
SoundWarning string `json:"soundWarning" gorm:"default:warning.mp3"`
|
||||
SoundTimeUp string `json:"soundTimeUp" gorm:"default:timeup.mp3"`
|
||||
SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
|
||||
WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
|
||||
Theme string `json:"theme" gorm:"default:dark"`
|
||||
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
||||
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
|
||||
}
|
||||
98
internal/models/types.go
Normal file
98
internal/models/types.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package models
|
||||
|
||||
type SpeakerStatus string
|
||||
|
||||
const (
|
||||
SpeakerStatusPending SpeakerStatus = "pending"
|
||||
SpeakerStatusSpeaking SpeakerStatus = "speaking"
|
||||
SpeakerStatusDone SpeakerStatus = "done"
|
||||
SpeakerStatusSkipped SpeakerStatus = "skipped"
|
||||
)
|
||||
|
||||
type TimerState struct {
|
||||
Running bool `json:"running"`
|
||||
Paused bool `json:"paused"`
|
||||
CurrentSpeakerID uint `json:"currentSpeakerId"`
|
||||
CurrentSpeaker string `json:"currentSpeaker"`
|
||||
SpeakerElapsed int `json:"speakerElapsed"` // seconds
|
||||
SpeakerLimit int `json:"speakerLimit"` // seconds
|
||||
MeetingElapsed int `json:"meetingElapsed"` // seconds
|
||||
MeetingLimit int `json:"meetingLimit"` // seconds
|
||||
SpeakerOvertime bool `json:"speakerOvertime"`
|
||||
MeetingOvertime bool `json:"meetingOvertime"`
|
||||
Warning bool `json:"warning"`
|
||||
WarningSeconds int `json:"warningSeconds"` // seconds before end for warning
|
||||
TotalSpeakersTime int `json:"totalSpeakersTime"` // sum of all speaker limits
|
||||
SpeakingOrder int `json:"speakingOrder"`
|
||||
TotalSpeakers int `json:"totalSpeakers"`
|
||||
RemainingQueue []QueuedSpeaker `json:"remainingQueue"`
|
||||
AllSpeakers []SpeakerInfo `json:"allSpeakers"`
|
||||
}
|
||||
|
||||
type SpeakerInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TimeLimit int `json:"timeLimit"`
|
||||
Order int `json:"order"`
|
||||
Status SpeakerStatus `json:"status"`
|
||||
}
|
||||
|
||||
type QueuedSpeaker struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TimeLimit int `json:"timeLimit"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
type SessionStats struct {
|
||||
SessionID uint `json:"sessionId"`
|
||||
Date string `json:"date"`
|
||||
TotalDuration int `json:"totalDuration"`
|
||||
MeetingLimit int `json:"meetingLimit"`
|
||||
Overtime bool `json:"overtime"`
|
||||
ParticipantCount int `json:"participantCount"`
|
||||
PresentCount int `json:"presentCount"`
|
||||
AbsentCount int `json:"absentCount"`
|
||||
OvertimeCount int `json:"overtimeCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
ParticipantStats []ParticipantStats `json:"participantStats"`
|
||||
}
|
||||
|
||||
type ParticipantStats struct {
|
||||
ParticipantID uint `json:"participantId"`
|
||||
Name string `json:"name"`
|
||||
Duration int `json:"duration"`
|
||||
TimeLimit int `json:"timeLimit"`
|
||||
Overtime bool `json:"overtime"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Present bool `json:"present"`
|
||||
SpeakingOrder int `json:"speakingOrder"`
|
||||
}
|
||||
|
||||
type AggregatedStats struct {
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalMeetingTime int `json:"totalMeetingTime"`
|
||||
AverageMeetingTime float64 `json:"averageMeetingTime"`
|
||||
OvertimeSessions int `json:"overtimeSessions"`
|
||||
OvertimePercentage float64 `json:"overtimePercentage"`
|
||||
AverageAttendance float64 `json:"averageAttendance"`
|
||||
ParticipantBreakdown []ParticipantBreakdown `json:"participantBreakdown"`
|
||||
}
|
||||
|
||||
type ParticipantBreakdown struct {
|
||||
ParticipantID uint `json:"participantId"`
|
||||
Name string `json:"name"`
|
||||
SessionsAttended int `json:"sessionsAttended"`
|
||||
TotalSpeakingTime int `json:"totalSpeakingTime"`
|
||||
AverageSpeakingTime float64 `json:"averageSpeakingTime"`
|
||||
OvertimeCount int `json:"overtimeCount"`
|
||||
SkipCount int `json:"skipCount"`
|
||||
AttendanceRate float64 `json:"attendanceRate"`
|
||||
}
|
||||
|
||||
type ExportData struct {
|
||||
ExportedAt string `json:"exportedAt"`
|
||||
Participants []Participant `json:"participants"`
|
||||
Sessions []SessionStats `json:"sessions"`
|
||||
Statistics AggregatedStats `json:"statistics"`
|
||||
}
|
||||
331
internal/storage/storage.go
Normal file
331
internal/storage/storage.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"daily-timer/internal/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New() (*Storage, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = "."
|
||||
}
|
||||
|
||||
appDir := filepath.Join(configDir, "DailyTimer")
|
||||
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(appDir, "daily-timer.db")
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&models.Participant{},
|
||||
&models.Meeting{},
|
||||
&models.MeetingSession{},
|
||||
&models.ParticipantLog{},
|
||||
&models.SessionAttendance{},
|
||||
&models.Settings{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
s := &Storage{db: db}
|
||||
if err := s.ensureDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ensureDefaults() error {
|
||||
var settings models.Settings
|
||||
result := s.db.First(&settings)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
settings = models.Settings{
|
||||
DefaultParticipantTime: 120,
|
||||
DefaultMeetingTime: 3600,
|
||||
SoundEnabled: true,
|
||||
SoundWarning: "warning",
|
||||
SoundTimeUp: "timeup",
|
||||
SoundMeetingEnd: "meeting_end",
|
||||
WarningThreshold: 30,
|
||||
Theme: "dark",
|
||||
WindowWidth: 800,
|
||||
WindowFullHeight: true,
|
||||
}
|
||||
if err := s.db.Create(&settings).Error; err != nil {
|
||||
return fmt.Errorf("failed to create default settings: %w", err)
|
||||
}
|
||||
} else if settings.DefaultMeetingTime == 900 {
|
||||
// Migrate old default value to new default (60 min instead of 15)
|
||||
s.db.Model(&settings).Update("default_meeting_time", 3600)
|
||||
}
|
||||
|
||||
var meeting models.Meeting
|
||||
result = s.db.First(&meeting)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
meeting = models.Meeting{
|
||||
Name: "Daily Standup",
|
||||
TimeLimit: 3600,
|
||||
}
|
||||
if err := s.db.Create(&meeting).Error; err != nil {
|
||||
return fmt.Errorf("failed to create default meeting: %w", err)
|
||||
}
|
||||
} else if meeting.TimeLimit == 900 {
|
||||
// Migrate old default value to new default (60 min instead of 15)
|
||||
s.db.Model(&meeting).Update("time_limit", 3600)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Participants
|
||||
func (s *Storage) GetParticipants() ([]models.Participant, error) {
|
||||
var participants []models.Participant
|
||||
err := s.db.Where("active = ?", true).Order("\"order\" ASC").Find(&participants).Error
|
||||
return participants, err
|
||||
}
|
||||
|
||||
func (s *Storage) GetAllParticipants() ([]models.Participant, error) {
|
||||
var participants []models.Participant
|
||||
err := s.db.Order("\"order\" ASC").Find(&participants).Error
|
||||
return participants, err
|
||||
}
|
||||
|
||||
func (s *Storage) CreateParticipant(p *models.Participant) error {
|
||||
return s.db.Create(p).Error
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateParticipant(p *models.Participant) error {
|
||||
return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||
"name": p.Name,
|
||||
"email": p.Email,
|
||||
"time_limit": p.TimeLimit,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteParticipant(id uint) error {
|
||||
return s.db.Model(&models.Participant{}).Where("id = ?", id).Update("active", false).Error
|
||||
}
|
||||
|
||||
func (s *Storage) ReorderParticipants(ids []uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&models.Participant{}).Where("id = ?", id).Update("\"order\"", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Meetings
|
||||
func (s *Storage) GetMeeting() (*models.Meeting, error) {
|
||||
var meeting models.Meeting
|
||||
err := s.db.First(&meeting).Error
|
||||
return &meeting, err
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateMeeting(m *models.Meeting) error {
|
||||
return s.db.Save(m).Error
|
||||
}
|
||||
|
||||
// Sessions
|
||||
func (s *Storage) CreateSession(meetingID uint) (*models.MeetingSession, error) {
|
||||
session := &models.MeetingSession{
|
||||
MeetingID: meetingID,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := s.db.Create(session).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *Storage) EndSession(sessionID uint, totalDuration int) error {
|
||||
now := time.Now()
|
||||
return s.db.Model(&models.MeetingSession{}).Where("id = ?", sessionID).Updates(map[string]interface{}{
|
||||
"ended_at": now,
|
||||
"total_duration": totalDuration,
|
||||
"completed": true,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *Storage) GetSession(id uint) (*models.MeetingSession, error) {
|
||||
var session models.MeetingSession
|
||||
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||
Preload("Attendance").Preload("Attendance.Participant").
|
||||
First(&session, id).Error
|
||||
return &session, err
|
||||
}
|
||||
|
||||
func (s *Storage) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
|
||||
var sessions []models.MeetingSession
|
||||
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||
Preload("Attendance").Preload("Attendance.Participant").
|
||||
Order("started_at DESC").Limit(limit).Offset(offset).Find(&sessions).Error
|
||||
return sessions, err
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteSession(id uint) error {
|
||||
// Delete related logs first
|
||||
if err := s.db.Where("session_id = ?", id).Delete(&models.ParticipantLog{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete related attendance
|
||||
if err := s.db.Where("session_id = ?", id).Delete(&models.SessionAttendance{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete session
|
||||
return s.db.Delete(&models.MeetingSession{}, id).Error
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteAllSessions() error {
|
||||
// Delete all logs
|
||||
if err := s.db.Exec("DELETE FROM participant_logs").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete all attendance
|
||||
if err := s.db.Exec("DELETE FROM session_attendances").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete all sessions
|
||||
return s.db.Exec("DELETE FROM meeting_sessions").Error
|
||||
}
|
||||
|
||||
// Participant logs
|
||||
func (s *Storage) CreateParticipantLog(log *models.ParticipantLog) error {
|
||||
return s.db.Create(log).Error
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateParticipantLog(log *models.ParticipantLog) error {
|
||||
return s.db.Save(log).Error
|
||||
}
|
||||
|
||||
// Attendance
|
||||
func (s *Storage) SetAttendance(sessionID, participantID uint, present, late bool) error {
|
||||
attendance := models.SessionAttendance{
|
||||
SessionID: sessionID,
|
||||
ParticipantID: participantID,
|
||||
Present: present,
|
||||
JoinedLate: late,
|
||||
}
|
||||
return s.db.Create(&attendance).Error
|
||||
}
|
||||
|
||||
// Settings
|
||||
func (s *Storage) GetSettings() (*models.Settings, error) {
|
||||
var settings models.Settings
|
||||
err := s.db.First(&settings).Error
|
||||
return &settings, err
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateSettings(settings *models.Settings) error {
|
||||
return s.db.Save(settings).Error
|
||||
}
|
||||
|
||||
// Statistics
|
||||
func (s *Storage) GetAggregatedStats(from, to time.Time) (*models.AggregatedStats, error) {
|
||||
var sessions []models.MeetingSession
|
||||
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||
Preload("Attendance").
|
||||
Where("started_at BETWEEN ? AND ?", from, to).
|
||||
Where("completed = ?", true).
|
||||
Find(&sessions).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(sessions) == 0 {
|
||||
return &models.AggregatedStats{}, nil
|
||||
}
|
||||
|
||||
meeting, _ := s.GetMeeting()
|
||||
|
||||
totalTime := 0
|
||||
overtimeSessions := 0
|
||||
participantData := make(map[uint]*models.ParticipantBreakdown)
|
||||
|
||||
for _, session := range sessions {
|
||||
totalTime += session.TotalDuration
|
||||
if meeting != nil && session.TotalDuration > meeting.TimeLimit {
|
||||
overtimeSessions++
|
||||
}
|
||||
|
||||
for _, log := range session.ParticipantLogs {
|
||||
if _, ok := participantData[log.ParticipantID]; !ok {
|
||||
participantData[log.ParticipantID] = &models.ParticipantBreakdown{
|
||||
ParticipantID: log.ParticipantID,
|
||||
Name: log.Participant.Name,
|
||||
}
|
||||
}
|
||||
pd := participantData[log.ParticipantID]
|
||||
pd.SessionsAttended++
|
||||
pd.TotalSpeakingTime += log.Duration
|
||||
if log.Overtime {
|
||||
pd.OvertimeCount++
|
||||
}
|
||||
if log.Skipped {
|
||||
pd.SkipCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
participants, _ := s.GetAllParticipants()
|
||||
totalSessions := len(sessions)
|
||||
|
||||
breakdowns := make([]models.ParticipantBreakdown, 0, len(participantData))
|
||||
for _, pd := range participantData {
|
||||
if pd.SessionsAttended > 0 {
|
||||
pd.AverageSpeakingTime = float64(pd.TotalSpeakingTime) / float64(pd.SessionsAttended)
|
||||
}
|
||||
pd.AttendanceRate = float64(pd.SessionsAttended) / float64(totalSessions) * 100
|
||||
breakdowns = append(breakdowns, *pd)
|
||||
}
|
||||
|
||||
avgAttendance := 0.0
|
||||
if len(participants) > 0 && totalSessions > 0 {
|
||||
totalAttended := 0
|
||||
for _, pd := range participantData {
|
||||
totalAttended += pd.SessionsAttended
|
||||
}
|
||||
avgAttendance = float64(totalAttended) / float64(totalSessions)
|
||||
}
|
||||
|
||||
return &models.AggregatedStats{
|
||||
TotalSessions: totalSessions,
|
||||
TotalMeetingTime: totalTime,
|
||||
AverageMeetingTime: float64(totalTime) / float64(totalSessions),
|
||||
OvertimeSessions: overtimeSessions,
|
||||
OvertimePercentage: float64(overtimeSessions) / float64(totalSessions) * 100,
|
||||
AverageAttendance: avgAttendance,
|
||||
ParticipantBreakdown: breakdowns,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Close() error {
|
||||
sqlDB, err := s.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
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