From 91943c7c11f58a9e260e1dff472d6f2651a2cc89 Mon Sep 17 00:00:00 2001 From: Parthiban Nallathambi Date: Sat, 19 Sep 2026 20:50:35 +0530 Subject: [PATCH] Add --begin/--end and --timezone to start Allow creating timesheet records with explicit begin/end times, and a -z/--timezone to name the IANA zone used to read them (falls back to the PHP default). Invalid zone fails cleanly without entering the refine prompt. Co-authored-by: webemerge Signed-off-by: webemerge Signed-off-by: Parthiban Nallathambi --- src/Command/StartCommand.php | 47 +++++++++++++++++++++++++-- src/Command/TimesheetCommandTrait.php | 20 ++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/Command/StartCommand.php b/src/Command/StartCommand.php index aba60ad..c6c68ff 100644 --- a/src/Command/StartCommand.php +++ b/src/Command/StartCommand.php @@ -11,6 +11,7 @@ use Swagger\Client\ApiException; use Swagger\Client\Model\TimesheetEditForm; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -25,12 +26,15 @@ protected function configure(): void $this ->setName('start') ->setDescription('Starts a new timesheet') - ->setHelp('This command lets you start a new timesheet') + ->setHelp('This command lets you start a new timesheet and optionally end it immediately.') ->addOption('customer', 'c', InputOption::VALUE_OPTIONAL, 'The customer to filter the project list, can be an ID or a search term or empty (you will be prompted for a customer).') ->addOption('project', 'p', InputOption::VALUE_OPTIONAL, 'The project to use, can be an ID or a search term or empty. You will be prompted for the project.') ->addOption('activity', 'a', InputOption::VALUE_OPTIONAL, 'The activity ID to use') ->addOption('tags', 't', InputOption::VALUE_OPTIONAL, 'Comma separated list of tag names') ->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'The timesheet description') + ->addOption('begin', 'b', InputOption::VALUE_OPTIONAL, 'The begin (date and) time in a format supported by PHP') + ->addOption('end', 'e', InputOption::VALUE_OPTIONAL, 'The end (date and) time in a format supported by PHP') + ->addOption('timezone', 'z', InputOption::VALUE_OPTIONAL, 'IANA timezone (e.g. Asia/Kolkata) for interpreting --begin/--end. Falls back to the PHP default timezone if omitted.') ; } @@ -38,6 +42,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + $begin = $input->getOption('begin'); + $end = $input->getOption('end'); + + try { + $zone = $this->resolveTimezone($input->getOption('timezone')); + + if (null !== $begin) { + $begin = $this->parseDateTime((string) $begin, 'begin', $zone); + } + + if (null !== $end) { + if (null === $begin) { + throw new \InvalidArgumentException('Option "end" requires "begin" to be set.'); + } + + $end = $this->parseDateTime((string) $end, 'end', $zone); + + if ($begin > $end) { + throw new \InvalidArgumentException('Value for field "end" must not be earlier than "begin".'); + } + } + } catch (\InvalidArgumentException $ex) { + $io->error($ex->getMessage()); + + return Command::FAILURE; + } + $customer = null; $customerId = $input->getOption('customer'); @@ -81,6 +112,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $form->setDescription($description); } + if (null !== $begin) { + $form->setBegin($begin); + } + + if (null !== $end) { + $form->setEnd($end); + } + try { $timesheet = $api->postPostTimesheet($form); } catch (ApiException $ex) { @@ -89,9 +128,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } + // running timesheets have no end, despite the generated model's @return + $timesheetEnd = $timesheet->getEnd(); + $fields = [ 'ID' => $timesheet->getId(), 'Begin' => $timesheet->getBegin()->format(\DateTime::ISO8601), + 'End' => null !== $timesheetEnd ? $timesheetEnd->format(\DateTime::ISO8601) : null, 'Description' => $timesheet->getDescription(), 'Tags' => implode(PHP_EOL, $timesheet->getTags()), 'Customer' => $customer->getName(), @@ -99,7 +142,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'Activity' => $activity->getName(), ]; - $io->success('Started timesheet'); + $io->success(null === $timesheetEnd ? 'Started timesheet' : 'Created timesheet'); $io->horizontalTable(array_keys($fields), [$fields]); return 0; diff --git a/src/Command/TimesheetCommandTrait.php b/src/Command/TimesheetCommandTrait.php index ef93eb1..af30bf1 100644 --- a/src/Command/TimesheetCommandTrait.php +++ b/src/Command/TimesheetCommandTrait.php @@ -321,4 +321,24 @@ private function askForActivity(SymfonyStyle $io, array $activities): ?Activity return null; } + + private function parseDateTime(string $value, string $valueName, \DateTimeZone $zone): \DateTime + { + try { + return new \DateTime($value, $zone); + } catch (\Exception $e) { + throw new \InvalidArgumentException(\sprintf('Value "%s" for field "%s" is not a valid DateTime.', $value, $valueName)); + } + } + + private function resolveTimezone(?string $timezone): \DateTimeZone + { + $name = $timezone ?? date_default_timezone_get(); + + try { + return new \DateTimeZone($name); + } catch (\Exception $e) { + throw new \InvalidArgumentException(\sprintf('Invalid timezone "%s".', $name)); + } + } }