Skip to content
Open
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ The leave create endpoint (`POST /api/leave/`) requires these fields in the requ
- **Required**: `RequestedEmpId`, `StartDate` (DateTimeOffset), `EndDate` (DateTimeOffset), `LeaveTypeId`, `UserStartTime`, `UserEndTime`, `AllDay`
- **Optional**: `Note`, `OptionalEmp` (CC emails), `ApprovedBy` (email), `TimeLessOverride`

`TimeLessOverride` is in **hours** and replaces the lunch-break deduction on every leave day
(`0` = no break, `null` = deduct the employee's lunch break). The leave timesheets EasyLeave
creates take their `less` from it too. `--less` and the MCP `lessMinutes` take whole minutes,
like `ts create`, and the services convert.

The leave update endpoint (`PUT /api/leave/`) uses the same full payload plus `Id`.
It is a replacement operation, not a patch. All CLI and MCP updates must go through
`LeaveUpdateService`, which reads the existing request and preserves omitted API-returned fields
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ tp leave create --start 2026-03-30 --end 2026-03-30 --type "Annual Leave" \
tp leave update <ID> --start 2026-04-01 --end 2026-04-01 \
--note "Updated travel date" --yes

# Make a 09:00-13:00 half day count as 4 hours by removing the lunch-break deduction
tp leave update <ID> --less 0 --yes

# Validate the same update and inspect the complete payload without applying it
tp leave update <ID> --start 2026-04-01 --end 2026-04-01 \
--note "Updated travel date" --dry-run --json
Expand All @@ -288,6 +291,7 @@ Leave create options:
- `--half-day` for partial day requests (start and end date must be the same)
- `--start-time` / `--end-time` override defaults (09:00/18:00)
- `--timezone` overrides the request timezone with an IANA or Windows timezone ID
- `--less` sets the break in whole minutes (`--less 0` for none), replacing the lunch-break deduction

Leave create uses `--timezone` first when supplied, then the TimePro user profile timezone when it is set. If neither is set, the CLI/MCP host uses the machine timezone as the browser-equivalent fallback; agents can control that by choosing the environment used to launch `tp`.

Expand All @@ -297,7 +301,8 @@ field that was not explicitly changed. Stored workday times are preserved when T
returns them; older list responses omit them, so update falls back to the current profile
times and then 09:00-18:00. Use `--start-time` and `--end-time` when those values must be
explicit. Use `--clear-approved-by` or `--clear-cc` to remove those values, and
`--half-day` / `--full-day` to change the day mode. Both create and update support
`--half-day` / `--full-day` to change the day mode. `--less <MINUTES>` overrides the break
deducted from each leave day, and `--clear-less` goes back to the lunch-break default. Both create and update support
`--dry-run`; combine it with `--json` to inspect the exact API payload without creating
or changing leave.

Expand Down
14 changes: 13 additions & 1 deletion src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using SSW.TimePro.Cli.Features.Timesheets;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Output;
using Spectre.Console;
Expand Down Expand Up @@ -50,6 +51,10 @@ public class Settings : CommandSettings
[Description("End time override (HH:mm, default: 18:00)")]
public string? EndTime { get; set; }

[CommandOption("--less <MINUTES>")]
[Description("Break/less time in whole minutes, e.g. --less 0 for no break. Overrides the lunch-break default")]
public string? Less { get; set; }

[CommandOption("--timezone <TIMEZONE_ID>")]
[Description("Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone")]
public string? TimeZoneId { get; set; }
Expand Down Expand Up @@ -84,6 +89,12 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
return 1;
}

if (!LessOption.TryParse(settings.Less, out var lessMinutes, out var lessError))
{
WriteValidationError(settings.Json, lessError!);
return 1;
}

try
{
var plan = await _createService.PrepareAsync(
Expand All @@ -98,7 +109,8 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
HalfDay: settings.HalfDay,
StartTime: settings.StartTime,
EndTime: settings.EndTime,
TimeZoneId: settings.TimeZoneId),
TimeZoneId: settings.TimeZoneId,
LessMinutes: lessMinutes),
cancellationToken);

