feat: initial daily-timer implementation
This commit is contained in:
331
internal/storage/storage.go
Normal file
331
internal/storage/storage.go
Normal file
@@ -0,0 +1,331 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user