rounding rules via admin screen, round begin when starting record (#1229)

This commit is contained in:
Kevin Papst
2019-11-10 13:57:05 +01:00
committed by GitHub
parent fa1c79e15c
commit c6c4098759
47 changed files with 1100 additions and 185 deletions

View File

@@ -21,11 +21,13 @@ kimai:
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
# TIME-TRACKING # TIME-TRACKING
# All configs related to time-tracking, timesheets and record management # All configs related to time-tracking, timesheets and record management
# Most settings can be configured in the System configuration screen
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
timesheet: timesheet:
# renders timesheet descriptions with markdown # Allows to render timesheet descriptions with markdown
markdown_content: false # This setting can be changed through the Administration screen
# markdown_content: false
# The time-tracking mode that should be used. # The time-tracking mode that should be used.
# See https://www.kimai.org/documentation/timesheet.html#tracking-modes # See https://www.kimai.org/documentation/timesheet.html#tracking-modes
@@ -42,36 +44,36 @@ kimai:
# Rounding rules are used to round the begin & end dates and the duration for timesheet records. # Rounding rules are used to round the begin & end dates and the duration for timesheet records.
# The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded. # The "default" rule will round "begin" down and "end" up to the full minute, the "duration" will not be rounded.
# Find out more about rounding rules at https://www.kimai.org/documentation/timesheet.html # Find out more about rounding rules at https://www.kimai.org/documentation/timesheet.html
rounding: # rounding:
default: # default:
days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] # days: ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
begin: 1 # begin: 1
end: 1 # end: 1
duration: 0 # duration: 0
mode: default # mode: default
# # If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration. # If you want to apply different hourly rates for specific weekdays, you can uncomment the "rates" configuration.
# # The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays". # The "weekend" rule will add 50% to each timesheet entry that will be recorded on "saturdays" or "sundays".
# # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation # See documentation at: https://www.kimai.org/documentation/timesheet.html#rate-calculation
# rates: # rates:
# weekend: # weekend:
# days: ['saturday','sunday'] # days: ['saturday','sunday']
# factor: 1.5 # factor: 1.5
#
# # If you want to limit the max. active entries per user, you can do it here. # If you want to limit the max. active entries per user, you can do it here.
# # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings # The soft_limit is used as theme setting and displays a warning color if the user has reached X active recordings
# # The hard_limit is used to detect how many active records are allowed per user: # The hard_limit is used to detect how many active records are allowed per user:
# # - by default a user can only have one active time-record: it is automatically stopped when a new one is started # - by default a user can only have one active time-record: it is automatically stopped when a new one is started
# # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown # - when hard_limit is > 1 and the user is trying to start a new entry after reaching the limit, a warning is shown
# # and the user has to stop an active entry first # and the user has to stop an active entry first
# active_entries: # active_entries:
# soft_limit: 1 # soft_limit: 1
# hard_limit: 3 # hard_limit: 3
#
# # Rules that define timesheet validation and behaviour # Rules that define timesheet validation and behaviour
# rules: # allow_future_times: whether records in the future can be created
# # whether records in the future can be created # rules:
# allow_future_times: true # allow_future_times: true
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------

View File

@@ -118,12 +118,18 @@ services:
# TIMESHEET RECORD CALCULATOR # TIMESHEET RECORD CALCULATOR
# ================================================================================ # ================================================================================
App\Timesheet\Calculator\DurationCalculator: App\Timesheet\RoundingService:
arguments: ['%kimai.timesheet.rounding%'] arguments:
$roundingModes: !tagged timesheet.rounding_mode
$rules: '%kimai.timesheet.rounding%'
App\Timesheet\Calculator\RateCalculator: App\Timesheet\Calculator\RateCalculator:
arguments: ['%kimai.timesheet.rates%'] arguments: ['%kimai.timesheet.rates%']
App\Timesheet\TrackingModeService:
arguments:
$modes: !tagged timesheet.tracking_mode
# ================================================================================ # ================================================================================
# SECURITY & VOTER # SECURITY & VOTER
# ================================================================================ # ================================================================================

View File

@@ -17,7 +17,8 @@ parameters:
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#' - '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#' - '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
- '#Access to an undefined property Faker\\Generator::\$bs.#' - '#Access to an undefined property Faker\\Generator::\$bs.#'
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#' - '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
- '#Method App\\Controller\\AbstractController::getUser\(\) should return App\\Entity\\User|null but returns object|null. #'
excludes_analyse: excludes_analyse:
- %rootDir%/../../../src/Command/KimaiImporterCommand.php - %rootDir%/../../../src/Command/KimaiImporterCommand.php
- %rootDir%/../../../src/Ldap/LdapDriver.php - %rootDir%/../../../src/Ldap/LdapDriver.php

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
@@ -18,4 +19,12 @@ abstract class BaseApiController extends AbstractController
{ {
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT; public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s'; public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s';
/**
* @return User|null
*/
protected function getUser()
{
return parent::getUser();
}
} }

View File

