Skip to content

Commit 7678328

Browse files
jernejkclaude
andcommitted
feat(check): make ts check leave-aware (merge approved leave into coverage)
`ts check` previously flagged "No timesheets entered" on any zero-timesheet weekday, even when the day was fully covered by approved leave or a public holiday. It never fetched leave. Now the command fetches the employee's leave once per run (UPCOMING + PAST, deduped by Id, Approved-only) and merges it into each day's coverage: - Full-day leave/holiday -> day covered, info issue ("Public Holiday" / "On leave (...)"), no error. - Partial leave -> expected hours = 8 - leaveHours; under-hours only flagged when logged < expected. - Existing checks (overlap, missing descriptions, suggestions, over-10h) unchanged. Day-evaluation logic extracted into a pure `CheckEvaluator` for unit testing. JSON output gains per-day `leaveHours`, `leaveType` (always emitted, null when none), `covered`, `coverReason` ("logged"|"leave-full"|"leave-partial"| "holiday"|"missing"), plus top-level `allCovered`. A fully leave-covered week now exits 0. Human renderer shows a Leave/PH marker instead of a red error. Tests: 10 unit tests for CheckEvaluator + 4 integration tests (WireMock mocks both timesheet-day and leave UPCOMING/PAST endpoints) covering full-day leave, partial leave, and cancelled-leave scenarios. Placeholder data only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fec9786 commit 7678328

5 files changed

Lines changed: 794 additions & 85 deletions

File tree

Lines changed: 92 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
using System.ComponentModel;
2+
using System.Text.Json.Serialization;
23
using SSW.TimePro.Cli.Infrastructure.ApiClient;
34
using SSW.TimePro.Cli.Infrastructure.Config;
45
using SSW.TimePro.Cli.Infrastructure.Output;
6+
using SSW.TimePro.Cli.Shared.Models;
57
using Spectre.Console;
68
using Spectre.Console.Cli;
79

810
namespace SSW.TimePro.Cli.Features.Timesheets;
911

