332 lines
8.9 KiB
Go
332 lines
8.9 KiB
Go
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()
|
|
}
|