@@ -19,6 +19,7 @@ use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\RoundingService;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService; use App\Timesheet\TrackingModeService;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
@@ -75,6 +76,10 @@ class TimesheetController extends BaseApiController
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
private $dispatcher; private $dispatcher;
/**
* @var RoundingService
*/
private $roundingService;
public function __construct( public function __construct(
ViewHandlerInterface $viewHandler, ViewHandlerInterface $viewHandler,
@@ -83,7 +88,8 @@ class TimesheetController extends BaseApiController
TimesheetConfiguration $configuration, TimesheetConfiguration $configuration,
TagRepository $tagRepository, TagRepository $tagRepository,
TrackingModeService $trackingModeService, TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher EventDispatcherInterface $dispatcher,
RoundingService $roundingService
) { ) {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
@@ -92,6 +98,7 @@ class TimesheetController extends BaseApiController
$this->tagRepository = $tagRepository; $this->tagRepository = $tagRepository;
$this->trackingModeService = $trackingModeService; $this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->roundingService = $roundingService;
} }
protected function getTrackingMode(): TrackingModeInterface protected function getTrackingMode(): TrackingModeInterface
@@ -293,12 +300,12 @@ class TimesheetController extends BaseApiController
{ {
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setUser($this->getUser()); $timesheet->setUser($this->getUser());
$timesheet->setBegin($this->dateTime->createDateTime());
$event = new TimesheetMetaDefinitionEvent($timesheet); $event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$mode->create($timesheet, $request);
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
@@ -608,6 +615,7 @@ class TimesheetController extends BaseApiController
->setActivity($timesheet->getActivity()) ->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject()) ->setProject($timesheet->getProject())
; ;
$this->roundingService->roundBegin($copyTimesheet);
if (null !== ($copy = $paramFetcher->get('copy'))) { if (null !== ($copy = $paramFetcher->get('copy'))) {
if (in_array($copy, ['rates', 'all'])) { if (in_array($copy, ['rates', 'all'])) {

View File

@@ -47,4 +47,29 @@ class TimesheetConfiguration implements SystemBundleConfiguration
{ {
return (int) $this->find('active_entries.soft_limit'); return (int) $this->find('active_entries.soft_limit');
} }
public function getDefaultRoundingDays(): string
{
return (string) $this->find('rounding.default.days');
}
public function getDefaultRoundingMode(): string
{
return (string) $this->find('rounding.default.mode');
}
public function getDefaultRoundingBegin(): int
{
return (int) $this->find('rounding.default.begin');
}
public function getDefaultRoundingEnd(): int
{
return (int) $this->find('rounding.default.end');
}
public function getDefaultRoundingDuration(): int
{
return (int) $this->find('rounding.default.duration');
}
} }

View File

@@ -9,6 +9,7 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Translation\DataCollectorTranslator; use Symfony\Component\Translation\DataCollectorTranslator;
use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface;
@@ -36,6 +37,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get('translator'); return $this->container->get('translator');
} }
/**
* @return User|null
*/
protected function getUser()
{
return parent::getUser();
}
/** /**
* Adds a "successful" flash message to the stack. * Adds a "successful" flash message to the stack.
* *

View File

@@ -16,8 +16,10 @@ use App\Form\Model\SystemConfiguration as SystemConfigurationModel;
use App\Form\SystemConfigurationForm; use App\Form\SystemConfigurationForm;
use App\Form\Type\EnhancedSelectboxType; use App\Form\Type\EnhancedSelectboxType;
use App\Form\Type\LanguageType; use App\Form\Type\LanguageType;
use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType; use App\Form\Type\SkinType;
use App\Form\Type\TrackingModeType; use App\Form\Type\TrackingModeType;
use App\Form\Type\WeekDaysType;
use App\Repository\ConfigurationRepository; use App\Repository\ConfigurationRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -215,6 +217,39 @@ class SystemConfigurationController extends AbstractController
new GreaterThanOrEqual(['value' => 1]) new GreaterThanOrEqual(['value' => 1])
]), ]),
]), ]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_ROUNDING)
->setConfiguration([
(new Configuration())
->setName('timesheet.rounding.default.mode')
->setType(RoundingModeType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rounding.default.begin')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.end')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.duration')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('timesheet.rounding.default.days')
->setType(WeekDaysType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel()) (new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER) ->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
->setConfiguration([ ->setConfiguration([

View File

@@ -178,7 +178,6 @@ abstract class TimesheetAbstractController extends AbstractController
{ {
$entry = new Timesheet(); $entry = new Timesheet();
$entry->setUser($this->getUser()); $entry->setUser($this->getUser());
$entry->setBegin($this->dateTime->createDateTime());
if ($request->query->get('project')) { if ($request->query->get('project')) {
$project = $projectRepository->find($request->query->get('project')); $project = $projectRepository->find($request->query->get('project'));

View File

@@ -36,10 +36,15 @@ class AppExtension extends Extension
if (isset($config['timesheet']['duration_only'])) { if (isset($config['timesheet']['duration_only'])) {
@trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED); @trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED);
if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) { if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) {
trigger_error('Found ambiguous configuration. Please remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); trigger_error('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.');
} }
} }
// we use a comma sepearated string internally, to be able to use it in combination with the database configuration system
foreach ($config['timesheet']['rounding'] as $name => $settings) {
$config['timesheet']['rounding'][$name]['days'] = implode(',', $settings['days']);
}
// safe alternatives to %kernel.project_dir% // safe alternatives to %kernel.project_dir%
$container->setParameter('kimai.data_dir', $config['data_dir']); $container->setParameter('kimai.data_dir', $config['data_dir']);
$container->setParameter('kimai.plugin_dir', $config['plugin_dir']); $container->setParameter('kimai.plugin_dir', $config['plugin_dir']);

View File

@@ -98,17 +98,15 @@ class Configuration implements ConfigurationInterface
->arrayPrototype() ->arrayPrototype()
->children() ->children()
->arrayNode('days') ->arrayNode('days')
->requiresAtLeastOneElement()
->useAttributeAsKey('key') ->useAttributeAsKey('key')
->isRequired()
->scalarPrototype()->end() ->scalarPrototype()->end()
->defaultValue([]) ->defaultValue(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'])
->end() ->end()
->integerNode('begin') ->integerNode('begin')
->defaultValue(0) ->defaultValue(1)
->end() ->end()
->integerNode('end') ->integerNode('end')
->defaultValue(0) ->defaultValue(1)
->end() ->end()
->integerNode('duration') ->integerNode('duration')
->defaultValue(0) ->defaultValue(0)
@@ -131,7 +129,15 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->end() ->end()
->end() ->end()
->defaultValue([]) ->defaultValue([
'default' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
'begin' => 1,
'end' => 1,
'duration' => 0,
'mode' => 'default'
]
])
->end() ->end()
->arrayNode('rates') ->arrayNode('rates')
->requiresAtLeastOneElement() ->requiresAtLeastOneElement()

View File

@@ -26,18 +26,10 @@ class TimesheetSubscriber implements EventSubscriber
protected $calculator; protected $calculator;
/** /**
* @param iterable $calculators * @param CalculatorInterface[] $calculators
*/ */
public function __construct(iterable $calculators) public function __construct(iterable $calculators)
{ {
foreach ($calculators as $calculator) {
if (!($calculator instanceof CalculatorInterface)) {
throw new \InvalidArgumentException(
'Invalid TimesheetCalculator implementation given. Expected CalculatorInterface but received ' .
get_class($calculator)
);
}
}
$this->calculator = $calculators; $this->calculator = $calculators;
} }

View File

@@ -11,6 +11,7 @@ namespace App\Form\Model;
class SystemConfiguration class SystemConfiguration
{ {
public const SECTION_ROUNDING = 'rounding';
public const SECTION_TIMESHEET = 'timesheet'; public const SECTION_TIMESHEET = 'timesheet';
public const SECTION_FORM_CUSTOMER = 'form_customer'; public const SECTION_FORM_CUSTOMER = 'form_customer';
public const SECTION_FORM_USER = 'form_user'; public const SECTION_FORM_USER = 'form_user';

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use App\Timesheet\RoundingService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select the timesheet mode.
*/
class RoundingModeType extends AbstractType
{
/**
* @var RoundingService
*/
private $service;
public function __construct(RoundingService $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$choices = [];
foreach ($this->service->getRoundingModes() as $mode) {
$id = $mode->getId();
$choices[ucfirst($id)] = $id;
}
$resolver->setDefaults([
'choices' => $choices,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select weekdays.
*/
class WeekDaysType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new CallbackTransformer(
function ($weekdays) {
return explode(',', $weekdays);
},
function ($weekdays) {
return implode(',', $weekdays);
}
));
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$choices = [
'Monday' => 'monday',
'Tuesday' => 'tuesday',
'Wednesday' => 'wednesday',
'Thursday' => 'thursday',
'Friday' => 'friday',
'Saturday' => 'saturday',
'Sunday' => 'sunday'
];
$resolver->setDefaults([
'multiple' => true,
'choices' => $choices,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -24,6 +24,8 @@ use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory; use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface; use App\Plugin\PluginInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator; use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Timesheet\Rounding\RoundingInterface;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Widget\WidgetInterface; use App\Widget\WidgetInterface;
use App\Widget\WidgetRendererInterface; use App\Widget\WidgetRendererInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
@@ -52,6 +54,8 @@ class Kernel extends BaseKernel
public const TAG_INVOICE_REPOSITORY = 'invoice.repository'; public const TAG_INVOICE_REPOSITORY = 'invoice.repository';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator'; public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter'; public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter';
public const TAG_TIMESHEET_TRACKING_MODE = 'timesheet.tracking_mode';
public const TAG_TIMESHEET_ROUNDING_MODE = 'timesheet.rounding_mode';
public function getCacheDir() public function getCacheDir()
{ {
@@ -75,6 +79,8 @@ class Kernel extends BaseKernel
$container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER); $container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET); $container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
$container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER); $container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER);
$container->registerForAutoconfiguration(TrackingModeInterface::class)->addTag(self::TAG_TIMESHEET_TRACKING_MODE);
$container->registerForAutoconfiguration(RoundingInterface::class)->addTag(self::TAG_TIMESHEET_ROUNDING_MODE);
/** @var SecurityExtension $extension */ /** @var SecurityExtension $extension */
$extension = $container->getExtension('security'); $extension = $container->getExtension('security');

View File

@@ -11,26 +11,19 @@ namespace App\Timesheet\Calculator;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface; use App\Timesheet\CalculatorInterface;
use App\Timesheet\Rounding\RoundingInterface; use App\Timesheet\RoundingService;
/** /**
* Implementation to calculate the durations for a timesheet record. * Implementation to calculate the durations for a timesheet record.
*
* This calculator takes the configuration %kimai.timesheet.rounding% as argument,
* so its rounding behaviour can be customized.
*/ */
class DurationCalculator implements CalculatorInterface final class DurationCalculator implements CalculatorInterface
{ {
/** /**
* @var array * @var RoundingService
*/ */
protected $roundings; private $roundings;
/** public function __construct(RoundingService $roundings)
* DurationCalculator constructor.
* @param array $roundings
*/
public function __construct(array $roundings)
{ {
$this->roundings = $roundings; $this->roundings = $roundings;
} }
@@ -44,37 +37,9 @@ class DurationCalculator implements CalculatorInterface
return; return;
} }
$this->applyDuration($record);
$this->applyRoundings($record);
}
/**
* @param Timesheet $record
*/
protected function applyDuration(Timesheet $record)
{
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp(); $duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration); $record->setDuration($duration);
}
/** $this->roundings->applyRoundings($record);
* @param Timesheet $record
*/
protected function applyRoundings(Timesheet $record)
{
foreach ($this->roundings as $rounding) {
$weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rounding['days']);
if (in_array(strtolower($weekday), $days)) {
$class = 'App\\Timesheet\\Rounding\\' . ucfirst($rounding['mode']) . 'Rounding';
/* @var $rounder RoundingInterface */
$rounder = new $class();
$rounder->roundBegin($record, $rounding['begin']);
$rounder->roundEnd($record, $rounding['end']);
$this->applyDuration($record);
$rounder->roundDuration($record, $rounding['duration']);
}
}
} }
} }

