diff --git a/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs
new file mode 100644
index 0000000..e5754b5
--- /dev/null
+++ b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs
@@ -0,0 +1,857 @@
+namespace BinDays.Api.Collectors.Collectors.Councils;
+
+using BinDays.Api.Collectors.Collectors.Vendors;
+using BinDays.Api.Collectors.Models;
+using BinDays.Api.Collectors.Utilities;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+
+///
+/// Collector implementation for Cheltenham Borough Council.
+///
+internal sealed class CheltenhamBoroughCouncil : GovUkCollectorBase, ICollector
+{
+ ///
+ public string Name => "Cheltenham Borough Council";
+
+ ///
+ public Uri WebsiteUrl => new("https://www.cheltenham.gov.uk/");
+
+ ///
+ public override string GovUkId => "cheltenham";
+
+ ///
+ /// The list of bin types for this collector.
+ ///
+ private readonly IReadOnlyCollection _binTypes =
+ [
+ new()
+ {
+ Name = "General Waste",
+ Colour = BinColour.Green,
+ Keys = [ "Refuse" ],
+ },
+ new()
+ {
+ Name = "Mixed Dry Recycling (Green Box)",
+ Colour = BinColour.Green,
+ Keys = [ "Recycling" ],
+ Type = BinType.Box,
+ },
+ new()
+ {
+ Name = "Paper, Glass & Cardboard Recycling (Blue Bag)",
+ Colour = BinColour.Blue,
+ Keys = [ "Recycling" ],
+ Type = BinType.Bag,
+ },
+ new()
+ {
+ Name = "Garden Waste",
+ Colour = BinColour.Brown,
+ Keys = [ "Garden" ],
+ },
+ new()
+ {
+ Name = "Food Waste",
+ Colour = BinColour.Green,
+ Keys = [ "Food" ],
+ Type = BinType.Caddy,
+ },
+ ];
+
+ ///
+ /// The base URL for StatMap Aurora requests.
+ ///
+ private const string _auroraBaseUrl = "https://maps.cheltenham.gov.uk/map/Aurora.svc";
+
+ ///
+ /// The script path used when requesting a new session.
+ ///
+ private const string _scriptPath = "%5CAurora%5CCBC%20Waste%20Streets.AuroraScript%24";
+
+ ///
+ /// The Google Calendar ICS feed for Cheltenham's week schedule.
+ ///
+ private const string _calendarUrl = "https://calendar.google.com/calendar/ical/v7oettki6t1pi2p7s0j6q6121k%40group.calendar.google.com/public/full.ics";
+
+ ///
+ public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse)
+ {
+ // Prepare client-side request for getting addresses
+ if (clientSideResponse == null)
+ {
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 1,
+ Url = $"{_auroraBaseUrl}/RequestSession?userName=guest%20CBC&password=&script={_scriptPath}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ };
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getAddressesResponse;
+ }
+ // Prepare client-side request to find locations for the postcode
+ else if (clientSideResponse.RequestId == 1)
+ {
+ var jsonDocument = ParseJsonp(clientSideResponse.Content);
+ var sessionId = jsonDocument.RootElement.GetProperty("Session").GetProperty("SessionId").GetString()!;
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 2,
+ Url = $"{_auroraBaseUrl}/FindLocation?sessionId={sessionId}&address={postcode}&limit=100&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ };
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getAddressesResponse;
+ }
+ // Process addresses from response
+ else if (clientSideResponse.RequestId == 2)
+ {
+ var responseJson = ParseJsonp(clientSideResponse.Content);
+ var locations = responseJson.RootElement.GetProperty("Locations").EnumerateArray();
+
+ // Iterate through each location, and create a new address object
+ var addresses = new List();
+ foreach (var location in locations)
+ {
+ var description = location.GetProperty("Description").GetString()!
+ .Replace("", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace("", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Trim();
+
+ var address = new Address
+ {
+ Property = description,
+ Postcode = postcode,
+ Uid = location.GetProperty("Id").GetString()!,
+ };
+
+ addresses.Add(address);
+ }
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ Addresses = [.. addresses],
+ };
+
+ return getAddressesResponse;
+ }
+
+ // Throw exception for invalid request
+ throw new InvalidOperationException("Invalid client-side request.");
+ }
+
+ ///
+ public GetBinDaysResponse GetBinDays(Address address, ClientSideResponse? clientSideResponse)
+ {
+ // Prepare client-side request for creating a session
+ if (clientSideResponse == null)
+ {
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 1,
+ Url = $"{_auroraBaseUrl}/RequestSession?userName=guest%20CBC&password=&script={_scriptPath}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request for retrieving workflow tasks
+ else if (clientSideResponse.RequestId == 1)
+ {
+ var responseJson = ParseJsonp(clientSideResponse.Content);
+ var sessionId = responseJson.RootElement.GetProperty("Session").GetProperty("SessionId").GetString()!;
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 2,
+ Url = $"{_auroraBaseUrl}/GetWorkflow?sessionId={sessionId}&workflowId=wastestreet&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata =
+ {
+ { "sessionId", sessionId },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to open the script map
+ else if (clientSideResponse.RequestId == 2)
+ {
+ var workflowJson = ParseJsonp(clientSideResponse.Content);
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+
+ var taskIds = workflowJson.RootElement.GetProperty("Tasks").EnumerateArray().ToDictionary(
+ task => task.GetProperty("$type").GetString()!,
+ task => task.GetProperty("Id").GetString()!
+ );
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 3,
+ Url = $"{_auroraBaseUrl}/OpenScriptMap?sessionId={sessionId}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata =
+ {
+ { "sessionId", sessionId },
+ { "restoreTaskId", taskIds["StatMap.Aurora.RestoreStateTask, StatMapService"] },
+ { "saveTaskId", taskIds["StatMap.Aurora.SaveStateTask, StatMapService"] },
+ { "clearTaskId", taskIds["StatMap.Aurora.ClearResultSetTask, StatMapService"] },
+ { "visibilityTaskId", taskIds["StatMap.Aurora.ChangeLayersVisibilityTask, StatMapService"] },
+ { "drillTaskId", taskIds["StatMap.Aurora.DrillDownTask, StatMapService"] },
+ { "fetchTaskId", taskIds["StatMap.Aurora.FetchResultSetTask, StatMapService"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to restore state
+ else if (clientSideResponse.RequestId == 3)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var restoreTaskId = clientSideResponse.Options.Metadata["restoreTaskId"];
+
+ var job = """
+{
+ "Task": {
+ "$type": "StatMap.Aurora.RestoreStateTask, StatMapService"
+ }
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 4,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={restoreTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "saveTaskId", clientSideResponse.Options.Metadata["saveTaskId"] },
+ { "clearTaskId", clientSideResponse.Options.Metadata["clearTaskId"] },
+ { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] },
+ { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to save state
+ else if (clientSideResponse.RequestId == 4)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var saveTaskId = clientSideResponse.Options.Metadata["saveTaskId"];
+
+ var job = """
+{
+ "Task": {
+ "$type": "StatMap.Aurora.SaveStateTask, StatMapService"
+ }
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 5,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={saveTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "clearTaskId", clientSideResponse.Options.Metadata["clearTaskId"] },
+ { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] },
+ { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to clear the result set
+ else if (clientSideResponse.RequestId == 5)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var clearTaskId = clientSideResponse.Options.Metadata["clearTaskId"];
+
+ var job = """
+{
+ "Task": {
+ "$type": "StatMap.Aurora.ClearResultSetTask, StatMapService"
+ }
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 6,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={clearTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] },
+ { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to ensure layers are visible
+ else if (clientSideResponse.RequestId == 6)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var visibilityTaskId = clientSideResponse.Options.Metadata["visibilityTaskId"];
+
+ var job = """
+{
+ "Task": {
+ "$type": "StatMap.Aurora.ChangeLayersVisibilityTask, StatMapService"
+ }
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 7,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={visibilityTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to resolve the selected location
+ else if (clientSideResponse.RequestId == 7)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 8,
+ Url = $"{_auroraBaseUrl}/FindLocation?sessionId={sessionId}&locationId={address.Uid}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to drill into the selected location
+ else if (clientSideResponse.RequestId == 8)
+ {
+ var responseJson = ParseJsonp(clientSideResponse.Content);
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var drillTaskId = clientSideResponse.Options.Metadata["drillTaskId"];
+
+ var location = responseJson.RootElement.GetProperty("Locations").EnumerateArray().First();
+ var queryX = location.GetProperty("X").GetDouble();
+ var queryY = location.GetProperty("Y").GetDouble();
+
+ var job = $$"""
+{
+ "QueryX": {{queryX.ToString(CultureInfo.InvariantCulture)}},
+ "QueryY": {{queryY.ToString(CultureInfo.InvariantCulture)}},
+ "Task": {
+ "Type": "StatMap.Aurora.DrillDownTask, StatMapService"
+ }
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 9,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={drillTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "sessionId", sessionId },
+ { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] },
+ { "queryX", queryX.ToString(CultureInfo.InvariantCulture) },
+ { "queryY", queryY.ToString(CultureInfo.InvariantCulture) },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to fetch the result set
+ else if (clientSideResponse.RequestId == 9)
+ {
+ var sessionId = clientSideResponse.Options!.Metadata["sessionId"];
+ var fetchTaskId = clientSideResponse.Options.Metadata["fetchTaskId"];
+ var queryX = clientSideResponse.Options.Metadata["queryX"];
+ var queryY = clientSideResponse.Options.Metadata["queryY"];
+
+ var job = $$"""
+{
+ "QueryX": {{queryX}},
+ "QueryY": {{queryY}},
+ "Task": {
+ "Type": "StatMap.Aurora.FetchResultSetTask, StatMapService"
+ },
+ "ResultSetName": "inspection"
+}
+""";
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 10,
+ Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={fetchTaskId}&job={job}&callback=_jqjsp",
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request for the calendar feed and store service configuration
+ else if (clientSideResponse.RequestId == 10)
+ {
+ var fetchJson = ParseJsonp(clientSideResponse.Content);
+ var table = fetchJson.RootElement
+ .GetProperty("TaskResult")
+ .GetProperty("DistanceOrderedSet")
+ .GetProperty("ResultSet")
+ .GetProperty("Tables")
+ .EnumerateArray()
+ .First();
+
+ var columnNames = table.GetProperty("ColumnDefinitions")
+ .EnumerateArray()
+ .Select(column => column.GetProperty("ColumnName").GetString()!)
+ .ToList();
+
+ var record = table.GetProperty("Records").EnumerateArray().First().EnumerateArray().ToList();
+
+ var recordData = columnNames.Zip(record).ToDictionary(
+ pair => pair.First,
+ pair => pair.Second.ToString().Trim()
+ );
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 11,
+ Url = _calendarUrl,
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ Options = new ClientSideOptions
+ {
+ Metadata = new()
+ {
+ { "refuseDay", recordData["New_Refuse_Day_internal"] },
+ { "refuseWeek", recordData["Refuse_Week_External"] },
+ { "recyclingDay", recordData["New_Recycling_Round"] },
+ { "recyclingWeek", recordData["Amended_Recycling_Round"] },
+ { "foodDay", recordData["New_Food_Day"] },
+ { "foodWeek", recordData["New_Food_Week_Internal"] },
+ { "gardenDay", recordData["Garden_Bin_Crew"] },
+ { "gardenWeek", recordData["Garden_Bin_Week"] },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Process bin days using the calendar feed and service configuration
+ else if (clientSideResponse.RequestId == 11)
+ {
+ var week1Start = GetWeekStartDate(clientSideResponse.Content, "Week 1");
+ var week2Start = GetWeekStartDate(clientSideResponse.Content, "Week 2");
+ var endDate = DateOnly.FromDateTime(DateTime.Now.AddMonths(12));
+
+ var refuseDates = BuildCollectionDates(
+ clientSideResponse.Options!.Metadata["refuseDay"],
+ clientSideResponse.Options.Metadata["refuseWeek"],
+ week1Start,
+ week2Start,
+ endDate
+ );
+
+ var recyclingDates = BuildCollectionDates(
+ clientSideResponse.Options.Metadata["recyclingDay"],
+ clientSideResponse.Options.Metadata["recyclingWeek"],
+ week1Start,
+ week2Start,
+ endDate
+ );
+
+ var foodDates = BuildCollectionDates(
+ clientSideResponse.Options.Metadata["foodDay"],
+ clientSideResponse.Options.Metadata["foodWeek"],
+ week1Start,
+ week2Start,
+ endDate
+ );
+
+ var refuseBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Refuse");
+ var recyclingBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Recycling");
+ var foodBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Food");
+ var gardenBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Garden");
+
+ // Iterate through each collection date, and create a bin day entry
+ var binDays = new List();
+ foreach (var date in refuseDates)
+ {
+ binDays.Add(new BinDay
+ {
+ Address = address,
+ Date = date,
+ Bins = refuseBins,
+ });
+ }
+
+ // Iterate through each recycling date, and create a bin day entry
+ foreach (var date in recyclingDates)
+ {
+ binDays.Add(new BinDay
+ {
+ Address = address,
+ Date = date,
+ Bins = recyclingBins,
+ });
+ }
+
+ // Iterate through each food waste date, and create a bin day entry
+ foreach (var date in foodDates)
+ {
+ binDays.Add(new BinDay
+ {
+ Address = address,
+ Date = date,
+ Bins = foodBins,
+ });
+ }
+
+ var gardenWeek = clientSideResponse.Options.Metadata["gardenWeek"];
+ if (!string.IsNullOrWhiteSpace(clientSideResponse.Options.Metadata["gardenDay"])
+ && !string.IsNullOrWhiteSpace(gardenWeek))
+ {
+ var gardenDates = BuildCollectionDates(
+ clientSideResponse.Options.Metadata["gardenDay"],
+ gardenWeek,
+ week1Start,
+ week2Start,
+ endDate
+ );
+
+ // Iterate through each garden waste date, and create a bin day entry
+ foreach (var date in gardenDates)
+ {
+ binDays.Add(new BinDay
+ {
+ Address = address,
+ Date = date,
+ Bins = gardenBins,
+ });
+ }
+ }
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ BinDays = ProcessingUtilities.ProcessBinDays(binDays),
+ };
+
+ return getBinDaysResponse;
+ }
+
+ // Throw exception for invalid request
+ throw new InvalidOperationException("Invalid client-side request.");
+ }
+
+ ///
+ /// Parses a JSONP response into a JSON document.
+ ///
+ private static JsonDocument ParseJsonp(string content)
+ {
+ var startIndex = content.IndexOf('(');
+ var endIndex = content.LastIndexOf(')');
+ var jsonContent = content[(startIndex + 1)..endIndex];
+
+ return JsonDocument.Parse(jsonContent);
+ }
+
+ ///
+ /// Builds recurring collection dates for a service based on the week pattern.
+ ///
+ private static List BuildCollectionDates(
+ string dayOfWeekText,
+ string weekPattern,
+ DateOnly week1Start,
+ DateOnly week2Start,
+ DateOnly endDate
+ )
+ {
+ var dayOfWeek = ParseDayOfWeek(dayOfWeekText);
+ string? normalisedPattern;
+ if (weekPattern.Contains("weekly", StringComparison.OrdinalIgnoreCase))
+ {
+ normalisedPattern = "Weekly";
+ }
+ else if (weekPattern.Contains('1') && weekPattern.Contains('2'))
+ {
+ normalisedPattern = "Weekly";
+ }
+ else if (weekPattern.Contains('2'))
+ {
+ normalisedPattern = "2";
+ }
+ else
+ {
+ normalisedPattern = "1";
+ }
+
+ var week1Date = GetFirstWeekDate(week1Start, dayOfWeek);
+ var week2Date = GetFirstWeekDate(week2Start, dayOfWeek);
+
+ if (normalisedPattern.Equals("Weekly", StringComparison.OrdinalIgnoreCase))
+ {
+ var firstDate = week1Date < week2Date ? week1Date : week2Date;
+ return BuildRecurringDates(firstDate, 7, endDate);
+ }
+
+ var targetWeek = normalisedPattern == "2" ? week2Date : week1Date;
+
+ return BuildRecurringDates(targetWeek, 14, endDate);
+ }
+
+ ///
+ /// Parses a day of week string that may be abbreviated.
+ ///
+ private static DayOfWeek ParseDayOfWeek(string dayOfWeekText)
+ {
+ if (Enum.TryParse(dayOfWeekText, true, out DayOfWeek parsedDay))
+ {
+ return parsedDay;
+ }
+
+ var abbreviatedDay = CultureInfo.InvariantCulture.DateTimeFormat.AbbreviatedDayNames
+ .Select((day, index) => new { Day = day, Index = index })
+ .FirstOrDefault(day => day.Day.Equals(dayOfWeekText, StringComparison.OrdinalIgnoreCase));
+
+ if (abbreviatedDay != null)
+ {
+ return (DayOfWeek)abbreviatedDay.Index;
+ }
+
+ throw new InvalidOperationException("Invalid day of week provided.");
+ }
+
+ ///
+ /// Builds a list of dates at a specified interval.
+ ///
+ private static List BuildRecurringDates(DateOnly startDate, int intervalDays, DateOnly endDate)
+ {
+ var dates = new List();
+
+ for (var current = startDate; current <= endDate; current = current.AddDays(intervalDays))
+ {
+ dates.Add(current);
+ }
+
+ return dates;
+ }
+
+ ///
+ /// Gets the first date for a given day within a week.
+ ///
+ private static DateOnly GetFirstWeekDate(DateOnly weekStart, DayOfWeek dayOfWeek)
+ {
+ var offset = ((int)dayOfWeek - (int)weekStart.DayOfWeek + 7) % 7;
+
+ return weekStart.AddDays(offset);
+ }
+
+ ///
+ /// Extracts the week start date for a specific summary label from the ICS content.
+ ///
+ private static DateOnly GetWeekStartDate(string icsContent, string summaryLabel)
+ {
+ var events = icsContent.Split("BEGIN:VEVENT", StringSplitOptions.RemoveEmptyEntries);
+
+ // Iterate through each calendar event to locate the matching week summary
+ foreach (var calendarEvent in events)
+ {
+ var lines = calendarEvent
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ var summaryLine = lines.FirstOrDefault(line => line.StartsWith("SUMMARY", StringComparison.OrdinalIgnoreCase));
+
+ if (summaryLine == null || !summaryLine.Contains(summaryLabel, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var startLine = lines.FirstOrDefault(line => line.StartsWith("DTSTART", StringComparison.OrdinalIgnoreCase));
+
+ if (startLine == null)
+ {
+ continue;
+ }
+
+ var dateText = startLine.Split(':', 2)[1].Trim();
+
+ var startDate = DateOnly.ParseExact(
+ dateText,
+ "yyyyMMdd",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None
+ );
+
+ return startDate;
+ }
+
+ throw new InvalidOperationException("Week start date not found in calendar feed.");
+ }
+}
diff --git a/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs
new file mode 100644
index 0000000..1235e1d
--- /dev/null
+++ b/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs
@@ -0,0 +1,36 @@
+namespace BinDays.Api.IntegrationTests.Collectors.Councils;
+
+using BinDays.Api.Collectors.Collectors;
+using BinDays.Api.Collectors.Collectors.Councils;
+using BinDays.Api.Collectors.Services;
+using BinDays.Api.IntegrationTests.Helpers;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+public class CheltenhamBoroughCouncilTests
+{
+ private readonly IntegrationTestClient _client;
+ private static readonly ICollector _collector = new CheltenhamBoroughCouncil();
+ private readonly CollectorService _collectorService = new([_collector]);
+ private readonly ITestOutputHelper _outputHelper;
+
+ public CheltenhamBoroughCouncilTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ _client = new IntegrationTestClient(outputHelper);
+ }
+
+ [Theory]
+ [InlineData("GL51 7DU")]
+ public async Task GetBinDaysTest(string postcode)
+ {
+ await TestSteps.EndToEnd(
+ _client,
+ _collectorService,
+ _collector,
+ postcode,
+ _outputHelper
+ );
+ }
+}