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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ Leave create options:
- `--cc` comma-separated list of emails to notify
- `--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

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`.

### Week View

Expand Down Expand Up @@ -547,7 +550,7 @@ Current default tool groups include:
|-------|----------|
| Timesheets | Get, create, update, delete, suggested timesheets, accept suggestions, list iterations, `check_week` (leave-aware weekly coverage) |
| Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping |
| Leave | List EasyLeave entries (optionally filtered by `empId`), `get_leave_balance` (days since last leave + 12-month hours) |
| Leave | List EasyLeave entries (optionally filtered by `empId`), create EasyLeave requests using timezone override/profile/machine fallback, `get_leave_balance` (days since last leave + 12-month hours) |

Optional accounting MCP tools are enabled with:

Expand Down
53 changes: 22 additions & 31 deletions src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public class Settings : CommandSettings
[Description("End time override (HH:mm, default: 18:00)")]
public string? EndTime { 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; }

[CommandOption("--yes")]
[Description("Skip confirmation")]
public bool Yes { get; set; }
Expand Down Expand Up @@ -89,31 +93,25 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings

try
{
// Parse dates with timezone offset
if (!DateTimeOffset.TryParse(settings.Start, out var startDate))
var settingsDetails = string.IsNullOrWhiteSpace(settings.TimeZoneId)
? await _api.GetEmployeeSettingsAsync(cancellationToken)
: null;
if (!LeaveRequestParser.TryResolveRequestTimeZone(settings.TimeZoneId, settingsDetails, out var requestTimeZone, out var timeZoneError))
{
// Support plain yyyy-MM-dd by adding local timezone offset
if (!DateOnly.TryParse(settings.Start, out var startDateOnly))
{
OutputHelper.WriteError($"Invalid start date: '{settings.Start}'. Use yyyy-MM-dd format.");
return 1;
}
startDate = new DateTimeOffset(startDateOnly.ToDateTime(TimeOnly.MinValue), TimeZoneInfo.Local.GetUtcOffset(DateTime.Now));
WriteValidationError(settings.Json, timeZoneError ?? "Invalid leave request timezone");
return 1;
}

if (!DateTimeOffset.TryParse(settings.End, out var endDate))
if (!LeaveRequestParser.TryParseDateRange(
settings.Start,
settings.End,
requestTimeZone,
out var startDate,
out var endDate,
out var dateError))
{
if (!DateOnly.TryParse(settings.End, out var endDateOnly))
{
OutputHelper.WriteError($"Invalid end date: '{settings.End}'. Use yyyy-MM-dd format.");
return 1;
}
endDate = new DateTimeOffset(endDateOnly.ToDateTime(new TimeOnly(23, 59, 0)), TimeZoneInfo.Local.GetUtcOffset(DateTime.Now));
}
else if (endDate.TimeOfDay == TimeSpan.Zero)
{
// If end date was parsed but has no time component, set to 23:59
endDate = new DateTimeOffset(endDate.Date.AddHours(23).AddMinutes(59), endDate.Offset);
WriteValidationError(settings.Json, dateError ?? "Invalid leave date range");
return 1;
}

if (IsWeekend(startDate) || IsWeekend(endDate))
Expand All @@ -135,16 +133,9 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
}

var allDay = !settings.HalfDay;
var userStartTime = settings.StartTime ?? "09:00:00";
var userEndTime = settings.EndTime ?? "18:00:00";

// Normalize time format to HH:mm:ss
if (userStartTime.Length == 5) userStartTime += ":00";
if (userEndTime.Length == 5) userEndTime += ":00";

var optionalEmps = string.IsNullOrEmpty(settings.OptionalEmp)
? []
: settings.OptionalEmp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var userStartTime = LeaveRequestParser.NormalizeTime(settings.StartTime, LeaveRequestParser.DefaultStartTime);
var userEndTime = LeaveRequestParser.NormalizeTime(settings.EndTime, LeaveRequestParser.DefaultEndTime);
var optionalEmps = LeaveRequestParser.ParseOptionalEmployees(settings.OptionalEmp);

