diff --git a/Core/Models/EventOccurrenceParseGroup.cs b/Core/Models/EventOccurrenceParseGroup.cs
new file mode 100644
index 0000000..1c8c694
--- /dev/null
+++ b/Core/Models/EventOccurrenceParseGroup.cs
@@ -0,0 +1,10 @@
+using Core.Entities;
+
+namespace Core.Models;
+
+///
+/// Groups parsed occurrences by event definition and optional section school level from headers
+/// (e.g. "Prepared Speech - HS" vs "Prepared Speech - MS"). The same
+/// can appear in multiple groups.
+///
+public readonly record struct EventOccurrenceParseGroup(EventDefinition EventDefinition, SchoolLevel? SectionSchoolLevel);
diff --git a/Core/Models/EventOccurrenceParseResult.cs b/Core/Models/EventOccurrenceParseResult.cs
index a83b758..2f9a6f6 100644
--- a/Core/Models/EventOccurrenceParseResult.cs
+++ b/Core/Models/EventOccurrenceParseResult.cs
@@ -9,11 +9,11 @@ namespace Core.Models;
public class EventOccurrenceParseResult
{
///
- /// Dictionary of parsed event occurrences, keyed by EventDefinition.
- /// For special events (GeneralSchedule, MeetTheCandidates, ChapterOfficerMeeting, VotingDelegateMeeting, SocialGathering),
- /// the EventDefinition key will be the static instance.
+ /// Parsed occurrences keyed by event definition and optional section MS/HS from schedule headers.
+ /// Special events use static instances with
+ /// typically null.
///
- public IDictionary> Occurrences { get; set; } = new Dictionary>();
+ public IDictionary> Occurrences { get; set; } = new Dictionary>();
///
/// List of parsing errors (critical issues that prevented parsing).
diff --git a/Core/Parsers/EventOccurrenceParser.cs b/Core/Parsers/EventOccurrenceParser.cs
index 6184c5a..b115c6e 100644
--- a/Core/Parsers/EventOccurrenceParser.cs
+++ b/Core/Parsers/EventOccurrenceParser.cs
@@ -1,4 +1,4 @@
-using System.Text.RegularExpressions;
+using System.Text.RegularExpressions;
using Core.Entities;
using Core.Models;
using EventOccurrenceParsers = Core.Parsers.EventOccurrence;
@@ -12,7 +12,7 @@ namespace Core.Parsers;
///
public class EventOccurrenceParserResult
{
- public IDictionary> Occurrences { get; set; } = new Dictionary>();
+ public IDictionary> Occurrences { get; set; } = new Dictionary>();
public List Issues { get; set; } = new();
public List SkippedSectionHeaders { get; set; } = new();
public int SkippedEventCount { get; set; }
@@ -296,12 +296,14 @@ public class EventOccurrenceParser
Location = location
};
- if (!occurrences.ContainsKey(eventDefinition))
- occurrences.Add(eventDefinition, []);
- occurrences[eventDefinition].Add(eventOccurrence);
-
- // Reset section level when we successfully parse an occurrence (means we're in a valid section)
- currentSectionLevel = null;
+ var groupKey = new EventOccurrenceParseGroup(eventDefinition, currentSectionLevel);
+ if (!occurrences.TryGetValue(groupKey, out var groupList))
+ {
+ groupList = [];
+ occurrences[groupKey] = groupList;
+ }
+
+ groupList.Add(eventOccurrence);
}
return result;
diff --git a/Core/Services/EventOccurrenceParserService.cs b/Core/Services/EventOccurrenceParserService.cs
index 7e070e8..dfe1b28 100644
--- a/Core/Services/EventOccurrenceParserService.cs
+++ b/Core/Services/EventOccurrenceParserService.cs
@@ -65,7 +65,8 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
// Convert parsed occurrences to result format, handling special event types
foreach (var kvp in parsedOccurrences)
{
- var eventDefinition = kvp.Key;
+ var group = kvp.Key;
+ var eventDefinition = group.EventDefinition;
var occurrences = kvp.Value;
// Check if this is a special event type (not stored in database)
@@ -90,8 +91,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
};
}
- // Add to result with the special EventDefinition as key
- result.Occurrences[eventDefinition] = occurrences;
+ result.Occurrences[group] = occurrences;
}
else
{
@@ -102,7 +102,7 @@ public class EventOccurrenceParserService : IEventOccurrenceParserService
occurrence.SpecialEventType = null;
}
- result.Occurrences[eventDefinition] = occurrences;
+ result.Occurrences[group] = occurrences;
}
}
diff --git a/TSA Chapter Organizer.sln b/TSA Chapter Organizer.sln
index 8c305fe..a1585d8 100644
--- a/TSA Chapter Organizer.sln
+++ b/TSA Chapter Organizer.sln
@@ -8,6 +8,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{039E1539-EDA8-4F4E-ACC0-B8292827A3A9}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GoogleSheetsScheduleImport", "tools\GoogleSheetsScheduleImport\GoogleSheetsScheduleImport.csproj", "{8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}"
ProjectSection(ProjectDependencies) = postProject
{338B8571-2953-4EA3-A680-F000F1431DFF} = {338B8571-2953-4EA3-A680-F000F1431DFF}
@@ -35,6 +37,10 @@ Global
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5401DC8-8008-414A-ACE9-FAEF2B1B8113}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8F2E4A1C-3B5D-4E6F-9A0B-1C2D3E4F5A6B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs b/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs
new file mode 100644
index 0000000..7e2f5ca
--- /dev/null
+++ b/Tests/GoogleSheets/GlobalEventDeduplicatorTests.cs
@@ -0,0 +1,28 @@
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class GlobalEventDeduplicatorTests
+{
+ [Test]
+ public void Curfew_same_time_multiple_locations_becomes_one_line_without_location()
+ {
+ var lines = new List
+ {
+ new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room A", 10, 10, 1),
+ new("CURFEW", "April", 9, "11 p.m. - 12:30 a.m.", "Room B", 10, 10, 2),
+ new("Meeting", "April", 9, "9 a.m. - 10 a.m.", "Room A", 5, 5, 1)
+ };
+
+ var result = GlobalEventDeduplicator.Deduplicate(lines);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.Count, Is.EqualTo(2));
+ var curfew = result.Single(l => l.Name.Equals("CURFEW", StringComparison.OrdinalIgnoreCase));
+ Assert.That(curfew.Location, Is.Empty);
+ Assert.That(result.Any(l => l.Name == "Meeting"), Is.True);
+ });
+ }
+}
diff --git a/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs b/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs
new file mode 100644
index 0000000..bac095f
--- /dev/null
+++ b/Tests/GoogleSheets/ImportTextEmitterRoundTripTests.cs
@@ -0,0 +1,40 @@
+using Core.Entities;
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class ImportTextEmitterRoundTripTests
+{
+ [Test]
+ public void EmittedText_Parses_UnderGeneralSchedule()
+ {
+ var sheets = new List<(string SheetTitle, string SectionHeader, List Lines)>
+ {
+ ("Thursday", "General Schedule", new List
+ {
+ new(
+ Name: "Opening Ceremony",
+ Month: "April",
+ Day: 3,
+ TimeRange: "9 a.m. - 10 a.m.",
+ Location: "Main Hall",
+ SourceRowStart: 1,
+ SourceRowEnd: 1,
+ SourceCol: 1)
+ })
+ };
+
+ var text = ImportTextEmitter.Build(sheets);
+ var result = ParserRoundTripValidator.Validate(text, new List
+ {
+ EventDefinition.GeneralSchedule
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.IsSuccess, Is.True, string.Join("; ", result.Errors));
+ Assert.That(result.TotalParsed, Is.EqualTo(1));
+ });
+ }
+}
diff --git a/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs b/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs
new file mode 100644
index 0000000..23581c9
--- /dev/null
+++ b/Tests/GoogleSheets/OccurrenceDisplayNameReducerTests.cs
@@ -0,0 +1,29 @@
+using Core.Entities;
+using Core.Models;
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class OccurrenceDisplayNameReducerTests
+{
+ private static EventDefinition Cyber() =>
+ new()
+ {
+ Id = 1,
+ Name = "Cybersecurity",
+ ShortName = "Cyber",
+ Eligibility = "",
+ EventFormat = EventFormat.Team
+ };
+
+ [Test]
+ public void Strips_ms_prefix_and_event_name()
+ {
+ var n = OccurrenceDisplayNameReducer.ReduceForSection(
+ "MS Cybersecurity Semifinals Presentations",
+ Cyber(),
+ SchoolLevel.MiddleSchool);
+ Assert.That(n, Is.EqualTo("Semifinals Presentations"));
+ }
+}
diff --git a/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs b/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs
new file mode 100644
index 0000000..2a0b371
--- /dev/null
+++ b/Tests/GoogleSheets/OccurrenceEventMatcherTests.cs
@@ -0,0 +1,39 @@
+using Core.Entities;
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class OccurrenceEventMatcherTests
+{
+ private static EventDefinition E(string name, int id) =>
+ new()
+ {
+ Id = id,
+ Name = name,
+ ShortName = name,
+ Eligibility = "",
+ EventFormat = EventFormat.Team
+ };
+
+ [Test]
+ public void Ms_prefix_matches_Biotechnology()
+ {
+ var events = new List { E("Biotechnology", 1), E("Biotechnology Design", 2) };
+ var ok = OccurrenceEventMatcher.TryMatch("MS Biotechnology Semifinals Interviews April ...", events, out var evt, out var lvl);
+ Assert.Multiple(() =>
+ {
+ Assert.That(ok, Is.True);
+ Assert.That(evt!.Name, Is.EqualTo("Biotechnology"));
+ Assert.That(lvl, Is.EqualTo(Core.Models.SchoolLevel.MiddleSchool));
+ });
+ }
+
+ [Test]
+ public void No_Clear_prefix_goes_unmatched_or_general_bucket()
+ {
+ var events = new List { E("Opening Session", 1) };
+ var ok = OccurrenceEventMatcher.TryMatch("Opening Session April 10 9 a.m.", events, out var evt, out var lvl);
+ Assert.That(ok, Is.False);
+ }
+}
diff --git a/Tests/GoogleSheets/ScheduleGridExtractorTests.cs b/Tests/GoogleSheets/ScheduleGridExtractorTests.cs
new file mode 100644
index 0000000..347dfe8
--- /dev/null
+++ b/Tests/GoogleSheets/ScheduleGridExtractorTests.cs
@@ -0,0 +1,42 @@
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class ScheduleGridExtractorTests
+{
+ [Test]
+ public void Extract_SingleBlock_OneOccurrenceWithEndTimeFromNextSlot()
+ {
+ string?[][] values =
+ [
+ [null, "Main Hall"],
+ ["9:00 a.m.", "Opening Ceremony"],
+ ["10:00 a.m.", null]
+ ];
+ string?[][] bg =
+ [
+ [null, null],
+ [null, null],
+ [null, null]
+ ];
+ var grid = new GridSheetModel
+ {
+ SheetTitle = "Day1",
+ Values = values,
+ BackgroundKeys = bg
+ };
+ var warnings = new List();
+ var lines = ScheduleGridExtractor.Extract(grid, "April", 2, warnings);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(lines, Has.Count.EqualTo(1));
+ Assert.That(lines[0].Name, Is.EqualTo("Opening Ceremony"));
+ Assert.That(lines[0].Month, Is.EqualTo("April"));
+ Assert.That(lines[0].Day, Is.EqualTo(2));
+ Assert.That(lines[0].Location, Is.EqualTo("Main Hall"));
+ Assert.That(lines[0].TimeRange, Is.EqualTo("9 a.m. - 10 a.m."));
+ });
+ }
+}
diff --git a/Tests/GoogleSheets/TextNormalizationTests.cs b/Tests/GoogleSheets/TextNormalizationTests.cs
new file mode 100644
index 0000000..b65db35
--- /dev/null
+++ b/Tests/GoogleSheets/TextNormalizationTests.cs
@@ -0,0 +1,14 @@
+using GoogleSheetsScheduleImport;
+
+namespace Tests.GoogleSheets;
+
+[TestFixture]
+public class TextNormalizationTests
+{
+ [Test]
+ public void Collapses_line_separator_and_newlines()
+ {
+ var s = "Banquet Room\u2028E";
+ Assert.That(TextNormalization.ForSheetCell(s), Is.EqualTo("Banquet Room E"));
+ }
+}
diff --git a/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs b/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs
index 1f0d563..e0d8434 100644
--- a/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs
+++ b/Tests/Parsers/EventOccurrenceParserIssues_Tests.cs
@@ -226,9 +226,10 @@ public class EventOccurrenceParserIssues_Tests
// Verify successful occurrence is still parsed (if any valid lines exist)
// The "Valid Event" line should parse successfully despite other issues
var validEvent = events.First(e => e.Name == "Valid Event");
- if (result.Occurrences.ContainsKey(validEvent))
+ var validGroup = new EventOccurrenceParseGroup(validEvent, null);
+ if (result.Occurrences.ContainsKey(validGroup))
{
- Assert.That(result.Occurrences[validEvent], Has.Count.EqualTo(1));
+ Assert.That(result.Occurrences[validGroup], Has.Count.EqualTo(1));
}
// Note: It's acceptable if the valid event doesn't parse if there are critical issues,
// but typically it should still parse since it's a valid line
@@ -350,11 +351,12 @@ public class EventOccurrenceParserIssues_Tests
// Verify occurrences were parsed correctly (if they were parsed)
var testEvent = events.First(e => e.Name == "Test Event");
- if (result.Occurrences.ContainsKey(testEvent))
+ var testGroup = new EventOccurrenceParseGroup(testEvent, null);
+ if (result.Occurrences.ContainsKey(testGroup))
{
- Assert.That(result.Occurrences[testEvent], Has.Count.EqualTo(1));
+ Assert.That(result.Occurrences[testGroup], Has.Count.EqualTo(1));
- var occurrence = result.Occurrences[testEvent].First();
+ var occurrence = result.Occurrences[testGroup].First();
Assert.That(occurrence.Name, Is.EqualTo("Test Event"));
Assert.That(occurrence.Location, Is.EqualTo("Room 101"));
}
@@ -362,8 +364,8 @@ public class EventOccurrenceParserIssues_Tests
// The important thing is that the parser doesn't crash and processes the input
// Verify locations are extracted correctly (pattern matching is no longer used)
- var testEventOccurrence = result.Occurrences.ContainsKey(testEvent)
- ? result.Occurrences[testEvent].FirstOrDefault()
+ var testEventOccurrence = result.Occurrences.ContainsKey(testGroup)
+ ? result.Occurrences[testGroup].FirstOrDefault()
: null;
if (testEventOccurrence != null)
{
@@ -412,10 +414,11 @@ public class EventOccurrenceParserIssues_Tests
// Check that the location is correctly extracted (should be "Mtg. Room 14", not "– NOON Mtg. Room 14")
// General Schedule section uses EventDefinition.GeneralSchedule
- Assert.That(result.Occurrences, Does.ContainKey(EventDefinition.GeneralSchedule),
- $"Result should contain GeneralSchedule. Found events: {string.Join(", ", result.Occurrences.Keys.Select(e => e.Name))}");
+ var gsGroup = new EventOccurrenceParseGroup(EventDefinition.GeneralSchedule, null);
+ Assert.That(result.Occurrences, Does.ContainKey(gsGroup),
+ $"Result should contain GeneralSchedule. Found groups: {string.Join(", ", result.Occurrences.Keys.Select(k => k.EventDefinition.Name))}");
- var occurrences = result.Occurrences[EventDefinition.GeneralSchedule];
+ var occurrences = result.Occurrences[gsGroup];
Assert.That(occurrences, Has.Count.GreaterThan(0),
"Should have at least one occurrence in General Schedule");
@@ -501,7 +504,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
- Assert.That(result.Occurrences.ContainsKey(events[0]));
+ Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -528,7 +531,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
- Assert.That(result.Occurrences.ContainsKey(events[0]));
+ Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -554,7 +557,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
- Assert.That(result.Occurrences.ContainsKey(events[0]));
+ Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
@@ -580,7 +583,7 @@ public class EventOccurrenceParserIssues_Tests
// Assert
Assert.That(result.Issues, Has.Count.EqualTo(0));
Assert.That(result.Occurrences.Values.Sum(list => list.Count), Is.EqualTo(1));
- Assert.That(result.Occurrences.ContainsKey(events[0]));
+ Assert.That(result.Occurrences.ContainsKey(new EventOccurrenceParseGroup(events[0], SchoolLevel.MiddleSchool)));
}
finally
{
diff --git a/Tests/Parsers/EventOccurrenceParser_Tests.cs b/Tests/Parsers/EventOccurrenceParser_Tests.cs
index a524418..2c7d122 100644
--- a/Tests/Parsers/EventOccurrenceParser_Tests.cs
+++ b/Tests/Parsers/EventOccurrenceParser_Tests.cs
@@ -107,17 +107,24 @@ public class EventOccurrenceParser_Tests
///
/// Writes special events summary to console.
///
- private static void WriteSpecialEventsSummary(IDictionary> occurrences)
+ private static void WriteSpecialEventsSummary(IDictionary> occurrences)
{
Console.WriteLine($"\n--- Special Events Found ---");
- if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var gs))
- Console.WriteLine($" GeneralSchedule: {gs.Count} occurrences");
- if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var mtc))
- Console.WriteLine($" MeetTheCandidates: {mtc.Count} occurrences");
- if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var com))
- Console.WriteLine($" ChapterOfficerMeeting: {com.Count} occurrences");
- if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var vdm))
- Console.WriteLine($" VotingDelegateMeeting: {vdm.Count} occurrences");
+ static int CountFor(IDictionary> occ, EventDefinition def) =>
+ occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).Sum(kvp => kvp.Value.Count);
+
+ var gs = CountFor(occurrences, EventDefinition.GeneralSchedule);
+ if (gs > 0)
+ Console.WriteLine($" GeneralSchedule: {gs} occurrences");
+ var mtc = CountFor(occurrences, EventDefinition.MeetTheCandidates);
+ if (mtc > 0)
+ Console.WriteLine($" MeetTheCandidates: {mtc} occurrences");
+ var com = CountFor(occurrences, EventDefinition.ChapterOfficerMeeting);
+ if (com > 0)
+ Console.WriteLine($" ChapterOfficerMeeting: {com} occurrences");
+ var vdm = CountFor(occurrences, EventDefinition.VotingDelegateMeeting);
+ if (vdm > 0)
+ Console.WriteLine($" VotingDelegateMeeting: {vdm} occurrences");
}
///
@@ -237,43 +244,26 @@ public class EventOccurrenceParser_Tests
///
/// Writes special events to console output.
///
- private static void WriteSpecialEvents(IDictionary> occurrences)
+ private static void WriteSpecialEvents(IDictionary> occurrences)
{
+ static List ListFor(IDictionary> occ, EventDefinition def) =>
+ occ.Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, def)).SelectMany(kvp => kvp.Value).ToList();
+
Console.WriteLine("General Schedule");
- if (occurrences.TryGetValue(EventDefinition.GeneralSchedule, out var generalSchedule))
- {
- foreach (var eo in generalSchedule.OrderBy(occurrence => occurrence.StartTime))
- {
- Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
- }
- }
+ foreach (var eo in ListFor(occurrences, EventDefinition.GeneralSchedule).OrderBy(o => o.StartTime))
+ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Meet the Candidates");
- if (occurrences.TryGetValue(EventDefinition.MeetTheCandidates, out var meetTheCandidates))
- {
- foreach (var eo in meetTheCandidates)
- {
- Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
- }
- }
+ foreach (var eo in ListFor(occurrences, EventDefinition.MeetTheCandidates))
+ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Chapter Officer Meeting");
- if (occurrences.TryGetValue(EventDefinition.ChapterOfficerMeeting, out var chapterOfficerMeeting))
- {
- foreach (var eo in chapterOfficerMeeting)
- {
- Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
- }
- }
+ foreach (var eo in ListFor(occurrences, EventDefinition.ChapterOfficerMeeting))
+ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
Console.WriteLine("Voting Delegate Meeting");
- if (occurrences.TryGetValue(EventDefinition.VotingDelegateMeeting, out var votingDelegateMeeting))
- {
- foreach (var eo in votingDelegateMeeting)
- {
- Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
- }
- }
+ foreach (var eo in ListFor(occurrences, EventDefinition.VotingDelegateMeeting))
+ Console.WriteLine($"\t{eo.StartTime.DayOfWeek} {eo.Time}, {eo.Name}, {eo.Location}");
}
#endregion
@@ -290,7 +280,11 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
- if (!dictionary.TryGetValue(@event, out var eventOccurrences))
+ var eventOccurrences = dictionary
+ .Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
+ .SelectMany(kvp => kvp.Value)
+ .ToList();
+ if (eventOccurrences.Count == 0)
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -320,7 +314,11 @@ public class EventOccurrenceParser_Tests
{
Console.WriteLine($"{@event.Name}");
- if (!dictionary.TryGetValue(@event, out var eventOccurrences))
+ var eventOccurrences = dictionary
+ .Where(kvp => ReferenceEquals(kvp.Key.EventDefinition, @event))
+ .SelectMany(kvp => kvp.Value)
+ .ToList();
+ if (eventOccurrences.Count == 0)
{
Console.WriteLine($"!!! eventDefinition not found {@event.Name}");
continue;
@@ -447,13 +445,13 @@ public class EventOccurrenceParser_Tests
// Total expected MS occurrences: 16
var msEventCount = 0;
- if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var csOccurrences))
+ if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var csOccurrences))
msEventCount += csOccurrences.Count;
- if (coding != null && result.Occurrences.TryGetValue(coding, out var codingOccurrences))
+ if (coding != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(coding, SchoolLevel.MiddleSchool), out var codingOccurrences))
msEventCount += codingOccurrences.Count;
- if (communityServiceVideo != null && result.Occurrences.TryGetValue(communityServiceVideo, out var csvOccurrences))
+ if (communityServiceVideo != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(communityServiceVideo, SchoolLevel.MiddleSchool), out var csvOccurrences))
msEventCount += csvOccurrences.Count;
- if (constructionChallenge != null && result.Occurrences.TryGetValue(constructionChallenge, out var ccOccurrences))
+ if (constructionChallenge != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(constructionChallenge, SchoolLevel.MiddleSchool), out var ccOccurrences))
msEventCount += ccOccurrences.Count;
// When no school level is set, HS events should be processed (not skipped)
@@ -512,7 +510,7 @@ public class EventOccurrenceParser_Tests
Assert.That(lateTimeOccurrence, Is.Not.Null, "Should parse 11:59 p.m. time format");
// Verify specific locations are parsed
- if (childrensStories != null && result.Occurrences.TryGetValue(childrensStories, out var childrensStoriesOccurrences))
+ if (childrensStories != null && result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(childrensStories, SchoolLevel.MiddleSchool), out var childrensStoriesOccurrences))
{
var locations = childrensStoriesOccurrences
.Select(eo => eo.Location)
@@ -563,20 +561,17 @@ public class EventOccurrenceParser_Tests
"HS section header should NOT be in SkippedSectionHeaders when no school level is set");
// With no school level filtering, both MS and HS events are processed
- if (result.Occurrences.TryGetValue(biotechnology, out var allOccurrences))
- {
- // With no school level set, we process all occurrences (both MS and HS)
- // Expected: 2 MS occurrences (Submit Entry, Judging) + 3 HS occurrences (Submit Entry, Judging, Pick-up) = 5 total
- Assert.That(allOccurrences, Has.Count.EqualTo(5),
- "Should have all 5 occurrences (2 MS + 3 HS) when no school level is set. " +
- $"Found {allOccurrences.Count} occurrences total.");
-
- // Verify all expected occurrence names are present
- var occurrenceNames = allOccurrences.Select(o => o.Name).ToList();
- Assert.That(occurrenceNames, Does.Contain("Submit Entry"), "Should have Submit Entry occurrences");
- Assert.That(occurrenceNames, Does.Contain("Judging"), "Should have Judging occurrences");
- Assert.That(occurrenceNames, Does.Contain("Pick-up"), "Should have Pick-up occurrence");
- }
+ result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.MiddleSchool), out var msOccurrences);
+ result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(biotechnology!, SchoolLevel.HighSchool), out var hsOccurrences);
+ msOccurrences ??= [];
+ hsOccurrences ??= [];
+ Assert.That(msOccurrences, Has.Count.EqualTo(2), "MS section should have 2 occurrences");
+ Assert.That(hsOccurrences, Has.Count.EqualTo(3), "HS section should have 3 occurrences");
+
+ var allNames = msOccurrences.Concat(hsOccurrences).Select(o => o.Name).ToList();
+ Assert.That(allNames, Does.Contain("Submit Entry"));
+ Assert.That(allNames, Does.Contain("Judging"));
+ Assert.That(allNames, Does.Contain("Pick-up"));
Assert.Pass("All events processed when no school level is set");
}
@@ -585,4 +580,34 @@ public class EventOccurrenceParser_Tests
EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
}
}
+
+ [Test]
+ public void Parse_SameEvent_HS_then_MS_ProducesTwoGroups()
+ {
+ var testContent = "Prepared Speech - HS\n" +
+ "Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4\n" +
+ "Prepared Speech - MS\n" +
+ "Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10";
+ var tempFile = EventOccurrenceParserTestHelpers.CreateTempFile(testContent);
+ var events = new[] { EventOccurrenceParserTestHelpers.CreateTestEvent("Prepared Speech") };
+ var parser = new EventOccurrenceParser(tempFile, events);
+
+ try
+ {
+ var result = parser.Parse();
+ Assert.That(result.Issues, Has.Count.EqualTo(0));
+
+ var def = events[0];
+ Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.HighSchool), out var hsList), Is.True);
+ Assert.That(result.Occurrences.TryGetValue(new EventOccurrenceParseGroup(def, SchoolLevel.MiddleSchool), out var msList), Is.True);
+ Assert.That(hsList, Has.Count.EqualTo(1));
+ Assert.That(msList, Has.Count.EqualTo(1));
+ Assert.That(hsList![0].Name, Does.Contain("Extemporaneous"));
+ Assert.That(msList![0].Name, Does.Contain("Prelims"));
+ }
+ finally
+ {
+ EventOccurrenceParserTestHelpers.CleanupTempFile(tempFile);
+ }
+ }
}
\ No newline at end of file
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
index 705cb6a..3e08bb4 100644
--- a/Tests/Tests.csproj
+++ b/Tests/Tests.csproj
@@ -1,4 +1,4 @@
-
+
net9.0
enable
@@ -18,6 +18,7 @@
+
diff --git a/WebApp/Components/Features/Calendar/Import.razor b/WebApp/Components/Features/Calendar/Import.razor
index 9163457..7f5873a 100644
--- a/WebApp/Components/Features/Calendar/Import.razor
+++ b/WebApp/Components/Features/Calendar/Import.razor
@@ -121,7 +121,7 @@
@if (_parseResult.IsSuccess && _parseResult.TotalParsed > 0)
{
- Successfully parsed @_parseResult.TotalParsed occurrence(s) from @_parseResult.Occurrences.Count event definition(s)
+ Successfully parsed @_parseResult.TotalParsed occurrence(s) in @_parseResult.Occurrences.Count group(s)
@if (_parseResult.SkippedEventCount > 0)
{
(Skipped @_parseResult.SkippedEventCount event occurrence(s) from other school level)
@@ -201,9 +201,11 @@
{
Occurrences by Event:
- @foreach (var kvp in _parseResult.Occurrences.OrderBy(x => GetEventName(x.Key)))
+ @foreach (var kvp in _parseResult.Occurrences
+ .OrderBy(x => GetEventName(x.Key.EventDefinition))
+ .ThenBy(x => x.Key.SectionSchoolLevel switch { SchoolLevel.MiddleSchool => 0, SchoolLevel.HighSchool => 1, _ => 2 }))
{
-
+
Name
@@ -395,6 +397,17 @@
return eventDefinition.Name;
}
+ private string GetOccurrenceGroupTitle(EventOccurrenceParseGroup group)
+ {
+ var title = GetEventName(group.EventDefinition);
+ return group.SectionSchoolLevel switch
+ {
+ SchoolLevel.MiddleSchool => $"{title} (MS)",
+ SchoolLevel.HighSchool => $"{title} (HS)",
+ _ => title
+ };
+ }
+
private Color GetIssueTypeColor(ParsingIssueType issueType)
{
return issueType switch
diff --git a/docs/instructions/google-sheets-schedule-import.md b/docs/instructions/google-sheets-schedule-import.md
new file mode 100644
index 0000000..24eb48f
--- /dev/null
+++ b/docs/instructions/google-sheets-schedule-import.md
@@ -0,0 +1,78 @@
+---
+created: 2026-03-30
+description: Extract a multi-day Google Sheets schedule grid into event-occurrence import text for the web app.
+---
+
+# Import event schedule from Google Sheets
+
+This repo includes a console tool that reads a **public** Google Sheet (with grid data and cell formatting), interprets each tab as one calendar day, and writes **plain text** in the same format as the legacy PDF-derived files consumed by **Calendar → Import Event Occurrences** (`/calendar/event-occurrences/import`).
+
+## Prerequisites
+
+1. **Google Cloud API key** with the **Google Sheets API** enabled.
+2. The spreadsheet must be readable with that key (typically **File → Share → Anyone with the link** *Viewer*, or share explicitly as needed for your key type).
+3. A **mapping JSON** file that lists each tab title and the calendar **month/day** for that tab (see sample under `docs/notes/`).
+
+## Expected grid layout
+
+- **Row 1:** Column `B` onward = **location** names (column `A` may be blank or a label).
+- **Column A (from row 2 down):** **Start time** for each row (e.g. `9:00 AM`, `9:00 a.m.`). The tool infers **slot length** from consecutive times and uses the next row’s time as the **end** of a block when cells span multiple rows.
+- **Data cells:** Event title (required for a block) and/or **non-white background** (treated as part of the same block as adjacent cells with the same text + color). **Merged cells** are expanded so the anchor value applies to the whole merge.
+
+## Default section header
+
+Output uses **`General Schedule`** as the section header unless you override it in mapping. That matches grids where each cell is its own schedule item (conference-style). If every line should belong to a specific competition event, set `sectionHeader` for that tab to something like `Biotechnology - MS` (must fuzzy-match an event in your database when importing).
+
+Under **General Schedule**, the parser assigns occurrences to the **General Schedule** event type in the app (see `EventDefinitionResolver` / `EventDefinition.GeneralSchedule`).
+
+## Grouping into competition events (PDF-style sections)
+
+If you set **`eventDefinitionsCsv`** in the mapping JSON (or pass **`--events-csv`**), the tool loads event names from the CSV **`Event`** column and, when possible, rewrites output like the state schedule text:
+
+- Section header: **`{Event Name} - MS`** or **`{Event Name} - HS`**
+- Occurrence lines under that section use **activity text only** (the tool drops the leading `MS`/`HS` and the repeated event name so lines read like the PDF, e.g. `On-Site Preliminary Exam …` not `MS Cybersecurity On-Site …`).
+- Only rows whose cell text has a **leading** `MS `, `HS `, `MS/`, or `HS/` prefix (after normalization) are grouped; the remainder is fuzzy-matched to an event (see `OccurrenceEventMatcher`).
+- **`MS/HS …`** combined rows → **General Schedule** (no single section).
+- Unmatched lines (opening session, meet-the-candidates, help desk, etc.) stay under a final **`General Schedule`** block.
+
+Use **`--no-group-by-event`** to force the old “one General Schedule per sheet” layout.
+
+## Site-wide rows (e.g. CURFEW)
+
+If the same label appears across **every location column** for the same time (typical for **CURFEW**), the tool emits **one line per date and time** with **no location**. Built-in: `CURFEW` (case-insensitive). Optional mapping field **`siteWideEventNames`** adds more titles (e.g. `["Fire drill"]`).
+
+## Run the tool
+
+From the repository root:
+
+```powershell
+$env:GOOGLE_SHEETS_API_KEY = ""
+
+dotnet run --project tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj -- `
+ --sheet-url "https://docs.google.com/spreadsheets/d//edit" `
+ --mapping path/to/mapping.json `
+ --output path/to/event-times.txt
+```
+
+Optional flags:
+
+| Flag | Purpose |
+|------|--------|
+| `--year 2026` | Year for date validation (also in mapping file). |
+| `--tabs "Day 1,Day 2"` | Only these tab titles (exact match). |
+| `--events-csv path\to\Event Definitions.csv` | Load event names for stricter parser validation (`Event` column). |
+| `--strict` | Exit code `1` if the built-in parser reports errors or parses zero occurrences. |
+
+The tool always runs a **parser round-trip** on the generated text and prints errors/issues to the console.
+
+## Import into the app
+
+1. Open **Import Event Occurrences** in the web app.
+2. Paste the contents of the generated `.txt` file.
+3. **Parse**, review results, then **Save to Database** as usual.
+
+## Troubleshooting
+
+- **403 / access denied:** Confirm the sheet is visible to the API key and Sheets API is enabled.
+- **Wrong durations:** Ensure time labels in column A are consistent; the tool infers slot length from the most common delta between consecutive rows.
+- **Parser issues on import:** Use `--events-csv` pointing at your chapter’s event definitions export; fix `sectionHeader` in mapping if items should sit under a specific `Event Name - MS/HS` section.
diff --git a/docs/notes/event-times-2026.txt b/docs/notes/event-times-2026.txt
new file mode 100644
index 0000000..2e0cca0
--- /dev/null
+++ b/docs/notes/event-times-2026.txt
@@ -0,0 +1,163 @@
+# THURSDAY 4/9
+General Schedule
+Registration April 9 3 p.m. - 7 p.m. CCC Lobby (outside of Banquet Rooms)
+FCCLA/TSA Store April 9 3 p.m. - 7 p.m. Meeting Room 1
+MS/HS Prompt Releases (Virtual via App) April 9 6 p.m. - 9 p.m. Banquet Room E
+MS/HS Event Turn-In April 9 6 p.m. - 9 p.m. Banquet Room G
+HS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room H
+MS Testing Room April 9 6 p.m. - 9 p.m. Banquet Room I
+MEMCO Meeting April 9 6 p.m. - 7 p.m. Banquet Room J
+MS/HS Time Sign-Ups (Virtual via App) April 9 6:30 p.m. - 7:30 p.m. Banquet Room F
+Coordinators Meeting April 9 7 p.m. - 8 p.m. Banquet Room J
+SOT Candidates Meeting April 9 8 p.m. - 9:30 p.m. Banquet Room J
+CURFEW April 9 11 p.m. - 12:30 a.m.
+
+# FRIDAY 4/10
+CAD foundations - MS
+HS 2D CAD Architecture/ HS 3D CAD Engineering On-Site Challenge April 10 10:30 a.m. - 4:30 p.m. Banquet Room G
+Career Prep - MS
+Semifinals Interviews April 10 1 p.m. - 3 p.m. Meeting Room 10
+Challenging Technology Issues - HS
+Debating Technological Issues Prelims Pre-Debate Meeting April 10 10 a.m. - 10:30 a.m. Meeting Room 6
+Debating Technological Issues Prelims Presentation Room (Heat 1) April 10 1 p.m. - 5 p.m. Meeting Room 4
+Debating Technological Issues Prelims Presentation Room (Heat 2) April 10 1 p.m. - 5 p.m. Meeting Room 5
+Challenging Technology Issues - MS
+Leadership Strategies Holding Room April 10 10:30 a.m. - 4 p.m. Meeting Room 7
+Prelims Presentation Room April 10 10:30 a.m. - 1 p.m. Meeting Room 8
+Coding - HS
+Extemporaneous Speech/Debating Technological Issues Holding Room Room April 10 10 a.m. - 5 p.m. Meeting Room 3
+Data Science & Analytics - HS
+"Quarterfinals" Presentation April 10 12:30 p.m. - 5 p.m. Meeting Room 9
+Digital Photography - MS
+Semifinals Challenge April 10 10 a.m. - 1 p.m. Meeting Room 19
+Semifinals Interviews April 10 3 p.m. - 4 p.m. Meeting Room 16
+Electrical Applications - MS
+Semifinals Challenge April 10 2 p.m. - 3:30 p.m. Meeting Room 19
+Forensic Technology - HS
+Future Technology and Engineering Teacher Semifinals Presentations April 10 10 a.m. - 1 p.m. Meeting Room 18
+Leadership Strategies - MS
+Prelims Presentation Room April 10 1:30 p.m. - 4 p.m. Meeting Room 8
+Mass Production - HS
+Digital Video Production Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 17
+Music Production Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 17
+Prepared Speech - HS
+Extemporaneous Speech Presentation Room (Heat 1) April 10 10 a.m. - 12:30 p.m. Meeting Room 4
+Extemporaneous Speech Presentation Room (Heat 2) April 10 10 a.m. - 12:30 p.m. Meeting Room 5
+Prepared Presentation Prelims Presentation April 10 10 a.m. - 3:30 p.m. Meeting Room 21
+Prepared Speech - MS
+Prelims Presentation Room April 10 10:30 a.m. - 12:30 p.m. Meeting Room 10
+System Control Technology - MS
+HS System Control Technology On-Site Challenge April 10 10 a.m. - 2 p.m. Banquet Room F
+Tech Bowl - HS
+Photographic Tech Semifinals Prompt Release April 10 11 a.m. - 12 p.m. Meeting Room 6
+Video Game Design - MS
+Semifinals Interviews April 10 12:30 p.m. - 2:30 p.m. Meeting Room 16
+Website Design - MS
+Semifinals Interviews April 10 10 a.m. - 12 p.m. Meeting Room 16
+General Schedule
+FCCLA/TSA Store April 10 8 a.m. - 4 p.m. Meeting Room 1
+MS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
+HS Static Event Turn-In April 10 8 a.m. - 9 a.m. Exhibit Hall C
+Opening Session April 10 9 a.m. - 10 a.m. Exhibit Hall A
+TECHSPO April 10 10 a.m. - 4 p.m. Main Hallway
+Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
+Help Desk April 10 10 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
+Advisor Meeting April 10 10 a.m. - 11 a.m. Banquet Room E
+HS Software Development Semifinals Presentations April 10 10 a.m. - 12 p.m. Meeting Room 9
+MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
+MS/HS Open Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall B
+MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
+MS/HS Closed Viewing Events April 10 10 a.m. - 5 p.m. Exhibit Hall C
+Workshop April 10 11:30 a.m. - 1:30 p.m. Banquet Room E
+HS STEM Mass Media Semifinals Press Conference April 10 12:30 p.m. - 3:30 p.m. Meeting Room 6
+TSA Meet the Candidates April 10 1:30 p.m. - 2:30 p.m. Main Hallway
+HS Animatronics Presentations/ Interviews April 10 1:30 p.m. - 4:30 p.m. Meeting Room 18
+Workshop April 10 2:30 p.m. - 4:30 p.m. Banquet Room E
+HS VR Semifinals Interviews April 10 2:30 p.m. - 4:30 p.m. Banquet Room F
+Community Service Video Seminfinal Interviews April 10 3 p.m. - 4 p.m. Meeting Room 17
+TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
+TSA Meet the Candidates April 10 4:30 p.m. - 5:30 p.m. Main Hallway
+(no title) April 10 4:30 p.m. - 5:30 p.m. Banquet Room E
+Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
+Dance/Game Night April 10 8 p.m. - 9:30 p.m. Exhibit Hall D
+(no title) April 10 10 p.m. - 11 p.m. Banquet Room E
+CURFEW April 10 11 p.m. - 12:30 a.m.
+
+# SATURDAY 4/11
+Challenging Technology Issues - HS
+Debating Technological Issues Semifinals Pre-Debate Meeting April 11 9:30 a.m. - 10 a.m. Meeting Room 7
+Debating Technological Issues Semifinals Presentation Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 8
+Challenging Technology Issues - MS
+Semifinals Holding Room April 11 9:30 a.m. - 11 a.m. Meeting Room 9
+Semifinals Presentation Room April 11 9:30 a.m. - 11 a.m. Meeting Room 10
+Chapter Team - HS
+Semifinals Presentation April 11 1:30 p.m. - 3:30 p.m. Meeting Room 16
+Chapter Team - MS
+Semifinals Presentation April 11 11:30 a.m. - 1 p.m. Meeting Room 16
+Children's Stories - HS
+Semifinals Interviews April 11 9:30 a.m. - 12:30 p.m. Meeting Room 18
+Children's Stories - MS
+Semifinals Interviews April 11 1 p.m. - 4 p.m. Meeting Room 18
+Coding - HS
+Semifinals Challenge April 11 9:30 a.m. - 12 p.m. Meeting Room 19
+Debating Technological Issues Semifinals Holding Room April 11 10:30 a.m. - 12:30 p.m. Meeting Room 7
+Extemporaneous Speech Semifinals Holding Room April 11 1 p.m. - 2:30 p.m. Meeting Room 7
+Coding - MS
+Semifinals Challenge April 11 12:30 p.m. - 3 p.m. Meeting Room 19
+Cybersecurity - MS
+Seminals Presentations April 11 3:30 p.m. - 4:30 p.m. Meeting Room 19
+Data Science & Analytics - HS
+Forensic Science Written Analysis Room April 11 9:30 a.m. - 2:30 p.m. Meeting Room 5
+Semifinals Challenge April 11 3 p.m. - 5 p.m. Meeting Room 6
+Data Science & Analytics - MS
+Data Science and Analytics Presentations April 11 3 p.m. - 4:30 p.m. Meeting Room 4
+Data Science and Analytics Preparation Room April 11 3 p.m. - 4:30 p.m. Meeting Room 5
+Forensic Technology - HS
+Photographic Technology Semifinals Interviews April 11 3 p.m. - 5 p.m. Meeting Room 7
+Forensic Technology - MS
+Semifinals Presentation Room April 11 1:30 p.m. - 4:30 p.m. Meeting Room 9
+Leadership Strategies - MS
+Semifinals Holding Room April 11 11:30 a.m. - 1 p.m. Meeting Room 9
+Semifinals Presentation Room April 11 11:30 a.m. - 1 p.m. Meeting Room 10
+Medical Technology - HS
+Fashion Design and Technology Semifinals Presentation/ Interviews April 11 2:30 p.m. - 4:30 p.m. Meeting Room 17
+Prepared Speech - HS
+Extemporaneous Speech Semifinals Presentation Room April 11 1 p.m. - 2:30 p.m. Meeting Room 8
+Prepared Presentation Semifinals Presentation Room April 11 3 p.m. - 5 p.m. Meeting Room 8
+Prepared Speech - MS
+Semifinals Presentation Room April 11 1:30 p.m. - 3 p.m. Meeting Room 10
+Promotional Marketing - HS
+Promotional Design Semifinals Challenge April 11 11:30 a.m. - 2:30 p.m. Meeting Room 6
+Promotional Marketing - MS
+Semifinals Challenge April 11 9:30 a.m. - 11 a.m. Meeting Room 6
+STEM Animation - MS
+Semifinals Interviews April 11 9:30 a.m. - 11 a.m. Meeting Room 16
+Tech Bowl - MS
+HS Technology Bowl Semifinals Bracket Play April 11 9 a.m. - 5 p.m. Banquet Rooms G
+HS Technology Bowl Semifinals Holding Room April 11 9 a.m. - 5 p.m. Banquet Rooms H
+Video Game Design - HS
+Semifinals Interviews April 11 12 p.m. - 2 p.m. Meeting Room 17
+Website Design - HS
+Webmaster Semifinals Interviews April 11 9:30 a.m. - 11:30 a.m. Meeting Room 17
+General Schedule
+Voting Delegate Meeting April 11 8 a.m. - 9 a.m. Banquet Room F
+Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
+Help Desk April 11 9 a.m. - 5 p.m. CCC Lobby (outside of Banquet Rooms)
+Tennessee TSA Store April 11 9 a.m. - 4 p.m. Meeting Room 1
+HS Forensic Science CSI April 11 9:30 a.m. - 2:30 p.m. Meeting Room 4
+MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
+MS/HS Open Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall B
+MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
+MS/HS Closed Viewing Events April 11 9:30 a.m. - 4:30 p.m. Exhibit Hall C
+TECHSPO April 11 10 a.m. - 4 p.m. Main Hallway
+MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
+MS/HS Static Event Pick-Up April 11 5 p.m. - 5:30 p.m. Exhibit Hall C
+General Session 2: Business Meeting April 11 5:30 p.m. - 6:30 p.m. Exhibit Hall A
+Senior Social April 11 8:30 p.m. - 9:30 p.m. Banquet Room F
+Chapter Officer Meeting April 11 8:30 p.m. - 9 p.m. Banquet Rooms G
+CURFEW April 11 11 p.m. - 12:30 a.m.
+
+# SUNDAY 4/12
+General Schedule
+General Session 3: Awards Ceremony April 12 8:30 a.m. - 12 p.m. Exhibit Hall A
+New SOT Pictures April 12 12 p.m. - 12:30 p.m. Exhibit Hall A
\ No newline at end of file
diff --git a/docs/notes/google-sheets-import-sample-mapping.json b/docs/notes/google-sheets-import-sample-mapping.json
new file mode 100644
index 0000000..56b4d6b
--- /dev/null
+++ b/docs/notes/google-sheets-import-sample-mapping.json
@@ -0,0 +1,23 @@
+{
+ "year": 2026,
+ "defaultSectionHeader": "General Schedule",
+ "sheets": [
+ {
+ "title": "Wednesday",
+ "month": "April",
+ "day": 2,
+ "sectionHeader": null
+ },
+ {
+ "title": "Thursday",
+ "month": "April",
+ "day": 3
+ },
+ {
+ "title": "Friday",
+ "month": "April",
+ "day": 4,
+ "sectionHeader": "General Schedule"
+ }
+ ]
+}
diff --git a/docs/notes/tnslc-2026-mapping.json b/docs/notes/tnslc-2026-mapping.json
new file mode 100644
index 0000000..04c0e7b
--- /dev/null
+++ b/docs/notes/tnslc-2026-mapping.json
@@ -0,0 +1,27 @@
+{
+ "year": 2026,
+ "eventDefinitionsCsv": "../../Tests/Parsers/TestInput/2024 Event Definitions.csv",
+ "defaultSectionHeader": "General Schedule",
+ "sheets": [
+ {
+ "title": "THURSDAY 4/9",
+ "month": "April",
+ "day": 9
+ },
+ {
+ "title": "FRIDAY 4/10",
+ "month": "April",
+ "day": 10
+ },
+ {
+ "title": "SATURDAY 4/11",
+ "month": "April",
+ "day": 11
+ },
+ {
+ "title": "SUNDAY 4/12",
+ "month": "April",
+ "day": 12
+ }
+ ]
+}
diff --git a/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs b/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs
new file mode 100644
index 0000000..0df18bf
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/EventDefinitionsCsvLoader.cs
@@ -0,0 +1,82 @@
+using System.Text;
+using Core.Entities;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Loads minimal rows from Tests-style CSV (column "Event").
+///
+public static class EventDefinitionsCsvLoader
+{
+ public static List Load(string path)
+ {
+ var list = new List();
+ var lines = File.ReadAllLines(path);
+ if (lines.Length < 2)
+ return list;
+
+ var header = ParseCsvLine(lines[0]);
+ var eventIdx = header.FindIndex(h => h.Equals("Event", StringComparison.OrdinalIgnoreCase));
+ if (eventIdx < 0)
+ throw new InvalidOperationException($"CSV '{path}' must contain an 'Event' column header.");
+
+ for (var i = 1; i < lines.Length; i++)
+ {
+ if (string.IsNullOrWhiteSpace(lines[i]))
+ continue;
+ var cols = ParseCsvLine(lines[i]);
+ if (eventIdx >= cols.Count)
+ continue;
+ var name = cols[eventIdx].Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ continue;
+ list.Add(new EventDefinition
+ {
+ Id = i,
+ Name = name,
+ ShortName = name,
+ Eligibility = "",
+ EventFormat = EventFormat.Team
+ });
+ }
+
+ return list;
+ }
+
+ private static List ParseCsvLine(string line)
+ {
+ var result = new List();
+ var cur = new StringBuilder();
+ var inQuotes = false;
+ for (var i = 0; i < line.Length; i++)
+ {
+ var ch = line[i];
+ if (inQuotes)
+ {
+ if (ch == '"')
+ {
+ if (i + 1 < line.Length && line[i + 1] == '"')
+ {
+ cur.Append('"');
+ i++;
+ }
+ else inQuotes = false;
+ }
+ else cur.Append(ch);
+ }
+ else
+ {
+ if (ch == '"') inQuotes = true;
+ else if (ch == ',')
+ {
+ result.Add(cur.ToString());
+ cur.Clear();
+ }
+ else cur.Append(ch);
+ }
+ }
+
+ result.Add(cur.ToString());
+ return result;
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs b/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs
new file mode 100644
index 0000000..37a1841
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/GlobalEventDeduplicator.cs
@@ -0,0 +1,54 @@
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Collapses duplicate site-wide rows (same event name, date, time) that appear in every location column
+/// (e.g. CURFEW shaded across all rooms) into a single line with no location — still under
+/// General Schedule, which the parser maps to .
+///
+public static class GlobalEventDeduplicator
+{
+ private static readonly HashSet BuiltinSiteWideNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "CURFEW"
+ };
+
+ ///
+ /// First occurrence in row/column order is kept; location is cleared so the import line is not room-specific.
+ ///
+ public static List Deduplicate(
+ IReadOnlyList lines,
+ IReadOnlyCollection? extraSiteWideNames = null)
+ {
+ var siteWide = new HashSet(BuiltinSiteWideNames, StringComparer.OrdinalIgnoreCase);
+ if (extraSiteWideNames != null)
+ {
+ foreach (var n in extraSiteWideNames)
+ {
+ if (!string.IsNullOrWhiteSpace(n))
+ siteWide.Add(n.Trim());
+ }
+ }
+
+ var seen = new HashSet<(string Name, string Month, int Day, string Time)>();
+ var ordered = lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol).ToList();
+ var result = new List(ordered.Count);
+
+ foreach (var line in ordered)
+ {
+ var name = TextNormalization.ForEmitLine(line.Name);
+ if (string.IsNullOrEmpty(name) || !siteWide.Contains(name))
+ {
+ result.Add(line);
+ continue;
+ }
+
+ var key = (name, line.Month, line.Day, line.TimeRange);
+ if (!seen.Add(key))
+ continue;
+
+ result.Add(line with { Location = string.Empty, Name = name });
+ }
+
+ return result;
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs b/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs
new file mode 100644
index 0000000..bd642a8
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/GoogleSheetGridReader.cs
@@ -0,0 +1,143 @@
+using Google.Apis.Sheets.v4;
+using Google.Apis.Sheets.v4.Data;
+using Color = Google.Apis.Sheets.v4.Data.Color;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Fetches raw grid data via Sheets API (public API key).
+///
+public sealed class GoogleSheetGridReader
+{
+ private readonly SheetsService _service;
+
+ public GoogleSheetGridReader(string apiKey)
+ {
+ if (string.IsNullOrWhiteSpace(apiKey))
+ throw new ArgumentException("API key is required for Google Sheets access.", nameof(apiKey));
+ _service = new SheetsService(new Google.Apis.Services.BaseClientService.Initializer
+ {
+ ApiKey = apiKey,
+ ApplicationName = "TSA GoogleSheetsScheduleImport"
+ });
+ }
+
+ public Spreadsheet FetchSpreadsheet(string spreadsheetId)
+ {
+ var req = _service.Spreadsheets.Get(spreadsheetId);
+ req.IncludeGridData = true;
+ return req.Execute();
+ }
+
+ public static GridSheetModel BuildModel(Sheet sheet)
+ {
+ var title = sheet.Properties?.Title ?? "(untitled)";
+ var grid = sheet.Data?.FirstOrDefault();
+ if (grid?.RowData == null || grid.RowData.Count == 0)
+ {
+ return new GridSheetModel
+ {
+ SheetTitle = title,
+ Values = Array.Empty(),
+ BackgroundKeys = Array.Empty()
+ };
+ }
+
+ var rowCount = grid.RowData.Count;
+ var colCount = grid.RowData.Max(r => r.Values?.Count ?? 0);
+
+ var values = new string?[rowCount][];
+ var bg = new string?[rowCount][];
+
+ for (var r = 0; r < rowCount; r++)
+ {
+ values[r] = new string?[colCount];
+ bg[r] = new string?[colCount];
+ var row = grid.RowData[r];
+ for (var c = 0; c < colCount; c++)
+ {
+ string? text = null;
+ string? hex = null;
+ if (row.Values != null && c < row.Values.Count)
+ {
+ var cell = row.Values[c];
+ text = string.IsNullOrWhiteSpace(cell.FormattedValue)
+ ? cell.EffectiveValue?.StringValue
+ : cell.FormattedValue;
+ if (!string.IsNullOrEmpty(text))
+ text = TextNormalization.ForSheetCell(text);
+ var color = cell.UserEnteredFormat?.BackgroundColor
+ ?? cell.EffectiveFormat?.BackgroundColor;
+ hex = ColorToHexKey(color);
+ }
+
+ values[r][c] = string.IsNullOrWhiteSpace(text) ? null : text;
+ bg[r][c] = hex;
+ }
+ }
+
+ ApplyMerges(sheet.Merges, values, bg);
+ NormalizeAllValueCells(values);
+
+ return new GridSheetModel
+ {
+ SheetTitle = title,
+ Values = values,
+ BackgroundKeys = bg
+ };
+ }
+
+ private static void ApplyMerges(IList? merges, string?[][] values, string?[][] bg)
+ {
+ if (merges == null || merges.Count == 0)
+ return;
+
+ foreach (var range in merges)
+ {
+ var r0 = range.StartRowIndex ?? 0;
+ var r1 = range.EndRowIndex ?? r0;
+ var c0 = range.StartColumnIndex ?? 0;
+ var c1 = range.EndColumnIndex ?? c0;
+ if (r1 <= r0 || c1 <= c0)
+ continue;
+
+ var anchorText = values[r0][c0];
+ var anchorBg = bg[r0][c0];
+ for (var r = r0; r < r1; r++)
+ {
+ for (var c = c0; c < c1; c++)
+ {
+ if (values[r][c] == null && anchorText != null)
+ values[r][c] = anchorText;
+ if (bg[r][c] == null && anchorBg != null)
+ bg[r][c] = anchorBg;
+ }
+ }
+ }
+ }
+
+ /// Re-run after merges so copied anchor text is also single-line.
+ private static void NormalizeAllValueCells(string?[][] values)
+ {
+ for (var r = 0; r < values.Length; r++)
+ for (var c = 0; c < values[r].Length; c++)
+ {
+ if (values[r][c] is { } v && !string.IsNullOrWhiteSpace(v))
+ values[r][c] = TextNormalization.ForEmitLine(v);
+ }
+ }
+
+ private static string? ColorToHexKey(Color? color)
+ {
+ if (color == null)
+ return null;
+ var r = color.Red ?? 1f;
+ var g = color.Green ?? 1f;
+ var b = color.Blue ?? 1f;
+ // Treat near-white as no color key for grouping
+ if (r >= 0.99f && g >= 0.99f && b >= 0.99f)
+ return null;
+ static byte F(float x) => (byte)(Math.Clamp(x, 0f, 1f) * 255f);
+ return $"#{F(r):X2}{F(g):X2}{F(b):X2}";
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj b/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj
new file mode 100644
index 0000000..a191d46
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/GoogleSheetsScheduleImport.csproj
@@ -0,0 +1,17 @@
+
+
+ Exe
+ net9.0
+ enable
+ enable
+ GoogleSheetsScheduleImport
+ GoogleSheetsScheduleImport
+
+
+
+
+
+
+
+
+
diff --git a/tools/GoogleSheetsScheduleImport/GridSheetModel.cs b/tools/GoogleSheetsScheduleImport/GridSheetModel.cs
new file mode 100644
index 0000000..476800a
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/GridSheetModel.cs
@@ -0,0 +1,28 @@
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Normalized grid: row 0 = location headers (col 0 empty or ignored), column 0 = time labels (row 0 ignored).
+///
+public sealed class GridSheetModel
+{
+ public required string SheetTitle { get; init; }
+
+ /// display values, sanitized hyphens; [row][col]
+ public required string?[][] Values { get; init; }
+
+ /// Optional RGB hex backgrounds for block grouping (#RRGGBB or null)
+ public required string?[][]? BackgroundKeys { get; init; }
+
+ public int RowCount => Values.Length;
+ public int ColCount => Values.Length == 0 ? 0 : Values[0].Length;
+}
+
+public readonly record struct ParsedOccurrenceLine(
+ string Name,
+ string Month,
+ int Day,
+ string TimeRange,
+ string Location,
+ int SourceRowStart,
+ int SourceRowEnd,
+ int SourceCol);
diff --git a/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs b/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs
new file mode 100644
index 0000000..2b8306d
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/GroupedImportTextEmitter.cs
@@ -0,0 +1,91 @@
+using System.Text;
+using Core.Entities;
+using Core.Models;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Emits PDF-style section headers Event Name - MS / Event Name - HS before occurrence lines,
+/// matching competition schedule imports.
+///
+public static class GroupedImportTextEmitter
+{
+ private readonly struct SectionKey(int eventDefinitionId, SchoolLevel level) : IEquatable
+ {
+ public int EventDefinitionId { get; } = eventDefinitionId;
+ public SchoolLevel Level { get; } = level;
+
+ public bool Equals(SectionKey other) =>
+ EventDefinitionId == other.EventDefinitionId && Level == other.Level;
+
+ public override bool Equals(object? obj) => obj is SectionKey other && Equals(other);
+
+ public override int GetHashCode() => HashCode.Combine(EventDefinitionId, Level);
+ }
+
+ public static string Build(
+ IReadOnlyList<(string SheetTitle, string SectionHeader, List Lines)> sheets,
+ IReadOnlyList matchableEvents,
+ int year)
+ {
+ var idToDef = matchableEvents.Where(e => e.Id != 0).ToDictionary(e => e.Id);
+ var sb = new StringBuilder();
+ foreach (var (sheetTitle, _, lines) in sheets)
+ {
+ sb.AppendLine($"# {sheetTitle}");
+
+ var general = new List();
+ var bySection = new Dictionary>();
+
+ foreach (var line in lines)
+ {
+ if (!OccurrenceEventMatcher.TryMatch(line.Name, matchableEvents, out var evt, out var lvl)
+ || evt == null
+ || !lvl.HasValue)
+ {
+ general.Add(line);
+ continue;
+ }
+
+ var key = new SectionKey(evt.Id, lvl.Value);
+ if (!bySection.TryGetValue(key, out var list))
+ {
+ list = [];
+ bySection[key] = list;
+ }
+
+ list.Add(line);
+ }
+
+ foreach (var key in bySection.Keys.OrderBy(k => HeaderSortKey(idToDef, k), StringComparer.OrdinalIgnoreCase))
+ {
+ if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
+ continue;
+ sb.AppendLine($"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}");
+ foreach (var line in OccurrenceChronologicalSort.Sort(bySection[key], year))
+ {
+ var displayName = OccurrenceDisplayNameReducer.ReduceForSection(line.Name, def, key.Level);
+ sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line with { Name = displayName }));
+ }
+ }
+
+ if (general.Count > 0)
+ {
+ sb.AppendLine("General Schedule");
+ foreach (var line in OccurrenceChronologicalSort.Sort(general, year))
+ sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+
+ private static string HeaderSortKey(Dictionary idToDef, SectionKey key)
+ {
+ if (!idToDef.TryGetValue(key.EventDefinitionId, out var def))
+ return $"{key.EventDefinitionId} - {key.Level}";
+ return $"{def.Name} - {SchoolLevelPrefixParser.ToSectionSuffix(key.Level)}";
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs b/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs
new file mode 100644
index 0000000..cd4481e
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/ImportLineFormatter.cs
@@ -0,0 +1,13 @@
+namespace GoogleSheetsScheduleImport;
+
+public static class ImportLineFormatter
+{
+ public static string FormatOccurrenceLine(ParsedOccurrenceLine line)
+ {
+ var name = TextNormalization.ForEmitLine(line.Name);
+ var time = TextNormalization.ForEmitLine(line.TimeRange);
+ var loc = TextNormalization.ForEmitLine(line.Location);
+ var tail = string.IsNullOrEmpty(loc) ? string.Empty : $" {loc}";
+ return $"{name} {line.Month} {line.Day} {time}{tail}";
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs b/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs
new file mode 100644
index 0000000..4379487
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/ImportTextEmitter.cs
@@ -0,0 +1,26 @@
+using System.Text;
+
+namespace GoogleSheetsScheduleImport;
+
+public static class ImportTextEmitter
+{
+ ///
+ /// Builds text compatible with / Import.razor paste target.
+ ///
+ public static string Build(
+ IReadOnlyList<(string SheetTitle, string SectionHeader, List Lines)> sheets)
+ {
+ var sb = new StringBuilder();
+ foreach (var (sheetTitle, sectionHeader, lines) in sheets)
+ {
+ sb.AppendLine($"# {sheetTitle}");
+ sb.AppendLine(sectionHeader);
+ foreach (var line in lines.OrderBy(l => l.SourceRowStart).ThenBy(l => l.SourceCol))
+ sb.AppendLine(ImportLineFormatter.FormatOccurrenceLine(line));
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString().TrimEnd();
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/MappingConfig.cs b/tools/GoogleSheetsScheduleImport/MappingConfig.cs
new file mode 100644
index 0000000..943ee80
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/MappingConfig.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// JSON config: sheet tab titles mapped to calendar days plus optional defaults.
+///
+public sealed class MappingConfig
+{
+ /// Calendar year for occurrence dates (overridden by CLI --year).
+ public int? Year { get; set; }
+
+ ///
+ /// Emitted before occurrence lines for each sheet (unless overridden per sheet).
+ /// Use "General Schedule" when grid cells are generic schedule items.
+ ///
+ public string? DefaultSectionHeader { get; set; }
+
+ /// Per-sheet overrides and day mapping.
+ public List? Sheets { get; set; }
+
+ ///
+ /// Event titles (cell text) to treat as site-wide: one output line per date+time, no location.
+ /// If omitted, built-in rules still include CURFEW.
+ ///
+ public List? SiteWideEventNames { get; set; }
+
+ ///
+ /// Path to event definitions CSV (column Event), relative to this mapping file or absolute.
+ /// When set (or when --events-csv is passed), output is grouped into Event - MS/HS sections when possible.
+ ///
+ public string? EventDefinitionsCsv { get; set; }
+}
+
+public sealed class SheetDayMapping
+{
+ /// Exact tab title as it appears in Google Sheets.
+ public string Title { get; set; } = "";
+
+ /// Month name matching EventOccurrenceGrammar (e.g. "April").
+ public string Month { get; set; } = "";
+
+ /// Day of month (1-31).
+ public int Day { get; set; }
+
+ /// Optional section header line for this tab only (e.g. "General Schedule" or "Biotechnology - MS").
+ public string? SectionHeader { get; set; }
+
+ [JsonIgnore]
+ public string? NormalizedMonth => string.IsNullOrWhiteSpace(Month) ? null : Month.Trim();
+
+ public void Validate()
+ {
+ if (string.IsNullOrWhiteSpace(Title))
+ throw new InvalidOperationException("Mapping entry must include a non-empty Title (sheet tab name).");
+ if (string.IsNullOrWhiteSpace(Month))
+ throw new InvalidOperationException($"Sheet '{Title}': Month is required.");
+ if (Day is < 1 or > 31)
+ throw new InvalidOperationException($"Sheet '{Title}': Day must be between 1 and 31.");
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs b/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs
new file mode 100644
index 0000000..ea79c57
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/OccurrenceChronologicalSort.cs
@@ -0,0 +1,31 @@
+using Core.Parsers.EventOccurrence;
+using Core.Utility;
+
+namespace GoogleSheetsScheduleImport;
+
+public static class OccurrenceChronologicalSort
+{
+ public static List Sort(IEnumerable lines, int year)
+ {
+ return lines
+ .OrderBy(l => Key(l, year))
+ .ThenBy(l => l.SourceRowStart)
+ .ThenBy(l => l.SourceCol)
+ .ToList();
+ }
+
+ private static DateTime Key(ParsedOccurrenceLine line, int year)
+ {
+ try
+ {
+ var d = TextUtil.ParseDate(line.Month, line.Day.ToString(), year);
+ var timePart = TimeParser.ExtractStartTime(line.TimeRange);
+ var t = TimeParser.Parse(timePart);
+ return new DateTime(d, t);
+ }
+ catch
+ {
+ return DateTime.MaxValue;
+ }
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs b/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs
new file mode 100644
index 0000000..9187051
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/OccurrenceDisplayNameReducer.cs
@@ -0,0 +1,50 @@
+using Core.Entities;
+using Core.Models;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// For grouped output, occurrence lines should look like the PDF schedule: activity text only, not
+/// MS EventName Activity when the section is already EventName - MS.
+///
+public static class OccurrenceDisplayNameReducer
+{
+ public static string ReduceForSection(string occurrenceName, EventDefinition matched, SchoolLevel level)
+ {
+ var (remainder, lvl) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(occurrenceName);
+ if (!lvl.HasValue || lvl.Value != level)
+ return TextNormalization.ForEmitLine(occurrenceName);
+
+ var r = remainder.Trim();
+ var eventName = matched.Name.Trim();
+ if (r.StartsWith(eventName, StringComparison.OrdinalIgnoreCase))
+ {
+ r = r[eventName.Length..].TrimStart();
+ r = TrimLeadingJoiners(r);
+ }
+
+ r = TextNormalization.ForEmitLine(r);
+ if (string.IsNullOrWhiteSpace(r))
+ return TextNormalization.ForEmitLine(remainder.Trim());
+
+ return r;
+ }
+
+ private static string TrimLeadingJoiners(string s)
+ {
+ var r = s;
+ while (r.Length > 0)
+ {
+ var c = r[0];
+ if (c is '/' or '-' or ':' or '&' or '–' or '—' or ',' or '.')
+ {
+ r = r[1..].TrimStart();
+ continue;
+ }
+
+ break;
+ }
+
+ return r;
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs b/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs
new file mode 100644
index 0000000..80720b4
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/OccurrenceEventMatcher.cs
@@ -0,0 +1,71 @@
+using Core.Entities;
+using Core.Models;
+using FuzzySharp;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Maps a sheet cell title to the closest event definition (fuzzy) plus MS/HS for section headers.
+///
+public static class OccurrenceEventMatcher
+{
+ private const int MinTokenScore = 62;
+
+ public static bool TryMatch(
+ string occurrenceName,
+ IReadOnlyList events,
+ out EventDefinition? matched,
+ out SchoolLevel? level)
+ {
+ matched = null;
+ level = null;
+ if (string.IsNullOrWhiteSpace(occurrenceName) || events.Count == 0)
+ return false;
+
+ var normalized = TextNormalization.ForEmitLine(occurrenceName);
+ var (remainder, prefixLevel) = SchoolLevelPrefixParser.StripLeadingSchoolPrefix(normalized);
+
+ // Section headers require "Event Name - MS|HS". Titles without a clear school prefix stay in General Schedule.
+ if (!prefixLevel.HasValue)
+ return false;
+
+ var best = FindBest(remainder, events);
+ if (best == null || string.IsNullOrWhiteSpace(remainder))
+ return false;
+
+ matched = best;
+ level = prefixLevel;
+ return true;
+ }
+
+ private static EventDefinition? FindBest(string text, IReadOnlyList events)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ return null;
+
+ EventDefinition? best = null;
+ var bestScore = 0;
+ foreach (var e in events)
+ {
+ if (string.IsNullOrWhiteSpace(e.Name))
+ continue;
+
+ var score = Math.Max(
+ Fuzz.TokenSetRatio(text, e.Name),
+ Fuzz.PartialRatio(text, e.Name));
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ best = e;
+ }
+ else if (score == bestScore && best != null && e.Name.Length > best.Name.Length)
+ best = e;
+ }
+
+ if (best == null || bestScore < MinTokenScore)
+ return null;
+
+ return best;
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs b/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs
new file mode 100644
index 0000000..dfa281c
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/ParserRoundTripValidator.cs
@@ -0,0 +1,14 @@
+using Core.Entities;
+using Core.Models;
+using Core.Services;
+
+namespace GoogleSheetsScheduleImport;
+
+public static class ParserRoundTripValidator
+{
+ public static EventOccurrenceParseResult Validate(string text, ICollection events)
+ {
+ var parser = new EventOccurrenceParserService(null);
+ return parser.ParseFromText(text, events);
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/Program.cs b/tools/GoogleSheetsScheduleImport/Program.cs
new file mode 100644
index 0000000..5851a3d
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/Program.cs
@@ -0,0 +1,242 @@
+using System.Linq;
+using System.Text.Json;
+using Core.Entities;
+using Core.Parsers;
+using Google.Apis.Sheets.v4.Data;
+using GoogleSheetsScheduleImport;
+
+static void PrintUsage()
+{
+ Console.Error.WriteLine("""
+ Google Sheets -> Event occurrence import text (for /calendar/event-occurrences/import)
+
+ Required:
+ --sheet-url Google Sheet URL or raw spreadsheet id
+ --mapping Tab titles -> month/day (+ optional section headers)
+ --output Output file path
+
+ Optional:
+ --year Calendar year (overrides mapping file)
+ --api-key Google API key (else env GOOGLE_SHEETS_API_KEY)
+ --tabs Only process these tab titles (exact match)
+ --events-csv Event definitions CSV with 'Event' column (grouping + validation)
+ --no-group-by-event Keep a single General Schedule block per sheet (no MS/HS sections)
+ --strict Exit code 1 if parser reports errors or zero occurrences
+
+ Environment:
+ GOOGLE_SHEETS_API_KEY Default API key for Sheets API (spreadsheet must be accessible to the key)
+ """);
+}
+
+try
+{
+ var argsDict = ParseArgs(args);
+ if (argsDict.ContainsKey("help") || argsDict.ContainsKey("h"))
+ {
+ PrintUsage();
+ return 0;
+ }
+
+ var sheetUrl = GetRequired(argsDict, "sheet-url");
+ var mappingPath = GetRequired(argsDict, "mapping");
+ var outputPath = GetRequired(argsDict, "output");
+
+ var mappingJson = await File.ReadAllTextAsync(mappingPath);
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip };
+ var mapping = JsonSerializer.Deserialize(mappingJson, options)
+ ?? throw new InvalidOperationException("Mapping file is empty or invalid JSON.");
+
+ var year = int.TryParse(argsDict.GetValueOrDefault("year"), out var y) ? y
+ : mapping.Year ?? DateTime.Now.Year;
+
+ var apiKey = argsDict.GetValueOrDefault("api-key")
+ ?? Environment.GetEnvironmentVariable("GOOGLE_SHEETS_API_KEY");
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ Console.Error.WriteLine("Missing API key: pass --api-key or set GOOGLE_SHEETS_API_KEY.");
+ return 2;
+ }
+
+ HashSet? tabFilter = null;
+ if (argsDict.TryGetValue("tabs", out var tabsArg) && !string.IsNullOrWhiteSpace(tabsArg))
+ {
+ tabFilter = new HashSet(
+ tabsArg.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
+ StringComparer.Ordinal);
+ }
+
+ var spreadsheetId = SpreadsheetId.FromUrlOrId(sheetUrl);
+ var reader = new GoogleSheetGridReader(apiKey);
+ var spreadsheet = reader.FetchSpreadsheet(spreadsheetId);
+
+ var sheetMappings = mapping.Sheets ?? [];
+ if (sheetMappings.Count == 0)
+ throw new InvalidOperationException("Mapping file must include a non-empty \"sheets\" array with tab titles and dates.");
+
+ foreach (var sm in sheetMappings)
+ sm.Validate();
+
+ var defaultSection = mapping.DefaultSectionHeader?.Trim();
+ if (string.IsNullOrEmpty(defaultSection))
+ defaultSection = "General Schedule";
+
+ var mappingDir = Path.GetDirectoryName(Path.GetFullPath(mappingPath)) ?? Directory.GetCurrentDirectory();
+ var eventsCsvArg = argsDict.GetValueOrDefault("events-csv");
+ var eventsCsvConfigured = mapping.EventDefinitionsCsv;
+ var eventsCsvPath = !string.IsNullOrWhiteSpace(eventsCsvArg)
+ ? (Path.IsPathRooted(eventsCsvArg) ? eventsCsvArg : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), eventsCsvArg)))
+ : (!string.IsNullOrWhiteSpace(eventsCsvConfigured)
+ ? Path.GetFullPath(Path.Combine(mappingDir, eventsCsvConfigured!))
+ : null);
+
+ List csvEvents = new();
+ if (!string.IsNullOrWhiteSpace(eventsCsvPath) && File.Exists(eventsCsvPath))
+ csvEvents = EventDefinitionsCsvLoader.Load(eventsCsvPath);
+ else if (!string.IsNullOrWhiteSpace(eventsCsvPath))
+ throw new InvalidOperationException($"Event definitions CSV not found: {eventsCsvPath}");
+
+ var groupByEvent = csvEvents.Count > 0 && !argsDict.ContainsKey("no-group-by-event");
+
+ List validationEvents = MergeValidationEvents(csvEvents);
+
+ var warnings = new List();
+ var sheetOutputs = new List<(string Title, string SectionHeader, List Lines)>();
+
+ foreach (var sheet in spreadsheet.Sheets ?? Enumerable.Empty())
+ {
+ var title = sheet.Properties?.Title ?? "";
+ if (tabFilter != null && !tabFilter.Contains(title))
+ continue;
+
+ var dayMap = sheetMappings.FirstOrDefault(m =>
+ m.Title.Equals(title, StringComparison.Ordinal));
+ if (dayMap == null)
+ {
+ warnings.Add($"Skipping sheet '{title}': no entry in mapping JSON.");
+ continue;
+ }
+
+ var month = dayMap.NormalizedMonth!;
+ if (!EventOccurrenceGrammar.MonthNames.Any(m => m.Equals(month, StringComparison.OrdinalIgnoreCase)))
+ warnings.Add($"Sheet '{title}': month '{month}' is not a standard grammar month name.");
+ try
+ {
+ Core.Utility.TextUtil.ParseDate(month, dayMap.Day.ToString(), year);
+ }
+ catch (Exception ex)
+ {
+ warnings.Add($"Sheet '{title}': invalid date {month} {dayMap.Day}, {year}: {ex.Message}");
+ }
+
+ var grid = GoogleSheetGridReader.BuildModel(sheet);
+ var lines = ScheduleGridExtractor.Extract(grid, month, dayMap.Day, warnings);
+ lines = GlobalEventDeduplicator.Deduplicate(lines, mapping.SiteWideEventNames);
+ var section = string.IsNullOrWhiteSpace(dayMap.SectionHeader) ? defaultSection : dayMap.SectionHeader!.Trim();
+ sheetOutputs.Add((title, section, lines));
+ }
+
+ foreach (var sm in sheetMappings)
+ {
+ var exists = spreadsheet.Sheets?.Any(s => string.Equals(s.Properties?.Title, sm.Title, StringComparison.Ordinal)) ?? false;
+ if (!exists)
+ warnings.Add($"Mapping references sheet '{sm.Title}' but it was not found in the spreadsheet.");
+ }
+
+ var text = groupByEvent
+ ? GroupedImportTextEmitter.Build(sheetOutputs, csvEvents, year)
+ : ImportTextEmitter.Build(sheetOutputs);
+ var outDir = Path.GetDirectoryName(Path.GetFullPath(outputPath));
+ if (!string.IsNullOrEmpty(outDir))
+ Directory.CreateDirectory(outDir);
+ await File.WriteAllTextAsync(outputPath, text, System.Text.Encoding.UTF8);
+
+ Console.WriteLine($"Wrote {outputPath} ({text.Length} characters).");
+
+ foreach (var w in warnings)
+ Console.WriteLine($"WARNING: {w}");
+
+ var strict = argsDict.ContainsKey("strict");
+
+ var parseResult = ParserRoundTripValidator.Validate(text, validationEvents);
+ Console.WriteLine($"Parser round-trip: success={parseResult.IsSuccess}, occurrences={parseResult.TotalParsed}, issues={parseResult.Issues.Count}, errors={parseResult.Errors.Count}");
+ foreach (var err in parseResult.Errors)
+ Console.WriteLine($" ERROR: {err}");
+ foreach (var issue in parseResult.Issues.Take(20))
+ Console.WriteLine($" Issue L{issue.LineNumber}: {issue.Message}");
+ if (parseResult.Issues.Count > 20)
+ Console.WriteLine($" ... and {parseResult.Issues.Count - 20} more issues.");
+
+ if (strict && (!parseResult.IsSuccess || parseResult.TotalParsed == 0))
+ {
+ Console.Error.WriteLine("Strict mode: failing due to parser errors or zero occurrences parsed.");
+ return 1;
+ }
+
+ return 0;
+}
+catch (Exception ex)
+{
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ Console.Error.WriteLine(ex.ToString());
+ return 1;
+}
+
+static Dictionary ParseArgs(string[] args)
+{
+ var d = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 0; i < args.Length; i++)
+ {
+ var a = args[i];
+ if (!a.StartsWith("--", StringComparison.Ordinal))
+ continue;
+ var key = a[2..];
+ if (string.IsNullOrEmpty(key))
+ continue;
+ if (i + 1 < args.Length && !args[i + 1].StartsWith("--"))
+ {
+ d[key] = args[i + 1];
+ i++;
+ }
+ else
+ d[key] = "true";
+ }
+
+ return d;
+}
+
+static string GetRequired(Dictionary d, string key)
+{
+ if (!d.TryGetValue(key, out var v) || string.IsNullOrWhiteSpace(v))
+ throw new InvalidOperationException($"Missing required --{key}");
+ return v;
+}
+
+static List MergeValidationEvents(List fromCsv)
+{
+ if (fromCsv.Count == 0)
+ {
+ return
+ [
+ EventDefinition.GeneralSchedule,
+ EventDefinition.MeetTheCandidates,
+ EventDefinition.ChapterOfficerMeeting,
+ EventDefinition.VotingDelegateMeeting,
+ EventDefinition.SocialGathering
+ ];
+ }
+
+ var list = new List(fromCsv);
+ foreach (var extra in new EventDefinition[]
+ {
+ EventDefinition.MeetTheCandidates,
+ EventDefinition.ChapterOfficerMeeting,
+ EventDefinition.VotingDelegateMeeting,
+ EventDefinition.SocialGathering
+ })
+ {
+ if (list.All(e => !string.Equals(e.Name, extra.Name, StringComparison.OrdinalIgnoreCase)))
+ list.Add(extra);
+ }
+
+ return list;
+}
diff --git a/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs b/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs
new file mode 100644
index 0000000..8938e6e
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/ScheduleGridExtractor.cs
@@ -0,0 +1,149 @@
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Interprets location headers + time rows + colored/value blocks as occurrence lines.
+///
+public static class ScheduleGridExtractor
+{
+ public static List Extract(
+ GridSheetModel grid,
+ string month,
+ int day,
+ List warnings)
+ {
+ var result = new List();
+ if (grid.RowCount < 2 || grid.ColCount < 2)
+ {
+ warnings.Add($"Sheet '{grid.SheetTitle}': grid too small; need at least a header row and time column.");
+ return result;
+ }
+
+ var locations = new string[grid.ColCount];
+ for (var c = 1; c < grid.ColCount; c++)
+ {
+ var header = grid.Values[0][c];
+ locations[c] = string.IsNullOrWhiteSpace(header) ? $"Column {c + 1}" : header.Trim();
+ }
+
+ var rowTimes = new TimeOnly?[grid.RowCount];
+ rowTimes[0] = null;
+ for (var r = 1; r < grid.RowCount; r++)
+ {
+ var cell = grid.Values[r][0];
+ if (!TimeCellParser.TryParse(cell, out var t))
+ {
+ if (!string.IsNullOrWhiteSpace(cell))
+ warnings.Add($"Sheet '{grid.SheetTitle}' row {r + 1}: could not parse time label '{cell}'.");
+ rowTimes[r] = null;
+ }
+ else
+ rowTimes[r] = t;
+ }
+
+ var slot = InferSlotDuration(rowTimes, warnings, grid.SheetTitle);
+
+ for (var c = 1; c < grid.ColCount; c++)
+ {
+ var location = locations[c];
+ var r = 1;
+ while (r < grid.RowCount)
+ {
+ if (!IsOccupied(grid, r, c))
+ {
+ r++;
+ continue;
+ }
+
+ var startRow = r;
+ var name = grid.Values[r][c] ?? "";
+ var key = BlockKey(grid, r, c);
+
+ var endRow = r;
+ while (endRow + 1 < grid.RowCount &&
+ IsOccupied(grid, endRow + 1, c) &&
+ BlockKey(grid, endRow + 1, c) == key)
+ {
+ endRow++;
+ }
+
+ var startTime = rowTimes[startRow];
+ if (startTime == null)
+ {
+ warnings.Add(
+ $"Sheet '{grid.SheetTitle}' ({location}): block at row {startRow + 1} has no parseable start time in column A.");
+ r = endRow + 1;
+ continue;
+ }
+
+ var endInstant = ComputeEndTime(rowTimes, endRow, grid.RowCount, slot);
+
+ var timeRange = TimeFormatter.ToParserTimeRange(startTime.Value, endInstant);
+ var title = name.Trim();
+ if (string.IsNullOrEmpty(title))
+ title = "(no title)";
+
+ result.Add(new ParsedOccurrenceLine(
+ Name: title,
+ Month: month,
+ Day: day,
+ TimeRange: timeRange,
+ Location: location,
+ SourceRowStart: startRow,
+ SourceRowEnd: endRow,
+ SourceCol: c));
+
+ r = endRow + 1;
+ }
+ }
+
+ return result;
+ }
+
+ private static bool IsOccupied(GridSheetModel grid, int r, int c)
+ {
+ var v = grid.Values[r][c];
+ var bg = grid.BackgroundKeys?[r][c];
+ if (!string.IsNullOrWhiteSpace(v))
+ return true;
+ return bg != null; // colored empty cell
+ }
+
+ private static (string NameKey, string? Bg) BlockKey(GridSheetModel grid, int r, int c)
+ {
+ var raw = grid.Values[r][c] ?? "";
+ var v = raw.Trim();
+ var bg = grid.BackgroundKeys?[r][c];
+ return (v, bg);
+ }
+
+ private static TimeOnly ComputeEndTime(TimeOnly?[] rowTimes, int endRow, int rowCount, TimeSpan slot)
+ {
+ if (endRow + 1 < rowCount && rowTimes[endRow + 1] != null)
+ return rowTimes[endRow + 1]!.Value;
+
+ var lastStart = rowTimes[endRow] ?? throw new InvalidOperationException();
+ return lastStart.Add(slot);
+ }
+
+ private static TimeSpan InferSlotDuration(TimeOnly?[] rowTimes, List warnings, string sheetTitle)
+ {
+ var deltas = new List();
+ for (var i = 1; i < rowTimes.Length - 1; i++)
+ {
+ if (rowTimes[i] == null || rowTimes[i + 1] == null)
+ continue;
+ var minutes = (int)(rowTimes[i + 1]!.Value - rowTimes[i]!.Value).TotalMinutes;
+ if (minutes > 0 && minutes <= 24 * 60)
+ deltas.Add(minutes);
+ }
+
+ if (deltas.Count == 0)
+ {
+ warnings.Add($"Sheet '{sheetTitle}': could not infer time-slot length from column A; defaulting to 30 minutes.");
+ return TimeSpan.FromMinutes(30);
+ }
+
+ var g = deltas.GroupBy(d => d).OrderByDescending(g => g.Count()).First();
+ return TimeSpan.FromMinutes(g.Key);
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs b/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs
new file mode 100644
index 0000000..c96c4ec
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/SchoolLevelPrefixParser.cs
@@ -0,0 +1,40 @@
+using Core.Models;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Strips leading MS / HS markers from grid titles so titles can be fuzzy-matched to .
+///
+public static class SchoolLevelPrefixParser
+{
+ /// Remainder text and school level when unambiguous; null level for MS/HS combined or unknown.
+ public static (string Remainder, SchoolLevel? Level) StripLeadingSchoolPrefix(string raw)
+ {
+ var s = TextNormalization.ForEmitLine(raw);
+ if (string.IsNullOrEmpty(s))
+ return (s, null);
+
+ if (s.StartsWith("MS/HS", StringComparison.OrdinalIgnoreCase))
+ {
+ var rest = s[5..].TrimStart();
+ if (rest.StartsWith('/'))
+ rest = rest[1..].TrimStart();
+ return (rest, null);
+ }
+
+ if (s.Length >= 3 && s.StartsWith("MS ", StringComparison.OrdinalIgnoreCase))
+ return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
+ if (s.Length >= 3 && s.StartsWith("HS ", StringComparison.OrdinalIgnoreCase))
+ return (s[3..].TrimStart(), SchoolLevel.HighSchool);
+
+ if (s.Length >= 3 && s.StartsWith("MS/", StringComparison.OrdinalIgnoreCase))
+ return (s[3..].TrimStart(), SchoolLevel.MiddleSchool);
+ if (s.Length >= 3 && s.StartsWith("HS/", StringComparison.OrdinalIgnoreCase))
+ return (s[3..].TrimStart(), SchoolLevel.HighSchool);
+
+ return (s, null);
+ }
+
+ public static string ToSectionSuffix(SchoolLevel level) =>
+ level == SchoolLevel.MiddleSchool ? "MS" : "HS";
+}
diff --git a/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs b/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs
new file mode 100644
index 0000000..59d67f6
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/SpreadsheetId.cs
@@ -0,0 +1,25 @@
+using System.Text.RegularExpressions;
+
+namespace GoogleSheetsScheduleImport;
+
+public static class SpreadsheetId
+{
+ private static readonly Regex IdRegex = new(
+ @"/spreadsheets/d/([a-zA-Z0-9-_]+)",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// Extracts spreadsheet id from a full Google Sheets URL or returns the string if it already looks like an id.
+ ///
+ public static string FromUrlOrId(string input)
+ {
+ var trimmed = input.Trim();
+ var m = IdRegex.Match(trimmed);
+ if (m.Success)
+ return m.Groups[1].Value;
+ if (trimmed.Length > 20 && !trimmed.Contains('/') && !trimmed.Contains(':'))
+ return trimmed;
+ throw new ArgumentException(
+ "Expected a Google Sheets URL (…/spreadsheets/d/{id}/…) or a raw spreadsheet id.", nameof(input));
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/TextNormalization.cs b/tools/GoogleSheetsScheduleImport/TextNormalization.cs
new file mode 100644
index 0000000..22bfd86
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/TextNormalization.cs
@@ -0,0 +1,44 @@
+using System.Text;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Google Sheets cells can contain line breaks as LF/CR or Unicode line/paragraph separators.
+/// Import text must be one logical line per occurrence.
+///
+public static class TextNormalization
+{
+ public static string ForSheetCell(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return string.Empty;
+ return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
+ }
+
+ public static string ForEmitLine(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ return string.Empty;
+ return CollapseWhitespace(Core.Utility.TextUtil.SanitizeInput(raw.Trim()));
+ }
+
+ private static string CollapseWhitespace(string s)
+ {
+ var sb = new StringBuilder(s.Length);
+ var pendingSpace = false;
+ foreach (var ch in s)
+ {
+ if (char.IsWhiteSpace(ch))
+ pendingSpace = true;
+ else
+ {
+ if (pendingSpace && sb.Length > 0)
+ sb.Append(' ');
+ pendingSpace = false;
+ sb.Append(ch);
+ }
+ }
+
+ return sb.ToString().Trim();
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/TimeCellParser.cs b/tools/GoogleSheetsScheduleImport/TimeCellParser.cs
new file mode 100644
index 0000000..e86402d
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/TimeCellParser.cs
@@ -0,0 +1,53 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Core.Parsers.EventOccurrence;
+
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Parses time labels from the first column of schedule grids.
+///
+public static class TimeCellParser
+{
+ private static readonly Regex ClockRegex = new(
+ @"^(?\d{1,2})(?::(?\d{2}))?\s*(?a\.?m\.?|p\.?m\.?|AM|PM|am|pm)\s*$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ public static bool TryParse(string? cellText, out TimeOnly time)
+ {
+ time = default;
+ if (string.IsNullOrWhiteSpace(cellText))
+ return false;
+ var t = cellText.Trim();
+ if (t.Equals("NOON", StringComparison.OrdinalIgnoreCase))
+ {
+ time = new TimeOnly(12, 0);
+ return true;
+ }
+
+ var m = ClockRegex.Match(t);
+ if (m.Success)
+ {
+ var h = int.Parse(m.Groups["h"].Value, CultureInfo.InvariantCulture);
+ var minute = m.Groups["m"].Success ? int.Parse(m.Groups["m"].Value, CultureInfo.InvariantCulture) : 0;
+ var apStr = m.Groups["ap"].Value;
+ var isPm = apStr.Contains('P', StringComparison.OrdinalIgnoreCase);
+ var isAm = apStr.Contains('A', StringComparison.OrdinalIgnoreCase);
+ if (isPm && h < 12) h += 12;
+ if (isAm && h == 12) h = 0;
+ time = new TimeOnly(h, minute);
+ return true;
+ }
+
+ // Fallback: use core TimeParser if string already looks like parsed format
+ try
+ {
+ time = TimeParser.Parse(t);
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/tools/GoogleSheetsScheduleImport/TimeFormatter.cs b/tools/GoogleSheetsScheduleImport/TimeFormatter.cs
new file mode 100644
index 0000000..c627228
--- /dev/null
+++ b/tools/GoogleSheetsScheduleImport/TimeFormatter.cs
@@ -0,0 +1,26 @@
+namespace GoogleSheetsScheduleImport;
+
+///
+/// Formats times for the existing occurrence parser (see Core.Parsers.EventOccurrenceGrammar / TimePatterns).
+///
+public static class TimeFormatter
+{
+ public static string ToParserTimeString(TimeOnly time)
+ {
+ var h12 = time.Hour % 12;
+ if (h12 == 0) h12 = 12;
+ var minute = time.Minute;
+ var isPm = time.Hour >= 12;
+ var ap = isPm ? "p.m." : "a.m.";
+ if (minute == 0)
+ return $"{h12} {ap}";
+ return $"{h12}:{minute:D2} {ap}";
+ }
+
+ public static string ToParserTimeRange(TimeOnly start, TimeOnly end)
+ {
+ if (start == end)
+ return ToParserTimeString(start);
+ return $"{ToParserTimeString(start)} - {ToParserTimeString(end)}";
+ }
+}