if (!settings.Json && (!settings.Yes || settings.DryRun))
Expand Down
8 changes: 6 additions & 2 deletions src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public sealed record LeaveCreateOptions(
bool HalfDay = false,
string? StartTime = null,
string? EndTime = null,
string? TimeZoneId = null);
string? TimeZoneId = null,
int? LessMinutes = null);

public sealed record LeaveCreatePlan(CreateLeaveRequest Request, string TypeLabel);

Expand Down Expand Up @@ -58,6 +59,9 @@ public async Task<LeaveCreatePlan> PrepareAsync(
throw new LeaveCreateValidationException("--start, --end, and --type are required");
}

if (options.LessMinutes is < 0)
throw new LeaveCreateValidationException("Break/less time must be zero or greater");

if (string.IsNullOrWhiteSpace(options.Note))
{
throw new LeaveCreateValidationException(
Expand Down Expand Up @@ -140,7 +144,7 @@ public async Task<LeaveCreatePlan> PrepareAsync(
AllDay = allDay,
OptionalEmp = LeaveRequestParser.ParseOptionalEmployees(options.Cc),
ApprovedBy = options.ApprovedBy?.Trim(),
TimeLessOverride = null
TimeLessOverride = options.LessMinutes / 60m
};

return new LeaveCreatePlan(request, options.Type);
Expand Down
36 changes: 31 additions & 5 deletions src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ public sealed record LeaveUpdateOptions(
bool? AllDay = null,
string? StartTime = null,
string? EndTime = null,
string? TimeZoneId = null);
string? TimeZoneId = null,
int? LessMinutes = null,
bool ClearLess = false);

public sealed record LeaveUpdatePlan(
LeaveEntry Existing,
Expand Down Expand Up @@ -56,6 +58,12 @@ public async Task<LeaveUpdatePlan> PrepareAsync(
if (options.ClearCc && options.Cc is not null)
throw new LeaveUpdateValidationException("Use either cc or clearCc, not both");

if (options.ClearLess && options.LessMinutes is not null)
throw new LeaveUpdateValidationException("Use either less or clearLess, not both");

if (options.LessMinutes is < 0)
throw new LeaveUpdateValidationException("Break/less time must be zero or greater");

var normalizedLeaveId = parsedLeaveId.ToString();
var existing = await _lookup.FindAsync(normalizedLeaveId, employeeId, ct)
?? throw new LeaveUpdateValidationException(
Expand All @@ -69,7 +77,7 @@ public async Task<LeaveUpdatePlan> PrepareAsync(
var changes = DescribeChanges(options);
if (changes.Count == 0)
throw new LeaveUpdateValidationException(
"No changes specified. Use --start, --end, --type, --note, --approved-by, --cc, --half-day, --full-day, --start-time, or --end-time.");
"No changes specified. Use --start, --end, --type, --note, --approved-by, --cc, --half-day, --full-day, --start-time, --end-time, --less, or --clear-less.");

var employeeSettings = await _api.GetEmployeeSettingsAsync(ct);
if (!LeaveRequestParser.TryResolveRequestTimeZone(
Expand Down Expand Up @@ -101,8 +109,14 @@ public async Task<LeaveUpdatePlan> PrepareAsync(

var allDay = options.AllDay ?? existing.AllDay;

// A partial-day request's window is its stored start and end. The list response omits
// UserStartTime/UserEndTime, and the profile workday would silently widen the window.
var keepsPartialWindow = !existing.AllDay && !allDay;
var existingWindowStart = keepsPartialWindow ? TimePart(existing.StartDateLocal) : null;
var existingWindowEnd = keepsPartialWindow ? TimePart(existing.EndDateLocal) : null;

if (!LeaveRequestParser.TryParseWorkdayTime(
options.StartTime ?? existing.UserStartTime ?? employeeSettings?.StartTime,
options.StartTime ?? existing.UserStartTime ?? existingWindowStart ?? employeeSettings?.StartTime,
LeaveRequestParser.DefaultStartTime,
"start",
out var userStartTime,
Expand All @@ -112,7 +126,7 @@ public async Task<LeaveUpdatePlan> PrepareAsync(
}

if (!LeaveRequestParser.TryParseWorkdayTime(
options.EndTime ?? existing.UserEndTime ?? employeeSettings?.EndTime,
options.EndTime ?? existing.UserEndTime ?? existingWindowEnd ?? employeeSettings?.EndTime,
LeaveRequestParser.DefaultEndTime,
"end",
out var userEndTime,
Expand Down Expand Up @@ -184,7 +198,11 @@ options.Type is null
AllDay = allDay,
OptionalEmp = optionalEmployees,
ApprovedBy = approvedBy,
TimeLessOverride = existing.TimeLessOverride
TimeLessOverride = options.ClearLess
? null
: options.LessMinutes is { } lessMinutes
? lessMinutes / 60m
: existing.TimeLessOverride
};

return new LeaveUpdatePlan(existing, request, changes);
Expand Down Expand Up @@ -217,11 +235,19 @@ private static List<string> DescribeChanges(LeaveUpdateOptions options)
if (options.AllDay is not null) changes.Add(options.AllDay.Value ? "Day mode -> full day" : "Day mode -> partial day");
if (options.StartTime is not null) changes.Add($"Workday start -> {options.StartTime}");
if (options.EndTime is not null) changes.Add($"Workday end -> {options.EndTime}");
if (options.ClearLess) changes.Add("Break -> lunch-break default");
else if (options.LessMinutes is not null) changes.Add($"Break -> {options.LessMinutes} min");
return changes;
}

private static string? DatePart(string? value) => value?.Split('T')[0];

private static string? TimePart(string? value)
{
var parts = value?.Split('T');
return parts is { Length: 2 } && parts[1].Length >= 5 ? parts[1][..5] : null;
}

private static string Truncate(string value) =>
value.Length > 50 ? value[..50] + "..." : value;

Expand Down
19 changes: 18 additions & 1 deletion src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using SSW.TimePro.Cli.Features.Timesheets;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Infrastructure.Output;
Expand Down Expand Up @@ -67,6 +68,14 @@ public class Settings : CommandSettings
[Description("Employee workday end time (HH:mm)")]
public string? EndTime { get; set; }

[CommandOption("--less <MINUTES>")]
[Description("Break/less time in whole minutes, e.g. --less 0 for no break. Overrides the lunch-break default")]
public string? Less { get; set; }

[CommandOption("--clear-less")]
[Description("Remove the break override and use the lunch-break default again")]
public bool ClearLess { get; set; }

[CommandOption("--timezone <TIMEZONE_ID>")]
[Description("Timezone for changed dates (IANA or Windows ID)")]
public string? TimeZoneId { get; set; }
Expand Down Expand Up @@ -108,6 +117,12 @@ protected override async Task<int> ExecuteAsync(
return 1;
}

if (!LessOption.TryParse(settings.Less, out var lessMinutes, out var lessError))
{
WriteValidationError(settings.Json, lessError!);
return 1;
}

try
{
var plan = await _updateService.PrepareAsync(
Expand All @@ -125,7 +140,9 @@ protected override async Task<int> ExecuteAsync(
AllDay: settings.HalfDay ? false : settings.FullDay ? true : null,
StartTime: settings.StartTime,
EndTime: settings.EndTime,
TimeZoneId: settings.TimeZoneId),
TimeZoneId: settings.TimeZoneId,
LessMinutes: lessMinutes,
ClearLess: settings.ClearLess),
cancellationToken);

if (!settings.Json)
Expand Down
10 changes: 8 additions & 2 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public async Task<string> CreateLeave(
[Description("Start time override (HH:mm, default 09:00)")] string? startTime = null,
[Description("End time override (HH:mm, default 18:00)")] string? endTime = null,
[Description("Timezone override (IANA or Windows ID); takes priority over the TimePro user profile timezone")] string? timeZoneId = null,
[Description("Break/less time in whole minutes (0 for no break); omit to use the lunch-break default")] int? lessMinutes = null,
[Description("Validate and return the proposed request without creating leave")] bool dryRun = false,
CancellationToken ct = default)
{
Expand All @@ -127,7 +128,8 @@ public async Task<string> CreateLeave(
HalfDay: halfDay,
StartTime: startTime,
EndTime: endTime,
TimeZoneId: timeZoneId),
TimeZoneId: timeZoneId,
LessMinutes: lessMinutes),
ct);

if (dryRun)
Expand Down Expand Up @@ -158,6 +160,8 @@ public async Task<string> UpdateLeave(
[Description("Employee workday start time (HH:mm); omit to preserve it when returned, otherwise use the profile value")] string? startTime = null,
[Description("Employee workday end time (HH:mm); omit to preserve it when returned, otherwise use the profile value")] string? endTime = null,
[Description("Timezone for changed dates (IANA or Windows ID)")] string? timeZoneId = null,
[Description("Break/less time in whole minutes (0 for no break); omit to preserve")] int? lessMinutes = null,
[Description("Remove the break override and use the lunch-break default again")] bool clearLess = false,
[Description("Validate and return the proposed update without applying it")] bool dryRun = false,
CancellationToken ct = default)
{
Expand All @@ -182,7 +186,9 @@ public async Task<string> UpdateLeave(
AllDay: halfDay is null ? null : !halfDay.Value,
StartTime: startTime,
EndTime: endTime,
TimeZoneId: timeZoneId),
TimeZoneId: timeZoneId,
LessMinutes: lessMinutes,
ClearLess: clearLess),
ct);

if (dryRun)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 \
tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 --note "Reason" --dry-run --json
tp leave update <ID> --start 2026-04-01 --end 2026-04-01 --note "Updated reason" --yes
tp leave update <ID> --note "Updated reason" --dry-run --json
# Leave deducts the lunch break, so a 09:00-13:00 half day counts 3 hours. --less 0 removes it.
tp leave update <ID> --less 0 --yes
tp leave cancel <ID> --reason "Plans changed" --yes --wait
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@
"description": "Request partial-day leave; start and end must be the same day",
"type": "boolean"
},
"lessMinutes": {
"default": null,
"description": "Break/less time in whole minutes (0 for no break); omit to use the lunch-break default",
"type": [
"integer",
"null"
]
},
"note": {
"description": "Leave note/reason",
"type": "string"
Expand Down Expand Up @@ -1376,6 +1384,11 @@
"description": "Remove all current CC recipients",
"type": "boolean"
},
"clearLess": {
"default": false,
"description": "Remove the break override and use the lunch-break default again",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "Validate and return the proposed update without applying it",
Expand Down Expand Up @@ -1409,6 +1422,14 @@
"description": "Leave entry ID (GUID)",
"type": "string"
},
"lessMinutes": {
"default": null,
"description": "Break/less time in whole minutes (0 for no break); omit to preserve",
"type": [
"integer",
"null"
]
},
"note": {
"default": null,
"description": "New leave note/reason; omit to preserve",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@
"description": "Request partial-day leave; start and end must be the same day",
"type": "boolean"
},
"lessMinutes": {
"default": null,
"description": "Break/less time in whole minutes (0 for no break); omit to use the lunch-break default",
"type": [
"integer",
"null"
]
},
"note": {
"description": "Leave note/reason",
"type": "string"
Expand Down Expand Up @@ -592,6 +600,11 @@
"description": "Remove all current CC recipients",
"type": "boolean"
},
"clearLess": {
"default": false,
"description": "Remove the break override and use the lunch-break default again",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "Validate and return the proposed update without applying it",
Expand Down Expand Up @@ -625,6 +638,14 @@
"description": "Leave entry ID (GUID)",
"type": "string"
},
"lessMinutes": {
"default": null,
"description": "Break/less time in whole minutes (0 for no break); omit to preserve",
"type": [
"integer",
"null"
]
},
"note": {
"default": null,
"description": "New leave note/reason; omit to preserve",
Expand Down
Loading