namespace PureGym open System open System.Text.Json.Serialization /// Describes the opening hours of a given gym. [] type GymOpeningHours = { /// If this is true, there should be no OpeningHours (but nothing enforces that). IsAlwaysOpen : bool /// This is a pretty unstructured list, which is in general not really parseable: it's human-readable only. OpeningHours : string list } /// Human-readable representation override this.ToString () = if this.IsAlwaysOpen then "always open" else this.OpeningHours |> String.concat ", " /// How a human can authenticate with a gym when they physically try to enter it [] type GymAccessOptions = { /// This gym has PIN entry pads PinAccess : bool /// This gym has a QR code scanner. QR codes can be generated with the PureGym app. QrCodeAccess : bool } /// Human-readable representation override this.ToString () = $"Pin access: %c{Char.emoji this.PinAccess}; QR code access: %c{Char.emoji this.QrCodeAccess}" /// Where a gym is on the Earth [] type GymLocation = { /// Measured in degrees [] Longitude : float /// Measured in degrees [] Latitude : float } /// The postal address of a gym [] type GymAddress = { /// E.g. "Canterbury Court" [] AddressLine1 : string /// E.g. "Units 4, 4A, 5 And 5A" AddressLine2 : string option /// E.g. "Kennington Park" AddressLine3 : string option /// E.g. "LONDON" [] Town : string /// Never seen this in the wild, sorry County : string option /// E.g. "SW9 6DE" [] Postcode : string } /// Human-readable statement of the address override this.ToString () = [ yield Some this.AddressLine1 yield this.AddressLine2 yield this.AddressLine3 match this.County with | None -> yield Some $"%s{this.Town} %s{this.Postcode}" | Some county -> yield Some this.Town yield Some $"%s{county} %s{this.Postcode}" ] |> Seq.choose id |> String.concat "\n" /// Metadata about a physical gym [] type Gym = { // The following fields are returned but are always null // ReasonsToJoin : string // VirtualTourUrl : Uri // PersonalTrainersUrl : Uri // WebViewUrl : Uri // FloorPlanUrl : Uri // StaffMembers : string /// The name of this gym, e.g. "London Oval" [] Name : string /// This gym's ID in the PureGym system, e.g. 19 [] Id : int /// I don't know what this status is. Please tell me if you know! [] Status : int /// Postal address of this gym [] Address : GymAddress /// Phone number of this gym, e.g. "+44 1234 567890" [] PhoneNumber : string /// Contact email address for this gym's staff [] EmailAddress : string /// When this gym is open [] GymOpeningHours : GymOpeningHours /// How a human can physically authenticate when they physically enter this gym [] AccessOptions : GymAccessOptions /// Where this gym is physically located [] Location : GymLocation /// The IANA time zone this gym observes, e.g. "Europe/London" [] TimeZone : string /// This is a date-time in the format yyyy-MM-ddTHH:mm:ss+01 Europe/London ReopenDate : string option } /// Human-readable representation of the most important information about this gym override this.ToString () = $"""%s{this.Name} (%i{this.Id}) {this.Address} %s{this.EmailAddress} %s{this.PhoneNumber} Opening hours: %s{string this.GymOpeningHours} %s{string this.AccessOptions} """ /// A human member of PureGym [] type Member = { /// This member's ID. This is a fairly large number. Id : int /// No idea what this is - please tell me if you know! CompoundMemberId : string /// First name, e.g. "Patrick" FirstName : string /// Last name, e.g. "Stevens" LastName : string /// ID of the gym designated as this user's home gym. This is also the "Id" field of the appropriate Gym object. HomeGymId : int /// The name of the gym designated as this user's home gym. This is also the "Name" field of the appropriate /// Gym object. HomeGymName : string /// This user's email address EmailAddress : string /// This user's gym access pin, probably 8 digits GymAccessPin : string /// This user's recorded date of birth [] DateOfBirth : DateOnly /// This user's phone number, human-readable MobileNumber : string /// This user's registered home postcode [] Postcode : string /// E.g. "Corporate" MembershipName : string /// No idea what this is MembershipLevel : int /// No idea what this is SuspendedReason : int /// No idea what this is MemberStatus : int } /// Statistics for how many people are currently at a gym [] type GymAttendance = { /// This appears always to be just equal to TotalPeopleInGym, but a string. [] Description : string /// How many people are in the gym as of this statistics snapshot [] TotalPeopleInGym : int /// How many people are in classes at the gym as of this statistics snapshot [] TotalPeopleInClasses : int /// E.g. " or fewer" TotalPeopleSuffix : string option /// Whether the number of people in the gym is approximate. This appears to become true when the number /// of people in the gym is small enough (e.g. in Oval the threshold is 10). [] IsApproximate : bool /// When the query was received (I think) AttendanceTime : DateTime /// When the "total people in gym" snapshot was taken that is reported here LastRefreshed : DateTime /// When the "number of people in classes" snapshot was taken that is reported here LastRefreshedPeopleInClasses : DateTime /// Maximum capacity of the gym, or 0 if no listed capacity MaximumCapacity : int } /// Human-readable representation override this.ToString () = let totalPeopleSuffix = match this.TotalPeopleSuffix with | None -> "" | Some suffix -> suffix let capacity = if this.MaximumCapacity = 0 then "" else $" out of %i{this.MaximumCapacity} maximum" let classes = if this.TotalPeopleInClasses = 0 then "" else $"\n%i{this.TotalPeopleInClasses} in classes" $"""%i{this.TotalPeopleInGym}%s{totalPeopleSuffix} in gym%s{capacity} (is exact: %c{Char.emoji (not this.IsApproximate)})%s{classes} Query made at %s{this.AttendanceTime.ToString "s"}%s{this.AttendanceTime.ToString "zzz"} Snapshot correct as of %s{this.LastRefreshed.ToString "s"}%s{this.LastRefreshed.ToString "zzz"} Classes info correct as of %s{this.LastRefreshedPeopleInClasses.ToString "s"}%s{this.LastRefreshedPeopleInClasses.ToString "zzz"}""" /// The visit statistics for a particular human to a particular gym. /// The semantics of this class are basically unknown. type MemberActivityThisMonth = { /// How many minutes, including classes, have been logged so far this month TotalDurationMinutes : int /// How long, in minutes, each visit has been on average this month AverageDurationMinutes : int /// How many visits have been made this month, excluding classes TotalVisits : int /// How many classes have been attended this month TotalClasses : int /// Whether this block of statistics is estimated rather than exact IsEstimated : bool /// When this data was constructed LastRefreshed : DateTime } /// Don't use this type. It's public because System.Text.Json can't do private types. [] type MemberActivityDto = { [] TotalDuration : int [] AverageDuration : int [] TotalVisits : int [] TotalClasses : int [] IsEstimated : bool [] LastRefreshed : DateTime } member this.ToMemberActivity () = { TotalDurationMinutes = this.TotalDuration AverageDurationMinutes = this.AverageDuration TotalVisits = this.TotalVisits TotalClasses = this.TotalClasses IsEstimated = this.IsEstimated LastRefreshed = this.LastRefreshed } /// Aggregation of visits made to some particular gym in some defined time period. [] type SessionsAggregate = { /// Number of gym "activities" within some query-defined time period; presumably this is like classes? /// It's always 0 for me. [] Activities : int /// Number of visits to the gym within some query-defined time period. [] Visits : int /// In minutes: total time spent in gym during the query-defined time period. [] Duration : int } /// The DTO for gym info returned from the Sessions endpoint. [] type VisitGym = { // Omitting Location, GymAccess, ContactInfo, TimeZone because these were all null for me /// The PureGym ID of this gym, e.g. 19 [] Id : int /// E.g. "London Oval", the canonical name of this gym [] Name : string /// For some reason this always seems to be "Blocked" [] Status : string } /// Summary of a single visit to a gym. [] type Visit = { // Omitted Name because it always was null for me /// Whether the Duration field is estimated. [] IsDurationEstimated : bool /// When the visit began. [] StartTime : DateTime /// In minutes. [] Duration : int /// Which gym was visited [] Gym : VisitGym } /// Human-readable non-round-trip representation. override this.ToString () = let startTime = this.StartTime.ToString "yyyy-MM-dd HH:mm" $"%s{this.Gym.Name}: %s{startTime} (%i{this.Duration} minutes)" /// Aggregate statistics for gym visits across a time period. [] type SessionsSummary = { /// Aggregate stats for gym visits within the query-dependent time period. [] Total : SessionsAggregate /// Aggregate stats for gym visits "this week", whatever that means to PureGym. [] ThisWeek : SessionsAggregate } /// Human-readable non-round-trippable representation override this.ToString () = $"%i{this.Total.Visits} visits, totalling %i{this.Total.Duration} minutes" /// Information about a particular user's visits to a particular gym. [] type Sessions = { /// Aggregated summary over some time period. [] Summary : SessionsSummary /// List of all individual visits made within some time period. [] Visits : Visit list } /// Human-readable non-round-trip representation. override this.ToString () = let summary = string this.Summary let visits = this.Visits |> Seq.map string |> String.concat "\n" $"%s{summary}\n%s{visits}"