if (!settings.Yes && !settings.Json)
{
Expand Down
156 changes: 156 additions & 0 deletions src/SSW.TimePro.Cli/Features/Leave/LeaveRequestParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System.Globalization;
using SSW.TimePro.Cli.Shared.Models;

namespace SSW.TimePro.Cli.Features.Leave;

internal static class LeaveRequestParser
{
public const string DefaultStartTime = "09:00:00";
public const string DefaultEndTime = "18:00:00";

public static bool TryParseDateRange(
string start,
string end,
TimeZoneInfo timeZone,
out DateTimeOffset startDate,
out DateTimeOffset endDate,
out string? error)
{
startDate = default;
endDate = default;
error = null;

if (!TryParseStartDate(start, timeZone, out startDate))
{
error = $"Invalid start date: '{start}'. Use yyyy-MM-dd format.";
return false;
}

if (!TryParseEndDate(end, timeZone, out endDate))
{
error = $"Invalid end date: '{end}'. Use yyyy-MM-dd format.";
return false;
}

return true;
}

public static string NormalizeTime(string? time, string defaultValue)
{
var normalized = string.IsNullOrWhiteSpace(time) ? defaultValue : time.Trim();
return normalized.Length == 5 ? normalized + ":00" : normalized;
}

public static List<string> ParseOptionalEmployees(string? optionalEmp) =>
string.IsNullOrWhiteSpace(optionalEmp)
? []
: optionalEmp.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();

public static bool TryResolveRequestTimeZone(
string? overrideTimeZoneId,
EmployeeSettings? settings,
out TimeZoneInfo timeZone,
out string? error)
{
if (!string.IsNullOrWhiteSpace(overrideTimeZoneId))
{
if (TimeZoneInfo.TryFindSystemTimeZoneById(overrideTimeZoneId.Trim(), out timeZone!))
{
error = null;
return true;
}

error = $"Timezone override has an unknown timezone: '{overrideTimeZoneId}'. Use a valid IANA or Windows timezone ID.";
timeZone = TimeZoneInfo.Local;
return false;
}

if (settings is not null && !string.IsNullOrWhiteSpace(settings.TimezoneId))
{
if (TimeZoneInfo.TryFindSystemTimeZoneById(settings.TimezoneId.Trim(), out timeZone!))
{
error = null;
return true;
}

error = $"TimePro user profile has an unknown timezone: '{settings.TimezoneId}'. Update the profile timezone or run on a machine with that timezone available.";
timeZone = TimeZoneInfo.Local;
return false;
}

timeZone = TimeZoneInfo.Local;
error = null;
return true;
}

private static bool TryParseStartDate(string value, TimeZoneInfo timeZone, out DateTimeOffset result)
{
if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
{
result = ToDateTimeOffset(dateOnly, TimeOnly.MinValue, timeZone);
return true;
}

return TryParseDateTime(value, timeZone, useEndOfDayForDateOnly: false, out result);
}

private static bool TryParseEndDate(string value, TimeZoneInfo timeZone, out DateTimeOffset result)
{
if (DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
{
result = ToDateTimeOffset(dateOnly, new TimeOnly(23, 59, 0), timeZone);
return true;
}

return TryParseDateTime(value, timeZone, useEndOfDayForDateOnly: true, out result);
}

private static DateTimeOffset ToDateTimeOffset(DateOnly date, TimeOnly time, TimeZoneInfo timeZone)
{
var dateTime = date.ToDateTime(time);
return new DateTimeOffset(dateTime, timeZone.GetUtcOffset(dateTime));
}

private static bool TryParseDateTime(
string value,
TimeZoneInfo timeZone,
bool useEndOfDayForDateOnly,
out DateTimeOffset result)
{
var trimmed = value.Trim();

if (HasExplicitOffset(trimmed))
return DateTimeOffset.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);

if (!DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime))
{
result = default;
return false;
}

if (useEndOfDayForDateOnly && dateTime.TimeOfDay == TimeSpan.Zero)
dateTime = dateTime.Date.AddHours(23).AddMinutes(59);

dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified);
result = new DateTimeOffset(dateTime, timeZone.GetUtcOffset(dateTime));
return true;
}

private static bool HasExplicitOffset(string value)
{
if (value.EndsWith('Z') || value.EndsWith('z'))
return true;

var timeSeparator = Math.Max(value.LastIndexOf('T'), value.LastIndexOf(' '));
if (timeSeparator < 0)
return false;

for (var i = value.Length - 1; i > timeSeparator; i--)
{
if (value[i] is '+' or '-')
return true;
}

return false;
}
}
73 changes: 73 additions & 0 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;
using SSW.TimePro.Cli.Features.Leave;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Infrastructure.Config;
using SSW.TimePro.Cli.Shared.Models;

