diff --git a/local/controller/stateHistory.go b/local/controller/stateHistory.go index 5a640d6..865dbb1 100644 --- a/local/controller/stateHistory.go +++ b/local/controller/stateHistory.go @@ -26,6 +26,7 @@ func NewStateHistoryController(as *service.StateHistoryService, routeGroups *com } routeGroups.StateHistory.Get("/", ac.getAll) + routeGroups.StateHistory.Get("/statistics", ac.getStatistics) return ac } @@ -36,7 +37,7 @@ func NewStateHistoryController(as *service.StateHistoryService, routeGroups *com // @Description Return StateHistorys // @Tags StateHistory // @Success 200 {array} string -// @Router /v1/StateHistory [get] +// @Router /v1/state-history [get] func (ac *StateHistoryController) getAll(c *fiber.Ctx) error { var filter model.StateHistoryFilter if err := common.ParseQueryFilter(c, &filter); err != nil { @@ -52,5 +53,30 @@ func (ac *StateHistoryController) getAll(c *fiber.Ctx) error { }) } + return c.JSON(result) +} + +// getStatistics returns StateHistorys +// +// @Summary Return StateHistorys +// @Description Return StateHistorys +// @Tags StateHistory +// @Success 200 {array} string +// @Router /v1/state-history/statistics [get] +func (ac *StateHistoryController) getStatistics(c *fiber.Ctx) error { + var filter model.StateHistoryFilter + if err := common.ParseQueryFilter(c, &filter); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + result, err := ac.service.GetStatistics(c, &filter) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Error retrieving state history statistics", + }) + } + return c.JSON(result) } \ No newline at end of file diff --git a/local/model/cache.go b/local/model/cache.go index 083e851..5702a4d 100644 --- a/local/model/cache.go +++ b/local/model/cache.go @@ -117,4 +117,145 @@ func (c *LookupCache) Clear() { defer c.Unlock() c.data = make(map[string]interface{}) +} + +// ConfigEntry represents a cached configuration entry with its update time +type ConfigEntry[T any] struct { + Data T + UpdatedAt time.Time +} + +// getConfigFromCache is a generic helper function to retrieve cached configs +func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string, expirationTime time.Duration) (*T, bool) { + if entry, ok := cache[serverID]; ok { + if time.Since(entry.UpdatedAt) < expirationTime { + return &entry.Data, true + } + } + return nil, false +} + +// updateConfigInCache is a generic helper function to update cached configs +func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID string, data T) { + cache[serverID] = &ConfigEntry[T]{ + Data: data, + UpdatedAt: time.Now(), + } +} + +// ServerConfigCache manages cached server configurations +type ServerConfigCache struct { + sync.RWMutex + configuration map[string]*ConfigEntry[Configuration] + assistRules map[string]*ConfigEntry[AssistRules] + event map[string]*ConfigEntry[EventConfig] + eventRules map[string]*ConfigEntry[EventRules] + settings map[string]*ConfigEntry[ServerSettings] + config CacheConfig +} + +// NewServerConfigCache creates a new server configuration cache +func NewServerConfigCache(config CacheConfig) *ServerConfigCache { + return &ServerConfigCache{ + configuration: make(map[string]*ConfigEntry[Configuration]), + assistRules: make(map[string]*ConfigEntry[AssistRules]), + event: make(map[string]*ConfigEntry[EventConfig]), + eventRules: make(map[string]*ConfigEntry[EventRules]), + settings: make(map[string]*ConfigEntry[ServerSettings]), + config: config, + } +} + +// GetConfiguration retrieves a cached configuration +func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, bool) { + c.RLock() + defer c.RUnlock() + return getConfigFromCache(c.configuration, serverID, c.config.ExpirationTime) +} + +// GetAssistRules retrieves cached assist rules +func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool) { + c.RLock() + defer c.RUnlock() + return getConfigFromCache(c.assistRules, serverID, c.config.ExpirationTime) +} + +// GetEvent retrieves cached event configuration +func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) { + c.RLock() + defer c.RUnlock() + return getConfigFromCache(c.event, serverID, c.config.ExpirationTime) +} + +// GetEventRules retrieves cached event rules +func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) { + c.RLock() + defer c.RUnlock() + return getConfigFromCache(c.eventRules, serverID, c.config.ExpirationTime) +} + +// GetSettings retrieves cached server settings +func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool) { + c.RLock() + defer c.RUnlock() + return getConfigFromCache(c.settings, serverID, c.config.ExpirationTime) +} + +// UpdateConfiguration updates the configuration cache +func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configuration) { + c.Lock() + defer c.Unlock() + updateConfigInCache(c.configuration, serverID, config) +} + +// UpdateAssistRules updates the assist rules cache +func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules) { + c.Lock() + defer c.Unlock() + updateConfigInCache(c.assistRules, serverID, rules) +} + +// UpdateEvent updates the event configuration cache +func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) { + c.Lock() + defer c.Unlock() + updateConfigInCache(c.event, serverID, event) +} + +// UpdateEventRules updates the event rules cache +func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules) { + c.Lock() + defer c.Unlock() + updateConfigInCache(c.eventRules, serverID, rules) +} + +// UpdateSettings updates the server settings cache +func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSettings) { + c.Lock() + defer c.Unlock() + updateConfigInCache(c.settings, serverID, settings) +} + +// InvalidateServerCache removes all cached configurations for a specific server +func (c *ServerConfigCache) InvalidateServerCache(serverID string) { + c.Lock() + defer c.Unlock() + + delete(c.configuration, serverID) + delete(c.assistRules, serverID) + delete(c.event, serverID) + delete(c.eventRules, serverID) + delete(c.settings, serverID) +} + +// Clear removes all entries from the cache +func (c *ServerConfigCache) Clear() { + c.Lock() + defer c.Unlock() + + c.configuration = make(map[string]*ConfigEntry[Configuration]) + c.assistRules = make(map[string]*ConfigEntry[AssistRules]) + c.event = make(map[string]*ConfigEntry[EventConfig]) + c.eventRules = make(map[string]*ConfigEntry[EventRules]) + c.settings = make(map[string]*ConfigEntry[ServerSettings]) } \ No newline at end of file diff --git a/local/model/stateHistory.go b/local/model/stateHistory.go index 667a53f..845a02e 100644 --- a/local/model/stateHistory.go +++ b/local/model/stateHistory.go @@ -53,7 +53,19 @@ type StateHistory struct { ID uint `gorm:"primaryKey" json:"id"` ServerID uint `json:"serverId" gorm:"not null"` Session string `json:"session"` + Track string `json:"track"` PlayerCount int `json:"playerCount"` DateCreated time.Time `json:"dateCreated"` + SessionStart time.Time `json:"sessionStart"` SessionDurationMinutes int `json:"sessionDurationMinutes"` + SessionID uint `json:"sessionId" gorm:"not null"` // Unique identifier for each session/event +} + +type RecentSession struct { + ID uint `json:"id"` + Date time.Time `json:"date"` + Type string `json:"type"` + Track string `json:"track"` + Duration int `json:"duration"` + Players int `json:"players"` } \ No newline at end of file diff --git a/local/model/stateHistoryStats.go b/local/model/stateHistoryStats.go new file mode 100644 index 0000000..c381253 --- /dev/null +++ b/local/model/stateHistoryStats.go @@ -0,0 +1,38 @@ +package model + +import "time" + +type SessionCount struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type DailyActivity struct { + Date time.Time `json:"date"` + SessionsCount int `json:"sessionsCount"` +} + +type PlayerCountPoint struct { + Timestamp time.Time `json:"timestamp"` + Count int `json:"count"` +} + +type StateHistoryStats struct { + AveragePlayers float64 `json:"averagePlayers"` + PeakPlayers int `json:"peakPlayers"` + TotalSessions int `json:"totalSessions"` + TotalPlaytime int `json:"totalPlaytime"` // in minutes + PlayerCountOverTime []PlayerCountPoint `json:"playerCountOverTime"` + SessionTypes []SessionCount `json:"sessionTypes"` + DailyActivity []DailyActivity `json:"dailyActivity"` + RecentSessions []RecentSession `json:"recentSessions"` +} + +type RecentSession struct { + ID uint `json:"id"` + Date time.Time `json:"date"` + Type string `json:"type"` + Track string `json:"track"` + Duration int `json:"duration"` + Players int `json:"players"` +} \ No newline at end of file diff --git a/local/service/config.go b/local/service/config.go index 9dd5807..06f430b 100644 --- a/local/service/config.go +++ b/local/service/config.go @@ -11,6 +11,7 @@ import ( "log" "os" "path/filepath" + "strconv" "time" "github.com/gofiber/fiber/v2" @@ -63,13 +64,19 @@ func mustDecode[T any](fileName, path string) (T, error) { type ConfigService struct { repository *repository.ConfigRepository serverRepository *repository.ServerRepository - serverService *ServerService + serverService *ServerService + configCache *model.ServerConfigCache } func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService { return &ConfigService{ repository: repository, serverRepository: serverRepository, + configCache: model.NewServerConfigCache(model.CacheConfig{ + ExpirationTime: 5 * time.Minute, // Cache configs for 5 minutes + ThrottleTime: 1 * time.Second, // Prevent rapid re-reads + DefaultStatus: model.StatusUnknown, + }), } } @@ -148,6 +155,9 @@ func (as ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{ return nil, err } + // Invalidate all configs for this server since configs can be interdependent + as.configCache.InvalidateServerCache(strconv.Itoa(serverID)) + as.serverService.StartAccServerRuntime(server) // Log change @@ -170,71 +180,145 @@ func (as ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{ func (as ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) { serverID, _ := ctx.ParamsInt("id") configFile := ctx.Params("file") + serverIDStr := strconv.Itoa(serverID) server, err := as.serverRepository.GetByID(ctx.UserContext(), serverID) - if err != nil { log.Print("Server not found") return nil, fiber.NewError(404, "Server not found") } + // Try to get from cache based on config file type + switch configFile { + case ConfigurationJson: + if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok { + return cached, nil + } + case AssistRulesJson: + if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok { + return cached, nil + } + case EventJson: + if cached, ok := as.configCache.GetEvent(serverIDStr); ok { + return cached, nil + } + case EventRulesJson: + if cached, ok := as.configCache.GetEventRules(serverIDStr); ok { + return cached, nil + } + case SettingsJson: + if cached, ok := as.configCache.GetSettings(serverIDStr); ok { + return cached, nil + } + } + decoded, err := DecodeFileName(configFile)(server.ConfigPath) if err != nil { return nil, err } + // Cache the result based on config file type + switch configFile { + case ConfigurationJson: + if config, ok := decoded.(model.Configuration); ok { + as.configCache.UpdateConfiguration(serverIDStr, config) + } + case AssistRulesJson: + if rules, ok := decoded.(model.AssistRules); ok { + as.configCache.UpdateAssistRules(serverIDStr, rules) + } + case EventJson: + if event, ok := decoded.(model.EventConfig); ok { + as.configCache.UpdateEvent(serverIDStr, event) + } + case EventRulesJson: + if rules, ok := decoded.(model.EventRules); ok { + as.configCache.UpdateEventRules(serverIDStr, rules) + } + case SettingsJson: + if settings, ok := decoded.(model.ServerSettings); ok { + as.configCache.UpdateSettings(serverIDStr, settings) + } + } + return decoded, nil } // GetConfigs -// Gets physical config file and caches it in database. -// -// Args: -// context.Context: Application context -// Returns: -// string: Application version +// Gets all configurations for a server, using cache when possible. func (as ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, error) { serverID, _ := ctx.ParamsInt("id") + serverIDStr := strconv.Itoa(serverID) server, err := as.serverRepository.GetByID(ctx.UserContext(), serverID) - if err != nil { log.Print("Server not found") return nil, fiber.NewError(404, "Server not found") } - decodedconfiguration, err := mustDecode[model.Configuration](ConfigurationJson, server.ConfigPath) - if err != nil { - return nil, err + configs := &model.Configurations{} + + // Load configuration + if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok { + configs.Configuration = *cached + } else { + config, err := mustDecode[model.Configuration](ConfigurationJson, server.ConfigPath) + if err != nil { + return nil, err + } + configs.Configuration = config + as.configCache.UpdateConfiguration(serverIDStr, config) } - decodedAssistRules, err := mustDecode[model.AssistRules](AssistRulesJson, server.ConfigPath) - if err != nil { - return nil, err + // Load assist rules + if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok { + configs.AssistRules = *cached + } else { + rules, err := mustDecode[model.AssistRules](AssistRulesJson, server.ConfigPath) + if err != nil { + return nil, err + } + configs.AssistRules = rules + as.configCache.UpdateAssistRules(serverIDStr, rules) } - decodedevent, err := mustDecode[model.EventConfig](EventJson, server.ConfigPath) - if err != nil { - return nil, err + // Load event config + if cached, ok := as.configCache.GetEvent(serverIDStr); ok { + configs.Event = *cached + } else { + event, err := mustDecode[model.EventConfig](EventJson, server.ConfigPath) + if err != nil { + return nil, err + } + configs.Event = event + as.configCache.UpdateEvent(serverIDStr, event) } - decodedeventRules, err := mustDecode[model.EventRules](EventRulesJson, server.ConfigPath) - if err != nil { - return nil, err + // Load event rules + if cached, ok := as.configCache.GetEventRules(serverIDStr); ok { + configs.EventRules = *cached + } else { + rules, err := mustDecode[model.EventRules](EventRulesJson, server.ConfigPath) + if err != nil { + return nil, err + } + configs.EventRules = rules + as.configCache.UpdateEventRules(serverIDStr, rules) } - decodedsettings, err := mustDecode[model.ServerSettings](SettingsJson, server.ConfigPath) - if err != nil { - return nil, err + // Load settings + if cached, ok := as.configCache.GetSettings(serverIDStr); ok { + configs.Settings = *cached + } else { + settings, err := mustDecode[model.ServerSettings](SettingsJson, server.ConfigPath) + if err != nil { + return nil, err + } + configs.Settings = settings + as.configCache.UpdateSettings(serverIDStr, settings) } - return &model.Configurations{ - Configuration: decodedconfiguration, - Event: decodedevent, - EventRules: decodedeventRules, - Settings: decodedsettings, - AssistRules: decodedAssistRules, - }, nil + return configs, nil } func readAndDecode[T interface{}](path string, configFile string) (T, error) { diff --git a/local/service/server.go b/local/service/server.go index b81b46c..533fe11 100644 --- a/local/service/server.go +++ b/local/service/server.go @@ -8,6 +8,7 @@ import ( "context" "log" "path/filepath" + "strconv" "sync" "time" @@ -23,7 +24,7 @@ type ServerService struct { lastInsertTimes sync.Map // Track last insert time per server debouncers sync.Map // Track debounce timers per server logTailers sync.Map // Track log tailers per server - sessionCache sync.Map // Cache of server event sessions + sessionIDs sync.Map // Track current session ID per server } type pendingState struct { @@ -91,42 +92,94 @@ func (s *ServerService) shouldInsertStateHistory(serverID uint) bool { return false } +func (s *ServerService) getNextSessionID(serverID uint) uint { + currentID, _ := s.sessionIDs.LoadOrStore(serverID, uint(0)) + nextID := currentID.(uint) + 1 + s.sessionIDs.Store(serverID, nextID) + return nextID +} + func (s *ServerService) insertStateHistory(serverID uint, state *model.ServerState) { + // Get or create session ID when session changes + currentSessionInterface, exists := s.instances.Load(serverID) + var sessionID uint + if !exists { + sessionID = s.getNextSessionID(serverID) + } else { + serverInstance := currentSessionInterface.(*tracking.AccServerInstance) + if serverInstance.State == nil || serverInstance.State.Session != state.Session { + sessionID = s.getNextSessionID(serverID) + } else { + sessionIDInterface, exists := s.sessionIDs.Load(serverID) + if !exists { + sessionID = s.getNextSessionID(serverID) + } else { + sessionID = sessionIDInterface.(uint) + } + } + } + s.stateHistoryRepo.Insert(context.Background(), &model.StateHistory{ ServerID: serverID, Session: state.Session, + Track: state.Track, PlayerCount: state.PlayerCount, DateCreated: time.Now().UTC(), + SessionStart: state.SessionStart, SessionDurationMinutes: state.SessionDurationMinutes, + SessionID: sessionID, }) } func (s *ServerService) updateSessionDuration(server *model.Server, sessionType string) { - sessionsInterface, exists := s.sessionCache.Load(server.ID) - if !exists { - // Try to load sessions from config - event, err := DecodeFileName(EventJson)(server.ConfigPath) + serverIDStr := strconv.FormatUint(uint64(server.ID), 10) + + // Get event config from cache or load it + var event model.EventConfig + if cached, ok := s.configService.configCache.GetEvent(serverIDStr); ok { + event = *cached + } else { + event, err := mustDecode[model.EventConfig](EventJson, server.ConfigPath) if err != nil { logging.Error("Failed to load event config for server %d: %v", server.ID, err) return } - evt := event.(model.EventConfig) - s.sessionCache.Store(server.ID, evt.Sessions) - sessionsInterface = evt.Sessions + s.configService.configCache.UpdateEvent(serverIDStr, event) } - sessions := sessionsInterface.([]model.Session) - if sessionType == "" && len(sessions) > 0 { - sessionType = sessions[0].SessionType + var configuration model.Configuration + if cached, ok := s.configService.configCache.GetConfiguration(serverIDStr); ok { + configuration = *cached + } else { + configuration, err := mustDecode[model.Configuration](ConfigurationJson, server.ConfigPath) + if err != nil { + logging.Error("Failed to load configuration config for server %d: %v", server.ID, err) + return + } + s.configService.configCache.UpdateConfiguration(serverIDStr, configuration) } - for _, session := range sessions { - if session.SessionType == sessionType { - if instance, ok := s.instances.Load(server.ID); ok { - serverInstance := instance.(*tracking.AccServerInstance) + + if instance, ok := s.instances.Load(server.ID); ok { + serverInstance := instance.(*tracking.AccServerInstance) + serverInstance.State.Track = event.Track + serverInstance.State.MaxConnections = configuration.MaxConnections.ToInt() + + // Check if session type has changed + if serverInstance.State.Session != sessionType { + // Get new session ID for the new session + sessionID := s.getNextSessionID(server.ID) + s.sessionIDs.Store(server.ID, sessionID) + } + + if sessionType == "" && len(event.Sessions) > 0 { + sessionType = event.Sessions[0].SessionType + } + for _, session := range event.Sessions { + if session.SessionType == sessionType { serverInstance.State.SessionDurationMinutes = session.SessionDurationMinutes.ToInt() serverInstance.State.Session = sessionType + break } - break } } } @@ -177,17 +230,10 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) { instance = instanceInterface.(*tracking.AccServerInstance) } - config, _ := DecodeFileName(ConfigurationJson)(server.ConfigPath) - cfg := config.(model.Configuration) - event, _ := DecodeFileName(EventJson)(server.ConfigPath) - evt := event.(model.EventConfig) + // Invalidate config cache for this server before loading new configs + serverIDStr := strconv.FormatUint(uint64(server.ID), 10) + s.configService.configCache.InvalidateServerCache(serverIDStr) - instance.State.MaxConnections = cfg.MaxConnections.ToInt() - instance.State.Track = evt.Track - - // Cache sessions for duration lookup - s.sessionCache.Store(server.ID, evt.Sessions) - s.updateSessionDuration(server, instance.State.Session) // Ensure log tailing is running (regardless of server status) diff --git a/local/service/stateHistory.go b/local/service/stateHistory.go index 7463934..9f03ceb 100644 --- a/local/service/stateHistory.go +++ b/local/service/stateHistory.go @@ -4,6 +4,8 @@ import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" "log" + "sort" + "time" "github.com/gofiber/fiber/v2" ) @@ -40,4 +42,152 @@ func (s *StateHistoryService) Insert(ctx *fiber.Ctx, model *model.StateHistory) return err } return nil +} + +func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateHistoryFilter) (*model.StateHistoryStats, error) { + // Get all state history entries based on filter + entries, err := s.repository.GetAll(ctx.UserContext(), filter) + if err != nil { + log.Printf("Error getting state history for statistics: %v", err) + return nil, err + } + + stats := &model.StateHistoryStats{ + PlayerCountOverTime: make([]model.PlayerCountPoint, 0), + SessionTypes: make([]model.SessionCount, 0), + DailyActivity: make([]model.DailyActivity, 0), + RecentSessions: make([]model.RecentSession, 0), + } + + if len(*entries) == 0 { + return stats, nil + } + + // Maps to track unique sessions and their details + sessionMap := make(map[uint]*struct { + StartTime time.Time + EndTime time.Time + Session string + Track string + MaxPlayers int + }) + + // Maps for aggregating statistics + dailySessionCount := make(map[string]int) + sessionTypeCount := make(map[string]int) + totalPlayers := 0 + peakPlayers := 0 + + // Process each state history entry + for _, entry := range *entries { + // Track player count over time + stats.PlayerCountOverTime = append(stats.PlayerCountOverTime, model.PlayerCountPoint{ + Timestamp: entry.DateCreated, + Count: entry.PlayerCount, + }) + + // Update peak players + if entry.PlayerCount > peakPlayers { + peakPlayers = entry.PlayerCount + } + + totalPlayers += entry.PlayerCount + + // Process session information using SessionID + if _, exists := sessionMap[entry.SessionID]; !exists { + sessionMap[entry.SessionID] = &struct { + StartTime time.Time + EndTime time.Time + Session string + Track string + MaxPlayers int + }{ + StartTime: entry.DateCreated, + Session: entry.Session, + Track: entry.Track, + MaxPlayers: entry.PlayerCount, + } + + // Count session types + sessionTypeCount[entry.Session]++ + + // Count daily sessions + dateStr := entry.DateCreated.Format("2006-01-02") + dailySessionCount[dateStr]++ + } else { + session := sessionMap[entry.SessionID] + session.EndTime = entry.DateCreated + if entry.PlayerCount > session.MaxPlayers { + session.MaxPlayers = entry.PlayerCount + } + } + } + + // Calculate statistics + stats.PeakPlayers = peakPlayers + stats.TotalSessions = len(sessionMap) + if len(*entries) > 0 { + stats.AveragePlayers = float64(totalPlayers) / float64(len(*entries)) + } + + // Process session types + for sessionType, count := range sessionTypeCount { + stats.SessionTypes = append(stats.SessionTypes, model.SessionCount{ + Name: sessionType, + Count: count, + }) + } + + // Process daily activity + for dateStr, count := range dailySessionCount { + date, _ := time.Parse("2006-01-02", dateStr) + stats.DailyActivity = append(stats.DailyActivity, model.DailyActivity{ + Date: date, + SessionsCount: count, + }) + } + + // Calculate total playtime and prepare recent sessions + var recentSessions []model.RecentSession + totalPlaytime := 0 + + for sessionID, session := range sessionMap { + if !session.EndTime.IsZero() { + duration := int(session.EndTime.Sub(session.StartTime).Minutes()) + totalPlaytime += duration + + recentSessions = append(recentSessions, model.RecentSession{ + ID: sessionID, + Date: session.StartTime, + Type: session.Session, + Track: session.Track, + Duration: duration, + Players: session.MaxPlayers, + }) + } + } + + stats.TotalPlaytime = totalPlaytime + + // Sort recent sessions by date (newest first) and limit to last 10 + sort.Slice(recentSessions, func(i, j int) bool { + return recentSessions[i].Date.After(recentSessions[j].Date) + }) + + if len(recentSessions) > 10 { + recentSessions = recentSessions[:10] + } + stats.RecentSessions = recentSessions + + // Sort daily activity by date + sort.Slice(stats.DailyActivity, func(i, j int) bool { + return stats.DailyActivity[i].Date.Before(stats.DailyActivity[j].Date) + }) + + // Sort player count over time by timestamp + sort.Slice(stats.PlayerCountOverTime, func(i, j int) bool { + return stats.PlayerCountOverTime[i].Timestamp.Before(stats.PlayerCountOverTime[j].Timestamp) + }) + + return stats, nil } \ No newline at end of file