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
|
||||
}
|
||||
Reference in New Issue
Block a user