613 lines
13 KiB
Go
613 lines
13 KiB
Go
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.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
|
|
}
|
|
|
|
// 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
|
|
}
|