10-
[Description("Validate timesheets for a week — check for gaps and issues")]
12+
[Description("Validate timesheets for a week — check for gaps and issues (leave-aware)")]
1113
public class CheckCommand : AsyncCommand<CheckCommand.Settings>
1214
{
15+
private const int LeavePageSize = 200;
16+
1317
private readonly ITimeProApiClient _api;
1418
private readonly IConfigService _config;
1519

@@ -54,73 +58,45 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
5458

5559
try
5660
{
57-
var dayResults = new List<object>();
61+
// Fetch leave ONCE for the run — the checked week may be entirely past
62+
// (e.g. --week -1) or a Mon–Thu leave may already be "past" by Friday,
63+
// so query both UPCOMING and PAST and merge.
64+
var approvedLeave = await LoadApprovedLeaveAsync(empId, cancellationToken);
65+
66+
var dayResults = new List<DayJson>();
67+
var dayChecks = new List<CheckEvaluator.DayCheck>();
5868
int errors = 0, warnings = 0, infos = 0;
5969

6070
for (var d = monday; d <= friday; d = d.AddDays(1))
6171
{
62-
var timesheets = await _api.GetTimesheetsAsync(empId, d, CancellationToken.None);
72+
var timesheets = await _api.GetTimesheetsAsync(empId, d, cancellationToken);
6373
var real = timesheets.Where(t => !t.IsSuggested).ToList();
6474
var suggested = timesheets.Where(t => t.IsSuggested).ToList();
65-
var totalHours = real.Sum(t => t.TotalTime);
66-
67-
var issues = new List<(string severity, string message)>();
68-
69-
if (real.Count == 0)
70-
{
71-
issues.Add(("error", "No timesheets entered"));
72-
errors++;
73-
}
74-
else if (totalHours < 7.5m)
75-
{
76-
issues.Add(("warning", $"Under 8 hours ({totalHours:0.0}h)"));
77-
warnings++;
78-
}
79-
80-
if (totalHours > 10m)
81-
{
82-
issues.Add(("warning", $"Over 10 hours ({totalHours:0.0}h)"));
83-
warnings++;
84-
}
8575

86-
// Check overlapping times
87-
for (int i = 0; i < real.Count; i++)
88-
{
89-
for (int j = i + 1; j < real.Count; j++)
90-
{
91-
if (TimesOverlap(real[i].StartTime, real[i].EndTime, real[j].StartTime, real[j].EndTime))
92-
{
93-
issues.Add(("error", $"Overlap: #{real[i].TimeId} and #{real[j].TimeId}"));
94-
errors++;
95-
}
96-
}
97-
}
98-
99-
// Check for missing descriptions
100-
foreach (var ts in real.Where(t => !t.HasNotes || string.IsNullOrWhiteSpace(t.Notes)))
101-
{
102-
issues.Add(("warning", $"#{ts.TimeId} has no description"));
103-
warnings++;
104-
}
76+
var check = CheckEvaluator.EvaluateDay(d, real, suggested.Count, approvedLeave);
77+
dayChecks.Add(check);
10578

106-
// Unaccepted suggested timesheets
107-
if (suggested.Count > 0)
108-
{
109-
issues.Add(("info", $"{suggested.Count} suggested timesheet(s) not accepted"));
110-
infos++;
111-
}
79+
errors += check.Issues.Count(i => i.Severity == "error");
80+
warnings += check.Issues.Count(i => i.Severity == "warning");
81+
infos += check.Issues.Count(i => i.Severity == "info");
11282

113-
dayResults.Add(new
83+
dayResults.Add(new DayJson
11484
{
115-
date = d.ToString("yyyy-MM-dd"),
116-
dayOfWeek = d.DayOfWeek.ToString(),
117-
totalHours,
118-
timesheetCount = real.Count,
119-
suggestedCount = suggested.Count,
120-
issues = issues.Select(i => new { i.severity, i.message })
85+
Date = check.Date.ToString("yyyy-MM-dd"),
86+
DayOfWeek = check.Date.DayOfWeek.ToString(),
87+
TotalHours = check.TotalHours,
88+
TimesheetCount = check.TimesheetCount,
89+
SuggestedCount = check.SuggestedCount,
90+
LeaveHours = check.LeaveHours,
91+
LeaveType = check.LeaveType,
92+
Covered = check.Covered,
93+
CoverReason = check.CoverReason,
94+
Issues = check.Issues.Select(i => new IssueJson(i.Severity, i.Message)).ToList()
12195
});
12296
}
12397

98+
var allCovered = dayChecks.All(c => c.Covered);
99+
124100
var result = new
125101
{
126102
empId,
@@ -129,6 +105,7 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
129105
errors,
130106
warnings,
131107
infos,
108+
allCovered,
132109
days = dayResults
133110
};
134111

@@ -137,39 +114,47 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
137114
AnsiConsole.WriteLine();
138115
AnsiConsole.Write(new Rule($"[bold]Week Check: {monday:MMM d} - {friday:MMM d, yyyy}[/]").LeftJustified().RuleStyle("dim"));
139116

140-
foreach (var dayObj in dayResults)
117+
foreach (var check in dayChecks)
141118
{
142-
// Use dynamic to access anonymous type
143-
var day = (dynamic)dayObj;
144-
string date = day.date;
145-
string dow = day.dayOfWeek;
146-
decimal hours = day.totalHours;
147-
var issueList = ((IEnumerable<dynamic>)day.issues).ToList();
148-
149119
string icon;
150-
if (issueList.Any(i => (string)i.severity == "error"))
120+
if (check.Issues.Any(i => i.Severity == "error"))
151121
icon = "[red]x[/]";
152-
else if (issueList.Any(i => (string)i.severity == "warning"))
122+
else if (check.Issues.Any(i => i.Severity == "warning"))
153123
icon = "[yellow]![/]";
154124
else
155125
icon = "[green]v[/]";
156126

157-
var dateOnly = DateOnly.ParseExact(date, "yyyy-MM-dd");
158-
AnsiConsole.MarkupLine($" {icon} {dateOnly:ddd dd} {hours,5:0.0}h {(issueList.Count == 0 ? "[green]OK[/]" : "")}");
127+
var marker = check.CoverReason switch
128+
{
129+
"holiday" => " [blue]PH[/]",
130+
"leave-full" => " [blue]Leave[/]",
131+
"leave-partial" => $" [blue]Leave {check.LeaveHours:0.0}h[/]",
132+
_ => ""
133+
};
134+
135+
var statusLabel = check.Issues.Count == 0
136+
? "[green]OK[/]"
137+
: (!check.HasError && check.Covered ? "[green]OK[/]" : "");
159138

160-
foreach (var issue in issueList)
139+
AnsiConsole.MarkupLine($" {icon} {check.Date:ddd dd} {check.TotalHours,5:0.0}h{marker} {statusLabel}");
140+
141+
foreach (var issue in check.Issues)
161142
{
162-
string sev = issue.severity;
163-
string msg = issue.message;
164-
var color = sev switch { "error" => "red", "warning" => "yellow", _ => "dim" };
165-
AnsiConsole.MarkupLine($" [{color}]{Markup.Escape(msg)}[/]");
143+
var color = issue.Severity switch
144+
{
145+
"error" => "red",
146+
"warning" => "yellow",
147+
"info" when check.CoverReason is "holiday" or "leave-full" => "blue",
148+
_ => "dim"
149+
};
150+
AnsiConsole.MarkupLine($" [{color}]{Markup.Escape(issue.Message)}[/]");
166151
}
167152
}
168153

169154
AnsiConsole.Write(new Rule().RuleStyle("dim"));
170155

171156
if (errors == 0 && warnings == 0)
172-
OutputHelper.WriteSuccess("All clear — no issues found");
157+
OutputHelper.WriteSuccess(allCovered ? "All clear — every day covered" : "All clear — no issues found");
173158
else
174159
AnsiConsole.MarkupLine($" [red]{errors} error(s)[/], [yellow]{warnings} warning(s)[/], [dim]{infos} info(s)[/]");
175160

@@ -185,23 +170,45 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
185170
}
186171
}
187172

188-
private static bool TimesOverlap(string? start1, string? end1, string? start2, string? end2)
173+
/// <summary>
174+
/// Loads approved leave for the employee across both UPCOMING and PAST filters,
175+
/// dedupes by Id, and reduces to date-ranged day records.
176+
/// </summary>
177+
private async Task<List<CheckEvaluator.LeaveDay>> LoadApprovedLeaveAsync(string empId, CancellationToken ct)
189178
{
190-
if (start1 is null || end1 is null || start2 is null || end2 is null) return false;
191-
try
192-
{
193-
var s1 = DateTime.Parse(start1);
194-
var e1 = DateTime.Parse(end1);
195-
var s2 = DateTime.Parse(start2);
196-
var e2 = DateTime.Parse(end2);
197-
return s1 < e2 && s2 < e1;
198-
}
199-
catch
179+
var entries = new List<LeaveEntry>();
180+
181+
foreach (var filter in new[] { "UPCOMING", "PAST" })
200182
{
201-
return false;
183+
var response = await _api.GetLeaveAsync(filter, 1, LeavePageSize, empId, ct);
184+
var items = response?.Leaves?.Items;
185+
if (items is not null)
186+
entries.AddRange(items);
202187
}
188+
189+
return CheckEvaluator.ToLeaveDays(entries);
203190
}
204191

205192
private static string ResolveEmpId(string? requestedEmpId, string defaultEmpId) =>
206193
string.IsNullOrWhiteSpace(requestedEmpId) ? defaultEmpId : requestedEmpId.Trim();
194+
195+
/// <summary>Per-day JSON shape. <see cref="LeaveType"/> is always emitted (null when no leave).</summary>
196+
private sealed class DayJson
197+
{
198+
public string Date { get; init; } = string.Empty;
199+
public string DayOfWeek { get; init; } = string.Empty;
200+
public decimal TotalHours { get; init; }
201+
public int TimesheetCount { get; init; }
202+
public int SuggestedCount { get; init; }
203+
public decimal LeaveHours { get; init; }
204+
205+
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
206+
public string? LeaveType { get; init; }
207+
208+
public bool Covered { get; init; }
209+
public string CoverReason { get; init; } = string.Empty;
210+
public IReadOnlyList<IssueJson> Issues { get; init; } = [];
211+
}
212+
213+
private sealed record IssueJson(string Severity, string Message);
207214
}

0 commit comments

Comments
 (0)