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);
}
}