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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,16 @@ stay apart on purpose — `rate get --json` answers a miss with the full `RateLo
while the MCP tool still returns the raw response and a bare `null`; aligning them is a 0.4.0
contract change, not a patch.

### Shared timesheet reads

`TimesheetLookup.ForRangeAsync` is the one per-day range read. The weekend policy is its explicit
`WeekendPolicy` argument, not a duplicated loop: `ts get` includes Saturday and Sunday, the MCP
`GetTimesheets` tool skips them (a weekend-only range answers empty without calling the API).
`TimesheetLookup.RefreshAndReadSuggestedAsync` is the refresh-then-read behind `ts suggest` and
`GetSuggestedTimesheets`; the refresh is a server-side write, so it happens once, in there.
`WeekCheckResult` is the week-coverage document both `ts check --json` and `CheckWeek` serialise.
The projections that remain per-surface are envelope shapes only, declared in the parity table.

### `--json` error envelope

On the `--json` path, failures emit a structured envelope to **stdout** so stdout stays valid JSON: `{"error":{"code":<int|null>,"message":"...","detail":<string|null>}}` (all keys always present), with a non-zero exit code. Human-readable error/warning text goes to **stderr**.
Expand Down Expand Up @@ -306,6 +316,9 @@ tests fail until you do.
- `McpStdioClient` launches the real `tp mcp` with `TIMEPRO_CLI_CONFIG_DIR` pointing at a throwaway
config. `Goldens/Mcp/Discovery/` holds the `tools/list` snapshots with accounting off (18 tools)
and on (47); `Goldens/Mcp/Calls/` holds `tools/call` envelopes.
- `TimesheetToolsUsingApiDirectly` is the shrink-only allowlist of timesheet tools still calling
`ITimeProApiClient` themselves; `ToolIlScanner` reads the tools' IL (constructor inspection cannot
answer it, since the shared services take the client as an argument).
- `McpCliParityTable` pairs every tool with its CLI command. Differences are declared per case as
JSON paths — there is no generic normalisation — and `ExpectParity` flips to true as each slice
lands. `ToolsWithoutCliMirror` may only shrink.
Expand Down
62 changes: 15 additions & 47 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/TimesheetMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,18 @@ public async Task<string> GetTimesheets(
? DateOnly.ParseExact(endDate, "yyyy-MM-dd")
: start;

var allTimesheets = new List<object>();
for (var d = start; d <= end; d = d.AddDays(1))
// Weekends are skipped, so a weekend-only range answers with an empty array and no call.
var days = await TimesheetLookup.ForRangeAsync(
_api, targetEmpId, start, end, WeekendPolicy.Skip, ct);

var allTimesheets = days.SelectMany(day => day.Entries.Select(t => new
{
if (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;

var dayTimesheets = await _api.GetTimesheetsAsync(targetEmpId, d, ct);
allTimesheets.AddRange(dayTimesheets.Select(t => new
{
t.TimeId, t.EmpId, t.EmpName, t.Client, t.ClientId, t.Project, t.ProjectId,
date = d.ToString("yyyy-MM-dd"),
t.StartTime, t.EndTime, t.TotalTime,
t.Location, t.BillableId, t.IsSuggested,
t.Notes, t.IsLocked, t.InvoiceId
}));
}
t.TimeId, t.EmpId, t.EmpName, t.Client, t.ClientId, t.Project, t.ProjectId,
date = day.Date.ToString("yyyy-MM-dd"),
t.StartTime, t.EndTime, t.TotalTime,
t.Location, t.BillableId, t.IsSuggested,
t.Notes, t.IsLocked, t.InvoiceId
})).ToList();

return JsonSerializer.Serialize(allTimesheets, JsonOpts);
}
Expand Down Expand Up @@ -229,32 +225,7 @@ public async Task<string> CheckWeek(
// Shared orchestration with `tp ts check` — fetch + leave-merge + per-day eval.
var coverage = await WeekCoverageService.EvaluateWeekAsync(_api, targetEmpId, week, ct);

var result = new
{
empId = coverage.EmpId,
weekStart = coverage.Monday.ToString("yyyy-MM-dd"),
weekEnd = coverage.Friday.ToString("yyyy-MM-dd"),
errors = coverage.Errors,
warnings = coverage.Warnings,
infos = coverage.Infos,
allCovered = coverage.AllCovered,
pendingSuggestions = CheckEvaluator.CountPendingSuggestions(coverage.Days),
days = coverage.Days.Select(check => new
{
date = check.Date.ToString("yyyy-MM-dd"),
dayOfWeek = check.Date.DayOfWeek.ToString(),
totalHours = check.TotalHours,
timesheetCount = check.TimesheetCount,
suggestedCount = check.SuggestedCount,
leaveHours = check.LeaveHours,
leaveType = check.LeaveType,
covered = check.Covered,
coverReason = check.CoverReason,
issues = check.Issues.Select(i => new { i.Severity, i.Message })
})
};

return JsonSerializer.Serialize(result, JsonOpts);
return JsonSerializer.Serialize(WeekCheckResult.From(coverage), JsonOpts);
}

[McpServerTool]
Expand All @@ -267,13 +238,10 @@ public async Task<string> GetSuggestedTimesheets(
if (tenant?.EmployeeId is null)
return """{"error": "Not logged in"}""";

var dateOnly = DateOnly.ParseExact(date, "yyyy-MM-dd");
await _api.RefreshSuggestedTimesheetsAsync(tenant.EmployeeId, dateOnly, ct);

var all = await _api.GetTimesheetsAsync(tenant.EmployeeId, dateOnly, ct);
var suggested = all.Where(t => t.IsSuggested).ToList();
var day = await TimesheetLookup.RefreshAndReadSuggestedAsync(
_api, tenant.EmployeeId, DateOnly.ParseExact(date, "yyyy-MM-dd"), ct);

return JsonSerializer.Serialize(suggested, JsonOpts);
return JsonSerializer.Serialize(day.Entries, JsonOpts);
}

[McpServerTool]
Expand Down
58 changes: 4 additions & 54 deletions src/SSW.TimePro.Cli/Features/Timesheets/CheckCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.ComponentModel;
using System.Text.Json.Serialization;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Infrastructure.Output;
Expand Down Expand Up @@ -59,42 +58,13 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
var coverage = await WeekCoverageService.EvaluateWeekAsync(_api, empId, offset, cancellationToken);

var dayChecks = coverage.Days;
var errors = coverage.Errors;
var warnings = coverage.Warnings;
var infos = coverage.Infos;
var allCovered = coverage.AllCovered;
var monday = coverage.Monday;
var friday = coverage.Friday;

var dayResults = dayChecks.Select(check => new DayJson
{
Date = check.Date.ToString("yyyy-MM-dd"),
DayOfWeek = check.Date.DayOfWeek.ToString(),
TotalHours = check.TotalHours,
TimesheetCount = check.TimesheetCount,
SuggestedCount = check.SuggestedCount,
LeaveHours = check.LeaveHours,
LeaveType = check.LeaveType,
Covered = check.Covered,
CoverReason = check.CoverReason,
Issues = check.Issues.Select(i => new IssueJson(i.Severity, i.Message)).ToList()
}).ToList();

var pendingSuggestions = CheckEvaluator.CountPendingSuggestions(dayChecks);
var summary = CheckEvaluator.Summarize(errors, warnings, infos, pendingSuggestions, allCovered, settings.Strict);

var result = new
{
empId,
weekStart = monday.ToString("yyyy-MM-dd"),
weekEnd = friday.ToString("yyyy-MM-dd"),
errors,
warnings,
infos,
allCovered,
pendingSuggestions,
days = dayResults
};
var result = WeekCheckResult.From(coverage);
var summary = CheckEvaluator.Summarize(
coverage.Errors, coverage.Warnings, coverage.Infos,
result.PendingSuggestions, coverage.AllCovered, settings.Strict);

OutputHelper.Render(result, settings.Json, _ =>
{
Expand Down Expand Up @@ -162,24 +132,4 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings

private static string ResolveEmpId(string? requestedEmpId, string defaultEmpId) =>
string.IsNullOrWhiteSpace(requestedEmpId) ? defaultEmpId : requestedEmpId.Trim();

/// <summary>Per-day JSON shape. <see cref="LeaveType"/> is always emitted (null when no leave).</summary>
private sealed class DayJson
{
public string Date { get; init; } = string.Empty;
public string DayOfWeek { get; init; } = string.Empty;
public decimal TotalHours { get; init; }
public int TimesheetCount { get; init; }
public int SuggestedCount { get; init; }
public decimal LeaveHours { get; init; }

[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public string? LeaveType { get; init; }

public bool Covered { get; init; }
public string CoverReason { get; init; } = string.Empty;
public IReadOnlyList<IssueJson> Issues { get; init; } = [];
}

private sealed record IssueJson(string Severity, string Message);
}
28 changes: 13 additions & 15 deletions src/SSW.TimePro.Cli/Features/Timesheets/GetCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,22 +132,20 @@ private async Task<int> RenderDay(string empId, DateOnly date, Settings settings

private async Task<int> RenderRange(string empId, DateOnly start, DateOnly end, Settings settings, bool isWeek)
{
// Fetch all days in range
var allTimesheets = new Dictionary<DateOnly, List<TimesheetItem>>();
for (var d = start; d <= end; d = d.AddDays(1))
{
var dayTimesheets = await _api.GetTimesheetsAsync(empId, d, CancellationToken.None);
allTimesheets[d] = dayTimesheets;
}
// The CLI shows weekend work; the MCP GetTimesheets tool skips it.
var fetched = await TimesheetLookup.ForRangeAsync(
_api, empId, start, end, WeekendPolicy.Include, CancellationToken.None);

var allTimesheets = fetched.ToDictionary(d => d.Date, d => d.Entries);

if (settings.Json)
{
var days = allTimesheets.Select(kvp => new
var days = fetched.Select(day => new
{
date = kvp.Key.ToString("yyyy-MM-dd"),
dayOfWeek = kvp.Key.DayOfWeek.ToString(),
timesheets = kvp.Value,
totalHours = kvp.Value.Where(t => !t.IsSuggested).Sum(t => t.TotalTime)
date = day.Date.ToString("yyyy-MM-dd"),
dayOfWeek = day.Date.DayOfWeek.ToString(),
timesheets = day.Entries,
totalHours = day.Entries.Where(t => !t.IsSuggested).Sum(t => t.TotalTime)
}).ToList();

object jsonData = isWeek
Expand All @@ -170,7 +168,7 @@ private async Task<int> RenderRange(string empId, DateOnly start, DateOnly end,
return 0;
}

private void RenderWeekCompact(DateOnly start, DateOnly end, Dictionary<DateOnly, List<TimesheetItem>> allTimesheets)
private void RenderWeekCompact(DateOnly start, DateOnly end, IReadOnlyDictionary<DateOnly, IReadOnlyList<TimesheetItem>> allTimesheets)
{
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($" [bold]Week of {start:MMM d} - {end:MMM d, yyyy}[/]");
Expand Down Expand Up @@ -228,15 +226,15 @@ private void RenderWeekCompact(DateOnly start, DateOnly end, Dictionary<DateOnly
AnsiConsole.WriteLine();
}

private void RenderWeekDetailed(DateOnly start, DateOnly end, Dictionary<DateOnly, List<TimesheetItem>> allTimesheets)
private void RenderWeekDetailed(DateOnly start, DateOnly end, IReadOnlyDictionary<DateOnly, IReadOnlyList<TimesheetItem>> allTimesheets)
{
foreach (var (date, timesheets) in allTimesheets.OrderBy(x => x.Key))
{
RenderDayDetailed(date, timesheets);
}
}

private void RenderDayDetailed(DateOnly date, List<TimesheetItem> timesheets)
private void RenderDayDetailed(DateOnly date, IReadOnlyList<TimesheetItem> timesheets)
{
var realTimesheets = timesheets.Where(t => !t.IsSuggested).ToList();
var dayTotal = realTimesheets.Sum(t => t.TotalTime);
Expand Down
16 changes: 6 additions & 10 deletions src/SSW.TimePro.Cli/Features/Timesheets/SuggestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,22 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
dates.Add(date);
}

var allSuggested = new List<(DateOnly Date, List<TimesheetItem> Items)>();
var allSuggested = new List<TimesheetDay>();

foreach (var date in dates)
{
// Refresh suggested timesheets first
await _api.RefreshSuggestedTimesheetsAsync(tenant.EmployeeId, date, CancellationToken.None);

// Fetch all timesheets and filter to suggested
var all = await _api.GetTimesheetsAsync(tenant.EmployeeId, date, CancellationToken.None);
var suggested = all.Where(t => t.IsSuggested).ToList();
if (suggested.Count > 0)
allSuggested.Add((date, suggested));
var day = await TimesheetLookup.RefreshAndReadSuggestedAsync(
_api, tenant.EmployeeId, date, CancellationToken.None);
if (day.Entries.Count > 0)
allSuggested.Add(day);
}

if (settings.Json)
{
OutputHelper.WriteJson(allSuggested.Select(d => new
{
date = d.Date.ToString("yyyy-MM-dd"),
suggested = d.Items
suggested = d.Entries
}));
return 0;
}
Expand Down
49 changes: 49 additions & 0 deletions src/SSW.TimePro.Cli/Features/Timesheets/TimesheetLookup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ namespace SSW.TimePro.Cli.Features.Timesheets;

public sealed record TimesheetDay(DateOnly Date, IReadOnlyList<TimesheetItem> Entries);

/// <summary>Whether a date range read covers Saturday and Sunday. The two surfaces answer differently.</summary>
public enum WeekendPolicy
{
Include,
Skip
}

/// <summary>
/// Finding an entry and reading one back after a write. Both the read-merge update path and the
/// empty-body write responses need a day's worth of entries, so the lookups are shared.
Expand All @@ -14,6 +21,48 @@ public static class TimesheetLookup
{
private const int SearchDays = 28;

/// <summary>
/// Reads every day in <paramref name="start"/>..<paramref name="end"/> in order. Callers pass
/// their own <paramref name="weekends"/> policy: <c>ts get</c> shows weekend work, the MCP
/// GetTimesheets tool skips it.
/// </summary>
public static async Task<List<TimesheetDay>> ForRangeAsync(
ITimeProApiClient api,
string empId,
DateOnly start,
DateOnly end,
WeekendPolicy weekends,
CancellationToken ct = default)
{
var days = new List<TimesheetDay>();
for (var d = start; d <= end; d = d.AddDays(1))
{
if (weekends == WeekendPolicy.Skip && d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
continue;

days.Add(new TimesheetDay(d, await api.GetTimesheetsAsync(empId, d, ct)));
}

return days;
}

/// <summary>
/// The suggestion read behind <c>ts suggest</c> and the MCP GetSuggestedTimesheets tool.
/// The refresh is a server-side write that regenerates the day's suggestions, so it happens
/// exactly once here rather than once per surface.
/// </summary>
public static async Task<TimesheetDay> RefreshAndReadSuggestedAsync(
ITimeProApiClient api,
string empId,
DateOnly date,
CancellationToken ct = default)
{
await api.RefreshSuggestedTimesheetsAsync(empId, date, ct);

var entries = await api.GetTimesheetsAsync(empId, date, ct);
return new TimesheetDay(date, entries.Where(t => t.IsSuggested).ToList());
}

public static async Task<(TimesheetItem Item, TimesheetDay Day)?> FindAsync(
ITimeProApiClient api,
string empId,
Expand Down
Loading