From 0966578fb0274c3ace88656ceb17c53395f74dc6 Mon Sep 17 00:00:00 2001 From: Jonas Lima de Amorim Date: Sat, 25 Jul 2026 01:34:52 -0300 Subject: [PATCH] feat(add): add --interactive guided prompt mode Add an -i/--interactive flag that prompts for each field (date, hour, work item, duration, activity type from a list, comment) and runs them through the same AddValidator/submit path. The three previously-required flags become optional so the flag-less flow works; validation now guards a missing type instead of throwing. --- README.md | 14 ++++++-- Timetracker.Tests/AddValidatorTests.cs | 11 ++++++ Timetracker.Tests/ValidationUtilsTests.cs | 2 ++ Timetracker/Options/AddOptions.cs | 11 ++++-- Timetracker/Program.cs | 42 +++++++++++++++++++++++ Timetracker/Utils/ValidationUtils.cs | 3 +- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fb01819..e372d87 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,16 @@ 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" @@ -135,10 +138,15 @@ 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 diff --git a/Timetracker.Tests/AddValidatorTests.cs b/Timetracker.Tests/AddValidatorTests.cs index 5769988..c3ecdaa 100644 --- a/Timetracker.Tests/AddValidatorTests.cs +++ b/Timetracker.Tests/AddValidatorTests.cs @@ -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")] diff --git a/Timetracker.Tests/ValidationUtilsTests.cs b/Timetracker.Tests/ValidationUtilsTests.cs index 0679311..2ac320c 100644 --- a/Timetracker.Tests/ValidationUtilsTests.cs +++ b/Timetracker.Tests/ValidationUtilsTests.cs @@ -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" }; diff --git a/Timetracker/Options/AddOptions.cs b/Timetracker/Options/AddOptions.cs index c7ced31..d751bb4 100644 --- a/Timetracker/Options/AddOptions.cs +++ b/Timetracker/Options/AddOptions.cs @@ -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.")] @@ -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; } } diff --git a/Timetracker/Program.cs b/Timetracker/Program.cs index 6c83b9d..1aa68fb 100644 --- a/Timetracker/Program.cs +++ b/Timetracker/Program.cs @@ -216,6 +216,9 @@ static async Task 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); @@ -254,6 +257,45 @@ static async Task 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 activities) +{ + AnsiConsole.MarkupLine("[grey]Fill in the entry (press Enter to accept defaults):[/]"); + + opts.ActivityDate = AnsiConsole.Prompt( + new TextPrompt("Date (YYYY/MM/DD, 'today' or 'yesterday'):") + .DefaultValue(string.IsNullOrEmpty(opts.ActivityDate) ? "today" : opts.ActivityDate)); + + opts.ActivityStartHour = AnsiConsole.Prompt( + new TextPrompt("Start hour (HH:MM):") + .DefaultValue(string.IsNullOrEmpty(opts.ActivityStartHour) ? "09:00" : opts.ActivityStartHour)); + + opts.WorkItemId = AnsiConsole.Prompt( + new TextPrompt("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("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() + .Title("Activity type:") + .UseConverter(a => Markup.Escape(a.Name)) + .AddChoices(activities)); + opts.ActivityType = activity.Name; + + var comment = AnsiConsole.Prompt( + new TextPrompt("Comment:") + .AllowEmpty() + .DefaultValue(opts.ActivityComment ?? string.Empty)); + opts.ActivityComment = string.IsNullOrEmpty(comment) ? null : comment; +} + static async Task ListActions(ListOptions opts, CancellationToken cancellationToken) { if (!ConfigService.ConfigExists()) diff --git a/Timetracker/Utils/ValidationUtils.cs b/Timetracker/Utils/ValidationUtils.cs index e7970a7..90df923 100644 --- a/Timetracker/Utils/ValidationUtils.cs +++ b/Timetracker/Utils/ValidationUtils.cs @@ -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 activities, string type) => activities.Contains(type.ToUpper()); + public static bool ValidType(IEnumerable activities, string type) => + !string.IsNullOrEmpty(type) && activities.Contains(type.ToUpper()); public static bool ValidUrl(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) &&