11using System . ComponentModel ;
2+ using System . Text . Json . Serialization ;
23using SSW . TimePro . Cli . Infrastructure . ApiClient ;
34using SSW . TimePro . Cli . Infrastructure . Config ;
45using SSW . TimePro . Cli . Infrastructure . Output ;
6+ using SSW . TimePro . Cli . Shared . Models ;
57using Spectre . Console ;
68using Spectre . Console . Cli ;
79
810namespace 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) " ) ]
1113public 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