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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,24 +121,32 @@ Create a new time entry.
| Option | Short | Required | Description |
|---|---|---|---|
| `--date` | `-d` | no | Date: `YYYY/MM/DD`, `today`, or `yesterday` (default: today) |
| `--work-item` | `-w` | yes | Work Item ID |
| `--length` | `-l` | yes | Duration in hours (e.g. `0.5`, `1.5`) |
| `--type` | `-t` | yes | Activity type name (see `activities`) |
| `--work-item` | `-w` | yes* | Work Item ID |
| `--length` | `-l` | yes* | Duration in hours (e.g. `0.5`, `1.5`) |
| `--type` | `-t` | yes* | Activity type name (see `activities`) |
| `--comment` | `-c` | no | Comment for the entry |
| `--hour` | `-h` | no | Start time in `HH:MM` format (default: `09:00`) |
| `--interactive` | `-i` | no | Prompt for each field instead of passing flags |
| `--dry-run` | | no | Preview the entry locally without submitting |

*Required in the flag-based flow; supplied through prompts when `--interactive` is used.

```bash
# Log 2 hours of development today (date defaults to today)
timetracker add -w 12345 -l 2 -t Development -c "Feature X"

# Log half an hour of a meeting starting at 14:00
timetracker add -d 2026/06/19 -w 12345 -l 0.5 -t Meeting -h 14:00

# Guided mode — prompts for date, hour, work item, duration, type, and comment
timetracker add --interactive

# Preview before submitting
timetracker add -d today -w 12345 -l 1 -t Development --dry-run
```

In `--interactive` mode, date and start hour default to today and `09:00`, the activity type is picked from a list, and the comment is optional. The collected values still pass through the same validation as the flag-based flow.

---

### list
Expand Down
11 changes: 11 additions & 0 deletions Timetracker.Tests/AddValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ public void UnknownActivityType_Fails()
Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Fact]
public void MissingActivityType_Fails()
{
// With --type no longer required by the parser, the validator must reject a null type
// instead of throwing (the flag-less path relies on this).
var opts = ValidOptions();
opts.ActivityType = null;

Assert.False(new AddValidator(Activities).Validate(opts).IsValid);
}

