Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,97 @@ public void Map_BlankAndNamelessHeaders_AreNotWorkers()
}));
}

[Test]
public void PlanAppends_SiteWithBothColumns_AppendsNothing()
{
var appends = PlanTimerSheetColumns.PlanAppends(
Headers("Albert Doba - timer", "Albert Doba - tekst"),
["Albert Doba"]);

Assert.That(appends.Headers, Is.Empty);
Assert.That(appends.Problems, Is.Empty);
}

[Test]
public void PlanAppends_NewSite_AppendsThePairInOrder()
{
var appends = PlanTimerSheetColumns.PlanAppends(
Headers("Albert Doba - timer", "Albert Doba - tekst"),
["Albert Doba", "Phien Van Le"]);

Assert.That(appends.Headers, Is.EqualTo(new[] { "Phien Van Le - timer", "Phien Van Le - tekst" }));
Assert.That(appends.Problems, Is.Empty);
}

/// <summary>
/// A header retyped with other spacing, capitals or a different dash used to
/// be treated as absent, so every push appended another column for the same
/// worker.
/// </summary>
[TestCase("phien van le - TIMER")]
[TestCase("Phien Van Le – timer")]
public void PlanAppends_HeaderVariantOfTheSameName_IsNotDuplicated(string timerHeader)
{
var appends = PlanTimerSheetColumns.PlanAppends(
Headers(timerHeader, "Phien Van Le - tekst"),
["Phien Van Le"]);

Assert.That(appends.Headers, Is.Empty);
}

/// <summary>
/// Tenant 1063's Malaika: her "- tekst" column had gone missing, and the
/// half appended on its own landed after two other workers' pairs.
/// </summary>
[Test]
public void PlanAppends_SiteWithHalfAPair_AppendsAWholePairAndNamesTheOldColumn()
{
var appends = PlanTimerSheetColumns.PlanAppends(
Headers("Malaika Luna Jørgensen - timer", "Phien Van Le - timer", "Phien Van Le - tekst"),
["Malaika Luna Jørgensen", "Phien Van Le"]);

Assert.That(appends.Headers,
Is.EqualTo(new[] { "Malaika Luna Jørgensen - timer", "Malaika Luna Jørgensen - tekst" }));
Assert.That(appends.Problems.Single(),
Does.Contain("Malaika Luna Jørgensen").And.Contain("only one of its two columns (D)")
.And.Contain("column D"));
}

[Test]
public void PlanAppends_EmptySheet_AppendsEveryPairOnceEvenIfASiteRepeats()
{
var appends = PlanTimerSheetColumns.PlanAppends(
new List<object>(),
["Julius -", "julius -", ""]);

Assert.That(appends.Headers, Is.EqualTo(new[] { "Julius - - timer", "Julius - - tekst" }));
Assert.That(appends.Problems, Is.Empty);
}

/// <summary>
/// Headers written into A/B/C would sit in the date columns the import
/// skips, so the next push would not see them and would append them again.
/// </summary>
[Test]
public void PlanAppends_ShortOrEmptyHeaderRow_StartsAtTheFirstWorkerColumn()
{
Assert.That(PlanTimerSheetColumns.PlanAppends(new List<object>(), ["Albert Doba"]).FirstColumn,
Is.EqualTo(PlanTimerSheetColumns.FirstWorkerColumn));
Assert.That(PlanTimerSheetColumns.PlanAppends(new List<object> { "Dato" }, ["Albert Doba"]).FirstColumn,
Is.EqualTo(PlanTimerSheetColumns.FirstWorkerColumn));
}

[Test]
public void PlanAppends_PopulatedHeaderRow_StartsAfterTheLastHeader()
{
var appends = PlanTimerSheetColumns.PlanAppends(
Headers("Albert Doba - timer", "Albert Doba - tekst"),
["Albert Doba", "Phien Van Le"]);

Assert.That(appends.FirstColumn, Is.EqualTo(5));
Assert.That(PlanTimerSheetColumns.ColumnLetter(appends.FirstColumn), Is.EqualTo("F"));
}