namespace SSW.TimePro.Cli.Features.Mcp.Tools;

Expand Down Expand Up @@ -73,9 +75,80 @@ public async Task<string> GetLeaveBalance(
}, JsonOpts);
}

[McpServerTool]
[Description("Create an EasyLeave request for the current user. An explicit timezone override takes priority; otherwise uses the TimePro profile timezone first, then the MCP host machine timezone as the browser-equivalent fallback.")]
public async Task<string> CreateLeave(
[Description("Start date (yyyy-MM-dd)")] string start,
[Description("End date (yyyy-MM-dd)")] string end,
[Description("Leave type ID or active leave type name")] string type,
[Description("Leave note/reason")] string note,
[Description("Approver's email address")] string? approvedBy = null,
[Description("Comma-separated list of emails to notify")] string? cc = null,
[Description("Request partial-day leave; start and end must be the same day")] bool halfDay = false,
[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,
CancellationToken ct = default)
{
var tenant = _config.LoadActiveTenantConfig();
if (tenant?.EmployeeId is null)
return """{"error": "Not logged in. Run 'tp login --tenant <id>' first."}""";

if (string.IsNullOrWhiteSpace(note))
return JsonSerializer.Serialize(new { error = "note is required: a reason/description is mandatory for leave" }, JsonOpts);

var settings = string.IsNullOrWhiteSpace(timeZoneId)
? await _api.GetEmployeeSettingsAsync(ct)
: null;
if (!LeaveRequestParser.TryResolveRequestTimeZone(timeZoneId, settings, out var requestTimeZone, out var timeZoneError))
return JsonSerializer.Serialize(new { error = timeZoneError ?? "Invalid leave request timezone" }, JsonOpts);

if (!LeaveRequestParser.TryParseDateRange(start, end, requestTimeZone, out var startDate, out var endDate, out var dateError))
return JsonSerializer.Serialize(new { error = dateError ?? "Invalid leave date range" }, JsonOpts);

if (IsWeekend(startDate) || IsWeekend(endDate))
return JsonSerializer.Serialize(new { error = "Leave start and end dates must be weekdays" }, JsonOpts);

var leaveTypeId = await ResolveLeaveTypeAsync(type, ct);
if (leaveTypeId is null)
return JsonSerializer.Serialize(new { error = $"Unknown leave type: '{type}'." }, JsonOpts);

var request = new CreateLeaveRequest
{
RequestedEmpId = tenant.EmployeeId,
StartDate = startDate.ToString("o"),
EndDate = endDate.ToString("o"),
LeaveTypeId = leaveTypeId.Value,
Note = note.Trim(),
UserStartTime = LeaveRequestParser.NormalizeTime(startTime, LeaveRequestParser.DefaultStartTime),
UserEndTime = LeaveRequestParser.NormalizeTime(endTime, LeaveRequestParser.DefaultEndTime),
AllDay = !halfDay,
OptionalEmp = LeaveRequestParser.ParseOptionalEmployees(cc),
ApprovedBy = approvedBy,
TimeLessOverride = null
};

await _api.CreateLeaveAsync(request, ct);
return JsonSerializer.Serialize(new { success = true }, JsonOpts);
}

private static string? ResolveEmpId(string? empId, string? employeeId)
{
var requestedEmpId = !string.IsNullOrWhiteSpace(empId) ? empId : employeeId;
return string.IsNullOrWhiteSpace(requestedEmpId) ? null : requestedEmpId.Trim();
}

private async Task<int?> ResolveLeaveTypeAsync(string typeInput, CancellationToken ct)
{
if (int.TryParse(typeInput, out var id))
return id;

var types = await _api.GetLeaveTypesAsync(ct);
var match = types.FirstOrDefault(t =>
t.Name.Equals(typeInput, StringComparison.OrdinalIgnoreCase));
return match?.Id;
}

private static bool IsWeekend(DateTimeOffset date) =>
date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ tp leave list --filter UPCOMING --json
tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 \
--note "Reason" --approved-by "approver@northwind.example" \
--cc "notify1@northwind.example,notify2@northwind.example" --yes
# Leave create uses --timezone first, then the TimePro profile timezone, then the machine timezone.
tp leave cancel <ID> --reason "Plans changed" --yes
```

Expand Down
Loading
Loading