View File

@@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding;
use App\Entity\Timesheet; use App\Entity\Timesheet;
class CeilRounding implements RoundingInterface final class CeilRounding implements RoundingInterface
{ {
public function getId(): string
{
return 'ceil';
}
/** /**
* @param Timesheet $record * @param Timesheet $record
* @param int $minutes * @param int $minutes

View File

@@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding;
use App\Entity\Timesheet; use App\Entity\Timesheet;
class ClosestRounding implements RoundingInterface final class ClosestRounding implements RoundingInterface
{ {
public function getId(): string
{
return 'closest';
}
/** /**
* @param Timesheet $record * @param Timesheet $record
* @param int $minutes * @param int $minutes

View File

@@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding;
use App\Entity\Timesheet; use App\Entity\Timesheet;
class DefaultRounding implements RoundingInterface final class DefaultRounding implements RoundingInterface
{ {
public function getId(): string
{
return 'default';
}
/** /**
* @param Timesheet $record * @param Timesheet $record
* @param int $minutes * @param int $minutes

View File

@@ -11,8 +11,13 @@ namespace App\Timesheet\Rounding;
use App\Entity\Timesheet; use App\Entity\Timesheet;
class FloorRounding implements RoundingInterface final class FloorRounding implements RoundingInterface
{ {
public function getId(): string
{
return 'floor';
}
/** /**
* @param Timesheet $record * @param Timesheet $record
* @param int $minutes * @param int $minutes

View File

@@ -33,4 +33,9 @@ interface RoundingInterface
* @param int $minutes * @param int $minutes
*/ */
public function roundDuration(Timesheet $record, $minutes); public function roundDuration(Timesheet $record, $minutes);
/**
* @return string
*/
public function getId(): string;
} }

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Timesheet\Rounding\RoundingInterface;
final class RoundingService
{
/**
* @var array
*/
private $rules;
/**
* @var array
*/
private $rulesCache;
/**
* @var TimesheetConfiguration
*/
private $configuration;
/**
* @var RoundingInterface[]
*/
private $roundingModes;
/**
* @param TimesheetConfiguration $configuration
* @param RoundingInterface[] $roundingModes
* @param array $rules
*/
public function __construct(TimesheetConfiguration $configuration, iterable $roundingModes, array $rules)
{
$this->configuration = $configuration;
$this->roundingModes = $roundingModes;
$this->rules = $rules;
}
private function getRoundingRules(): array
{
if (empty($this->rulesCache)) {
$this->rulesCache = $this->rules;
if (empty($this->rulesCache) || array_key_exists('default', $this->rulesCache)) {
$this->rulesCache['default']['days'] = $this->configuration->getDefaultRoundingDays();
$this->rulesCache['default']['begin'] = $this->configuration->getDefaultRoundingBegin();
$this->rulesCache['default']['end'] = $this->configuration->getDefaultRoundingEnd();
$this->rulesCache['default']['duration'] = $this->configuration->getDefaultRoundingDuration();
$this->rulesCache['default']['mode'] = $this->configuration->getDefaultRoundingMode();
}
// see AppExtension, conversion from string to array due to system configuration ont allowing to store arrays
foreach ($this->rulesCache as $key => $settings) {
$days = explode(',', $settings['days']);
$days = array_map('trim', $days);
$days = array_map('strtolower', $days);
$this->rulesCache[$key]['days'] = $days;
}
}
return $this->rulesCache;
}
public function roundBegin(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
$weekday = $record->getBegin()->format('l');
if (in_array(strtolower($weekday), $rounding['days'])) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']);
}
}
}
public function roundEnd(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
$weekday = $record->getEnd()->format('l');
if (in_array(strtolower($weekday), $rounding['days'])) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundEnd($record, $rounding['end']);
}
}
}
public function roundDuration(Timesheet $record): void
{
foreach ($this->getRoundingRules() as $rounding) {
$weekday = $record->getEnd()->format('l');
if (in_array(strtolower($weekday), $rounding['days'])) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundDuration($record, $rounding['duration']);
}
}
}
public function applyRoundings(Timesheet $record): void
{
if (null === $record->getEnd()) {
return;
}
foreach ($this->getRoundingRules() as $rounding) {
$weekday = $record->getEnd()->format('l');
if (in_array(strtolower($weekday), $rounding['days'])) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']);
$rounder->roundEnd($record, $rounding['end']);
$duration = $record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp();
$record->setDuration($duration);
$rounder->roundDuration($record, $rounding['duration']);
}
}
}
/**
* @return RoundingInterface[]
*/
public function getRoundingModes(): iterable
{
return $this->roundingModes;
}
public function getRoundingMode(string $id): RoundingInterface
{
foreach ($this->roundingModes as $mode) {
if ($mode->getId() === $id) {
return $mode;
}
}
throw new \InvalidArgumentException('Unknown rounding mode: ' . $id);
}
}

