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() }