feat: initial daily-timer implementation

This commit is contained in:
Mikhail Kiselev
2026-02-08 05:17:37 +03:00
parent 537f72eb51
commit ef23291bdd
37 changed files with 7779 additions and 0 deletions

331
internal/storage/storage.go Normal file
View 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()
}