package app import ( "context" "encoding/json" "fmt" "os" "path/filepath" "time" "daily-timer/internal/models" "daily-timer/internal/services/updater" "daily-timer/internal/storage" "daily-timer/internal/timer" "daily-timer/internal/version" "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 updater *updater.Updater } func New(store *storage.Storage) *App { return &App{ store: store, currentLogs: make(map[uint]*models.ParticipantLog), updater: updater.New(), } } 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.finalizeSpeakerLogs(event.State) a.endMeetingSession(event.State) runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State) } } } func (a *App) finalizeSpeakerLogs(state models.TimerState) { if a.session == nil { return } // Only finalize existing logs, don't create new ones 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) } } } 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) SwitchToSpeaker(speakerID uint) { if a.timer != nil { a.timer.SwitchToSpeaker(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 } // Updates func (a *App) GetVersion() string { return version.Version } func (a *App) CheckForUpdates() (*updater.UpdateInfo, error) { return a.updater.CheckForUpdates() } func (a *App) DownloadAndInstallUpdate() error { err := a.updater.DownloadAndInstall(func(progress float64) { runtime.EventsEmit(a.ctx, "update:progress", progress) }) if err != nil { return err } runtime.EventsEmit(a.ctx, "update:complete", true) return nil } func (a *App) RestartApp() error { return a.updater.RestartApp() } // Sound Management func (a *App) getSoundsDir() (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", err } soundsDir := filepath.Join(configDir, "DailyTimer", "sounds") if err := os.MkdirAll(soundsDir, 0755); err != nil { return "", err } return soundsDir, nil } func (a *App) SelectCustomSound(soundType string) (string, error) { // Validate sound type validTypes := map[string]bool{"warning": true, "timeup": true, "meeting_end": true} if !validTypes[soundType] { return "", fmt.Errorf("invalid sound type: %s", soundType) } // Open file dialog selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Select Sound File", Filters: []runtime.FileFilter{ {DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.m4a;*.ogg"}, }, }) if err != nil { return "", err } if selection == "" { return "", nil // User cancelled } // Get sounds directory soundsDir, err := a.getSoundsDir() if err != nil { return "", err } // Determine destination filename ext := filepath.Ext(selection) destPath := filepath.Join(soundsDir, soundType+ext) // Copy file src, err := os.Open(selection) if err != nil { return "", err } defer func() { _ = src.Close() }() dst, err := os.Create(destPath) if err != nil { return "", err } defer func() { _ = dst.Close() }() if _, err := dst.ReadFrom(src); err != nil { return "", err } return destPath, nil } func (a *App) GetCustomSoundPath(soundType string) string { soundsDir, err := a.getSoundsDir() if err != nil { return "" } // Check for common audio extensions extensions := []string{".mp3", ".wav", ".m4a", ".ogg"} for _, ext := range extensions { path := filepath.Join(soundsDir, soundType+ext) if _, err := os.Stat(path); err == nil { return path } } return "" } func (a *App) ClearCustomSound(soundType string) error { soundsDir, err := a.getSoundsDir() if err != nil { return err } extensions := []string{".mp3", ".wav", ".m4a", ".ogg"} for _, ext := range extensions { path := filepath.Join(soundsDir, soundType+ext) if _, err := os.Stat(path); err == nil { if err := os.Remove(path); err != nil { return err } } } return nil }