[Theory]
[InlineData("24:00")]
[InlineData("9:60")]
Expand Down
2 changes: 2 additions & 0 deletions Timetracker.Tests/ValidationUtilsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public void TryResolveMonth(string input, bool expectedOk, int year, int month,
[InlineData("Development", true)]
[InlineData("DEVELOPMENT", true)]
[InlineData("meeting", false)]
[InlineData("", false)]
[InlineData(null, false)] // null is not a valid type (guards the flag-less flow)
public void ValidType_IsCaseInsensitiveAndUpperCases(string input, bool expected)
{
var activities = new[] { "DEVELOPMENT", "TESTING" };
Expand Down
11 changes: 8 additions & 3 deletions Timetracker/Options/AddOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ public class AddOptions
[Option('d', "date", Required = false, Default = "today", HelpText = "Date for the activity: YYYY/MM/DD (e.g., 2025/12/31), 'today' or 'yesterday'. Defaults to today.")]
public string ActivityDate { get; set; }

[Option('w', "work-item", Required = true, HelpText = "Specify the Work Item ID associated with the activity.")]
// Required for the flag-based flow, but not enforced by the parser so `--interactive`
// can supply them via prompts. Presence is validated by AddValidator.
[Option('w', "work-item", Required = false, HelpText = "Specify the Work Item ID associated with the activity.")]
public int WorkItemId { get; set; }

[Option('l', "length", Required = true, HelpText = "Specify the duration of the activity in hours (e.g., 0.5 for half an hour).")]
[Option('l', "length", Required = false, HelpText = "Specify the duration of the activity in hours (e.g., 0.5 for half an hour).")]
public decimal ActivityLength { get; set; }

[Option('t', "type", Required = true, HelpText = "Specify the type of activity. Use the 'activities' command to list available types.")]
[Option('t', "type", Required = false, HelpText = "Specify the type of activity. Use the 'activities' command to list available types.")]
public string ActivityType { get; set; }

[Option('c', "comment", Required = false, HelpText = "Provide a comment for the activity.")]
Expand All @@ -23,6 +25,9 @@ public class AddOptions
[Option('h', "hour", Required = false, HelpText = "Specify the start time of the activity in the format HH:MM (e.g., 09:00 or 21:00)", Default = "09:00")]
public string ActivityStartHour { get; set; }

[Option('i', "interactive", Required = false, HelpText = "Prompt for each field instead of passing flags.")]
public bool Interactive { get; set; }

[Option("dry-run", Required = false, HelpText = "Preview the entry that would be submitted without sending it to the server.")]
public bool DryRun { get; set; }
}
Expand Down
42 changes: 42 additions & 0 deletions Timetracker/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ static async Task<int> AddActions(AddOptions opts, CancellationToken cancellatio

var activities = ActivityService.GetActivities();

if (opts.Interactive)
PromptAddFields(opts, activities);

var validator = new AddValidator(activities.Select(x => x.Name.ToUpper()));

var result = validator.Validate(opts);
Expand Down Expand Up @@ -254,6 +257,45 @@ static async Task<int> AddActions(AddOptions opts, CancellationToken cancellatio
return 0;
}

// Fills the add options from interactive prompts, mirroring the "New entry" flow of the
// interactive command. Values still pass through AddValidator afterwards.
static void PromptAddFields(AddOptions opts, IList<Activity> activities)
{
AnsiConsole.MarkupLine("[grey]Fill in the entry (press Enter to accept defaults):[/]");

opts.ActivityDate = AnsiConsole.Prompt(
new TextPrompt<string>("Date (YYYY/MM/DD, 'today' or 'yesterday'):")
.DefaultValue(string.IsNullOrEmpty(opts.ActivityDate) ? "today" : opts.ActivityDate));

opts.ActivityStartHour = AnsiConsole.Prompt(
new TextPrompt<string>("Start hour (HH:MM):")
.DefaultValue(string.IsNullOrEmpty(opts.ActivityStartHour) ? "09:00" : opts.ActivityStartHour));

opts.WorkItemId = AnsiConsole.Prompt(
new TextPrompt<int>("Work Item ID:")
.Validate(id => id > 0 ? ValidationResult.Success() : ValidationResult.Error("Work Item ID must be greater than 0.")));

var hoursStr = AnsiConsole.Prompt(
new TextPrompt<string>("Duration in hours (e.g. 1 or 1.5):")
.Validate(v => decimal.TryParse(v, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var h) && h > 0
? ValidationResult.Success()
: ValidationResult.Error("Enter a number greater than 0, e.g. 1 or 1.5")));
opts.ActivityLength = decimal.Parse(hoursStr, System.Globalization.CultureInfo.InvariantCulture);

var activity = AnsiConsole.Prompt(
new SelectionPrompt<Activity>()
.Title("Activity type:")
.UseConverter(a => Markup.Escape(a.Name))
.AddChoices(activities));
opts.ActivityType = activity.Name;

var comment = AnsiConsole.Prompt(
new TextPrompt<string>("Comment:")
.AllowEmpty()
.DefaultValue(opts.ActivityComment ?? string.Empty));
opts.ActivityComment = string.IsNullOrEmpty(comment) ? null : comment;
}

static async Task<int> ListActions(ListOptions opts, CancellationToken cancellationToken)
{
if (!ConfigService.ConfigExists())
Expand Down
3 changes: 2 additions & 1 deletion Timetracker/Utils/ValidationUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public static bool ValidActivityDate(string date)
return Regex.IsMatch(date, @"^\d{4}/\d{2}/\d{2}$") && ValidDate(date);
}

public static bool ValidType(IEnumerable<string> activities, string type) => activities.Contains(type.ToUpper());
public static bool ValidType(IEnumerable<string> activities, string type) =>
!string.IsNullOrEmpty(type) && activities.Contains(type.ToUpper());

public static bool ValidUrl(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
Expand Down
Loading