feat: initial daily-timer implementation

This commit is contained in:
Mikhail Kiselev
2026-02-08 05:17:37 +03:00
parent 537f72eb51
commit ef23291bdd
37 changed files with 7779 additions and 0 deletions

481
internal/app/app.go Normal file
View 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
View 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
View 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
View 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
View 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)
}