View File

@@ -9,8 +9,25 @@
namespace App\Timesheet\TrackingMode; namespace App\Timesheet\TrackingMode;
class DefaultMode extends AbstractTrackingMode use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Timesheet\RoundingService;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request;
final class DefaultMode extends AbstractTrackingMode
{ {
/**
* @var RoundingService
*/
private $rounding;
public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration, RoundingService $rounding)
{
parent::__construct($dateTime, $configuration);
$this->rounding = $rounding;
}
public function canEditBegin(): bool public function canEditBegin(): bool
{ {
return true; return true;
@@ -40,4 +57,23 @@ class DefaultMode extends AbstractTrackingMode
{ {
return true; return true;
} }
public function create(Timesheet $timesheet, Request $request): void
{
parent::create($timesheet, $request);
if (null === $timesheet->getBegin()) {
$timesheet->setBegin($this->dateTime->createDateTime());
}
$this->rounding->roundBegin($timesheet);
if (null !== $timesheet->getEnd()) {
$this->rounding->roundEnd($timesheet);
if (null !== $timesheet->getDuration()) {
$this->rounding->roundDuration($timesheet);
}
}
}
} }

View File

@@ -14,16 +14,16 @@ use App\Entity\Timesheet;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class DurationFixedBeginMode implements TrackingModeInterface final class DurationFixedBeginMode implements TrackingModeInterface
{ {
/** /**
* @var UserDateTimeFactory * @var UserDateTimeFactory
*/ */
protected $dateTime; private $dateTime;
/** /**
* @var TimesheetConfiguration * @var TimesheetConfiguration
*/ */
protected $configuration; private $configuration;
public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration) public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration)
{ {

View File

@@ -12,7 +12,7 @@ namespace App\Timesheet\TrackingMode;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class DurationOnlyMode extends AbstractTrackingMode final class DurationOnlyMode extends AbstractTrackingMode
{ {
public function canEditBegin(): bool public function canEditBegin(): bool
{ {

View File

@@ -10,10 +10,21 @@
namespace App\Timesheet\TrackingMode; namespace App\Timesheet\TrackingMode;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class PunchInOutMode implements TrackingModeInterface final class PunchInOutMode implements TrackingModeInterface
{ {
/**
* @var UserDateTimeFactory
*/
private $dateTime;
public function __construct(UserDateTimeFactory $dateTime)
{
$this->dateTime = $dateTime;
}
public function canEditBegin(): bool public function canEditBegin(): bool
{ {
return false; return false;
@@ -36,6 +47,9 @@ class PunchInOutMode implements TrackingModeInterface
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, Request $request): void
{ {
if (null === $timesheet->getBegin()) {
$timesheet->setBegin($this->dateTime->createDateTime());
}
} }
public function getId(): string public function getId(): string

View File

@@ -10,28 +10,28 @@
namespace App\Timesheet; namespace App\Timesheet;
use App\Configuration\TimesheetConfiguration; use App\Configuration\TimesheetConfiguration;
use App\Timesheet\TrackingMode\DefaultMode;
use App\Timesheet\TrackingMode\DurationFixedBeginMode;
use App\Timesheet\TrackingMode\DurationOnlyMode;
use App\Timesheet\TrackingMode\PunchInOutMode;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
class TrackingModeService final class TrackingModeService
{ {
/** /**
* @var UserDateTimeFactory * @var TrackingModeInterface[]
*/ */
protected $dateTime; private $modes = [];
/** /**
* @var TimesheetConfiguration * @var TimesheetConfiguration
*/ */
protected $configuration; private $configuration;
public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration) /**
* @param TimesheetConfiguration $configuration
* @param TrackingModeInterface[] $modes
*/
public function __construct(TimesheetConfiguration $configuration, iterable $modes)
{ {
$this->dateTime = $dateTime;
$this->configuration = $configuration; $this->configuration = $configuration;
$this->modes = $modes;
} }
/** /**
@@ -39,12 +39,7 @@ class TrackingModeService
*/ */
public function getModes(): iterable public function getModes(): iterable
{ {
return [ return $this->modes;
new DefaultMode($this->dateTime, $this->configuration),
new PunchInOutMode(),
new DurationOnlyMode($this->dateTime, $this->configuration),
new DurationFixedBeginMode($this->dateTime, $this->configuration),
];
} }
public function getActiveMode(): TrackingModeInterface public function getActiveMode(): TrackingModeInterface

View File

@@ -49,6 +49,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
{ {
return [ return [
['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')], ['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')],
['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')],
['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')], ['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')],
['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')], ['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')],
['form[name=system_configuration_form_theme]', $this->createUrl('/admin/system-config/update/theme')], ['form[name=system_configuration_form_theme]', $this->createUrl('/admin/system-config/update/theme')],

View File

@@ -129,7 +129,15 @@ class AppExtensionTest extends TestCase
'kimai.timesheet' => [ 'kimai.timesheet' => [
'mode' => 'default', 'mode' => 'default',
'markdown_content' => false, 'markdown_content' => false,
'rounding' => [], 'rounding' => [
'default' => [
'begin' => 1,
'end' => 1,
'duration' => 0,
'mode' => 'default',
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday'
]
],
'rates' => [], 'rates' => [],
'active_entries' => [ 'active_entries' => [
'soft_limit' => 1, 'soft_limit' => 1,
@@ -141,7 +149,15 @@ class AppExtensionTest extends TestCase
'default_begin' => 'now', 'default_begin' => 'now',
], ],
'kimai.timesheet.rates' => [], 'kimai.timesheet.rates' => [],
'kimai.timesheet.rounding' => [], 'kimai.timesheet.rounding' => [
'default' => [
'begin' => 1,
'end' => 1,
'duration' => 0,
'mode' => 'default',
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday'
]
],
'kimai.ldap' => [ 'kimai.ldap' => [
'user' => [ 'user' => [
'baseDn' => null, 'baseDn' => null,
@@ -240,7 +256,7 @@ class AppExtensionTest extends TestCase
public function testDurationOnlyDeprecationIsTriggered() public function testDurationOnlyDeprecationIsTriggered()
{ {
$this->expectException(Notice::class); $this->expectException(Notice::class);
$this->expectExceptionMessage('Found ambiguous configuration. Please remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.'); $this->expectExceptionMessage('Found ambiguous configuration: remove "kimai.timesheet.duration_only" and set "kimai.timesheet.mode" instead.');
$minConfig = $this->getMinConfig(); $minConfig = $this->getMinConfig();
$minConfig['kimai']['timesheet']['duration_only'] = true; $minConfig['kimai']['timesheet']['duration_only'] = true;

View File

@@ -220,7 +220,15 @@ class ConfigurationTest extends TestCase
'default_begin' => 'now', 'default_begin' => 'now',
'mode' => 'default', 'mode' => 'default',
'markdown_content' => false, 'markdown_content' => false,
'rounding' => [], 'rounding' => [
'default' => [
'begin' => 1,
'end' => 1,
'duration' => 0,
'mode' => 'default',
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
]
],
'rates' => [], 'rates' => [],
'active_entries' => [ 'active_entries' => [
'soft_limit' => 1, 'soft_limit' => 1,

View File

@@ -24,12 +24,4 @@ class TimesheetSubscriberTest extends TestCase
$events = $sut->getSubscribedEvents(); $events = $sut->getSubscribedEvents();
$this->assertTrue(in_array(Events::onFlush, $events)); $this->assertTrue(in_array(Events::onFlush, $events));
} }
public function testConstructThrowsExceptionOnInvalidParam()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid TimesheetCalculator implementation given. Expected CalculatorInterface but received stdClass');
new TimesheetSubscriber([new \stdClass()]);
}
} }

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Mocks;
use App\Configuration\TimesheetConfiguration;
use App\Tests\Configuration\TestConfigLoader;
use App\Timesheet\Rounding\CeilRounding;
use App\Timesheet\Rounding\ClosestRounding;
use App\Timesheet\Rounding\DefaultRounding;
use App\Timesheet\Rounding\FloorRounding;
use App\Timesheet\RoundingService;
class RoundingServiceFactory extends AbstractMockFactory
{
public function create(?array $rules = null): RoundingService
{
$loader = new TestConfigLoader([]);
if (null === $rules) {
$rules = [
'default' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0,
'end' => 0,
'duration' => 0,
'mode' => 'default'
]
];
}
$configuration = new TimesheetConfiguration($loader, [
'rounding' => $rules
]);
$modes = [
new CeilRounding(),
new ClosestRounding(),
new DefaultRounding(),
new FloorRounding(),
];
return new RoundingService($configuration, $modes, $rules);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Mocks;
use App\Configuration\TimesheetConfiguration;
use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\DefaultMode;
use App\Timesheet\TrackingMode\DurationFixedBeginMode;
use App\Timesheet\TrackingMode\DurationOnlyMode;
use App\Timesheet\TrackingMode\PunchInOutMode;
use App\Timesheet\TrackingModeService;
class TrackingModeServiceFactory extends AbstractMockFactory
{
public function create(?string $mode = null, ?array $modes = null): TrackingModeService
{
if (null === $mode) {
$mode = 'default';
}
$dateTime = (new UserDateTimeFactoryFactory($this->getTestCase()))->create();
$loader = new TestConfigLoader([]);
$configuration = new TimesheetConfiguration($loader, ['mode' => $mode]);
if (null === $modes) {
$modes = [
new DefaultMode($dateTime, $configuration, (new RoundingServiceFactory($this->getTestCase()))->create()),
new PunchInOutMode($dateTime),
new DurationOnlyMode($dateTime, $configuration),
new DurationFixedBeginMode($dateTime, $configuration),
];
}
return new TrackingModeService($configuration, $modes);
}
}

View File

@@ -10,11 +10,13 @@
namespace App\Tests\Timesheet\Calculator; namespace App\Tests\Timesheet\Calculator;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Tests\Mocks\RoundingServiceFactory;
use App\Timesheet\Calculator\DurationCalculator; use App\Timesheet\Calculator\DurationCalculator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Timesheet\Calculator\DurationCalculator * @covers \App\Timesheet\Calculator\DurationCalculator
* @covers \App\Timesheet\RoundingService
*/ */
class DurationCalculatorTest extends TestCase class DurationCalculatorTest extends TestCase
{ {
@@ -24,7 +26,7 @@ class DurationCalculatorTest extends TestCase
$record->setBegin(new \DateTime()); $record->setBegin(new \DateTime());
$this->assertEquals(0, $record->getDuration()); $this->assertEquals(0, $record->getDuration());
$sut = new DurationCalculator([]); $sut = new DurationCalculator((new RoundingServiceFactory($this))->create());
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals(0, $record->getDuration()); $this->assertEquals(0, $record->getDuration());
} }
@@ -39,7 +41,7 @@ class DurationCalculatorTest extends TestCase
$record->setEnd($end); $record->setEnd($end);
$this->assertEquals(0, $record->getDuration()); $this->assertEquals(0, $record->getDuration());
$sut = new DurationCalculator($rules); $sut = new DurationCalculator((new RoundingServiceFactory($this))->create($rules));
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals($expectedDuration, $record->getDuration()); $this->assertEquals($expectedDuration, $record->getDuration());
} }
@@ -52,7 +54,7 @@ class DurationCalculatorTest extends TestCase
return [ return [
[ [
[], null,
$start, $start,
(clone $start)->setTimestamp($start->getTimestamp() + 1837), (clone $start)->setTimestamp($start->getTimestamp() + 1837),
1837 1837
@@ -60,7 +62,7 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 15, 'begin' => 15,
'end' => 15, 'end' => 15,
'duration' => 0, 'duration' => 0,
@@ -74,7 +76,7 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 0, 'duration' => 0,
@@ -88,7 +90,7 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 1, 'begin' => 1,
'end' => 1, 'end' => 1,
'duration' => 0, 'duration' => 0,
@@ -102,7 +104,7 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 30, 'duration' => 30,
@@ -116,14 +118,14 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 15, 'begin' => 15,
'end' => 0, 'end' => 0,
'duration' => 0, 'duration' => 0,
'mode' => 'default', 'mode' => 'default',
], ],
'weekdays' => [ 'foo' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0, 'begin' => 0,
'end' => 1, 'end' => 1,
'duration' => 30, 'duration' => 30,
@@ -137,14 +139,14 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 15, 'begin' => 15,
'end' => 0, 'end' => 0,
'duration' => 30, 'duration' => 30,
'mode' => 'default', 'mode' => 'default',
], ],
'weekdays' => [ 'foo' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0, 'begin' => 0,
'end' => 1, 'end' => 1,
'duration' => 0, 'duration' => 0,
@@ -158,14 +160,14 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 1, 'duration' => 1,
'mode' => 'default', 'mode' => 'default',
], ],
'weekdays' => [ 'foo' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 1, 'duration' => 1,
@@ -179,14 +181,14 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 1, 'begin' => 1,
'end' => 1, 'end' => 1,
'duration' => 1, 'duration' => 1,
'mode' => 'default', 'mode' => 'default',
], ],
'weekdays' => [ 'foo' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 1, 'begin' => 1,
'end' => 1, 'end' => 1,
'duration' => 1, 'duration' => 1,
@@ -200,14 +202,14 @@ class DurationCalculatorTest extends TestCase
[ [
[ [
'default' => [ 'default' => [
'days' => [$day], 'days' => $day,
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 0, 'duration' => 0,
'mode' => 'default', 'mode' => 'default',
], ],
'weekdays' => [ 'foo' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0, 'begin' => 0,
'end' => 0, 'end' => 0,
'duration' => 0, 'duration' => 0,

View File

@@ -215,7 +215,7 @@ class RateCalculatorTest extends TestCase
'days' => [$day], 'days' => [$day],
'factor' => 2.0 'factor' => 2.0
], ],
'weekdays' => [ 'foo' => [
'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'], 'days' => ['MonDay', 'tUEsdAy', 'WEdnesday', 'THursday', 'friDay', 'SATURday', 'sunDAY'],
'factor' => 1.5 'factor' => 1.5
], ],

View File

@@ -31,6 +31,7 @@ class CeilRoundingTest extends TestCase
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());
$sut = new CeilRounding(); $sut = new CeilRounding();
self::assertEquals('ceil', $sut->getId());
$sut->roundBegin($record, $roundBegin); $sut->roundBegin($record, $roundBegin);
$sut->roundEnd($record, $roundEnd); $sut->roundEnd($record, $roundEnd);
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());

View File

@@ -31,6 +31,7 @@ class ClosestRoundingTest extends TestCase
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());
$sut = new ClosestRounding(); $sut = new ClosestRounding();
self::assertEquals('closest', $sut->getId());
$sut->roundBegin($record, $roundBegin); $sut->roundBegin($record, $roundBegin);
$sut->roundEnd($record, $roundEnd); $sut->roundEnd($record, $roundEnd);
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());

View File

@@ -31,6 +31,7 @@ class DefaultRoundingTest extends TestCase
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());
$sut = new DefaultRounding(); $sut = new DefaultRounding();
self::assertEquals('default', $sut->getId());
$sut->roundBegin($record, $roundBegin); $sut->roundBegin($record, $roundBegin);
$sut->roundEnd($record, $roundEnd); $sut->roundEnd($record, $roundEnd);
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());

View File

@@ -31,6 +31,7 @@ class FloorRoundingTest extends TestCase
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());
$sut = new FloorRounding(); $sut = new FloorRounding();
self::assertEquals('floor', $sut->getId());
$sut->roundBegin($record, $roundBegin); $sut->roundBegin($record, $roundBegin);
$sut->roundEnd($record, $roundEnd); $sut->roundEnd($record, $roundEnd);
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp()); $record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());

View File

@@ -0,0 +1,251 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Timesheet;
use App\Entity\Timesheet;
use App\Tests\Mocks\RoundingServiceFactory;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Timesheet\RoundingService
*/
class RoundingServiceTest extends TestCase
{
public function testCalculateWithEmptyEnd()
{
$record = new Timesheet();
$record->setBegin(new \DateTime());
$this->assertEquals(0, $record->getDuration());
$sut = (new RoundingServiceFactory($this))->create();
$sut->applyRoundings($record);
$this->assertEquals(0, $record->getDuration());
}
/**
* @dataProvider getTestData
*/
public function testCalculate($rules, $start, $end, $expectedStart, $expectedEnd, $expectedDuration)
{
$record = new Timesheet();
$record->setBegin($start);
$record->setEnd($end);
$this->assertEquals(0, $record->getDuration());
$sut = (new RoundingServiceFactory($this))->create($rules);
$sut->roundBegin($record);
$this->assertEquals($expectedStart, $record->getBegin());
$sut->roundEnd($record);
$this->assertEquals($expectedEnd, $record->getEnd());
// set the proper duration
$record->setDuration($record->getEnd()->getTimestamp() - $record->getBegin()->getTimestamp());
$sut->roundDuration($record);
$this->assertEquals($expectedDuration, $record->getDuration());
}
public function getTestData()
{
$start = new \DateTime();
$start->setTime(12, 0, 0);
$day = $start->format('l');
return [
[
null,
$start,
(clone $start)->setTimestamp($start->getTimestamp() + 1837),
$start,
(clone $start)->setTimestamp($start->getTimestamp() + 1837),
1837
],
[
[
'default' => [
'days' => $day,
'begin' => 15,
'end' => 15,
'duration' => 0,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 17, 35),
(clone $start)->setTime(13, 32, 52),
(clone $start)->setTime(12, 15, 00),
(clone $start)->setTime(13, 45, 00),
5400
],
[
[
'default' => [
'days' => $day,
'begin' => 0,
'end' => 0,
'duration' => 0,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 17, 35),
(clone $start)->setTime(13, 32, 52),
(clone $start)->setTime(12, 17, 35),
(clone $start)->setTime(13, 32, 52),
4517
],
[
[
'default' => [
'days' => $day,
'begin' => 1,
'end' => 1,
'duration' => 0,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 17, 35),
(clone $start)->setTime(13, 32, 52),
(clone $start)->setTime(12, 17, 00),
(clone $start)->setTime(13, 33, 00),
4560
],
[
[
'default' => [
'days' => $day,
'begin' => 0,
'end' => 0,
'duration' => 30,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 10, 51),
(clone $start)->setTime(14, 40, 52),
(clone $start)->setTime(12, 10, 51),
(clone $start)->setTime(14, 40, 52),
10800
],
[
[
'default' => [
'days' => $day,
'begin' => 15,
'end' => 0,
'duration' => 0,
'mode' => 'default',
],
'foo' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0,
'end' => 1,
'duration' => 30,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 27, 35), // 12:15
(clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 => 2:30
(clone $start)->setTime(12, 15, 00), // 12:15
(clone $start)->setTime(14, 33, 00), // 14:33 => 2:18 => 2:30
9000
],
[
[
'default' => [
'days' => $day,
'begin' => 15,
'end' => 0,
'duration' => 30,
'mode' => 'default',
],
'foo' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0,
'end' => 1,
'duration' => 0,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 27, 35), // 12:15
(clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 (second duration will not be rounded)
(clone $start)->setTime(12, 15, 00), // 12:15
(clone $start)->setTime(14, 33, 00), // 14:33 => 2:18 (second duration will not be rounded)
9000
],
[
[
'default' => [
'days' => $day,
'begin' => 0,
'end' => 0,
'duration' => 1,
'mode' => 'default',
],
'foo' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0,
'end' => 0,
'duration' => 1,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
0
],
[
[
'default' => [
'days' => $day,
'begin' => 1,
'end' => 1,
'duration' => 1,
'mode' => 'default',
],
'foo' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 1,
'end' => 1,
'duration' => 1,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 27, 00), // no diff, to test ...
(clone $start)->setTime(12, 27, 00), // ... that no rounding is applied
(clone $start)->setTime(12, 27, 00), // no diff, to test ...
(clone $start)->setTime(12, 27, 00), // ... that no rounding is applied
0
],
[
[
'default' => [
'days' => $day,
'begin' => 0,
'end' => 0,
'duration' => 0,
'mode' => 'default',
],
'foo' => [
'days' => 'monday,tuesday,wednesday,thursday,friday,saturday,sunday',
'begin' => 0,
'end' => 0,
'duration' => 0,
'mode' => 'default',
],
],
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
0
],
];
}
}

View File

@@ -10,7 +10,9 @@
namespace App\Tests\Timesheet\TrackingMode; namespace App\Tests\Timesheet\TrackingMode;
use App\Configuration\TimesheetConfiguration; use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Tests\Configuration\TestConfigLoader; use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\RoundingServiceFactory;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\DefaultMode; use App\Timesheet\TrackingMode\DefaultMode;
@@ -19,6 +21,12 @@ use App\Timesheet\TrackingMode\DefaultMode;
*/ */
class DefaultModeTest extends AbstractTrackingModeTest class DefaultModeTest extends AbstractTrackingModeTest
{ {
protected function assertDefaultBegin(Timesheet $timesheet)
{
self::assertNotNull($timesheet->getBegin());
self::assertInstanceOf(\DateTime::class, $timesheet->getBegin());
}
/** /**
* @return DefaultMode * @return DefaultMode
*/ */
@@ -28,7 +36,7 @@ class DefaultModeTest extends AbstractTrackingModeTest
$dateTime = (new UserDateTimeFactoryFactory($this))->create(); $dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']); $configuration = new TimesheetConfiguration($loader, ['default_begin' => '13:47']);
return new DefaultMode($dateTime, $configuration); return new DefaultMode($dateTime, $configuration, (new RoundingServiceFactory($this))->create());
} }
public function testDefaultValues() public function testDefaultValues()

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Timesheet\TrackingMode; namespace App\Tests\Timesheet\TrackingMode;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\PunchInOutMode; use App\Timesheet\TrackingMode\PunchInOutMode;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -21,7 +22,8 @@ class PunchInOutModeTest extends TestCase
{ {
public function testDefaultValues() public function testDefaultValues()
{ {
$sut = new PunchInOutMode(); $dateTime = (new UserDateTimeFactoryFactory($this))->create();
$sut = new PunchInOutMode($dateTime);
self::assertFalse($sut->canEditBegin()); self::assertFalse($sut->canEditBegin());
self::assertFalse($sut->canEditEnd()); self::assertFalse($sut->canEditEnd());
@@ -33,13 +35,25 @@ class PunchInOutModeTest extends TestCase
public function testCreate() public function testCreate()
{ {
$startingTime = new \DateTime('22:54');
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setBegin(new \DateTime('22:54')); $timesheet->setBegin($startingTime);
$request = new Request(); $request = new Request();
$timesheetNew = clone $timesheet;
$sut = new PunchInOutMode(); $dateTime = (new UserDateTimeFactoryFactory($this))->create();
$sut = new PunchInOutMode($dateTime);
$sut->create($timesheet, $request); $sut->create($timesheet, $request);
self::assertEquals($timesheet, $timesheetNew); self::assertEquals($timesheet->getBegin(), $startingTime);
}
public function testCreateWithoutBegin()
{
$timesheet = new Timesheet();
$request = new Request();
$dateTime = (new UserDateTimeFactoryFactory($this))->create();
$sut = new PunchInOutMode($dateTime);
$sut->create($timesheet, $request);
self::assertInstanceOf(\DateTime::class, $timesheet->getBegin());
} }
} }

View File

@@ -9,11 +9,8 @@
namespace App\Tests\Timesheet; namespace App\Tests\Timesheet;
use App\Configuration\TimesheetConfiguration; use App\Tests\Mocks\TrackingModeServiceFactory;
use App\Tests\Configuration\TestConfigLoader;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
use App\Timesheet\TrackingMode\PunchInOutMode; use App\Timesheet\TrackingMode\PunchInOutMode;
use App\Timesheet\TrackingModeService;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
@@ -24,11 +21,7 @@ class TrackingModeServiceTest extends TestCase
{ {
public function testDefaultTrackingModesAreRegistered() public function testDefaultTrackingModesAreRegistered()
{ {
$loader = new TestConfigLoader([]); $sut = (new TrackingModeServiceFactory($this))->create('punch');
$dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['mode' => 'punch']);
$sut = new TrackingModeService($dateTime, $configuration);
$modes = $sut->getModes(); $modes = $sut->getModes();
self::assertGreaterThanOrEqual(4, $modes); self::assertGreaterThanOrEqual(4, $modes);
@@ -46,11 +39,7 @@ class TrackingModeServiceTest extends TestCase
public function testGetActiveMode() public function testGetActiveMode()
{ {
$loader = new TestConfigLoader([]); $sut = (new TrackingModeServiceFactory($this))->create('punch');
$dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['mode' => 'punch']);
$sut = new TrackingModeService($dateTime, $configuration);
self::assertInstanceOf(PunchInOutMode::class, $sut->getActiveMode()); self::assertInstanceOf(PunchInOutMode::class, $sut->getActiveMode());
} }
@@ -60,11 +49,7 @@ class TrackingModeServiceTest extends TestCase
$this->expectException(ServiceNotFoundException::class); $this->expectException(ServiceNotFoundException::class);
$this->expectExceptionMessage('You have requested a non-existent service "xxxxxx"'); $this->expectExceptionMessage('You have requested a non-existent service "xxxxxx"');
$loader = new TestConfigLoader([]); $sut = (new TrackingModeServiceFactory($this))->create('xxxxxx');
$dateTime = (new UserDateTimeFactoryFactory($this))->create();
$configuration = new TimesheetConfiguration($loader, ['mode' => 'xxxxxx']);
$sut = new TrackingModeService($dateTime, $configuration);
$sut->getActiveMode(); $sut->getActiveMode();
} }

View File

@@ -15,8 +15,7 @@ use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory; use App\Tests\Mocks\TrackingModeServiceFactory;
use App\Timesheet\TrackingModeService;
use App\Validator\Constraints\Timesheet as TimesheetConstraint; use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\TimesheetValidator; use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -39,10 +38,8 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
'rules' => [ 'rules' => [
'allow_future_times' => false, 'allow_future_times' => false,
], ],
'mode' => 'default',
]); ]);
$dateTime = (new UserDateTimeFactoryFactory($this))->create(); $service = (new TrackingModeServiceFactory($this))->create('default');
$service = new TrackingModeService($dateTime, $config);
return new TimesheetValidator($authMock, $config, $service); return new TimesheetValidator($authMock, $config, $service);
} }

View File

@@ -118,6 +118,74 @@
<source>label.theme.branding.title</source> <source>label.theme.branding.title</source>
<target>Browser Titel</target> <target>Browser Titel</target>
</trans-unit> </trans-unit>
<trans-unit id="rounding">
<source>rounding</source>
<target>Zeitenrundung</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.begin">
<source>label.timesheet.rounding.default.begin</source>
<target>Rundung des Startzeitpunkts in Minuten (0 = deaktiviert)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.end">
<source>label.timesheet.rounding.default.end</source>
<target>Rundung des Endzeitpunkts in Minuten (0 = deaktiviert)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.duration">
<source>label.timesheet.rounding.default.duration</source>
<target>Rundung der Dauer in Minuten (0 = deaktiviert)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.mode">
<source>label.timesheet.rounding.default.mode</source>
<target>Rundungsmodus</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.days">
<source>label.timesheet.rounding.default.days</source>
<target>Tage der Woche an denen gerundet wird</target>
</trans-unit>
<trans-unit id="Monday">
<source>Monday</source>
<target>Montag</target>
</trans-unit>
<trans-unit id="Tuesday">
<source>Tuesday</source>
<target>Dienstag</target>
</trans-unit>
<trans-unit id="Wednesday">
<source>Wednesday</source>
<target>Mittwoch</target>
</trans-unit>
<trans-unit id="Thursday">
<source>Thursday</source>
<target>Donnerstag</target>
</trans-unit>
<trans-unit id="Friday">
<source>Friday</source>
<target>Freitag</target>
</trans-unit>
<trans-unit id="Saturday">
<source>Saturday</source>
<target>Samstag</target>
</trans-unit>
<trans-unit id="Sunday">
<source>Sunday</source>
<target>Sonntag</target>
</trans-unit>
<trans-unit id="Ceil">
<source>Ceil</source>
<target>Ceil: Start, Ende und Dauer werden nach oben gerundet</target>
</trans-unit>
<trans-unit id="Closest">
<source>Closest</source>
<target>Closest: Mathematische Rundung zum nächsten Wert</target>
</trans-unit>
<trans-unit id="Default">
<source>Default</source>
<target>Standard: Start werden nach oben, Ende und Dauer nach oben gerundet</target>
</trans-unit>
<trans-unit id="Floor">
<source>Floor</source>
<target>Floor: Start, Ende und Dauer werden nach unten gerundet</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -118,6 +118,74 @@
<source>label.theme.branding.title</source> <source>label.theme.branding.title</source>
<target>Browser Title</target> <target>Browser Title</target>
</trans-unit> </trans-unit>
<trans-unit id="rounding">
<source>rounding</source>
<target>Time rounding</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.begin">
<source>label.timesheet.rounding.default.begin</source>
<target>Rounding of the start time in minutes (0 = deactivated)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.end">
<source>label.timesheet.rounding.default.end</source>
<target>Rounding of the end time in minutes (0 = deactivated)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.duration">
<source>label.timesheet.rounding.default.duration</source>
<target>Rounding of the duration in minutes (0 = deactivated)</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.mode">
<source>label.timesheet.rounding.default.mode</source>
<target>Rounding mode</target>
</trans-unit>
<trans-unit id="label.timesheet.rounding.default.days">
<source>label.timesheet.rounding.default.days</source>
<target>Days of the week when rounding will be applied</target>
</trans-unit>
<trans-unit id="Monday">
<source>Monday</source>
<target>Monday</target>
</trans-unit>
<trans-unit id="Tuesday">
<source>Tuesday</source>
<target>Tuesday</target>
</trans-unit>
<trans-unit id="Wednesday">
<source>Wednesday</source>
<target>Wednesday</target>
</trans-unit>
<trans-unit id="Thursday">
<source>Thursday</source>
<target>Thursday</target>
</trans-unit>
<trans-unit id="Friday">
<source>Friday</source>
<target>Friday</target>
</trans-unit>
<trans-unit id="Saturday">
<source>Saturday</source>
<target>Saturday</target>
</trans-unit>
<trans-unit id="Sunday">
<source>Sunday</source>
<target>Sunday</target>
</trans-unit>
<trans-unit id="Ceil">
<source>Ceil</source>
<target>Ceil: begin, end and duration will be rounded up</target>
</trans-unit>
<trans-unit id="Closest">
<source>Closest</source>
<target>Closest: mathematical rounding to the nearest value</target>
</trans-unit>
<trans-unit id="Default">
<source>Default</source>
<target>Standard: begin will be rounded down, end and duration up</target>
</trans-unit>
<trans-unit id="Floor">
<source>Floor</source>
<target>Floor: begin, end and duration will be rounded down</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>