package service import ( "context" "sort" "time" "bugulma/backend/internal/domain" "bugulma/backend/internal/repository" ) // Departure describes a single upcoming departure event type Departure struct { Time time.Time `json:"time"` TripID string `json:"trip_id"` RouteID string `json:"route_id"` RouteShort string `json:"route_short_name"` StopID string `json:"stop_id"` } // ScheduleService handles GTFS schedule queries type ScheduleService struct { gtfsRepo *repository.PublicTransportGTFSRepository ptRepo domain.PublicTransportRepository } func NewScheduleService(gtfsRepo *repository.PublicTransportGTFSRepository, ptRepo domain.PublicTransportRepository) *ScheduleService { return &ScheduleService{gtfsRepo: gtfsRepo, ptRepo: ptRepo} } // GetNextDepartures returns the next departures for a stop starting from fromTime func (s *ScheduleService) GetNextDepartures(ctx context.Context, stopID string, fromTime time.Time, limit int) ([]Departure, error) { // Load stop_times for the stop stopTimes, err := s.gtfsRepo.ListStopTimesByStop(ctx, stopID) if err != nil { return nil, err } // Build a map of serviceIDs active today today := fromTime.UTC() activeServices := map[string]bool{} // For each stop_time, check trip->service calendar // To keep this efficient, we'll iterate stopTimes and inspect their trip's service via DB var departures []Departure for _, st := range stopTimes { // quick filter by departure secs if st.DepartureSecs < secondsSinceMidnight(fromTime) { continue } // get trip trip, err := s.gtfsRepo.GetTripByID(ctx, st.TripID) if err != nil || trip == nil { continue } // check service calendar svcID := trip.ServiceID if svcID == "" { continue } if !activeServices[svcID] { // check calendar cal, err := s.gtfsRepo.GetCalendarByServiceID(ctx, svcID) active := false if err == nil && cal != nil { yy, mm, dd := today.Date() todayDate := time.Date(yy, mm, dd, 0, 0, 0, 0, time.UTC) if !todayDate.Before(cal.StartDate) && !todayDate.After(cal.EndDate) { weekday := today.Weekday() switch weekday { case time.Monday: active = cal.Monday case time.Tuesday: active = cal.Tuesday case time.Wednesday: active = cal.Wednesday case time.Thursday: active = cal.Thursday case time.Friday: active = cal.Friday case time.Saturday: active = cal.Saturday case time.Sunday: active = cal.Sunday } } } // TODO: consider calendar_dates exceptions (not implemented yet) if active { activeServices[svcID] = true } else { activeServices[svcID] = false } } if !activeServices[svcID] { continue } // Get route for trip var route domain.PublicTransportRoute if rt, err := s.gtfsRepo.GetRouteByTripID(ctx, st.TripID); err == nil && rt != nil { route = *rt } // compute departure time as today's date + departure secs dep := secondsToTimeUTC(fromTime, st.DepartureSecs) departures = append(departures, Departure{ Time: dep, TripID: st.TripID, RouteID: route.ID, RouteShort: route.ShortName, StopID: st.StopID, }) } // For frequencies we would need to expand entries - TODO: support frequencies expansion in future // Sort departures by time sort.Slice(departures, func(i, j int) bool { return departures[i].Time.Before(departures[j].Time) }) if limit > 0 && len(departures) > limit { departures = departures[:limit] } return departures, nil } func secondsSinceMidnight(t time.Time) int { h := t.Hour() m := t.Minute() s := t.Second() return h*3600 + m*60 + s } func secondsToTimeUTC(base time.Time, secs int) time.Time { // compute midnight of base date then add secs (handle > 86400) y, mo, d := base.Date() midnight := time.Date(y, mo, d, 0, 0, 0, 0, time.UTC) return midnight.Add(time.Duration(secs) * time.Second) }