From b1702c1531fa99c776b7c4ad94eb618a43acff28 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Thu, 26 Mar 2026 02:18:33 -0500 Subject: [PATCH 1/4] go backend wip --- apps/api/.env.example | 1 + apps/api/cmd/api/main.go | 25 -- apps/api/go.mod | 11 +- apps/api/go.sum | 10 + apps/api/gtfs/realtime.go | 203 ++++++++++ apps/api/gtfs/static.go | 348 ++++++++++++++++++ apps/api/main.go | 44 +++ apps/api/providers/amtrak/amtrak.go | 281 ++++++++++++++ apps/api/providers/amtrak/types.go | 125 +++++++ apps/api/providers/base/base.go | 78 ++++ apps/api/providers/brightline/brightline.go | 23 ++ apps/api/providers/metra/metra.go | 26 ++ .../providers/metrotransit/metrotransit.go | 24 ++ apps/api/providers/providers.go | 70 ++++ apps/api/providers/trirail/trirail.go | 31 ++ apps/api/routes/routes.go | 105 ++++++ apps/api/spec/realtime.go | 61 +++ apps/api/spec/schedule.go | 95 +++++ 18 files changed, 1535 insertions(+), 26 deletions(-) delete mode 100644 apps/api/cmd/api/main.go create mode 100644 apps/api/go.sum create mode 100644 apps/api/gtfs/realtime.go create mode 100644 apps/api/gtfs/static.go create mode 100644 apps/api/main.go create mode 100644 apps/api/providers/amtrak/amtrak.go create mode 100644 apps/api/providers/amtrak/types.go create mode 100644 apps/api/providers/base/base.go create mode 100644 apps/api/providers/brightline/brightline.go create mode 100644 apps/api/providers/metra/metra.go create mode 100644 apps/api/providers/metrotransit/metrotransit.go create mode 100644 apps/api/providers/providers.go create mode 100644 apps/api/providers/trirail/trirail.go create mode 100644 apps/api/routes/routes.go create mode 100644 apps/api/spec/realtime.go create mode 100644 apps/api/spec/schedule.go diff --git a/apps/api/.env.example b/apps/api/.env.example index 25241b7..32467aa 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1 +1,2 @@ PORT=8080 +METRA_API_KEY= diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go deleted file mode 100644 index 8a6caf9..0000000 --- a/apps/api/cmd/api/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "log" - "net/http" - "os" -) - -func main() { - port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - - mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) - }) - - log.Printf("starting server on :%s", port) - if err := http.ListenAndServe(":"+port, mux); err != nil { - log.Fatal(err) - } -} diff --git a/apps/api/go.mod b/apps/api/go.mod index 58aa296..08b25c0 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,3 +1,12 @@ -module github.com/rnielsen/tracky/api +module github.com/Tracky-Trains/tracky/api go 1.26 + +require ( + github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0 + google.golang.org/protobuf v1.36.11 +) + +require golang.org/x/crypto v0.49.0 + +require github.com/joho/godotenv v1.5.1 diff --git a/apps/api/go.sum b/apps/api/go.sum new file mode 100644 index 0000000..fcb0050 --- /dev/null +++ b/apps/api/go.sum @@ -0,0 +1,10 @@ +github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0 h1:f4P+fVYmSIWj4b/jvbMdmrmsx/Xb+5xCpYYtVXOdKoc= +github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0/go.mod h1:nSmbVVQSM4lp9gYvVaaTotnRxSwZXEdFnJARofg5V4g= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/apps/api/gtfs/realtime.go b/apps/api/gtfs/realtime.go new file mode 100644 index 0000000..9c26bdf --- /dev/null +++ b/apps/api/gtfs/realtime.go @@ -0,0 +1,203 @@ +package gtfs + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + gtfsrt "github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs" + "google.golang.org/protobuf/proto" + + "github.com/Tracky-Trains/tracky/api/spec" +) + +// FetchAndParsePositions downloads a GTFS-RT vehicle positions feed and returns +// parsed TrainPositions stamped with providerID. +func FetchAndParsePositions( + ctx context.Context, + url string, + providerID string, + apiKey string, +) ([]spec.TrainPosition, error) { + feed, err := fetchFeed(ctx, url, apiKey) + if err != nil { + return nil, fmt.Errorf("gtfs-rt positions: %w", err) + } + + now := time.Now() + var positions []spec.TrainPosition + + if b, err := json.MarshalIndent(feed.Entity, "", " "); err == nil { + filename := fmt.Sprintf("debug_%s_positions.json", providerID) + if err := os.WriteFile(filename, b, 0644); err != nil { + log.Printf("[DEBUG] failed to write %s: %v", filename, err) + } else { + log.Printf("[DEBUG] %s realtime positions (%d) saved to %s", providerID, len(feed.Entity), filename) + } + } + + for _, entity := range feed.Entity { + vp := entity.Vehicle + if vp == nil || vp.Trip == nil { + continue + } + + pos := spec.TrainPosition{ + Provider: providerID, + LastUpdated: now, + } + + if trip := vp.Trip; trip != nil { + pos.TripID = providerID + ":" + trip.GetTripId() + pos.RouteID = providerID + ":" + trip.GetRouteId() + pos.RunDate = parseStartDate(trip.GetStartDate()) + } + + if vehicle := vp.Vehicle; vehicle != nil { + pos.TrainNumber = vehicle.GetLabel() + pos.VehicleID = vehicle.GetId() + } + + if p := vp.Position; p != nil { + lat := float64(p.GetLatitude()) + lon := float64(p.GetLongitude()) + pos.Lat = &lat + pos.Lon = &lon + + if p.Bearing != nil { + h := fmt.Sprintf("%.0f", float64(p.GetBearing())) + pos.Heading = &h + } + + if p.Speed != nil { + // GTFS-RT speed is m/s — convert to mph + mph := float64(p.GetSpeed()) * 2.23694 + pos.SpeedMPH = &mph + } + } + + if vp.StopId != nil { + stopID := providerID + ":" + vp.GetStopId() + pos.CurrentStopCode = &stopID + } + + if vp.Timestamp != nil { + pos.LastUpdated = time.Unix(int64(vp.GetTimestamp()), 0) + } + + positions = append(positions, pos) + } + + return positions, nil +} + +// FetchAndParseTripUpdates downloads a GTFS-RT trip updates feed and returns +// parsed TrainStopTimes stamped with providerID. +func FetchAndParseTripUpdates( + ctx context.Context, + url string, + providerID string, + apiKey string, +) ([]spec.TrainStopTime, error) { + feed, err := fetchFeed(ctx, url, apiKey) + if err != nil { + return nil, fmt.Errorf("gtfs-rt trip updates: %w", err) + } + + now := time.Now() + var stopTimes []spec.TrainStopTime + + if b, err := json.MarshalIndent(feed.Entity, "", " "); err == nil { + filename := fmt.Sprintf("debug_%s_stoptimes.json", providerID) + if err := os.WriteFile(filename, b, 0644); err != nil { + log.Printf("[DEBUG] failed to write %s: %v", filename, err) + } else { + log.Printf("[DEBUG] %s trip updates (%d entities) saved to %s", providerID, len(feed.Entity), filename) + } + } + + for _, entity := range feed.Entity { + tu := entity.TripUpdate + if tu == nil { + continue + } + + tripID := "" + runDate := time.Time{} + + if trip := tu.Trip; trip != nil { + tripID = providerID + ":" + trip.GetTripId() + runDate = parseStartDate(trip.GetStartDate()) + } + + for _, stu := range tu.StopTimeUpdate { + st := spec.TrainStopTime{ + Provider: providerID, + TripID: tripID, + RunDate: runDate, + StopCode: providerID + ":" + stu.GetStopId(), + StopSequence: int(stu.GetStopSequence()), + LastUpdated: now, + } + + if arr := stu.Arrival; arr != nil && arr.Time != nil { + t := time.Unix(arr.GetTime(), 0) + st.EstimatedArr = &t + } + + if dep := stu.Departure; dep != nil && dep.Time != nil { + t := time.Unix(dep.GetTime(), 0) + st.EstimatedDep = &t + } + + stopTimes = append(stopTimes, st) + } + } + + return stopTimes, nil +} + +// fetchFeed downloads and unmarshals a GTFS-RT protobuf feed. +func fetchFeed(ctx context.Context, url string, apiKey string) (*gtfsrt.FeedMessage, error) { + data, err := fetchBytes(ctx, url, apiKey) + if err != nil { + return nil, fmt.Errorf("fetch %s: %w", url, err) + } + var feed gtfsrt.FeedMessage + if err := proto.Unmarshal(data, &feed); err != nil { + return nil, fmt.Errorf("fetch %s: unmarshal: %w", url, err) + } + return &feed, nil +} + +// fetchBytes performs a GET request and returns the response body. +// If apiKey is non-empty, it's sent as a Bearer token in the Authorization header. +func fetchBytes(ctx context.Context, url string, apiKey string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// parseStartDate parses a GTFS-RT start_date string (YYYYMMDD) into a time.Time. +func parseStartDate(s string) time.Time { + t, _ := time.Parse("20060102", s) + return t +} diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go new file mode 100644 index 0000000..352c5f8 --- /dev/null +++ b/apps/api/gtfs/static.go @@ -0,0 +1,348 @@ +package gtfs + +import ( + "archive/zip" + "bytes" + "context" + "encoding/csv" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/Tracky-Trains/tracky/api/spec" +) + +// FetchAndParseStatic downloads a GTFS zip from url, parses it, and returns +// slices of spec types stamped with agencyID. +func FetchAndParseStatic( + ctx context.Context, + url string, + agencyID string, +) ( + agencies []spec.Agency, + routes []spec.Route, + stops []spec.Stop, + trips []spec.Trip, + stopTimes []spec.ScheduledStopTime, + calendars []spec.ServiceCalendar, + exceptions []spec.ServiceException, + err error, +) { + data, err := fetchStaticBytes(ctx, url) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: fetch %s: %w", url, err) + } + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: open zip: %w", err) + } + + files := indexZip(zr) + + if f, ok := files["agency.txt"]; ok { + agencies, err = parseAgency(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + if f, ok := files["routes.txt"]; ok { + routes, err = parseRoutes(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + if f, ok := files["stops.txt"]; ok { + stops, err = parseStops(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + if f, ok := files["trips.txt"]; ok { + trips, err = parseTrips(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + if f, ok := files["stop_times.txt"]; ok { + stopTimes, err = parseStopTimes(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + // calendar.txt is optional — some feeds use only calendar_dates.txt + if f, ok := files["calendar.txt"]; ok { + calendars, err = parseCalendar(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + if f, ok := files["calendar_dates.txt"]; ok { + exceptions, err = parseCalendarDates(f, agencyID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, err + } + } + + return +} + +// fetchStaticBytes performs a GET request and returns the response body. +func fetchStaticBytes(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// indexZip returns a map of filename → *zip.File for the archive. +func indexZip(zr *zip.Reader) map[string]*zip.File { + m := make(map[string]*zip.File, len(zr.File)) + for _, f := range zr.File { + m[f.Name] = f + } + return m +} + +// readCSV opens a zip file and returns all rows as a slice of header→value maps. +func readCSV(f *zip.File) ([]map[string]string, error) { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("gtfs: open %s: %w", f.Name, err) + } + defer rc.Close() + + r := csv.NewReader(rc) + r.TrimLeadingSpace = true + + header, err := r.Read() + if err != nil { + return nil, fmt.Errorf("gtfs: read header %s: %w", f.Name, err) + } + // Trim BOM from first field if present + if len(header) > 0 { + header[0] = trimBOM(header[0]) + } + + var rows []map[string]string + for { + rec, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("gtfs: read %s: %w", f.Name, err) + } + row := make(map[string]string, len(header)) + for i, col := range header { + if i < len(rec) { + row[col] = rec[i] + } + } + rows = append(rows, row) + } + return rows, nil +} + +func trimBOM(s string) string { + if len(s) >= 3 && s[0] == 0xEF && s[1] == 0xBB && s[2] == 0xBF { + return s[3:] + } + return s +} + +func optStr(m map[string]string, key string) *string { + v, ok := m[key] + if !ok || v == "" { + return nil + } + return &v +} + +func optBool(m map[string]string, key string) *bool { + v, ok := m[key] + if !ok || v == "" { + return nil + } + b := v == "1" + return &b +} + +func optInt(m map[string]string, key string) *int { + v, ok := m[key] + if !ok || v == "" { + return nil + } + i, err := strconv.Atoi(v) + if err != nil { + return nil + } + return &i +} + +func parseAgency(f *zip.File, agencyID string) ([]spec.Agency, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.Agency, 0, len(rows)) + for _, r := range rows { + out = append(out, spec.Agency{ + AgencyID: agencyID, + Name: r["agency_name"], + URL: r["agency_url"], + Timezone: r["agency_timezone"], + Lang: optStr(r, "agency_lang"), + Phone: optStr(r, "agency_phone"), + }) + } + return out, nil +} + +func parseRoutes(f *zip.File, agencyID string) ([]spec.Route, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.Route, 0, len(rows)) + for _, r := range rows { + out = append(out, spec.Route{ + AgencyID: agencyID, + RouteID: agencyID + ":" + r["route_id"], + ShortName: r["route_short_name"], + LongName: r["route_long_name"], + Color: r["route_color"], + TextColor: r["route_text_color"], + ShapeID: optStr(r, "shape_id"), + }) + } + return out, nil +} + +func parseStops(f *zip.File, agencyID string) ([]spec.Stop, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.Stop, 0, len(rows)) + for _, r := range rows { + lat, _ := strconv.ParseFloat(r["stop_lat"], 64) + lon, _ := strconv.ParseFloat(r["stop_lon"], 64) + out = append(out, spec.Stop{ + AgencyID: agencyID, + StopID: agencyID + ":" + r["stop_id"], + Code: r["stop_code"], + Name: r["stop_name"], + Lat: lat, + Lon: lon, + Timezone: optStr(r, "stop_timezone"), + WheelchairBoarding: optBool(r, "wheelchair_boarding"), + }) + } + return out, nil +} + +func parseTrips(f *zip.File, agencyID string) ([]spec.Trip, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.Trip, 0, len(rows)) + for _, r := range rows { + out = append(out, spec.Trip{ + AgencyID: agencyID, + TripID: agencyID + ":" + r["trip_id"], + RouteID: agencyID + ":" + r["route_id"], + ServiceID: r["service_id"], + Headsign: r["trip_headsign"], + ShapeID: optStr(r, "shape_id"), + DirectionID: optInt(r, "direction_id"), + }) + } + return out, nil +} + +func parseStopTimes(f *zip.File, agencyID string) ([]spec.ScheduledStopTime, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.ScheduledStopTime, 0, len(rows)) + for _, r := range rows { + seq, _ := strconv.Atoi(r["stop_sequence"]) + out = append(out, spec.ScheduledStopTime{ + AgencyID: agencyID, + TripID: agencyID + ":" + r["trip_id"], + StopID: agencyID + ":" + r["stop_id"], + StopSequence: seq, + ArrivalTime: optStr(r, "arrival_time"), + DepartureTime: optStr(r, "departure_time"), + Timepoint: optBool(r, "timepoint"), + DropOffType: optInt(r, "drop_off_type"), + PickupType: optInt(r, "pickup_type"), + }) + } + return out, nil +} + +func parseCalendar(f *zip.File, agencyID string) ([]spec.ServiceCalendar, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.ServiceCalendar, 0, len(rows)) + for _, r := range rows { + start, _ := time.Parse("20060102", r["start_date"]) + end, _ := time.Parse("20060102", r["end_date"]) + out = append(out, spec.ServiceCalendar{ + AgencyID: agencyID, + ServiceID: r["service_id"], + Monday: r["monday"] == "1", + Tuesday: r["tuesday"] == "1", + Wednesday: r["wednesday"] == "1", + Thursday: r["thursday"] == "1", + Friday: r["friday"] == "1", + Saturday: r["saturday"] == "1", + Sunday: r["sunday"] == "1", + StartDate: start, + EndDate: end, + }) + } + return out, nil +} + +func parseCalendarDates(f *zip.File, agencyID string) ([]spec.ServiceException, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.ServiceException, 0, len(rows)) + for _, r := range rows { + date, _ := time.Parse("20060102", r["date"]) + exType, _ := strconv.Atoi(r["exception_type"]) + out = append(out, spec.ServiceException{ + AgencyID: agencyID, + ServiceID: r["service_id"], + Date: date, + ExceptionType: exType, + }) + } + return out, nil +} diff --git a/apps/api/main.go b/apps/api/main.go new file mode 100644 index 0000000..b1af9db --- /dev/null +++ b/apps/api/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "log" + "net/http" + "os" + + _ "github.com/joho/godotenv/autoload" + + "github.com/Tracky-Trains/tracky/api/providers" + "github.com/Tracky-Trains/tracky/api/providers/amtrak" + "github.com/Tracky-Trains/tracky/api/providers/brightline" + "github.com/Tracky-Trains/tracky/api/providers/metra" + "github.com/Tracky-Trains/tracky/api/providers/metrotransit" + "github.com/Tracky-Trains/tracky/api/providers/trirail" + "github.com/Tracky-Trains/tracky/api/routes" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + registry := providers.NewRegistry() + registry.Register(amtrak.New()) + registry.Register(brightline.New()) + registry.Register(metra.New()) + registry.Register(metrotransit.New()) + registry.Register(trirail.New()) + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + routes.Setup(mux, registry) + + log.Printf("starting server on :%s", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + log.Fatal(err) + } +} diff --git a/apps/api/providers/amtrak/amtrak.go b/apps/api/providers/amtrak/amtrak.go new file mode 100644 index 0000000..dc6c8d3 --- /dev/null +++ b/apps/api/providers/amtrak/amtrak.go @@ -0,0 +1,281 @@ +package amtrak + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/pbkdf2" + + "github.com/Tracky-Trains/tracky/api/providers" + "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/Tracky-Trains/tracky/api/spec" +) + +const ( + staticURL = "https://content.amtrak.com/content/gtfs/GTFS.zip" + realtimeURL = "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData" + + saltHex = "9a3686ac" + ivHex = "c6eb2f7f5c4740c1a2f708fefd947d39" + publicKey = "69af143c-e8cf-47f8-bf09-fc1f61e5cc33" + masterSegment = 88 // chars at the end of the payload holding the encrypted private key +) + +// amtrakTZ maps single-letter Amtrak timezone codes to IANA names. +var amtrakTZ = map[string]string{ + "P": "America/Los_Angeles", + "M": "America/Denver", + "C": "America/Chicago", + "E": "America/New_York", +} + +// Provider wraps the base provider and overrides realtime fetching. +// Amtrak does not publish a standard GTFS-RT feed; realtime data comes +// from their encrypted map API. +type Provider struct { + base *base.Provider +} + +// New returns an Amtrak provider. +func New() *Provider { + return &Provider{ + base: base.New(base.Config{ + ProviderID: "amtrak", + Name: "Amtrak", + StaticURL: staticURL, + // PositionsURL / TripUpdatesURL intentionally empty — FetchRealtime is overridden below + }), + } +} + +// ID returns "amtrak". +func (p *Provider) ID() string { + return p.base.ID() +} + +// FetchStatic delegates to the base provider — Amtrak's GTFS zip is standard. +func (p *Provider) FetchStatic(ctx context.Context) (*providers.StaticFeed, error) { + return p.base.FetchStatic(ctx) +} + +// FetchRealtime fetches and decrypts the Amtrak train status API, mapping +// the response to TrainPositions and TrainStopTimes. +func (p *Provider) FetchRealtime(ctx context.Context) (*providers.RealtimeFeed, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, realtimeURL, nil) + if err != nil { + return nil, fmt.Errorf("amtrak: build request: %w", err) + } + req.Header.Set("User-Agent", "tracky") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("amtrak: fetch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("amtrak: unexpected status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("amtrak: read body: %w", err) + } + + raw := string(body) + if len(raw) <= masterSegment { + return nil, fmt.Errorf("amtrak: payload too short (%d bytes)", len(raw)) + } + + plaintext, err := getDecryptedData(raw) + if err != nil { + return nil, fmt.Errorf("amtrak: decrypt: %w", err) + } + + if err := os.WriteFile("debug_amtrak.json", plaintext, 0644); err != nil { + log.Printf("[DEBUG] failed to write debug_amtrak.json: %v", err) + } else { + log.Printf("[DEBUG] amtrak realtime data saved to debug_amtrak.json") + } + + var data trainData + if err := json.Unmarshal(plaintext, &data); err != nil { + return nil, fmt.Errorf("amtrak: parse json: %w", err) + } + + var positions []spec.TrainPosition + var stopTimes []spec.TrainStopTime + now := time.Now() + + for _, f := range data.Features { + if len(f.Geometry.Coordinates) < 2 { + continue + } + props := f.Properties + tripID := "amtrak:" + props.TrainNum + runDate := now.UTC().Truncate(24 * time.Hour) + + // --- TrainPosition --- + lon := f.Geometry.Coordinates[0] + lat := f.Geometry.Coordinates[1] + + pos := spec.TrainPosition{ + Provider: "amtrak", + TripID: tripID, + RunDate: runDate, + TrainNumber: props.TrainNum, + RouteID: props.RouteName, + Lat: &lat, + Lon: &lon, + LastUpdated: now, + } + + if props.Heading != "" { + h := props.Heading + pos.Heading = &h + } + + if mph, err := strconv.ParseFloat(props.Velocity, 64); err == nil { + pos.SpeedMPH = &mph + } + + if props.EventCode != "" { + code := "amtrak:" + props.EventCode + pos.CurrentStopCode = &code + } + + if t := parseAmtrakTime(props.UpdatedAt, ""); t != nil { + pos.LastUpdated = *t + } + + positions = append(positions, pos) + + // --- TrainStopTimes from StationN entries --- + for _, station := range props.Stations { + st := spec.TrainStopTime{ + Provider: "amtrak", + TripID: tripID, + RunDate: runDate, + StopCode: "amtrak:" + station.Code, + StopSequence: station.Sequence, + LastUpdated: now, + } + + st.ScheduledArr = parseAmtrakTime(station.SchArr, station.Tz) + st.ScheduledDep = parseAmtrakTime(station.SchDep, station.Tz) + + if station.PostArr != "" || station.PostDep != "" { + // Train has passed this stop — post times are actuals. + st.ActualArr = parseAmtrakTime(station.PostArr, station.Tz) + st.ActualDep = parseAmtrakTime(station.PostDep, station.Tz) + } else { + // Upcoming stop — est times are live estimates. + st.EstimatedArr = parseAmtrakTime(station.EstArr, station.Tz) + st.EstimatedDep = parseAmtrakTime(station.EstDep, station.Tz) + } + + stopTimes = append(stopTimes, st) + } + } + + return &providers.RealtimeFeed{ + Positions: positions, + StopTimes: stopTimes, + }, nil +} + +// parseAmtrakTime parses an Amtrak datetime string in the format "01/02/2006 15:04:05". +// tz is a single-letter Amtrak timezone code; if empty or unknown, UTC is used. +// Returns nil on empty input or parse failure. +func parseAmtrakTime(s, tz string) *time.Time { + if s == "" { + return nil + } + loc := time.UTC + if ianaName, ok := amtrakTZ[tz]; ok { + if l, err := time.LoadLocation(ianaName); err == nil { + loc = l + } + } + t, err := time.ParseInLocation("01/02/2006 15:04:05", s, loc) + if err != nil { + return nil + } + return &t +} + +// getDecryptedData implements the two-pass decryption scheme: +// 1. The last masterSegment chars are an AES-encrypted private key, decrypted with publicKey. +// 2. The remainder is the encrypted train data, decrypted with the recovered private key. +func getDecryptedData(raw string) ([]byte, error) { + mainContent := raw[:len(raw)-masterSegment] + encryptedPrivateKey := raw[len(raw)-masterSegment:] + + privateKeyBytes, err := decrypt(encryptedPrivateKey, publicKey) + if err != nil { + return nil, fmt.Errorf("decrypting private key: %w", err) + } + privateKey := strings.SplitN(string(privateKeyBytes), "|", 2)[0] + + plaintext, err := decrypt(mainContent, privateKey) + if err != nil { + return nil, fmt.Errorf("decrypting train data: %w", err) + } + return plaintext, nil +} + +// decrypt decrypts a base64-encoded AES-128-CBC ciphertext using a key derived +// via PBKDF2-SHA1 (1000 iterations, 16-byte output) with the fixed salt and IV. +func decrypt(content, key string) ([]byte, error) { + salt, err := hex.DecodeString(saltHex) + if err != nil { + return nil, err + } + iv, err := hex.DecodeString(ivHex) + if err != nil { + return nil, err + } + + derivedKey := pbkdf2.Key([]byte(key), salt, 1000, 16, sha1.New) + + ciphertext, err := base64.StdEncoding.DecodeString(content) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + + block, err := aes.NewCipher(derivedKey) + if err != nil { + return nil, err + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("ciphertext length %d not a multiple of block size", len(ciphertext)) + } + + cipher.NewCBCDecrypter(block, iv).CryptBlocks(ciphertext, ciphertext) + return pkcs7Unpad(ciphertext), nil +} + +func pkcs7Unpad(b []byte) []byte { + if len(b) == 0 { + return b + } + pad := int(b[len(b)-1]) + if pad == 0 || pad > aes.BlockSize || pad > len(b) { + return b + } + return b[:len(b)-pad] +} diff --git a/apps/api/providers/amtrak/types.go b/apps/api/providers/amtrak/types.go new file mode 100644 index 0000000..80006bd --- /dev/null +++ b/apps/api/providers/amtrak/types.go @@ -0,0 +1,125 @@ +package amtrak + +import ( + "encoding/json" + "sort" + "strconv" + "strings" +) + +// trainData is the top-level GeoJSON FeatureCollection returned after decryption. +type trainData struct { + Features []trainFeature `json:"features"` +} + +// trainFeature is a single train as a GeoJSON feature. +type trainFeature struct { + Properties trainProperties `json:"properties"` + Geometry trainGeometry `json:"geometry"` +} + +// trainGeometry is a GeoJSON Point: coordinates are [lon, lat]. +type trainGeometry struct { + Coordinates []float64 `json:"coordinates"` +} + +// trainProperties holds the known scalar fields from the Amtrak properties object, +// plus Stations which is populated by the custom unmarshaler from the StationN keys. +type trainProperties struct { + TrainNum string `json:"TrainNum"` + RouteName string `json:"RouteName"` + Velocity string `json:"Velocity"` + Heading string `json:"Heading"` + EventCode string `json:"EventCode"` + UpdatedAt string `json:"updated_at"` + + // Stations is derived from the StationN keys by UnmarshalJSON, sorted by sequence. + Stations []stationEntry +} + +// UnmarshalJSON handles the trainProperties object. Known scalar fields are decoded +// normally via a shadow struct; StationN keys (Station1, Station2, ...) are collected +// into Stations. Each non-null StationN value is a JSON string that itself encodes a +// JSON object, so two levels of unmarshaling are required. +func (p *trainProperties) UnmarshalJSON(data []byte) error { + // Decode known scalar fields using a shadow alias to avoid recursion. + type shadow struct { + TrainNum string `json:"TrainNum"` + RouteName string `json:"RouteName"` + Velocity string `json:"Velocity"` + Heading string `json:"Heading"` + EventCode string `json:"EventCode"` + UpdatedAt string `json:"updated_at"` + } + var s shadow + if err := json.Unmarshal(data, &s); err != nil { + return err + } + p.TrainNum = s.TrainNum + p.RouteName = s.RouteName + p.Velocity = s.Velocity + p.Heading = s.Heading + p.EventCode = s.EventCode + p.UpdatedAt = s.UpdatedAt + + // Scan for StationN keys. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + for key, val := range raw { + if !strings.HasPrefix(key, "Station") { + continue + } + seq, err := strconv.Atoi(key[len("Station"):]) + if err != nil || seq <= 0 { + continue + } + if string(val) == "null" { + continue + } + + // The value is a JSON string whose content is another JSON object. + var jsonStr string + if err := json.Unmarshal(val, &jsonStr); err != nil { + continue + } + var entry stationEntry + if err := json.Unmarshal([]byte(jsonStr), &entry); err != nil { + continue + } + entry.Sequence = seq + p.Stations = append(p.Stations, entry) + } + + sort.Slice(p.Stations, func(i, j int) bool { + return p.Stations[i].Sequence < p.Stations[j].Sequence + }) + + return nil +} + +// stationEntry represents a single stop's status as embedded in a StationN value. +// +// Time semantics: +// - PostArr / PostDep present → train has already passed (actual times) +// - EstArr / EstDep present → upcoming stop with a live estimate +// - SchArr / SchDep always → the static scheduled time +// +// The Tz field is a single-letter abbreviation: P=Pacific, M=Mountain, C=Central, E=Eastern. +type stationEntry struct { + Sequence int // populated from the key name, not the JSON + + Code string `json:"code"` + Tz string `json:"tz"` + + SchArr string `json:"scharr"` + SchDep string `json:"schdep"` + + EstArr string `json:"estarr"` + EstDep string `json:"estdep"` + + PostArr string `json:"postarr"` + PostDep string `json:"postdep"` +} diff --git a/apps/api/providers/base/base.go b/apps/api/providers/base/base.go new file mode 100644 index 0000000..c44aaa1 --- /dev/null +++ b/apps/api/providers/base/base.go @@ -0,0 +1,78 @@ +package base + +import ( + "context" + "fmt" + + "github.com/Tracky-Trains/tracky/api/gtfs" + "github.com/Tracky-Trains/tracky/api/providers" +) + +// Config holds the configuration for a standard GTFS provider. +type Config struct { + ProviderID string + Name string + StaticURL string + PositionsURL string // GTFS-RT vehicle positions feed; empty = skip + TripUpdatesURL string // GTFS-RT trip updates feed; empty = skip + RealtimeAPIKey string // optional Bearer token for GTFS-RT requests +} + +// Provider is a standard GTFS/GTFS-RT provider implementation. +// It satisfies providers.Provider and can be used directly for well-behaved feeds, +// or embedded in a custom provider struct that overrides specific methods. +type Provider struct { + cfg Config +} + +// New creates a Provider from the given config. +func New(cfg Config) *Provider { + return &Provider{cfg: cfg} +} + +// ID returns the provider's canonical identifier. +func (p *Provider) ID() string { + return p.cfg.ProviderID +} + +// FetchStatic downloads and parses the GTFS static zip, returning a StaticFeed. +func (p *Provider) FetchStatic(ctx context.Context) (*providers.StaticFeed, error) { + agencies, routes, stops, trips, stopTimes, calendars, exceptions, err := + gtfs.FetchAndParseStatic(ctx, p.cfg.StaticURL, p.cfg.ProviderID) + if err != nil { + return nil, fmt.Errorf("%s: FetchStatic: %w", p.cfg.ProviderID, err) + } + return &providers.StaticFeed{ + Agencies: agencies, + Routes: routes, + Stops: stops, + Trips: trips, + StopTimes: stopTimes, + Calendars: calendars, + Exceptions: exceptions, + }, nil +} + +// FetchRealtime fetches vehicle positions and trip updates, returning a combined RealtimeFeed. +// Either URL may be empty, in which case that portion is skipped. +func (p *Provider) FetchRealtime(ctx context.Context) (*providers.RealtimeFeed, error) { + feed := &providers.RealtimeFeed{} + + if p.cfg.PositionsURL != "" { + positions, err := gtfs.FetchAndParsePositions(ctx, p.cfg.PositionsURL, p.cfg.ProviderID, p.cfg.RealtimeAPIKey) + if err != nil { + return nil, fmt.Errorf("%s: FetchRealtime positions: %w", p.cfg.ProviderID, err) + } + feed.Positions = positions + } + + if p.cfg.TripUpdatesURL != "" { + stopTimes, err := gtfs.FetchAndParseTripUpdates(ctx, p.cfg.TripUpdatesURL, p.cfg.ProviderID, p.cfg.RealtimeAPIKey) + if err != nil { + return nil, fmt.Errorf("%s: FetchRealtime trip updates: %w", p.cfg.ProviderID, err) + } + feed.StopTimes = stopTimes + } + + return feed, nil +} diff --git a/apps/api/providers/brightline/brightline.go b/apps/api/providers/brightline/brightline.go new file mode 100644 index 0000000..a3e4e97 --- /dev/null +++ b/apps/api/providers/brightline/brightline.go @@ -0,0 +1,23 @@ +package brightline + +import ( + "github.com/Tracky-Trains/tracky/api/providers/base" +) + +const ( + staticURL = "http://feed.gobrightline.com/bl_gtfs.zip" + positionsURL = "http://feed.gobrightline.com/position_updates.pb" + tripUpdatesURL = "http://feed.gobrightline.com/trip_updates.pb" +) + +// New returns a standard base provider configured for Brightline. +// Brightline correctly implements both GTFS and GTFS-RT, so no overrides are needed. +func New() *base.Provider { + return base.New(base.Config{ + ProviderID: "brightline", + Name: "Brightline", + StaticURL: staticURL, + PositionsURL: positionsURL, + TripUpdatesURL: tripUpdatesURL, + }) +} diff --git a/apps/api/providers/metra/metra.go b/apps/api/providers/metra/metra.go new file mode 100644 index 0000000..ebd718a --- /dev/null +++ b/apps/api/providers/metra/metra.go @@ -0,0 +1,26 @@ +package metra + +import ( + "os" + + "github.com/Tracky-Trains/tracky/api/providers/base" +) + +const ( + staticURL = "https://schedules.metrarail.com/gtfs/schedule.zip" + positionsURL = "https://gtfspublic.metrarr.com/gtfs/public/positions" + tripUpdatesURL = "https://gtfspublic.metrarr.com/gtfs/public/tripupdates" +) + +// New returns a standard base provider configured for Metra. +// Metra requires a Bearer token for GTFS-RT; set METRA_API_KEY in the environment. +func New() *base.Provider { + return base.New(base.Config{ + ProviderID: "metra", + Name: "Metra", + StaticURL: staticURL, + PositionsURL: positionsURL, + TripUpdatesURL: tripUpdatesURL, + RealtimeAPIKey: os.Getenv("METRA_API_KEY"), + }) +} diff --git a/apps/api/providers/metrotransit/metrotransit.go b/apps/api/providers/metrotransit/metrotransit.go new file mode 100644 index 0000000..76b8318 --- /dev/null +++ b/apps/api/providers/metrotransit/metrotransit.go @@ -0,0 +1,24 @@ +package metrotransit + +import ( + "github.com/Tracky-Trains/tracky/api/providers/base" +) + +// see https://svc.metrotransit.org/ +const ( + staticURL = "https://svc.metrotransit.org/mtgtfs/next/gtfs.zip" + positionsURL = "https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb" + tripUpdatesURL = "https://svc.metrotransit.org/mtgtfs/tripupdates.pb" +) + +// Returns a standard base provider configured for Metro Transit. + +func New() *base.Provider { + return base.New(base.Config{ + ProviderID: "metrotransit", + Name: "Metro Transit", + StaticURL: staticURL, + PositionsURL: positionsURL, + TripUpdatesURL: tripUpdatesURL, + }) +} diff --git a/apps/api/providers/providers.go b/apps/api/providers/providers.go new file mode 100644 index 0000000..ce9cef2 --- /dev/null +++ b/apps/api/providers/providers.go @@ -0,0 +1,70 @@ +package providers + +import ( + "context" + "fmt" + "sort" + + "github.com/Tracky-Trains/tracky/api/spec" +) + +// Provider is the interface every transit data provider must implement. +type Provider interface { + ID() string + FetchStatic(ctx context.Context) (*StaticFeed, error) + FetchRealtime(ctx context.Context) (*RealtimeFeed, error) +} + +// StaticFeed holds all data parsed from a GTFS static zip. +type StaticFeed struct { + Agencies []spec.Agency `json:"agencies"` + Routes []spec.Route `json:"routes"` + Stops []spec.Stop `json:"stops"` + Trips []spec.Trip `json:"trips"` + StopTimes []spec.ScheduledStopTime `json:"stopTimes"` + Calendars []spec.ServiceCalendar `json:"calendars"` + Exceptions []spec.ServiceException `json:"exceptions"` +} + +// RealtimeFeed holds all data parsed from a GTFS-RT protobuf feed. +type RealtimeFeed struct { + Positions []spec.TrainPosition `json:"positions"` + StopTimes []spec.TrainStopTime `json:"stopTimes"` +} + +// Registry maps provider IDs to their implementations. +type Registry struct { + providers map[string]Provider +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{providers: make(map[string]Provider)} +} + +// Register adds a provider to the registry. Panics on duplicate ID. +func (r *Registry) Register(p Provider) { + id := p.ID() + if _, exists := r.providers[id]; exists { + panic(fmt.Sprintf("providers: duplicate provider ID %q", id)) + } + r.providers[id] = p +} + +// Get returns the provider with the given ID. +func (r *Registry) Get(id string) (Provider, bool) { + p, ok := r.providers[id] + return p, ok +} + +// All returns all registered providers sorted by ID. +func (r *Registry) All() []Provider { + out := make([]Provider, 0, len(r.providers)) + for _, p := range r.providers { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { + return out[i].ID() < out[j].ID() + }) + return out +} diff --git a/apps/api/providers/trirail/trirail.go b/apps/api/providers/trirail/trirail.go new file mode 100644 index 0000000..028e30a --- /dev/null +++ b/apps/api/providers/trirail/trirail.go @@ -0,0 +1,31 @@ +package trirail + +import ( + "github.com/Tracky-Trains/tracky/api/providers/base" +) + +// see https://gtfsr.tri-rail.com/ +const ( + staticURL = "https://gtfs.tri-rail.com/gtfs.zip" + positionsURL = "https://gtfsr.tri-rail.com/download.aspx?file=position_updates.pb" + tripUpdatesURL = "https://gtfsr.tri-rail.com/download.aspx?file=trip_updates.pb" +) + +// Returns a standard base provider configured for Tri-Rail. +// +// Known issues with Tri-Rail's GTFS-RT feed (trip_updates): +// - stop_time_update entries omit stop_id — stops are identified only by stop_sequence. +// Resolving stop codes requires a static GTFS lookup by (trip_id, stop_sequence). +// - arrival/departure times are delay-only (e.g. {"delay": 0}) with no absolute time field. +// Estimated times must be derived by adding the delay to the static scheduled time. +// - trip entries omit route_id and start_date, so RouteID and RunDate are always empty. +// Both fields must be resolved from static GTFS using trip_id. +func New() *base.Provider { + return base.New(base.Config{ + ProviderID: "trirail", + Name: "Tri-Rail", + StaticURL: staticURL, + PositionsURL: positionsURL, + TripUpdatesURL: tripUpdatesURL, + }) +} diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go new file mode 100644 index 0000000..8e26721 --- /dev/null +++ b/apps/api/routes/routes.go @@ -0,0 +1,105 @@ +package routes + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/Tracky-Trains/tracky/api/providers" +) + +// Setup registers all routes onto mux. +func Setup(mux *http.ServeMux, registry *providers.Registry) { + mux.HandleFunc("GET /debug/providers", handleListProviders(registry)) + mux.HandleFunc("GET /debug/providers/{id}/static", handleSyncStatic(registry)) + mux.HandleFunc("GET /debug/providers/{id}/realtime", handleSyncRealtime(registry)) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// handleListProviders returns all registered providers. +func handleListProviders(registry *providers.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + type entry struct { + ID string `json:"id"` + StaticEndpoint string `json:"static_endpoint"` + RealtimeEndpoint string `json:"realtime_endpoint"` + } + all := registry.All() + out := make([]entry, len(all)) + for i, p := range all { + out[i] = entry{ + ID: p.ID(), + StaticEndpoint: "/debug/providers/" + p.ID() + "/static", + RealtimeEndpoint: "/debug/providers/" + p.ID() + "/realtime", + } + } + writeJSON(w, http.StatusOK, out) + } +} + +// handleSyncStatic triggers a GTFS static fetch for the given provider. +func handleSyncStatic(registry *providers.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, ok := registry.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + start := time.Now() + feed, err := p.FetchStatic(r.Context()) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "provider": id, + "error": err.Error(), + "elapsed_ms": elapsed, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "provider": id, + "elapsed_ms": elapsed, + "data": feed, + }) + } +} + +// handleSyncRealtime triggers a GTFS-RT fetch for the given provider. +func handleSyncRealtime(registry *providers.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, ok := registry.Get(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + start := time.Now() + feed, err := p.FetchRealtime(r.Context()) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "provider": id, + "error": err.Error(), + "elapsed_ms": elapsed, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "provider": id, + "elapsed_ms": elapsed, + "data": feed, + }) + } +} diff --git a/apps/api/spec/realtime.go b/apps/api/spec/realtime.go new file mode 100644 index 0000000..39cedee --- /dev/null +++ b/apps/api/spec/realtime.go @@ -0,0 +1,61 @@ +package spec + +import "time" + +// TrainPosition represents the current live position of an active train run. +// One row per (provider, trip_id, run_date). Upserted on every poll. +type TrainPosition struct { + Provider string `db:"provider" json:"provider"` + TripID string `db:"trip_id" json:"tripId"` + RunDate time.Time `db:"run_date" json:"runDate"` + TrainNumber string `db:"train_number" json:"trainNumber"` + RouteID string `db:"route_id" json:"routeId"` + VehicleID string `db:"vehicle_id" json:"vehicleId"` + Lat *float64 `db:"lat" json:"lat"` + Lon *float64 `db:"lon" json:"lon"` + Heading *string `db:"heading" json:"heading"` + SpeedMPH *float64 `db:"speed_mph" json:"speedMph"` + CurrentStopCode *string `db:"current_stop_code" json:"currentStopCode"` + LastUpdated time.Time `db:"last_updated" json:"lastUpdated"` +} + +// TrainStopTime represents a single stop within a single run of a trip. +// Serves dual purpose: live state (estimated times) and historical record (actual times). +// One row per (provider, trip_id, run_date, stop_code). +type TrainStopTime struct { + Provider string `db:"provider" json:"provider"` + TripID string `db:"trip_id" json:"tripId"` + RunDate time.Time `db:"run_date" json:"runDate"` + StopCode string `db:"stop_code" json:"stopCode"` + StopSequence int `db:"stop_sequence" json:"stopSequence"` + + // From static GTFS — written once, never updated + ScheduledArr *time.Time `db:"scheduled_arr" json:"scheduledArr"` + ScheduledDep *time.Time `db:"scheduled_dep" json:"scheduledDep"` + + // Live estimates — updated each poll until actual is known + EstimatedArr *time.Time `db:"estimated_arr" json:"estimatedArr"` + EstimatedDep *time.Time `db:"estimated_dep" json:"estimatedDep"` + + // Actuals — written once when train passes stop, permanent + ActualArr *time.Time `db:"actual_arr" json:"actualArr"` + ActualDep *time.Time `db:"actual_dep" json:"actualDep"` + + LastUpdated time.Time `db:"last_updated" json:"lastUpdated"` +} + +// IsPassed returns true if the train has already passed this stop. +func (s *TrainStopTime) IsPassed() bool { + return s.ActualArr != nil +} + +// IsLive returns true if this stop still has a pending estimate. +func (s *TrainStopTime) IsLive() bool { + return s.ActualArr == nil && s.EstimatedArr != nil +} + +// RunID returns a canonical string identifier for this specific train run. +// Useful for logging, caching keys, and display. +func (s *TrainStopTime) RunID() string { + return s.Provider + ":" + s.TripID + ":" + s.RunDate.Format("2006-01-02") +} diff --git a/apps/api/spec/schedule.go b/apps/api/spec/schedule.go new file mode 100644 index 0000000..b256885 --- /dev/null +++ b/apps/api/spec/schedule.go @@ -0,0 +1,95 @@ +package spec + +import "time" + +// Agency represents a transit operator. +// Maps to GTFS agency.txt. Root of the namespace for all entities. +// Replaces the custom Provider concept — agency_id is the canonical identifier. +type Agency struct { + AgencyID string `db:"agency_id" json:"agencyId"` // namespaced: 'amtrak', 'via', 'brightline' + Name string `db:"name" json:"name"` // 'National Railroad Passenger Corporation' + URL string `db:"url" json:"url"` // 'https://www.amtrak.com' + Timezone string `db:"timezone" json:"timezone"` // 'America/New_York' + Lang *string `db:"lang" json:"lang"` // 'en' + Phone *string `db:"phone" json:"phone"` + Country string `db:"country" json:"country"` // 'US', 'CA' — extension, not in GTFS spec +} + +// Route represents a named service operated by an agency. +// Maps to GTFS routes.txt. +type Route struct { + AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + RouteID string `db:"route_id" json:"routeId"` // namespaced: 'amtrak:coast-starlight' + ShortName string `db:"short_name" json:"shortName"` // '14' + LongName string `db:"long_name" json:"longName"` // 'Coast Starlight' + Color string `db:"color" json:"color"` // hex without #, e.g. '1D2E6E' + TextColor string `db:"text_color" json:"textColor"` // hex without #, e.g. 'FFFFFF' + ShapeID *string `db:"shape_id" json:"shapeId"` // reference into tile layer, not a DB table +} + +// Stop represents a physical station or stop. +// Maps to GTFS stops.txt. +type Stop struct { + AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + StopID string `db:"stop_id" json:"stopId"` // namespaced: 'amtrak:LAX' + Code string `db:"code" json:"code"` // native code: 'LAX' + Name string `db:"name" json:"name"` // 'Los Angeles' + Lat float64 `db:"lat" json:"lat"` + Lon float64 `db:"lon" json:"lon"` + Timezone *string `db:"timezone" json:"timezone"` // stop-local tz if different from agency + WheelchairBoarding *bool `db:"wheelchair_boarding" json:"wheelchairBoarding"` +} + +// Trip represents a scheduled service pattern. +// Maps to GTFS trips.txt — one row per trip_id in the feed. +// Note: a Trip is the template; a run is Trip + RunDate. +type Trip struct { + AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + TripID string `db:"trip_id" json:"tripId"` // namespaced: 'amtrak:5' + RouteID string `db:"route_id" json:"routeId"` // 'amtrak:coast-starlight' + ServiceID string `db:"service_id" json:"serviceId"` // links to ServiceCalendar + Headsign string `db:"headsign" json:"headsign"` // 'Chicago' + ShapeID *string `db:"shape_id" json:"shapeId"` // for geometry lookup, matches Route.ShapeID + DirectionID *int `db:"direction_id" json:"directionId"` // 0=outbound, 1=inbound +} + +// ScheduledStopTime represents a trip's scheduled arrival/departure at a stop. +// Maps to GTFS stop_times.txt. Static timetable only — never updated. +// Actual and estimated times live in TrainStopTime (realtime model). +type ScheduledStopTime struct { + AgencyID string `db:"agency_id" json:"agencyId"` + TripID string `db:"trip_id" json:"tripId"` + StopID string `db:"stop_id" json:"stopId"` + StopSequence int `db:"stop_sequence" json:"stopSequence"` + ArrivalTime *string `db:"arrival_time" json:"arrivalTime"` // string: GTFS allows >24:00:00 + DepartureTime *string `db:"departure_time" json:"departureTime"` // string: same reason + Timepoint *bool `db:"timepoint" json:"timepoint"` // true=exact, false=approximate + DropOffType *int `db:"drop_off_type" json:"dropOffType"` // 0=regular, 1=none, 2=phone, 3=arrange + PickupType *int `db:"pickup_type" json:"pickupType"` // same codes +} + +// ServiceCalendar represents which days of the week a service_id runs. +// Maps to GTFS calendar.txt. +type ServiceCalendar struct { + AgencyID string `db:"agency_id" json:"agencyId"` + ServiceID string `db:"service_id" json:"serviceId"` + Monday bool `db:"monday" json:"monday"` + Tuesday bool `db:"tuesday" json:"tuesday"` + Wednesday bool `db:"wednesday" json:"wednesday"` + Thursday bool `db:"thursday" json:"thursday"` + Friday bool `db:"friday" json:"friday"` + Saturday bool `db:"saturday" json:"saturday"` + Sunday bool `db:"sunday" json:"sunday"` + StartDate time.Time `db:"start_date" json:"startDate"` + EndDate time.Time `db:"end_date" json:"endDate"` +} + +// ServiceException represents a one-off addition or removal of service. +// Maps to GTFS calendar_dates.txt. +// Note: calendar.txt is optional if calendar_dates.txt covers all service dates. +type ServiceException struct { + AgencyID string `db:"agency_id" json:"agencyId"` + ServiceID string `db:"service_id" json:"serviceId"` + Date time.Time `db:"date" json:"date"` + ExceptionType int `db:"exception_type" json:"exceptionType"` // 1=service added, 2=service removed +} From 760ea8334465922e55226caf31027dff9669aaa8 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Sat, 28 Mar 2026 12:16:20 -0500 Subject: [PATCH 2/4] Implement backend structure with database integration and GTFS support - Add main.go for server and sync-realtime functionality - Create db.go for SQLite database handling and schema initialization - Introduce static.go for managing static GTFS data - Define spec types for agencies, routes, stops, trips, scheduled stop times, service calendars, and exceptions - Enhance routes.go to include endpoints for syncing static data - Update go.mod and go.sum for new dependencies --- apps/api/cmd/functions/sync-realtime/main.go | 64 +++++++ apps/api/{ => cmd/server}/main.go | 14 +- apps/api/db/db.go | 131 ++++++++++++++ apps/api/db/static.go | 177 +++++++++++++++++++ apps/api/go.mod | 13 ++ apps/api/go.sum | 21 +++ apps/api/gtfs/static.go | 69 ++++---- apps/api/routes/routes.go | 80 ++++++++- apps/api/spec/{schedule.go => static.go} | 27 +-- 9 files changed, 547 insertions(+), 49 deletions(-) create mode 100644 apps/api/cmd/functions/sync-realtime/main.go rename apps/api/{ => cmd/server}/main.go (80%) create mode 100644 apps/api/db/db.go create mode 100644 apps/api/db/static.go rename apps/api/spec/{schedule.go => static.go} (78%) diff --git a/apps/api/cmd/functions/sync-realtime/main.go b/apps/api/cmd/functions/sync-realtime/main.go new file mode 100644 index 0000000..b2caa99 --- /dev/null +++ b/apps/api/cmd/functions/sync-realtime/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + "log" + + _ "github.com/joho/godotenv/autoload" + + "github.com/Tracky-Trains/tracky/api/providers" + "github.com/Tracky-Trains/tracky/api/providers/amtrak" + "github.com/Tracky-Trains/tracky/api/providers/brightline" + "github.com/Tracky-Trains/tracky/api/providers/metra" + "github.com/Tracky-Trains/tracky/api/providers/metrotransit" + "github.com/Tracky-Trains/tracky/api/providers/trirail" +) + +func buildRegistry() *providers.Registry { + registry := providers.NewRegistry() + registry.Register(amtrak.New()) + registry.Register(brightline.New()) + registry.Register(metra.New()) + registry.Register(metrotransit.New()) + registry.Register(trirail.New()) + return registry +} + +// main is a stub — the DO Functions runtime provides its own entry point and +// invokes Main directly. This exists only to satisfy the Go compiler. +func main() {} + +// Main is the Digital Ocean Functions entry point. +// Optional arg: "provider" (string) — if set, only that provider is synced. +func Main(args map[string]interface{}) map[string]interface{} { + registry := buildRegistry() + ctx := context.Background() + + targets := registry.All() + if id, ok := args["provider"].(string); ok && id != "" { + p, ok := registry.Get(id) + if !ok { + return map[string]interface{}{ + "error": fmt.Sprintf("unknown provider: %s", id), + } + } + targets = []providers.Provider{p} + } + + results := make(map[string]interface{}, len(targets)) + for _, p := range targets { + feed, err := p.FetchRealtime(ctx) + if err != nil { + log.Printf("sync-realtime: %s: %v", p.ID(), err) + results[p.ID()] = map[string]interface{}{"error": err.Error()} + continue + } + results[p.ID()] = map[string]interface{}{ + "positions": len(feed.Positions), + "stop_times": len(feed.StopTimes), + } + } + + return map[string]interface{}{"body": results} +} diff --git a/apps/api/main.go b/apps/api/cmd/server/main.go similarity index 80% rename from apps/api/main.go rename to apps/api/cmd/server/main.go index b1af9db..7971129 100644 --- a/apps/api/main.go +++ b/apps/api/cmd/server/main.go @@ -7,6 +7,7 @@ import ( _ "github.com/joho/godotenv/autoload" + "github.com/Tracky-Trains/tracky/api/db" "github.com/Tracky-Trains/tracky/api/providers" "github.com/Tracky-Trains/tracky/api/providers/amtrak" "github.com/Tracky-Trains/tracky/api/providers/brightline" @@ -22,6 +23,17 @@ func main() { port = "8080" } + dbPath := os.Getenv("DATABASE_PATH") + if dbPath == "" { + dbPath = "tracky.db" + } + + database, err := db.Open(dbPath) + if err != nil { + log.Fatal(err) + } + defer database.Close() + registry := providers.NewRegistry() registry.Register(amtrak.New()) registry.Register(brightline.New()) @@ -35,7 +47,7 @@ func main() { w.Write([]byte("ok")) }) - routes.Setup(mux, registry) + routes.Setup(mux, registry, database) log.Printf("starting server on :%s", port) if err := http.ListenAndServe(":"+port, mux); err != nil { diff --git a/apps/api/db/db.go b/apps/api/db/db.go new file mode 100644 index 0000000..4c756c8 --- /dev/null +++ b/apps/api/db/db.go @@ -0,0 +1,131 @@ +package db + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// DB wraps a *sql.DB connection to a SQLite database. +type DB struct { + conn *sql.DB +} + +// Open creates or opens a SQLite database at the given path +// and initializes the schema. +func Open(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("db: open %s: %w", path, err) + } + + // SQLite performs best with a single connection for writes. + conn.SetMaxOpenConns(1) + + if err := initSchema(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("db: init schema: %w", err) + } + + return &DB{conn: conn}, nil +} + +// Close closes the underlying database connection. +func (d *DB) Close() error { + return d.conn.Close() +} + +func initSchema(conn *sql.DB) error { + _, err := conn.Exec(schemaSQL) + return err +} + +const schemaSQL = ` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS agencies ( + provider_id TEXT NOT NULL, + gtfs_agency_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + timezone TEXT NOT NULL DEFAULT '', + lang TEXT, + phone TEXT, + country TEXT NOT NULL DEFAULT '', + PRIMARY KEY (provider_id, gtfs_agency_id) +); + +CREATE TABLE IF NOT EXISTS routes ( + route_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + short_name TEXT NOT NULL DEFAULT '', + long_name TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '', + text_color TEXT NOT NULL DEFAULT '', + shape_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_routes_provider ON routes(provider_id); + +CREATE TABLE IF NOT EXISTS stops ( + stop_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + code TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + lat REAL NOT NULL DEFAULT 0, + lon REAL NOT NULL DEFAULT 0, + timezone TEXT, + wheelchair_boarding INTEGER +); +CREATE INDEX IF NOT EXISTS idx_stops_provider ON stops(provider_id); + +CREATE TABLE IF NOT EXISTS trips ( + trip_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + route_id TEXT NOT NULL, + service_id TEXT NOT NULL DEFAULT '', + headsign TEXT NOT NULL DEFAULT '', + shape_id TEXT, + direction_id INTEGER +); +CREATE INDEX IF NOT EXISTS idx_trips_provider ON trips(provider_id); +CREATE INDEX IF NOT EXISTS idx_trips_route ON trips(route_id); + +CREATE TABLE IF NOT EXISTS scheduled_stop_times ( + trip_id TEXT NOT NULL, + stop_sequence INTEGER NOT NULL, + provider_id TEXT NOT NULL, + stop_id TEXT NOT NULL, + arrival_time TEXT, + departure_time TEXT, + timepoint INTEGER, + drop_off_type INTEGER, + pickup_type INTEGER, + PRIMARY KEY (trip_id, stop_sequence) +); +CREATE INDEX IF NOT EXISTS idx_sst_provider ON scheduled_stop_times(provider_id); + +CREATE TABLE IF NOT EXISTS service_calendars ( + provider_id TEXT NOT NULL, + service_id TEXT NOT NULL, + monday INTEGER NOT NULL DEFAULT 0, + tuesday INTEGER NOT NULL DEFAULT 0, + wednesday INTEGER NOT NULL DEFAULT 0, + thursday INTEGER NOT NULL DEFAULT 0, + friday INTEGER NOT NULL DEFAULT 0, + saturday INTEGER NOT NULL DEFAULT 0, + sunday INTEGER NOT NULL DEFAULT 0, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + PRIMARY KEY (provider_id, service_id) +); + +CREATE TABLE IF NOT EXISTS service_exceptions ( + provider_id TEXT NOT NULL, + service_id TEXT NOT NULL, + date TEXT NOT NULL, + exception_type INTEGER NOT NULL, + PRIMARY KEY (provider_id, service_id, date) +); +` diff --git a/apps/api/db/static.go b/apps/api/db/static.go new file mode 100644 index 0000000..d3c2930 --- /dev/null +++ b/apps/api/db/static.go @@ -0,0 +1,177 @@ +package db + +import ( + "context" + "fmt" + + "github.com/Tracky-Trains/tracky/api/providers" +) + +// SyncCounts holds the number of rows inserted per entity type. +type SyncCounts struct { + Agencies int `json:"agencies"` + Routes int `json:"routes"` + Stops int `json:"stops"` + Trips int `json:"trips"` + StopTimes int `json:"stopTimes"` + Calendars int `json:"calendars"` + Exceptions int `json:"exceptions"` +} + +// SaveStaticFeed replaces all static GTFS data for the given agency within +// a single transaction. It deletes existing rows then inserts new data. +func (d *DB) SaveStaticFeed(ctx context.Context, providerID string, feed *providers.StaticFeed) (SyncCounts, error) { + tx, err := d.conn.BeginTx(ctx, nil) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: begin tx: %w", err) + } + defer tx.Rollback() + + // Delete in reverse-dependency order. + for _, table := range []string{ + "service_exceptions", + "service_calendars", + "scheduled_stop_times", + "trips", + "stops", + "routes", + "agencies", + } { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE provider_id = ?", providerID); err != nil { + return SyncCounts{}, fmt.Errorf("db: delete %s: %w", table, err) + } + } + + var counts SyncCounts + + // --- Agencies --- + if len(feed.Agencies) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO agencies (provider_id, gtfs_agency_id, name, url, timezone, lang, phone, country) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare agencies: %w", err) + } + defer stmt.Close() + for _, a := range feed.Agencies { + if _, err := stmt.ExecContext(ctx, a.ProviderID, a.GtfsAgencyID, a.Name, a.URL, a.Timezone, a.Lang, a.Phone, a.Country); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert agency %s/%s: %w", a.ProviderID, a.GtfsAgencyID, err) + } + counts.Agencies++ + } + } + + // --- Routes --- + if len(feed.Routes) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO routes (route_id, provider_id, short_name, long_name, color, text_color, shape_id) VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare routes: %w", err) + } + defer stmt.Close() + for _, r := range feed.Routes { + if _, err := stmt.ExecContext(ctx, r.RouteID, r.ProviderID, r.ShortName, r.LongName, r.Color, r.TextColor, r.ShapeID); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert route %s: %w", r.RouteID, err) + } + counts.Routes++ + } + } + + // --- Stops --- + if len(feed.Stops) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO stops (stop_id, provider_id, code, name, lat, lon, timezone, wheelchair_boarding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare stops: %w", err) + } + defer stmt.Close() + for _, s := range feed.Stops { + if _, err := stmt.ExecContext(ctx, s.StopID, s.ProviderID, s.Code, s.Name, s.Lat, s.Lon, s.Timezone, optBoolToNullInt(s.WheelchairBoarding)); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert stop %s: %w", s.StopID, err) + } + counts.Stops++ + } + } + + // --- Trips --- + if len(feed.Trips) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO trips (trip_id, provider_id, route_id, service_id, headsign, shape_id, direction_id) VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare trips: %w", err) + } + defer stmt.Close() + for _, t := range feed.Trips { + if _, err := stmt.ExecContext(ctx, t.TripID, t.ProviderID, t.RouteID, t.ServiceID, t.Headsign, t.ShapeID, t.DirectionID); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert trip %s: %w", t.TripID, err) + } + counts.Trips++ + } + } + + // --- Scheduled Stop Times --- + if len(feed.StopTimes) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO scheduled_stop_times (trip_id, stop_sequence, provider_id, stop_id, arrival_time, departure_time, timepoint, drop_off_type, pickup_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare stop_times: %w", err) + } + defer stmt.Close() + for _, st := range feed.StopTimes { + if _, err := stmt.ExecContext(ctx, st.TripID, st.StopSequence, st.ProviderID, st.StopID, st.ArrivalTime, st.DepartureTime, optBoolToNullInt(st.Timepoint), st.DropOffType, st.PickupType); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert stop_time %s/%d: %w", st.TripID, st.StopSequence, err) + } + counts.StopTimes++ + } + } + + // --- Service Calendars --- + if len(feed.Calendars) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO service_calendars (provider_id, service_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, start_date, end_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare calendars: %w", err) + } + defer stmt.Close() + for _, c := range feed.Calendars { + if _, err := stmt.ExecContext(ctx, + c.ProviderID, c.ServiceID, + boolToInt(c.Monday), boolToInt(c.Tuesday), boolToInt(c.Wednesday), + boolToInt(c.Thursday), boolToInt(c.Friday), boolToInt(c.Saturday), boolToInt(c.Sunday), + c.StartDate.Format("2006-01-02"), c.EndDate.Format("2006-01-02"), + ); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert calendar %s/%s: %w", c.ProviderID, c.ServiceID, err) + } + counts.Calendars++ + } + } + + // --- Service Exceptions --- + if len(feed.Exceptions) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO service_exceptions (provider_id, service_id, date, exception_type) VALUES (?, ?, ?, ?)`) + if err != nil { + return SyncCounts{}, fmt.Errorf("db: prepare exceptions: %w", err) + } + defer stmt.Close() + for _, e := range feed.Exceptions { + if _, err := stmt.ExecContext(ctx, e.ProviderID, e.ServiceID, e.Date.Format("2006-01-02"), e.ExceptionType); err != nil { + return SyncCounts{}, fmt.Errorf("db: insert exception %s/%s: %w", e.ProviderID, e.ServiceID, err) + } + counts.Exceptions++ + } + } + + if err := tx.Commit(); err != nil { + return SyncCounts{}, fmt.Errorf("db: commit: %w", err) + } + + return counts, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func optBoolToNullInt(b *bool) *int { + if b == nil { + return nil + } + v := boolToInt(*b) + return &v +} diff --git a/apps/api/go.mod b/apps/api/go.mod index 08b25c0..b8c5e93 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -10,3 +10,16 @@ require ( require golang.org/x/crypto v0.49.0 require github.com/joho/godotenv v1.5.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.0 // indirect +) diff --git a/apps/api/go.sum b/apps/api/go.sum index fcb0050..63a67a5 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -1,10 +1,31 @@ github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0 h1:f4P+fVYmSIWj4b/jvbMdmrmsx/Xb+5xCpYYtVXOdKoc= github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0/go.mod h1:nSmbVVQSM4lp9gYvVaaTotnRxSwZXEdFnJARofg5V4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go index 352c5f8..2d6223e 100644 --- a/apps/api/gtfs/static.go +++ b/apps/api/gtfs/static.go @@ -15,11 +15,11 @@ import ( ) // FetchAndParseStatic downloads a GTFS zip from url, parses it, and returns -// slices of spec types stamped with agencyID. +// slices of spec types stamped with providerID. func FetchAndParseStatic( ctx context.Context, url string, - agencyID string, + providerID string, ) ( agencies []spec.Agency, routes []spec.Route, @@ -43,35 +43,35 @@ func FetchAndParseStatic( files := indexZip(zr) if f, ok := files["agency.txt"]; ok { - agencies, err = parseAgency(f, agencyID) + agencies, err = parseAgency(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["routes.txt"]; ok { - routes, err = parseRoutes(f, agencyID) + routes, err = parseRoutes(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["stops.txt"]; ok { - stops, err = parseStops(f, agencyID) + stops, err = parseStops(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["trips.txt"]; ok { - trips, err = parseTrips(f, agencyID) + trips, err = parseTrips(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["stop_times.txt"]; ok { - stopTimes, err = parseStopTimes(f, agencyID) + stopTimes, err = parseStopTimes(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } @@ -79,14 +79,14 @@ func FetchAndParseStatic( // calendar.txt is optional — some feeds use only calendar_dates.txt if f, ok := files["calendar.txt"]; ok { - calendars, err = parseCalendar(f, agencyID) + calendars, err = parseCalendar(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["calendar_dates.txt"]; ok { - exceptions, err = parseCalendarDates(f, agencyID) + exceptions, err = parseCalendarDates(f, providerID) if err != nil { return nil, nil, nil, nil, nil, nil, nil, err } @@ -197,7 +197,7 @@ func optInt(m map[string]string, key string) *int { return &i } -func parseAgency(f *zip.File, agencyID string) ([]spec.Agency, error) { +func parseAgency(f *zip.File, providerID string) ([]spec.Agency, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -205,18 +205,19 @@ func parseAgency(f *zip.File, agencyID string) ([]spec.Agency, error) { out := make([]spec.Agency, 0, len(rows)) for _, r := range rows { out = append(out, spec.Agency{ - AgencyID: agencyID, - Name: r["agency_name"], - URL: r["agency_url"], - Timezone: r["agency_timezone"], - Lang: optStr(r, "agency_lang"), - Phone: optStr(r, "agency_phone"), + ProviderID: providerID, + GtfsAgencyID: r["agency_id"], + Name: r["agency_name"], + URL: r["agency_url"], + Timezone: r["agency_timezone"], + Lang: optStr(r, "agency_lang"), + Phone: optStr(r, "agency_phone"), }) } return out, nil } -func parseRoutes(f *zip.File, agencyID string) ([]spec.Route, error) { +func parseRoutes(f *zip.File, providerID string) ([]spec.Route, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -224,8 +225,8 @@ func parseRoutes(f *zip.File, agencyID string) ([]spec.Route, error) { out := make([]spec.Route, 0, len(rows)) for _, r := range rows { out = append(out, spec.Route{ - AgencyID: agencyID, - RouteID: agencyID + ":" + r["route_id"], + ProviderID: providerID, + RouteID: providerID + ":" + r["route_id"], ShortName: r["route_short_name"], LongName: r["route_long_name"], Color: r["route_color"], @@ -236,7 +237,7 @@ func parseRoutes(f *zip.File, agencyID string) ([]spec.Route, error) { return out, nil } -func parseStops(f *zip.File, agencyID string) ([]spec.Stop, error) { +func parseStops(f *zip.File, providerID string) ([]spec.Stop, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -246,8 +247,8 @@ func parseStops(f *zip.File, agencyID string) ([]spec.Stop, error) { lat, _ := strconv.ParseFloat(r["stop_lat"], 64) lon, _ := strconv.ParseFloat(r["stop_lon"], 64) out = append(out, spec.Stop{ - AgencyID: agencyID, - StopID: agencyID + ":" + r["stop_id"], + ProviderID: providerID, + StopID: providerID + ":" + r["stop_id"], Code: r["stop_code"], Name: r["stop_name"], Lat: lat, @@ -259,7 +260,7 @@ func parseStops(f *zip.File, agencyID string) ([]spec.Stop, error) { return out, nil } -func parseTrips(f *zip.File, agencyID string) ([]spec.Trip, error) { +func parseTrips(f *zip.File, providerID string) ([]spec.Trip, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -267,9 +268,9 @@ func parseTrips(f *zip.File, agencyID string) ([]spec.Trip, error) { out := make([]spec.Trip, 0, len(rows)) for _, r := range rows { out = append(out, spec.Trip{ - AgencyID: agencyID, - TripID: agencyID + ":" + r["trip_id"], - RouteID: agencyID + ":" + r["route_id"], + ProviderID: providerID, + TripID: providerID + ":" + r["trip_id"], + RouteID: providerID + ":" + r["route_id"], ServiceID: r["service_id"], Headsign: r["trip_headsign"], ShapeID: optStr(r, "shape_id"), @@ -279,7 +280,7 @@ func parseTrips(f *zip.File, agencyID string) ([]spec.Trip, error) { return out, nil } -func parseStopTimes(f *zip.File, agencyID string) ([]spec.ScheduledStopTime, error) { +func parseStopTimes(f *zip.File, providerID string) ([]spec.ScheduledStopTime, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -288,9 +289,9 @@ func parseStopTimes(f *zip.File, agencyID string) ([]spec.ScheduledStopTime, err for _, r := range rows { seq, _ := strconv.Atoi(r["stop_sequence"]) out = append(out, spec.ScheduledStopTime{ - AgencyID: agencyID, - TripID: agencyID + ":" + r["trip_id"], - StopID: agencyID + ":" + r["stop_id"], + ProviderID: providerID, + TripID: providerID + ":" + r["trip_id"], + StopID: providerID + ":" + r["stop_id"], StopSequence: seq, ArrivalTime: optStr(r, "arrival_time"), DepartureTime: optStr(r, "departure_time"), @@ -302,7 +303,7 @@ func parseStopTimes(f *zip.File, agencyID string) ([]spec.ScheduledStopTime, err return out, nil } -func parseCalendar(f *zip.File, agencyID string) ([]spec.ServiceCalendar, error) { +func parseCalendar(f *zip.File, providerID string) ([]spec.ServiceCalendar, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -312,7 +313,7 @@ func parseCalendar(f *zip.File, agencyID string) ([]spec.ServiceCalendar, error) start, _ := time.Parse("20060102", r["start_date"]) end, _ := time.Parse("20060102", r["end_date"]) out = append(out, spec.ServiceCalendar{ - AgencyID: agencyID, + ProviderID: providerID, ServiceID: r["service_id"], Monday: r["monday"] == "1", Tuesday: r["tuesday"] == "1", @@ -328,7 +329,7 @@ func parseCalendar(f *zip.File, agencyID string) ([]spec.ServiceCalendar, error) return out, nil } -func parseCalendarDates(f *zip.File, agencyID string) ([]spec.ServiceException, error) { +func parseCalendarDates(f *zip.File, providerID string) ([]spec.ServiceException, error) { rows, err := readCSV(f) if err != nil { return nil, err @@ -338,7 +339,7 @@ func parseCalendarDates(f *zip.File, agencyID string) ([]spec.ServiceException, date, _ := time.Parse("20060102", r["date"]) exType, _ := strconv.Atoi(r["exception_type"]) out = append(out, spec.ServiceException{ - AgencyID: agencyID, + ProviderID: providerID, ServiceID: r["service_id"], Date: date, ExceptionType: exType, diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 8e26721..599bc87 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -3,16 +3,20 @@ package routes import ( "encoding/json" "net/http" + "sync" "time" + "github.com/Tracky-Trains/tracky/api/db" "github.com/Tracky-Trains/tracky/api/providers" ) // Setup registers all routes onto mux. -func Setup(mux *http.ServeMux, registry *providers.Registry) { +func Setup(mux *http.ServeMux, registry *providers.Registry, database *db.DB) { mux.HandleFunc("GET /debug/providers", handleListProviders(registry)) mux.HandleFunc("GET /debug/providers/{id}/static", handleSyncStatic(registry)) mux.HandleFunc("GET /debug/providers/{id}/realtime", handleSyncRealtime(registry)) + + mux.HandleFunc("POST /sync/static", handleSyncAllStatic(registry, database)) } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -73,6 +77,80 @@ func handleSyncStatic(registry *providers.Registry) http.HandlerFunc { } } +// handleSyncAllStatic fetches static GTFS data from all providers and saves to the database. +func handleSyncAllStatic(registry *providers.Registry, database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + allProviders := registry.All() + + type fetchResult struct { + providerID string + feed *providers.StaticFeed + fetchErr error + fetchMs int64 + } + + results := make([]fetchResult, len(allProviders)) + var wg sync.WaitGroup + + for i, p := range allProviders { + wg.Add(1) + go func(idx int, prov providers.Provider) { + defer wg.Done() + start := time.Now() + feed, err := prov.FetchStatic(r.Context()) + results[idx] = fetchResult{ + providerID: prov.ID(), + feed: feed, + fetchErr: err, + fetchMs: time.Since(start).Milliseconds(), + } + }(i, p) + } + wg.Wait() + + type providerSummary struct { + ProviderID string `json:"providerId"` + FetchMs int64 `json:"fetchMs"` + SaveMs int64 `json:"saveMs"` + Counts *db.SyncCounts `json:"counts,omitempty"` + Error string `json:"error,omitempty"` + } + + totalStart := time.Now() + summaries := make([]providerSummary, len(results)) + + for i, res := range results { + s := providerSummary{ + ProviderID: res.providerID, + FetchMs: res.fetchMs, + } + + if res.fetchErr != nil { + s.Error = res.fetchErr.Error() + summaries[i] = s + continue + } + + saveStart := time.Now() + counts, err := database.SaveStaticFeed(r.Context(), res.providerID, res.feed) + s.SaveMs = time.Since(saveStart).Milliseconds() + + if err != nil { + s.Error = err.Error() + } else { + s.Counts = &counts + } + + summaries[i] = s + } + + writeJSON(w, http.StatusOK, map[string]any{ + "totalMs": time.Since(totalStart).Milliseconds(), + "providers": summaries, + }) + } +} + // handleSyncRealtime triggers a GTFS-RT fetch for the given provider. func handleSyncRealtime(registry *providers.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/api/spec/schedule.go b/apps/api/spec/static.go similarity index 78% rename from apps/api/spec/schedule.go rename to apps/api/spec/static.go index b256885..bb20f4a 100644 --- a/apps/api/spec/schedule.go +++ b/apps/api/spec/static.go @@ -6,19 +6,20 @@ import "time" // Maps to GTFS agency.txt. Root of the namespace for all entities. // Replaces the custom Provider concept — agency_id is the canonical identifier. type Agency struct { - AgencyID string `db:"agency_id" json:"agencyId"` // namespaced: 'amtrak', 'via', 'brightline' - Name string `db:"name" json:"name"` // 'National Railroad Passenger Corporation' - URL string `db:"url" json:"url"` // 'https://www.amtrak.com' - Timezone string `db:"timezone" json:"timezone"` // 'America/New_York' - Lang *string `db:"lang" json:"lang"` // 'en' - Phone *string `db:"phone" json:"phone"` - Country string `db:"country" json:"country"` // 'US', 'CA' — extension, not in GTFS spec + ProviderID string `db:"provider_id" json:"providerId"` // provider namespace: 'amtrak', 'brightline' + GtfsAgencyID string `db:"gtfs_agency_id" json:"gtfsAgencyId"` // native GTFS agency_id from feed + Name string `db:"name" json:"name"` // 'National Railroad Passenger Corporation' + URL string `db:"url" json:"url"` // 'https://www.amtrak.com' + Timezone string `db:"timezone" json:"timezone"` // 'America/New_York' + Lang *string `db:"lang" json:"lang"` // 'en' + Phone *string `db:"phone" json:"phone"` + Country string `db:"country" json:"country"` // 'US', 'CA' — extension, not in GTFS spec } // Route represents a named service operated by an agency. // Maps to GTFS routes.txt. type Route struct { - AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' RouteID string `db:"route_id" json:"routeId"` // namespaced: 'amtrak:coast-starlight' ShortName string `db:"short_name" json:"shortName"` // '14' LongName string `db:"long_name" json:"longName"` // 'Coast Starlight' @@ -30,7 +31,7 @@ type Route struct { // Stop represents a physical station or stop. // Maps to GTFS stops.txt. type Stop struct { - AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' StopID string `db:"stop_id" json:"stopId"` // namespaced: 'amtrak:LAX' Code string `db:"code" json:"code"` // native code: 'LAX' Name string `db:"name" json:"name"` // 'Los Angeles' @@ -44,7 +45,7 @@ type Stop struct { // Maps to GTFS trips.txt — one row per trip_id in the feed. // Note: a Trip is the template; a run is Trip + RunDate. type Trip struct { - AgencyID string `db:"agency_id" json:"agencyId"` // 'amtrak' + ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' TripID string `db:"trip_id" json:"tripId"` // namespaced: 'amtrak:5' RouteID string `db:"route_id" json:"routeId"` // 'amtrak:coast-starlight' ServiceID string `db:"service_id" json:"serviceId"` // links to ServiceCalendar @@ -57,7 +58,7 @@ type Trip struct { // Maps to GTFS stop_times.txt. Static timetable only — never updated. // Actual and estimated times live in TrainStopTime (realtime model). type ScheduledStopTime struct { - AgencyID string `db:"agency_id" json:"agencyId"` + ProviderID string `db:"provider_id" json:"providerId"` TripID string `db:"trip_id" json:"tripId"` StopID string `db:"stop_id" json:"stopId"` StopSequence int `db:"stop_sequence" json:"stopSequence"` @@ -71,7 +72,7 @@ type ScheduledStopTime struct { // ServiceCalendar represents which days of the week a service_id runs. // Maps to GTFS calendar.txt. type ServiceCalendar struct { - AgencyID string `db:"agency_id" json:"agencyId"` + ProviderID string `db:"provider_id" json:"providerId"` ServiceID string `db:"service_id" json:"serviceId"` Monday bool `db:"monday" json:"monday"` Tuesday bool `db:"tuesday" json:"tuesday"` @@ -88,7 +89,7 @@ type ServiceCalendar struct { // Maps to GTFS calendar_dates.txt. // Note: calendar.txt is optional if calendar_dates.txt covers all service dates. type ServiceException struct { - AgencyID string `db:"agency_id" json:"agencyId"` + ProviderID string `db:"provider_id" json:"providerId"` ServiceID string `db:"service_id" json:"serviceId"` Date time.Time `db:"date" json:"date"` ExceptionType int `db:"exception_type" json:"exceptionType"` // 1=service added, 2=service removed From 4ae4d546b2aa23d99bd61fb40cbd900cd36d2858 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Mon, 30 Mar 2026 20:42:12 -0500 Subject: [PATCH 3/4] feat: integrate AWS SDK for S3 and add tile generation functionality - Updated go.sum to include AWS SDK dependencies. - Enhanced FetchAndParseStatic function to log download progress and handle shapes.txt. - Introduced new provider for CTA with static and real-time URLs. - Modified StaticFeed struct to include Shapes data. - Added ShapePoint struct to represent shape geometry. - Implemented GeoJSON building from shapes and routes. - Created tile generation function using tippecanoe. - Added S3 upload functionality for tiles with environment variable configuration. - Developed a viewer for tiles using MapLibre GL. --- .gitignore | 8 + apps/api/.env.example | 7 + apps/api/Dockerfile.sync-static | 27 +++ apps/api/cmd/server/main.go | 2 + apps/api/cmd/sync-static/main.go | 150 +++++++++++++ apps/api/go.mod | 19 ++ apps/api/go.sum | 38 ++++ apps/api/gtfs/static.go | 72 +++++- apps/api/providers/base/base.go | 3 +- apps/api/providers/cta/cta.go | 24 ++ apps/api/providers/providers.go | 15 +- apps/api/spec/static.go | 14 +- apps/api/tiles/geojson.go | 174 ++++++++++++++ apps/api/tiles/tippecanoe.go | 34 +++ apps/api/tiles/upload.go | 77 +++++++ apps/api/tiles/viewer.html | 375 +++++++++++++++++++++++++++++++ 16 files changed, 1019 insertions(+), 20 deletions(-) create mode 100644 apps/api/Dockerfile.sync-static create mode 100644 apps/api/cmd/sync-static/main.go create mode 100644 apps/api/providers/cta/cta.go create mode 100644 apps/api/tiles/geojson.go create mode 100644 apps/api/tiles/tippecanoe.go create mode 100644 apps/api/tiles/upload.go create mode 100644 apps/api/tiles/viewer.html diff --git a/.gitignore b/.gitignore index 7e8930f..7f131e4 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,14 @@ apps/web/out/ # Go backend apps/api/bin/ +apps/api/server +apps/api/sync-realtime +apps/api/sync-static +*.db +*.db-shm +*.db-wal +*.pmtiles +apps/api/debug_* # bun (not used, but just in case) bun.lockb diff --git a/apps/api/.env.example b/apps/api/.env.example index 32467aa..9a43fd9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,2 +1,9 @@ PORT=8080 METRA_API_KEY= + +# Tile upload (DigitalOcean Spaces) +TILES_S3_ENDPOINT=https://.digitaloceanspaces.com +TILES_S3_BUCKET= +TILES_S3_ACCESS_KEY_ID= +TILES_S3_SECRET_ACCESS_KEY= +TILES_S3_REGION=us-east-1 diff --git a/apps/api/Dockerfile.sync-static b/apps/api/Dockerfile.sync-static new file mode 100644 index 0000000..ac1bb2c --- /dev/null +++ b/apps/api/Dockerfile.sync-static @@ -0,0 +1,27 @@ +# ── Build tippecanoe from source ───────────────────────────────────────────── +FROM debian:bookworm-slim AS tippecanoe-build +RUN apt-get update && apt-get install -y --no-install-recommends \ + git build-essential libsqlite3-dev zlib1g-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 https://github.com/felt/tippecanoe.git /tippecanoe \ + && cd /tippecanoe && make -j$(nproc) + +# ── Build Go binary ───────────────────────────────────────────────────────── +FROM golang:1.26-bookworm AS go-build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 go build -o /sync-static ./cmd/sync-static + +# ── Runtime ────────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libsqlite3-0 zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=tippecanoe-build /tippecanoe/tippecanoe /usr/local/bin/tippecanoe +COPY --from=go-build /sync-static /usr/local/bin/sync-static + +WORKDIR /data +ENTRYPOINT ["sync-static", "--upload"] diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 7971129..eebb911 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "github.com/Tracky-Trains/tracky/api/providers" "github.com/Tracky-Trains/tracky/api/providers/amtrak" "github.com/Tracky-Trains/tracky/api/providers/brightline" + "github.com/Tracky-Trains/tracky/api/providers/cta" "github.com/Tracky-Trains/tracky/api/providers/metra" "github.com/Tracky-Trains/tracky/api/providers/metrotransit" "github.com/Tracky-Trains/tracky/api/providers/trirail" @@ -37,6 +38,7 @@ func main() { registry := providers.NewRegistry() registry.Register(amtrak.New()) registry.Register(brightline.New()) + registry.Register(cta.New()) registry.Register(metra.New()) registry.Register(metrotransit.New()) registry.Register(trirail.New()) diff --git a/apps/api/cmd/sync-static/main.go b/apps/api/cmd/sync-static/main.go new file mode 100644 index 0000000..7c4af57 --- /dev/null +++ b/apps/api/cmd/sync-static/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "path/filepath" + + _ "github.com/joho/godotenv/autoload" + + "github.com/Tracky-Trains/tracky/api/db" + "github.com/Tracky-Trains/tracky/api/providers" + "github.com/Tracky-Trains/tracky/api/providers/amtrak" + "github.com/Tracky-Trains/tracky/api/providers/brightline" + "github.com/Tracky-Trains/tracky/api/providers/cta" + "github.com/Tracky-Trains/tracky/api/providers/metra" + "github.com/Tracky-Trains/tracky/api/providers/metrotransit" + "github.com/Tracky-Trains/tracky/api/providers/trirail" + "github.com/Tracky-Trains/tracky/api/tiles" +) + +func main() { + dbPath := flag.String("db", "tracky.db", "SQLite database path") + skipDB := flag.Bool("skip-db", false, "skip writing to the database") + skipTiles := flag.Bool("skip-tiles", false, "skip tile generation") + tilesDir := flag.String("tiles-dir", ".", "output directory for PMTiles files") + upload := flag.Bool("upload", false, "upload artifacts to S3 after generation") + providerFilter := flag.String("provider", "", "restrict to a single provider ID") + flag.Parse() + + ctx := context.Background() + + // ── Provider registry ─────────────────────────────────────────────── + + registry := providers.NewRegistry() + registry.Register(amtrak.New()) + registry.Register(brightline.New()) + registry.Register(cta.New()) + registry.Register(metra.New()) + registry.Register(metrotransit.New()) + registry.Register(trirail.New()) + + var selected []providers.Provider + if *providerFilter != "" { + p, ok := registry.Get(*providerFilter) + if !ok { + log.Fatalf("unknown provider: %s", *providerFilter) + } + selected = []providers.Provider{p} + } else { + selected = registry.All() + } + + // ── Open database ─────────────────────────────────────────────────── + + var database *db.DB + if !*skipDB { + var err error + database, err = db.Open(*dbPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer database.Close() + log.Printf("opened database: %s", *dbPath) + } + + // ── Process each provider sequentially ────────────────────────────── + + for _, p := range selected { + log.Printf("processing %s...", p.ID()) + + feed, err := p.FetchStatic(ctx) + if err != nil { + log.Printf("warning: %s fetch failed: %v", p.ID(), err) + continue + } + log.Printf("fetched %s: %d routes, %d stops, %d trips, %d stop_times, %d shapes", + p.ID(), len(feed.Routes), len(feed.Stops), len(feed.Trips), + len(feed.StopTimes), len(feed.Shapes)) + + // Write to database. + if database != nil { + counts, err := database.SaveStaticFeed(ctx, p.ID(), feed) + if err != nil { + log.Printf("warning: %s db write failed: %v", p.ID(), err) + } else { + log.Printf("saved %s to db: %+v", p.ID(), counts) + } + } + + // Generate tiles. + if !*skipTiles { + buildTiles(ctx, p.ID(), feed, *tilesDir, *upload) + } + } + + log.Printf("done") +} + +// buildTiles generates a PMTiles file for a single provider and optionally uploads it. +func buildTiles(ctx context.Context, providerID string, feed *providers.StaticFeed, dir string, doUpload bool) { + if len(feed.Shapes) == 0 { + log.Printf("skipping tiles for %s: no shapes", providerID) + return + } + + geojson, err := tiles.BuildGeoJSON(feed.Shapes, feed.Trips, feed.Routes) + if err != nil { + log.Fatalf("%s: building GeoJSON: %v", providerID, err) + } + + debugPath := filepath.Join(dir, providerID+".geojson") + if err := os.WriteFile(debugPath, geojson, 0644); err != nil { + log.Printf("warning: failed to write debug GeoJSON: %v", err) + } else { + log.Printf("wrote debug GeoJSON: %s (%.1f MB)", debugPath, float64(len(geojson))/(1024*1024)) + } + + tmpFile, err := os.CreateTemp("", fmt.Sprintf("tracky-%s-*.geojson", providerID)) + if err != nil { + log.Fatalf("%s: creating temp file: %v", providerID, err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := tmpFile.Write(geojson); err != nil { + tmpFile.Close() + log.Fatalf("%s: writing GeoJSON: %v", providerID, err) + } + tmpFile.Close() + + output := filepath.Join(dir, providerID+".pmtiles") + log.Printf("running tippecanoe → %s", output) + if err := tiles.GenerateTiles(ctx, tmpPath, output); err != nil { + log.Fatalf("%s: tippecanoe: %v", providerID, err) + } + + stat, _ := os.Stat(output) + log.Printf("generated %s (%.1f MB)", output, float64(stat.Size())/(1024*1024)) + + if doUpload { + objectKey := fmt.Sprintf("%s.pmtiles", providerID) + log.Printf("uploading %s to S3...", objectKey) + if err := tiles.Upload(ctx, output, objectKey); err != nil { + log.Fatalf("%s: upload: %v", providerID, err) + } + } +} diff --git a/apps/api/go.mod b/apps/api/go.mod index b8c5e93..df0431d 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -12,6 +12,25 @@ require golang.org/x/crypto v0.49.0 require github.com/joho/godotenv v1.5.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index 63a67a5..252c963 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -1,5 +1,43 @@ github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0 h1:f4P+fVYmSIWj4b/jvbMdmrmsx/Xb+5xCpYYtVXOdKoc= github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs v1.0.0/go.mod h1:nSmbVVQSM4lp9gYvVaaTotnRxSwZXEdFnJARofg5V4g= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= +github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go index 2d6223e..f3aef9c 100644 --- a/apps/api/gtfs/static.go +++ b/apps/api/gtfs/static.go @@ -7,6 +7,7 @@ import ( "encoding/csv" "fmt" "io" + "log" "net/http" "strconv" "time" @@ -28,16 +29,19 @@ func FetchAndParseStatic( stopTimes []spec.ScheduledStopTime, calendars []spec.ServiceCalendar, exceptions []spec.ServiceException, + shapes []spec.ShapePoint, err error, ) { + log.Printf("gtfs [%s]: downloading %s", providerID, url) data, err := fetchStaticBytes(ctx, url) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: fetch %s: %w", url, err) + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: fetch %s: %w", url, err) } + log.Printf("gtfs [%s]: downloaded %.1f MB", providerID, float64(len(data))/(1024*1024)) zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: open zip: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("gtfs: open zip: %w", err) } files := indexZip(zr) @@ -45,53 +49,72 @@ func FetchAndParseStatic( if f, ok := files["agency.txt"]; ok { agencies, err = parseAgency(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["routes.txt"]; ok { + log.Printf("gtfs [%s]: parsing routes.txt", providerID) routes, err = parseRoutes(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["stops.txt"]; ok { + log.Printf("gtfs [%s]: parsing stops.txt", providerID) stops, err = parseStops(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } + log.Printf("gtfs [%s]: %d stops", providerID, len(stops)) } if f, ok := files["trips.txt"]; ok { + log.Printf("gtfs [%s]: parsing trips.txt", providerID) trips, err = parseTrips(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } + log.Printf("gtfs [%s]: %d trips", providerID, len(trips)) } if f, ok := files["stop_times.txt"]; ok { + log.Printf("gtfs [%s]: parsing stop_times.txt", providerID) stopTimes, err = parseStopTimes(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } + log.Printf("gtfs [%s]: %d stop_times", providerID, len(stopTimes)) } // calendar.txt is optional — some feeds use only calendar_dates.txt if f, ok := files["calendar.txt"]; ok { + log.Printf("gtfs [%s]: parsing calendar.txt", providerID) calendars, err = parseCalendar(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } } if f, ok := files["calendar_dates.txt"]; ok { + log.Printf("gtfs [%s]: parsing calendar_dates.txt", providerID) exceptions, err = parseCalendarDates(f, providerID) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, err } } + // shapes.txt is optional — not all feeds include shape geometry. + if f, ok := files["shapes.txt"]; ok { + log.Printf("gtfs [%s]: parsing shapes.txt", providerID) + shapes, err = parseShapes(f, providerID) + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, err + } + log.Printf("gtfs [%s]: %d shapes", providerID, len(shapes)) + } + return } @@ -289,7 +312,7 @@ func parseStopTimes(f *zip.File, providerID string) ([]spec.ScheduledStopTime, e for _, r := range rows { seq, _ := strconv.Atoi(r["stop_sequence"]) out = append(out, spec.ScheduledStopTime{ - ProviderID: providerID, + ProviderID: providerID, TripID: providerID + ":" + r["trip_id"], StopID: providerID + ":" + r["stop_id"], StopSequence: seq, @@ -339,7 +362,7 @@ func parseCalendarDates(f *zip.File, providerID string) ([]spec.ServiceException date, _ := time.Parse("20060102", r["date"]) exType, _ := strconv.Atoi(r["exception_type"]) out = append(out, spec.ServiceException{ - ProviderID: providerID, + ProviderID: providerID, ServiceID: r["service_id"], Date: date, ExceptionType: exType, @@ -347,3 +370,30 @@ func parseCalendarDates(f *zip.File, providerID string) ([]spec.ServiceException } return out, nil } + +func parseShapes(f *zip.File, providerID string) ([]spec.ShapePoint, error) { + rows, err := readCSV(f) + if err != nil { + return nil, err + } + out := make([]spec.ShapePoint, 0, len(rows)) + for _, r := range rows { + lat, _ := strconv.ParseFloat(r["shape_pt_lat"], 64) + lon, _ := strconv.ParseFloat(r["shape_pt_lon"], 64) + seq, _ := strconv.Atoi(r["shape_pt_sequence"]) + sp := spec.ShapePoint{ + ProviderID: providerID, + ShapeID: r["shape_id"], + Lat: lat, + Lon: lon, + Sequence: seq, + } + if v, ok := r["shape_dist_traveled"]; ok && v != "" { + if d, err := strconv.ParseFloat(v, 64); err == nil { + sp.DistTraveled = &d + } + } + out = append(out, sp) + } + return out, nil +} diff --git a/apps/api/providers/base/base.go b/apps/api/providers/base/base.go index c44aaa1..04501dd 100644 --- a/apps/api/providers/base/base.go +++ b/apps/api/providers/base/base.go @@ -37,7 +37,7 @@ func (p *Provider) ID() string { // FetchStatic downloads and parses the GTFS static zip, returning a StaticFeed. func (p *Provider) FetchStatic(ctx context.Context) (*providers.StaticFeed, error) { - agencies, routes, stops, trips, stopTimes, calendars, exceptions, err := + agencies, routes, stops, trips, stopTimes, calendars, exceptions, shapes, err := gtfs.FetchAndParseStatic(ctx, p.cfg.StaticURL, p.cfg.ProviderID) if err != nil { return nil, fmt.Errorf("%s: FetchStatic: %w", p.cfg.ProviderID, err) @@ -50,6 +50,7 @@ func (p *Provider) FetchStatic(ctx context.Context) (*providers.StaticFeed, erro StopTimes: stopTimes, Calendars: calendars, Exceptions: exceptions, + Shapes: shapes, }, nil } diff --git a/apps/api/providers/cta/cta.go b/apps/api/providers/cta/cta.go new file mode 100644 index 0000000..f0afa19 --- /dev/null +++ b/apps/api/providers/cta/cta.go @@ -0,0 +1,24 @@ +package cta + +import ( + "github.com/Tracky-Trains/tracky/api/providers/base" +) + +const ( + staticURL = "https://www.transitchicago.com/downloads/sch_data/google_transit.zip" + positionsURL = "https://gtfspublic.metrarr.com/gtfs/public/positions" + tripUpdatesURL = "https://gtfspublic.metrarr.com/gtfs/public/tripupdates" +) + +// New returns a standard base provider configured for CTA. +// CTA requires a Bearer token for GTFS-RT; set CTA_API_KEY in the environment. +func New() *base.Provider { + return base.New(base.Config{ + ProviderID: "cta", + Name: "CTA", + StaticURL: staticURL, + PositionsURL: positionsURL, + TripUpdatesURL: tripUpdatesURL, + //RealtimeAPIKey: os.Getenv("CTA_API_KEY"), + }) +} diff --git a/apps/api/providers/providers.go b/apps/api/providers/providers.go index ce9cef2..acf3a40 100644 --- a/apps/api/providers/providers.go +++ b/apps/api/providers/providers.go @@ -17,13 +17,14 @@ type Provider interface { // StaticFeed holds all data parsed from a GTFS static zip. type StaticFeed struct { - Agencies []spec.Agency `json:"agencies"` - Routes []spec.Route `json:"routes"` - Stops []spec.Stop `json:"stops"` - Trips []spec.Trip `json:"trips"` - StopTimes []spec.ScheduledStopTime `json:"stopTimes"` - Calendars []spec.ServiceCalendar `json:"calendars"` - Exceptions []spec.ServiceException `json:"exceptions"` + Agencies []spec.Agency `json:"agencies"` + Routes []spec.Route `json:"routes"` + Stops []spec.Stop `json:"stops"` + Trips []spec.Trip `json:"trips"` + StopTimes []spec.ScheduledStopTime `json:"stopTimes"` + Calendars []spec.ServiceCalendar `json:"calendars"` + Exceptions []spec.ServiceException `json:"exceptions"` + Shapes []spec.ShapePoint `json:"shapes"` } // RealtimeFeed holds all data parsed from a GTFS-RT protobuf feed. diff --git a/apps/api/spec/static.go b/apps/api/spec/static.go index bb20f4a..1fdd6a1 100644 --- a/apps/api/spec/static.go +++ b/apps/api/spec/static.go @@ -89,8 +89,20 @@ type ServiceCalendar struct { // Maps to GTFS calendar_dates.txt. // Note: calendar.txt is optional if calendar_dates.txt covers all service dates. type ServiceException struct { - ProviderID string `db:"provider_id" json:"providerId"` + ProviderID string `db:"provider_id" json:"providerId"` ServiceID string `db:"service_id" json:"serviceId"` Date time.Time `db:"date" json:"date"` ExceptionType int `db:"exception_type" json:"exceptionType"` // 1=service added, 2=service removed } + +// ShapePoint represents a single point in a shape's geometry. +// Maps to a single row in GTFS shapes.txt. +// Not stored in SQLite — consumed exclusively by the tile generation pipeline. +type ShapePoint struct { + ProviderID string `json:"providerId"` + ShapeID string `json:"shapeId"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Sequence int `json:"sequence"` + DistTraveled *float64 `json:"distTraveled,omitempty"` +} diff --git a/apps/api/tiles/geojson.go b/apps/api/tiles/geojson.go new file mode 100644 index 0000000..4ffdf2a --- /dev/null +++ b/apps/api/tiles/geojson.go @@ -0,0 +1,174 @@ +package tiles + +import ( + "encoding/json" + "log" + "math" + "sort" + + "github.com/Tracky-Trains/tracky/api/spec" +) + +// maxGapDeg is the maximum gap in degrees between consecutive shape +// points before we split into a new line segment. ~0.35° ≈ 38.9 km. +const maxGapDeg = 0.35 + +// FeatureCollection is a minimal GeoJSON FeatureCollection. +type FeatureCollection struct { + Type string `json:"type"` + Features []Feature `json:"features"` +} + +// Feature is a minimal GeoJSON Feature with string properties. +type Feature struct { + Type string `json:"type"` + Properties map[string]string `json:"properties"` + Geometry json.RawMessage `json:"geometry"` +} + +// lineStringGeometry is a GeoJSON LineString geometry. +type lineStringGeometry struct { + Type string `json:"type"` + Coordinates [][2]float64 `json:"coordinates"` +} + +// multiLineStringGeometry is a GeoJSON MultiLineString geometry. +type multiLineStringGeometry struct { + Type string `json:"type"` + Coordinates [][][2]float64 `json:"coordinates"` +} + +// BuildGeoJSON takes shapes, trips, and routes from all providers and +// produces a GeoJSON FeatureCollection. Shapes are joined to routes +// via trips to pick up color and name properties. +func BuildGeoJSON( + shapes []spec.ShapePoint, + trips []spec.Trip, + routes []spec.Route, +) ([]byte, error) { + // 1. Group shape points by composite key (providerID:shapeID), + // then sort each group by sequence. + type shapeKey struct { + providerID string + shapeID string + } + grouped := make(map[shapeKey][]spec.ShapePoint) + for _, sp := range shapes { + k := shapeKey{sp.ProviderID, sp.ShapeID} + grouped[k] = append(grouped[k], sp) + } + for k, pts := range grouped { + sort.Slice(pts, func(i, j int) bool { + return pts[i].Sequence < pts[j].Sequence + }) + grouped[k] = pts + } + + // 2. Build route lookup by routeID. + routeByID := make(map[string]spec.Route, len(routes)) + for _, r := range routes { + routeByID[r.RouteID] = r + } + + // 3. Build shape→route join via trips. + // Trip.ShapeID is raw GTFS (not namespaced), so the join key is + // providerID + ":" + *trip.ShapeID, matching our grouped keys. + routeByShape := make(map[shapeKey]spec.Route) + for _, t := range trips { + if t.ShapeID == nil { + continue + } + k := shapeKey{t.ProviderID, *t.ShapeID} + if _, ok := routeByShape[k]; ok { + continue // already have a route for this shape + } + if route, ok := routeByID[t.RouteID]; ok { + routeByShape[k] = route + } + } + + // 4. Build features. + features := make([]Feature, 0, len(grouped)) + orphaned := 0 + for k, pts := range grouped { + route, ok := routeByShape[k] + if !ok { + orphaned++ + continue + } + + // Build coordinate segments, splitting at large gaps. + segments := [][][2]float64{{}} + for i, p := range pts { + coord := [2]float64{p.Lon, p.Lat} // GeoJSON is [lon, lat] + if i > 0 { + prev := pts[i-1] + dlat := math.Abs(p.Lat - prev.Lat) + dlon := math.Abs(p.Lon - prev.Lon) + if dlat > maxGapDeg || dlon > maxGapDeg { + segments = append(segments, [][2]float64{}) + } + } + segments[len(segments)-1] = append(segments[len(segments)-1], coord) + } + + // Drop any segments with fewer than 2 points (can't form a line). + var valid [][][2]float64 + for _, seg := range segments { + if len(seg) >= 2 { + valid = append(valid, seg) + } + } + if len(valid) == 0 { + continue + } + + var geom json.RawMessage + if len(valid) == 1 { + geom, _ = json.Marshal(lineStringGeometry{ + Type: "LineString", + Coordinates: valid[0], + }) + } else { + geom, _ = json.Marshal(multiLineStringGeometry{ + Type: "MultiLineString", + Coordinates: valid, + }) + } + + color := route.Color + if color == "" { + color = "888888" + } + + textColor := route.TextColor + if textColor == "" { + textColor = "FFFFFF" + } + + features = append(features, Feature{ + Type: "Feature", + Properties: map[string]string{ + "provider_id": k.providerID, + "route_id": route.RouteID, + "shape_id": k.shapeID, + "color": "#" + color, + "text_color": "#" + textColor, + "short_name": route.ShortName, + "long_name": route.LongName, + }, + Geometry: geom, + }) + } + + if orphaned > 0 { + log.Printf("tiles: skipped %d orphaned shapes (no matching trip/route)", orphaned) + } + log.Printf("tiles: built %d GeoJSON features from %d shape groups", len(features), len(grouped)) + + fc := FeatureCollection{ + Type: "FeatureCollection", + Features: features, + } + return json.Marshal(fc) +} diff --git a/apps/api/tiles/tippecanoe.go b/apps/api/tiles/tippecanoe.go new file mode 100644 index 0000000..e33d779 --- /dev/null +++ b/apps/api/tiles/tippecanoe.go @@ -0,0 +1,34 @@ +package tiles + +import ( + "bytes" + "context" + "fmt" + "os/exec" +) + +// GenerateTiles shells out to tippecanoe to convert a GeoJSON file into PMTiles. +func GenerateTiles(ctx context.Context, geojsonPath, outputPath string) error { + if _, err := exec.LookPath("tippecanoe"); err != nil { + return fmt.Errorf("tippecanoe not found on PATH; install via: brew install tippecanoe") + } + + args := []string{ + "-o", outputPath, + "--force", + "-Z2", "-z12", + "--drop-densest-as-needed", + "--extend-zooms-if-still-dropping", + "-l", "transit_routes", + geojsonPath, + } + + cmd := exec.CommandContext(ctx, "tippecanoe", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("tippecanoe failed: %w\n%s", err, stderr.String()) + } + return nil +} diff --git a/apps/api/tiles/upload.go b/apps/api/tiles/upload.go new file mode 100644 index 0000000..8495a55 --- /dev/null +++ b/apps/api/tiles/upload.go @@ -0,0 +1,77 @@ +package tiles + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// Upload sends a local file to an S3-compatible bucket (R2, DO Spaces, etc.). +// Configuration is read from environment variables: +// +// TILES_S3_ENDPOINT - e.g. https://.r2.cloudflarestorage.com +// TILES_S3_BUCKET - bucket name +// TILES_S3_ACCESS_KEY_ID - access key +// TILES_S3_SECRET_ACCESS_KEY - secret key +// TILES_S3_REGION - region (default "auto") +func Upload(ctx context.Context, localPath, objectKey string) error { + endpoint := os.Getenv("TILES_S3_ENDPOINT") + bucket := os.Getenv("TILES_S3_BUCKET") + accessKey := os.Getenv("TILES_S3_ACCESS_KEY_ID") + secretKey := os.Getenv("TILES_S3_SECRET_ACCESS_KEY") + region := os.Getenv("TILES_S3_REGION") + + if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" { + return fmt.Errorf("tiles: S3 upload requires TILES_S3_ENDPOINT, TILES_S3_BUCKET, TILES_S3_ACCESS_KEY_ID, TILES_S3_SECRET_ACCESS_KEY") + } + if region == "" { + region = "auto" + } + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion(region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), + ) + if err != nil { + return fmt.Errorf("tiles: load AWS config: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + + f, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("tiles: open %s: %w", localPath, err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("tiles: stat %s: %w", localPath, err) + } + + contentType := "application/x-protobuf" + cacheControl := "public, max-age=86400" + + _, err = client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(objectKey), + Body: f, + ContentType: aws.String(contentType), + CacheControl: aws.String(cacheControl), + }) + if err != nil { + return fmt.Errorf("tiles: upload to s3://%s/%s: %w", bucket, objectKey, err) + } + + log.Printf("tiles: uploaded %s (%.1f MB) to s3://%s/%s", localPath, float64(stat.Size())/(1024*1024), bucket, objectKey) + return nil +} diff --git a/apps/api/tiles/viewer.html b/apps/api/tiles/viewer.html new file mode 100644 index 0000000..34bb0d8 --- /dev/null +++ b/apps/api/tiles/viewer.html @@ -0,0 +1,375 @@ + + + + + + Tracky Tile Viewer + + + + + + +
+
Loading tiles...
+
+
+ Routes + +
+
+
+ + + From a5102d9b5ab4bc1bac5a8fe9ea5537363beace8c Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Mon, 30 Mar 2026 20:43:10 -0500 Subject: [PATCH 4/4] refactor: update import paths from Tracky-Trains to RailForLess --- apps/api/cmd/functions/sync-realtime/main.go | 14 +++--- apps/api/cmd/server/main.go | 18 ++++---- apps/api/cmd/sync-static/main.go | 18 ++++---- apps/api/db/static.go | 2 +- apps/api/go.mod | 2 +- apps/api/gtfs/realtime.go | 2 +- apps/api/gtfs/static.go | 44 +++++++++---------- apps/api/providers/amtrak/amtrak.go | 6 +-- apps/api/providers/base/base.go | 4 +- apps/api/providers/brightline/brightline.go | 2 +- apps/api/providers/cta/cta.go | 2 +- apps/api/providers/metra/metra.go | 2 +- .../providers/metrotransit/metrotransit.go | 2 +- apps/api/providers/providers.go | 2 +- apps/api/providers/trirail/trirail.go | 2 +- apps/api/routes/routes.go | 6 +-- apps/api/tiles/geojson.go | 2 +- 17 files changed, 65 insertions(+), 65 deletions(-) diff --git a/apps/api/cmd/functions/sync-realtime/main.go b/apps/api/cmd/functions/sync-realtime/main.go index b2caa99..60d6e1d 100644 --- a/apps/api/cmd/functions/sync-realtime/main.go +++ b/apps/api/cmd/functions/sync-realtime/main.go @@ -7,12 +7,12 @@ import ( _ "github.com/joho/godotenv/autoload" - "github.com/Tracky-Trains/tracky/api/providers" - "github.com/Tracky-Trains/tracky/api/providers/amtrak" - "github.com/Tracky-Trains/tracky/api/providers/brightline" - "github.com/Tracky-Trains/tracky/api/providers/metra" - "github.com/Tracky-Trains/tracky/api/providers/metrotransit" - "github.com/Tracky-Trains/tracky/api/providers/trirail" + "github.com/RailForLess/tracky/api/providers" + "github.com/RailForLess/tracky/api/providers/amtrak" + "github.com/RailForLess/tracky/api/providers/brightline" + "github.com/RailForLess/tracky/api/providers/metra" + "github.com/RailForLess/tracky/api/providers/metrotransit" + "github.com/RailForLess/tracky/api/providers/trirail" ) func buildRegistry() *providers.Registry { @@ -55,7 +55,7 @@ func Main(args map[string]interface{}) map[string]interface{} { continue } results[p.ID()] = map[string]interface{}{ - "positions": len(feed.Positions), + "positions": len(feed.Positions), "stop_times": len(feed.StopTimes), } } diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index eebb911..cb56b8a 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -7,15 +7,15 @@ import ( _ "github.com/joho/godotenv/autoload" - "github.com/Tracky-Trains/tracky/api/db" - "github.com/Tracky-Trains/tracky/api/providers" - "github.com/Tracky-Trains/tracky/api/providers/amtrak" - "github.com/Tracky-Trains/tracky/api/providers/brightline" - "github.com/Tracky-Trains/tracky/api/providers/cta" - "github.com/Tracky-Trains/tracky/api/providers/metra" - "github.com/Tracky-Trains/tracky/api/providers/metrotransit" - "github.com/Tracky-Trains/tracky/api/providers/trirail" - "github.com/Tracky-Trains/tracky/api/routes" + "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/providers" + "github.com/RailForLess/tracky/api/providers/amtrak" + "github.com/RailForLess/tracky/api/providers/brightline" + "github.com/RailForLess/tracky/api/providers/cta" + "github.com/RailForLess/tracky/api/providers/metra" + "github.com/RailForLess/tracky/api/providers/metrotransit" + "github.com/RailForLess/tracky/api/providers/trirail" + "github.com/RailForLess/tracky/api/routes" ) func main() { diff --git a/apps/api/cmd/sync-static/main.go b/apps/api/cmd/sync-static/main.go index 7c4af57..8739f86 100644 --- a/apps/api/cmd/sync-static/main.go +++ b/apps/api/cmd/sync-static/main.go @@ -10,15 +10,15 @@ import ( _ "github.com/joho/godotenv/autoload" - "github.com/Tracky-Trains/tracky/api/db" - "github.com/Tracky-Trains/tracky/api/providers" - "github.com/Tracky-Trains/tracky/api/providers/amtrak" - "github.com/Tracky-Trains/tracky/api/providers/brightline" - "github.com/Tracky-Trains/tracky/api/providers/cta" - "github.com/Tracky-Trains/tracky/api/providers/metra" - "github.com/Tracky-Trains/tracky/api/providers/metrotransit" - "github.com/Tracky-Trains/tracky/api/providers/trirail" - "github.com/Tracky-Trains/tracky/api/tiles" + "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/providers" + "github.com/RailForLess/tracky/api/providers/amtrak" + "github.com/RailForLess/tracky/api/providers/brightline" + "github.com/RailForLess/tracky/api/providers/cta" + "github.com/RailForLess/tracky/api/providers/metra" + "github.com/RailForLess/tracky/api/providers/metrotransit" + "github.com/RailForLess/tracky/api/providers/trirail" + "github.com/RailForLess/tracky/api/tiles" ) func main() { diff --git a/apps/api/db/static.go b/apps/api/db/static.go index d3c2930..5866001 100644 --- a/apps/api/db/static.go +++ b/apps/api/db/static.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/Tracky-Trains/tracky/api/providers" + "github.com/RailForLess/tracky/api/providers" ) // SyncCounts holds the number of rows inserted per entity type. diff --git a/apps/api/go.mod b/apps/api/go.mod index df0431d..8f89907 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,4 +1,4 @@ -module github.com/Tracky-Trains/tracky/api +module github.com/RailForLess/tracky/api go 1.26 diff --git a/apps/api/gtfs/realtime.go b/apps/api/gtfs/realtime.go index 9c26bdf..bbdc800 100644 --- a/apps/api/gtfs/realtime.go +++ b/apps/api/gtfs/realtime.go @@ -13,7 +13,7 @@ import ( gtfsrt "github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs" "google.golang.org/protobuf/proto" - "github.com/Tracky-Trains/tracky/api/spec" + "github.com/RailForLess/tracky/api/spec" ) // FetchAndParsePositions downloads a GTFS-RT vehicle positions feed and returns diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go index f3aef9c..69bb3ef 100644 --- a/apps/api/gtfs/static.go +++ b/apps/api/gtfs/static.go @@ -12,7 +12,7 @@ import ( "strconv" "time" - "github.com/Tracky-Trains/tracky/api/spec" + "github.com/RailForLess/tracky/api/spec" ) // FetchAndParseStatic downloads a GTFS zip from url, parses it, and returns @@ -228,7 +228,7 @@ func parseAgency(f *zip.File, providerID string) ([]spec.Agency, error) { out := make([]spec.Agency, 0, len(rows)) for _, r := range rows { out = append(out, spec.Agency{ - ProviderID: providerID, + ProviderID: providerID, GtfsAgencyID: r["agency_id"], Name: r["agency_name"], URL: r["agency_url"], @@ -248,13 +248,13 @@ func parseRoutes(f *zip.File, providerID string) ([]spec.Route, error) { out := make([]spec.Route, 0, len(rows)) for _, r := range rows { out = append(out, spec.Route{ - ProviderID: providerID, - RouteID: providerID + ":" + r["route_id"], - ShortName: r["route_short_name"], - LongName: r["route_long_name"], - Color: r["route_color"], - TextColor: r["route_text_color"], - ShapeID: optStr(r, "shape_id"), + ProviderID: providerID, + RouteID: providerID + ":" + r["route_id"], + ShortName: r["route_short_name"], + LongName: r["route_long_name"], + Color: r["route_color"], + TextColor: r["route_text_color"], + ShapeID: optStr(r, "shape_id"), }) } return out, nil @@ -270,7 +270,7 @@ func parseStops(f *zip.File, providerID string) ([]spec.Stop, error) { lat, _ := strconv.ParseFloat(r["stop_lat"], 64) lon, _ := strconv.ParseFloat(r["stop_lon"], 64) out = append(out, spec.Stop{ - ProviderID: providerID, + ProviderID: providerID, StopID: providerID + ":" + r["stop_id"], Code: r["stop_code"], Name: r["stop_name"], @@ -291,7 +291,7 @@ func parseTrips(f *zip.File, providerID string) ([]spec.Trip, error) { out := make([]spec.Trip, 0, len(rows)) for _, r := range rows { out = append(out, spec.Trip{ - ProviderID: providerID, + ProviderID: providerID, TripID: providerID + ":" + r["trip_id"], RouteID: providerID + ":" + r["route_id"], ServiceID: r["service_id"], @@ -336,17 +336,17 @@ func parseCalendar(f *zip.File, providerID string) ([]spec.ServiceCalendar, erro start, _ := time.Parse("20060102", r["start_date"]) end, _ := time.Parse("20060102", r["end_date"]) out = append(out, spec.ServiceCalendar{ - ProviderID: providerID, - ServiceID: r["service_id"], - Monday: r["monday"] == "1", - Tuesday: r["tuesday"] == "1", - Wednesday: r["wednesday"] == "1", - Thursday: r["thursday"] == "1", - Friday: r["friday"] == "1", - Saturday: r["saturday"] == "1", - Sunday: r["sunday"] == "1", - StartDate: start, - EndDate: end, + ProviderID: providerID, + ServiceID: r["service_id"], + Monday: r["monday"] == "1", + Tuesday: r["tuesday"] == "1", + Wednesday: r["wednesday"] == "1", + Thursday: r["thursday"] == "1", + Friday: r["friday"] == "1", + Saturday: r["saturday"] == "1", + Sunday: r["sunday"] == "1", + StartDate: start, + EndDate: end, }) } return out, nil diff --git a/apps/api/providers/amtrak/amtrak.go b/apps/api/providers/amtrak/amtrak.go index dc6c8d3..f88e91a 100644 --- a/apps/api/providers/amtrak/amtrak.go +++ b/apps/api/providers/amtrak/amtrak.go @@ -19,9 +19,9 @@ import ( "golang.org/x/crypto/pbkdf2" - "github.com/Tracky-Trains/tracky/api/providers" - "github.com/Tracky-Trains/tracky/api/providers/base" - "github.com/Tracky-Trains/tracky/api/spec" + "github.com/RailForLess/tracky/api/providers" + "github.com/RailForLess/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/spec" ) const ( diff --git a/apps/api/providers/base/base.go b/apps/api/providers/base/base.go index 04501dd..41e8018 100644 --- a/apps/api/providers/base/base.go +++ b/apps/api/providers/base/base.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/Tracky-Trains/tracky/api/gtfs" - "github.com/Tracky-Trains/tracky/api/providers" + "github.com/RailForLess/tracky/api/gtfs" + "github.com/RailForLess/tracky/api/providers" ) // Config holds the configuration for a standard GTFS provider. diff --git a/apps/api/providers/brightline/brightline.go b/apps/api/providers/brightline/brightline.go index a3e4e97..290fc1c 100644 --- a/apps/api/providers/brightline/brightline.go +++ b/apps/api/providers/brightline/brightline.go @@ -1,7 +1,7 @@ package brightline import ( - "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/providers/base" ) const ( diff --git a/apps/api/providers/cta/cta.go b/apps/api/providers/cta/cta.go index f0afa19..3c3aabf 100644 --- a/apps/api/providers/cta/cta.go +++ b/apps/api/providers/cta/cta.go @@ -1,7 +1,7 @@ package cta import ( - "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/providers/base" ) const ( diff --git a/apps/api/providers/metra/metra.go b/apps/api/providers/metra/metra.go index ebd718a..214932d 100644 --- a/apps/api/providers/metra/metra.go +++ b/apps/api/providers/metra/metra.go @@ -3,7 +3,7 @@ package metra import ( "os" - "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/providers/base" ) const ( diff --git a/apps/api/providers/metrotransit/metrotransit.go b/apps/api/providers/metrotransit/metrotransit.go index 76b8318..2a6fa7a 100644 --- a/apps/api/providers/metrotransit/metrotransit.go +++ b/apps/api/providers/metrotransit/metrotransit.go @@ -1,7 +1,7 @@ package metrotransit import ( - "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/providers/base" ) // see https://svc.metrotransit.org/ diff --git a/apps/api/providers/providers.go b/apps/api/providers/providers.go index acf3a40..836c54e 100644 --- a/apps/api/providers/providers.go +++ b/apps/api/providers/providers.go @@ -5,7 +5,7 @@ import ( "fmt" "sort" - "github.com/Tracky-Trains/tracky/api/spec" + "github.com/RailForLess/tracky/api/spec" ) // Provider is the interface every transit data provider must implement. diff --git a/apps/api/providers/trirail/trirail.go b/apps/api/providers/trirail/trirail.go index 028e30a..0dcf754 100644 --- a/apps/api/providers/trirail/trirail.go +++ b/apps/api/providers/trirail/trirail.go @@ -1,7 +1,7 @@ package trirail import ( - "github.com/Tracky-Trains/tracky/api/providers/base" + "github.com/RailForLess/tracky/api/providers/base" ) // see https://gtfsr.tri-rail.com/ diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 599bc87..a16733d 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -6,8 +6,8 @@ import ( "sync" "time" - "github.com/Tracky-Trains/tracky/api/db" - "github.com/Tracky-Trains/tracky/api/providers" + "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/providers" ) // Setup registers all routes onto mux. @@ -29,7 +29,7 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func handleListProviders(registry *providers.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type entry struct { - ID string `json:"id"` + ID string `json:"id"` StaticEndpoint string `json:"static_endpoint"` RealtimeEndpoint string `json:"realtime_endpoint"` } diff --git a/apps/api/tiles/geojson.go b/apps/api/tiles/geojson.go index 4ffdf2a..104c427 100644 --- a/apps/api/tiles/geojson.go +++ b/apps/api/tiles/geojson.go @@ -6,7 +6,7 @@ import ( "math" "sort" - "github.com/Tracky-Trains/tracky/api/spec" + "github.com/RailForLess/tracky/api/spec" ) // maxGapDeg is the maximum gap in degrees between consecutive shape