/// <summary>
/// The site lookup normalizes both sides with this, so a site named
/// "Julius -" matches its "Julius - - timer" header. The old import
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,41 +90,38 @@ public static async Task PushToGoogleSheet(Core core, TimePlanningPnDbContext db
.Select(x => x.Name)
.ToListAsync();

var newHeaders = existingHeaders.Cast<string>().ToList();
foreach (var siteName in siteNames)
// Matching a header by its exact text used to append a second
// column whenever a header had been retyped with other spacing, a
// different dash or other capitals. The planner matches the way the
// import does, and only ever appends.
var appends = PlanTimerSheetColumns.PlanAppends(existingHeaders, siteNames);
foreach (var problem in appends.Problems)
{
var timerHeader = $"{siteName} - timer";
var textHeader = $"{siteName} - tekst";
if (!newHeaders.Contains(timerHeader))
{
newHeaders.Add(timerHeader);
}

if (!newHeaders.Contains(textHeader))
{
newHeaders.Add(textHeader);
}
logger.LogWarning("PlanTimer sheet: {Problem}", problem);
SentrySdk.CaptureMessage($"PlanTimer sheet: {problem}", SentryLevel.Warning);
}

if (!existingHeaders.Cast<string>().SequenceEqual(newHeaders))
if (appends.Headers.Count > 0)
{
// Only the appended cells are written. Rewriting the whole row
// would restate every existing header, so any header a human had
// corrected would be silently reverted.
var firstNewColumn = appends.FirstColumn;
var range = $"{sheetName}!" +
$"{PlanTimerSheetColumns.ColumnLetter(firstNewColumn)}1:" +
$"{PlanTimerSheetColumns.ColumnLetter(firstNewColumn + appends.Headers.Count - 1)}1";
var updateRequest = new ValueRange
{
Values = new List<IList<object>> { newHeaders.Cast<object>().ToList() }
};

var columnLetter = GetColumnLetter(newHeaders.Count);
updateRequest = new ValueRange
{
Values = new List<IList<object>> { newHeaders.Cast<object>().ToList() }
Values = new List<IList<object>> { appends.Headers.Cast<object>().ToList() }
};
var updateHeaderRequest =
service.Spreadsheets.Values.Update(updateRequest, googleSheetId, $"{sheetName}!A1:{columnLetter}1");
service.Spreadsheets.Values.Update(updateRequest, googleSheetId, range);
updateHeaderRequest.ValueInputOption =
SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.RAW;
await updateHeaderRequest.ExecuteAsync();

logger.LogInformation("Headers updated successfully.");
logger.LogInformation("Appended {Count} header(s) to the PlanTimer sheet at {Range}.",
appends.Headers.Count, range);
}

AutoAdjustColumnWidths(service, googleSheetId, sheetName, logger);
Expand Down Expand Up @@ -642,23 +639,6 @@ static void AutoAdjustColumnWidths(SheetsService service, string spreadsheetId,
}
}

/// <summary>
/// Takes a ONE-based column number, unlike PlanTimerSheetColumns.ColumnLetter,
/// which takes the zero-based index the header map and its messages use.
/// </summary>
private static string GetColumnLetter(int columnIndex)
{
string columnLetter = "";
while (columnIndex > 0)
{
int modulo = (columnIndex - 1) % 26;
columnLetter = Convert.ToChar(65 + modulo) + columnLetter;
columnIndex = (columnIndex - modulo) / 26;
}

return columnLetter;
}

static void SetAlternatingColumnColors(SheetsService service, string spreadsheetId, int sheetId, int columnCount,
ILogger logger)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace TimePlanning.Pn.Infrastructure.Helpers;
public static class PlanTimerSheetColumns
{
/// <summary>Columns before this one hold the date and other non-worker data.</summary>
private const int FirstWorkerColumn = 3;
public const int FirstWorkerColumn = 3;

/// <summary>
/// Hyphen plus the en and em dashes a spreadsheet's autocorrect types. The
Expand Down Expand Up @@ -131,6 +131,68 @@ public static Layout Map(IList<object> headerRow)
return new Layout(byKey.Values.OrderBy(x => x.HoursColumn ?? x.TextColumn).ToList(), problems);
}

/// <summary>
/// <paramref name="FirstColumn"/> is the 0-based column the first appended
/// header belongs in. It never precedes FirstWorkerColumn: on a sheet whose
/// header row is empty or short, headers written into A/B/C would sit in the
/// date columns the import skips, and the next push would append them again.
/// </summary>
public sealed record HeaderAppends(
IReadOnlyList<string> Headers,
int FirstColumn,
IReadOnlyList<string> Problems);

/// <summary>
/// The headers a push must append so every site has both a "- timer" and a
/// "- tekst" column. Existing headers are matched the same way the import
/// matches them -- ignoring case, whitespace and dash style -- so a
/// hand-edited header is recognized instead of being duplicated.
///
/// Headers are only ever appended. Nothing is moved, renamed or removed, so
/// no column parts company with the data under it. A site holding only half
/// a pair therefore gets a complete new pair at the end and keeps its old
/// column; the import pairs columns by name, so it goes on reading the old
/// one until someone moves the data over and deletes it. Problems name that
/// column so it does not sit there unnoticed.
/// </summary>
public static HeaderAppends PlanAppends(IList<object> existingHeaders, IEnumerable<string> siteNames)
{
var existing = Map(existingHeaders).Workers.ToDictionary(x => x.Key);
var headers = new List<string>();
var problems = new List<string>();
var planned = new HashSet<string>();

foreach (var siteName in siteNames)
{
var key = NormalizeName(siteName);
if (key.Length == 0 || !planned.Add(key))
{
continue;
}

if (existing.TryGetValue(key, out var columns))
{
if (columns.HoursColumn != null && columns.TextColumn != null)
{
continue;
}

var letter = ColumnLetter(columns.HoursColumn ?? columns.TextColumn!.Value);
problems.Add(
$"\"{siteName}\" had only one of its two columns ({letter}); a complete pair was appended. The import keeps reading column {letter} until its data is moved to the new pair and the column is deleted.");
}

headers.Add(TimerHeader(siteName));
headers.Add(TextHeader(siteName));
}

return new HeaderAppends(headers, Math.Max(existingHeaders.Count, FirstWorkerColumn), problems);
}

public static string TimerHeader(string siteName) => $"{siteName} - timer";

public static string TextHeader(string siteName) => $"{siteName} - tekst";

/// <summary>
/// The cell value, or empty when the column is absent. The Sheets API drops
/// trailing empty cells, so rows are often shorter than the header row.
Expand Down
Loading