fix timezone problems in timesheet forms (#555)

This commit is contained in:
Kevin Papst
2019-02-13 21:37:54 +01:00
committed by GitHub
parent 2c6f57c7ce
commit ef33233624
23 changed files with 461 additions and 61 deletions

View File

@@ -23,9 +23,9 @@ Kimai is a [multi-language application](var/docs/translations.md) and already tr
### Requirements
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- The PHP extensions [xml](http://php.net/manual/en/book.xml.php), [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- Kimai needs its own sub-domain or you need to [recompile the frontend assets](var/docs/developers.md) for usage in a sub-directory
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
- A modern browser, Kimai v2 might be broken on old browsers like IE 10
## Documentation
@@ -56,12 +56,12 @@ You can see our development roadmap in the [Milestones](https://github.com/kevin
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome!
> Kimai 2 uses a rolling release concept for delivering updates.
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
> You can upgrade Kimai at any time, you don't need to wait for the next official release. Read the [upgrade docs](UPGRADING.md) first!
Release versions will be created on a regular base and you can use these tags if you are familiar with Git,
but we will not provide support for any specific version.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch and
intensively tested before merging. We have to do it this way, as we develop Kimai in our free time and want to put our
Every code change, whether it's a new feature or a bug fix, will be done on the master branch.
I have to do it this way, as I develop Kimai in my free time and want to put most
effort into the software instead of backporting changes for old versions.
## Extensions for Kimai 2

View File

@@ -15,6 +15,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -46,16 +47,23 @@ class TimesheetController extends BaseApiController
*/
protected $hardLimit;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, int $hardLimit)
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, int $hardLimit)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->hardLimit = $hardLimit;
$this->dateTime = $dateTime;
}
/**
@@ -164,7 +172,7 @@ class TimesheetController extends BaseApiController
{
$timesheet = new Timesheet();
$timesheet->setUser($this->getUser());
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin($this->dateTime->createDateTime());
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,

View File

@@ -15,6 +15,7 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection;
@@ -125,7 +126,7 @@ class KimaiImporterCommand extends Command
)
->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer', 'de')
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE')
;
}
@@ -249,7 +250,7 @@ class KimaiImporterCommand extends Command
$allImports = 0;
try {
$counter = $this->importUsers($io, $password, $users);
$counter = $this->importUsers($io, $password, $users, $rates);
$allImports += $counter;
$io->success('Imported users: ' . $counter);
} catch (\Exception $ex) {
@@ -398,16 +399,21 @@ class KimaiImporterCommand extends Command
}
/**
* @param string $table
* @param $table
* @param array $where
* @return array
*/
protected function fetchAllFromImport($table)
protected function fetchAllFromImport($table, array $where = [])
{
return $this->connection->createQueryBuilder()
$query = $this->connection->createQueryBuilder()
->select('*')
->from($this->connection->quoteIdentifier($this->dbPrefix . $table))
->execute()
->fetchAll();
->from($this->connection->quoteIdentifier($this->dbPrefix . $table));
foreach ($where as $column => $value) {
$query->andWhere($query->expr()->eq($column, $value));
}
return $query->execute()->fetchAll();
}
/**
@@ -467,10 +473,11 @@ class KimaiImporterCommand extends Command
* @param SymfonyStyle $io
* @param string $password
* @param array $users
* @param array $rates
* @return int
* @throws \Exception
*/
protected function importUsers(SymfonyStyle $io, $password, $users)
protected function importUsers(SymfonyStyle $io, $password, $users, $rates)
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
@@ -495,6 +502,34 @@ class KimaiImporterCommand extends Command
throw new \Exception('Failed to validate user: ' . $user->getUsername());
}
// find and migrate user preferences
$prefsToImport = ['ui.lang' => 'language', 'timezone' => 'timezone'];
$preferences = $this->fetchAllFromImport('preferences', ['userID' => $oldUser['userID']]);
foreach ($preferences as $pref) {
$key = $pref['option'];
if (!array_key_exists($key, $prefsToImport)) {
continue;
}
$newPref = new UserPreference();
$newPref
->setName($prefsToImport[$key])
->setValue($pref['value']);
$user->addPreference($newPref);
}
// find hourly rate
foreach ($rates as $ratesRow) {
if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) {
$newPref = new UserPreference();
$newPref
->setName(UserPreference::HOURLY_RATE)
->setValue($ratesRow['rate']);
$user->addPreference($newPref);
}
}
try {
$entityManager->persist($user);
$entityManager->flush();
@@ -571,7 +606,7 @@ class KimaiImporterCommand extends Command
->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city'])
->setTimezone($oldCustomer['timezone'])
->setVisible($isActive)
->setCountry($country)
->setCountry(strtoupper($country))
;
if (!$this->validateImport($io, $customer)) {
@@ -908,12 +943,18 @@ class KimaiImporterCommand extends Command
}
if (null === $activity) {
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
$io->error('Could not import timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
continue;
}
$duration = $oldRecord['end'] - $oldRecord['start'];
// FIXME create user on the fly
if (!isset($this->users[$oldRecord['userID']])) {
$io->error('Could not import timesheet record, unknown user: ' . $oldRecord['userID']);
continue;
}
$timesheet = new Timesheet();
$fixedRate = $oldRecord['fixedRate'];
@@ -934,15 +975,30 @@ class KimaiImporterCommand extends Command
$timesheet->setRate(round($rate, 2));
}
$user = $this->users[$oldRecord['userID']];
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
$dateTimezone = new \DateTimeZone('UTC');
$begin = new \DateTime('@' . $oldRecord['start']);
$begin->setTimezone($dateTimezone);
$end = new \DateTime('@' . $oldRecord['end']);
$end->setTimezone($dateTimezone);
// ---------- workaround for localizeDates ----------
// if getBegin() is not execute first, then the dates will we re-written in validateImport() below
$timesheet->setBegin($begin)->setEnd($end)->getBegin();
// --------------------------------------------------
$timesheet
->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null))
->setUser($this->users[$oldRecord['userID']])
->setBegin(new \DateTime('@' . $oldRecord['start']))
->setEnd(new \DateTime('@' . $oldRecord['end']))
->setBegin($begin)
->setEnd($end)
->setDuration($duration)
->setActivity($activity)
->setProject($project)
->setExported(intval($oldRecord['cleared']) !== 0)
->setTimezone($timezone)
;
if (!$this->validateImport($io, $timesheet)) {

View File

@@ -13,6 +13,7 @@ use App\Calendar\Service;
use App\Calendar\TimesheetEntity;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -30,13 +31,19 @@ class CalendarController extends AbstractController
* @var Service
*/
protected $calendar;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param Service $calendar
* @param UserDateTimeFactory $dateTime
*/
public function __construct(Service $calendar)
public function __construct(Service $calendar, UserDateTimeFactory $dateTime)
{
$this->calendar = $calendar;
$this->dateTime = $dateTime;
}
/**
@@ -47,7 +54,8 @@ class CalendarController extends AbstractController
{
return $this->render('calendar/user.html.twig', [
'config' => $this->calendar->getConfig(),
'google' => $this->calendar->getGoogle()
'google' => $this->calendar->getGoogle(),
'now' => $this->dateTime->createDateTime(),
]);
}

View File

@@ -18,6 +18,7 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -45,12 +46,20 @@ class ProfileController extends AbstractController
protected $encoder;
/**
* @param UserPasswordEncoderInterface $encoder
* @var UserDateTimeFactory
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
protected $dateTime;
/**
* @param UserPasswordEncoderInterface $encoder
* @param EventDispatcherInterface $dispatcher
* @param UserDateTimeFactory $dateTime
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, UserDateTimeFactory $dateTime)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
$this->dateTime = $dateTime;
}
/**
@@ -252,6 +261,7 @@ class ProfileController extends AbstractController
'user' => $user,
'stats' => $userStats,
'years' => $monthlyStats,
'local_time' => $this->dateTime->createDateTime(),
'forms' => []
];

View File

@@ -29,14 +29,6 @@ class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* @param int $hardLimit
*/
public function __construct(int $hardLimit)
{
$this->setHardLimit($hardLimit);
}
/**
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
@@ -162,7 +154,7 @@ class TimesheetController extends AbstractController
try {
$entry = new Timesheet();
$entry
->setBegin(new \DateTime())
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -27,6 +28,21 @@ trait TimesheetControllerTrait
*/
private $hardLimit = 1;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
*/
public function __construct(UserDateTimeFactory $dateTime, int $hardLimit)
{
$this->dateTime = $dateTime;
$this->setHardLimit($hardLimit);
}
/**
* @param int $hardLimit
*/
@@ -118,7 +134,7 @@ trait TimesheetControllerTrait
{
$entry = new Timesheet();
$entry->setUser($this->getUser());
$entry->setBegin(new \DateTime());
$entry->setBegin($this->dateTime->createDateTime());
$start = $request->get('begin');
if ($start !== null) {

View File

@@ -16,9 +16,6 @@ use KevinPapst\AdminLTEBundle\Model\UserModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Class NavbarShowUserSubscriber
*/
class NavbarShowUserSubscriber implements EventSubscriberInterface
{
/**
@@ -27,7 +24,6 @@ class NavbarShowUserSubscriber implements EventSubscriberInterface
protected $storage;
/**
* NavbarShowUserListener constructor.
* @param TokenStorageInterface $tokenStorage
*/
public function __construct(TokenStorageInterface $tokenStorage)
@@ -64,7 +60,7 @@ class NavbarShowUserSubscriber implements EventSubscriberInterface
->setIsOnline(true)
->setTitle($myUser->getTitle())
->setAvatar($myUser->getAvatar())
->setMemberSince(new \DateTime());
->setMemberSince($myUser->getRegisteredAt());
$event->setUser($user);
}

View File

@@ -46,7 +46,7 @@ class TimezoneSubscriber implements EventSubscriberInterface
*/
public function setTimezone(GetResponseEvent $event)
{
if (null === $this->storage->getToken()) {
if (!$this->canHandleEvent()) {
return;
}
@@ -55,4 +55,24 @@ class TimezoneSubscriber implements EventSubscriberInterface
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
date_default_timezone_set($timezone);
}
/**
* @return bool
*/
protected function canHandleEvent(): bool
{
if (null === $this->storage->getToken()) {
return false;
}
/* @var $user User */
$user = $this->storage->getToken()->getUser();
if (null === $user) {
return false;
}
return ($user instanceof User);
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Symfony\Component\HttpFoundation\Response;
@@ -27,11 +28,18 @@ class PDFRenderer implements RendererInterface
protected $twig;
/**
* @param \Twig_Environment $twig
* @var UserDateTimeFactory
*/
public function __construct(\Twig_Environment $twig)
protected $dateTime;
/**
* @param \Twig_Environment $twig
* @param UserDateTimeFactory $dateTime
*/
public function __construct(\Twig_Environment $twig, UserDateTimeFactory $dateTime)
{
$this->twig = $twig;
$this->dateTime = $dateTime;
}
/**
@@ -48,7 +56,7 @@ class PDFRenderer implements RendererInterface
$content = $this->twig->render('export/renderer/pdf.html.twig', [
'entries' => $timesheets,
'query' => $query,
'now' => new \DateTime(),
'now' => $this->dateTime->createDateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);

View File

@@ -20,6 +20,7 @@ use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
@@ -48,15 +49,22 @@ class TimesheetEditForm extends AbstractType
*/
private $durationOnly = false;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param CustomerRepository $customer
* @param ProjectRepository $project
* @param UserDateTimeFactory $dateTime
* @param bool $durationOnly
*/
public function __construct(CustomerRepository $customer, ProjectRepository $project, bool $durationOnly)
public function __construct(CustomerRepository $customer, ProjectRepository $project, UserDateTimeFactory $dateTime, bool $durationOnly)
{
$this->customers = $customer;
$this->projects = $project;
$this->dateTime = $dateTime;
$this->durationOnly = $durationOnly;
}
@@ -70,6 +78,7 @@ class TimesheetEditForm extends AbstractType
$customer = null;
$currency = false;
$end = null;
$begin = null;
if (isset($options['data'])) {
/** @var Timesheet $entry */
@@ -87,12 +96,21 @@ class TimesheetEditForm extends AbstractType
$currency = $customer->getCurrency();
}
$begin = $entry->getBegin();
$end = $entry->getEnd();
}
$timezone = $this->dateTime->getTimezone()->getName();
if (null !== $begin) {
$timezone = $begin->getTimezone()->getName();
}
if (null === $end || !$options['duration_only']) {
$builder->add('begin', DateTimePickerType::class, [
'label' => 'label.begin',
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
}
@@ -102,7 +120,9 @@ class TimesheetEditForm extends AbstractType
]);
} else {
$builder->add('end', DateTimePickerType::class, [
'label' => 'label.end',
'label' => 'label.begin',
'model_timezone' => $timezone,
'view_timezone' => $timezone,
'required' => false,
]);
}
@@ -135,7 +155,10 @@ class TimesheetEditForm extends AbstractType
}
$builder
->add('project', ProjectType::class, array_merge($projectOptions, [
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
'placeholder' => '',
'activity_enabled' => true,
// documentation is for NelmioApiDocBundle

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
@@ -26,11 +27,18 @@ class DatePickerType extends AbstractType
protected $localeSettings;
/**
* @param LocaleSettings $localeSettings
* @var UserDateTimeFactory
*/
public function __construct(LocaleSettings $localeSettings)
protected $dateTime;
/**
* @param LocaleSettings $localeSettings
* @param UserDateTimeFactory $dateTime
*/
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
{
$this->localeSettings = $localeSettings;
$this->dateTime = $dateTime;
}
/**
@@ -40,12 +48,15 @@ class DatePickerType extends AbstractType
{
$pickerFormat = $this->localeSettings->getDatePickerFormat();
$dateFormat = $this->localeSettings->getDateTypeFormat();
$timezone = $this->dateTime->getTimezone()->getName();
$resolver->setDefaults([
'widget' => 'single_text',
'html5' => false,
'format' => $dateFormat,
'format_picker' => $pickerFormat,
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
$resolver->setDefault('attr', function (Options $options) {

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
@@ -25,12 +26,18 @@ class DateTimePickerType extends AbstractType
*/
protected $localeSettings;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param LocaleSettings $localeSettings
*/
public function __construct(LocaleSettings $localeSettings)
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
{
$this->localeSettings = $localeSettings;
$this->dateTime = $dateTime;
}
/**
@@ -40,6 +47,7 @@ class DateTimePickerType extends AbstractType
{
$dateTimePicker = $this->localeSettings->getDateTimePickerFormat();
$dateTimeFormat = $this->localeSettings->getDateTimeTypeFormat();
$timezone = $this->dateTime->getTimezone()->getName();
$resolver->setDefaults([
'label' => 'label.begin',
@@ -48,6 +56,8 @@ class DateTimePickerType extends AbstractType
'format' => $dateTimeFormat,
'format_picker' => $dateTimePicker,
'with_seconds' => false,
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
$resolver->setDefault('attr', function (Options $options) {

View File

@@ -55,8 +55,11 @@ class TimesheetRepository extends AbstractRepository
}
// seems to be necessary so Doctrine will recognize a changed timestamp
$entry->setBegin(clone $entry->getBegin());
$entry->setEnd(new DateTime());
$begin = clone $entry->getBegin();
$end = new \DateTime('now', $begin->getTimezone());
$entry->setBegin($begin);
$entry->setEnd($end);
$entityManager = $this->getEntityManager();
$entityManager->persist($entry);
@@ -103,11 +106,11 @@ class TimesheetRepository extends AbstractRepository
/**
* @param $select
* @param User|null $user
* @param User $user
* @return int
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryThisMonth($select, ?User $user)
protected function queryThisMonth($select, User $user)
{
$begin = new DateTime('first day of this month 00:00:00');
$end = new DateTime('last day of this month 23:59:59');

View File

@@ -0,0 +1,82 @@
<?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\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class UserDateTimeFactory
{
/**
* @var \DateTimeZone
*/
protected $timezone;
/**
* @param TokenStorageInterface $tokenStorage
*/
public function __construct(TokenStorageInterface $tokenStorage)
{
if (null === $tokenStorage->getToken()) {
return;
}
/* @var $user User */
$user = $tokenStorage->getToken()->getUser();
$timezone = date_default_timezone_get();
if ($user instanceof User && null !== $user->getPreferenceValue('timezone')) {
$timezone = $user->getPreferenceValue('timezone');
}
$this->timezone = new \DateTimeZone($timezone);
}
/**
* @return \DateTimeZone
*/
public function getTimezone()
{
return $this->timezone;
}
/**
* @return \DateTime
*/
public function getStartOfMonth()
{
$date = $this->createDateTime('first day of this month');
$date->setTime(0, 0, 0);
return $date;
}
/**
* @return \DateTime
*/
public function getEndOfMonth()
{
$date = $this->createDateTime('last day of this month');
$date->setTime(23, 59, 59);
return $date;
}
/**
* @param string $datetime
* @return \DateTime
*/
public function createDateTime(string $datetime = 'now')
{
$date = new \DateTime($datetime, $this->timezone);
return $date;
}
}

View File

@@ -40,6 +40,7 @@ class DateExtensions extends \Twig_Extension
new TwigFilter('month_name', [$this, 'monthName']),
new TwigFilter('date_short', [$this, 'dateShort']),
new TwigFilter('date_time', [$this, 'dateTime']),
new TwigFilter('date_format', [$this, 'dateFormat']),
new TwigFilter('time', [$this, 'time']),
];
}
@@ -66,6 +67,16 @@ class DateExtensions extends \Twig_Extension
return date_format($date, $format);
}
/**
* @param DateTime $date
* @param string $format
* @return false|string
*/
public function dateFormat(DateTime $date, string $format)
{
return date_format($date, $format);
}
/**
* @param DateTime $date
* @return string

View File

@@ -58,6 +58,8 @@
droppable : false,
height: 'auto',
nowIndicator: true,
now: '{{ now|date_format('c') }}',
//timezone: '{{ app.user.preferenceValue("timezone") }}',
businessHours: {
dow: [{{ config.businessDays|join(',') }}],
start: '{{ config.businessTimeBegin }}',

View File

@@ -10,7 +10,7 @@
<div class="row">
<div class="col-md-3">
{{ macro.profile_box(user, stats) }}
{{ macro.profile_infos(user, stats) }}
{{ macro.profile_infos(user, stats, local_time) }}
</div>
<div class="col-md-9">
@@ -121,7 +121,7 @@
</div>
{% endblock %}
{% macro profile_infos(user, stats) %}
{% macro profile_infos(user, stats, local_time) %}
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">{{ 'profile.about_me'|trans }}</h3>
@@ -129,10 +129,13 @@
<div class="box-body">
<ul class="nav nav-stacked">
{# colors = purple, blue, aqua, red, green #}
{# FIXME hourly rate must be dynamic <li><a href="#">{{ 'label.hourly_rate'|trans }} <span class="pull-right badge bg-blue">70</span></a></li> #}
<li><a href="#">{{ 'label.id'|trans }} <span class="pull-right badge bg-aqua">{{ user.id }}</span></a></li>
<li><a href="#">{{ 'label.username'|trans }} <span class="pull-right badge bg-blue">{{ user.username }}</span></a></li>
<li><a href="#">{{ 'profile.first_entry'|trans }} <span class="pull-right badge bg-purple">{{ stats.firstEntry|date }}</span></a></li>
{% if is_granted('view_rate_own_timesheet') %}
<li><a href="#">{{ 'label.hourly_rate'|trans }} <span class="pull-right badge bg-blue">{{ user.preferenceValue('hourly_rate') }}</span></a></li>
{% endif %}
<li><a href="#">{{ 'label.now'|trans }} <span class="pull-right badge bg-blue">{{ local_time|date_short }} {{ local_time|time }}</span></a></li>
</ul>
</div>
</div>
@@ -155,7 +158,7 @@
<b>{{ 'stats.durationMonth'|trans }}</b> <a class="pull-right">{{ stats.durationThisMonth|duration }}</a>
</li>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('view_rate_own_timesheet') %}
<li class="list-group-item">
<b>{{ 'stats.amountMonth'|trans }}</b> <a class="pull-right">{{ stats.amountThisMonth|money }}</a>
</li>
@@ -165,7 +168,7 @@
<b>{{ 'stats.durationTotal'|trans }}</b> <a class="pull-right">{{ stats.durationTotal|duration }}</a>
</li>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('view_rate_own_timesheet') %}
<li class="list-group-item">
<b>{{ 'stats.amountTotal'|trans }}</b> <a class="pull-right">{{ stats.amountTotal|money }}</a>
</li>

View File

@@ -9,8 +9,12 @@
namespace App\Tests\Export\Renderer;
use App\Entity\User;
use App\Export\Renderer\PDFRenderer;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Twig\Loader\FilesystemLoader;
/**
@@ -19,10 +23,21 @@ use Twig\Loader\FilesystemLoader;
*/
class PdfRendererTest extends AbstractRendererTest
{
protected function getDateTimeFactory()
{
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn(new User());
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
return new UserDateTimeFactory($tokenStorage);
}
public function testConfiguration()
{
$sut = new PDFRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock(),
$this->getDateTimeFactory()
);
$this->assertEquals('pdf', $sut->getId());
@@ -43,7 +58,7 @@ class PdfRendererTest extends AbstractRendererTest
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$sut = new PDFRenderer($twig);
$sut = new PDFRenderer($twig, $this->getDateTimeFactory());
$response = $this->render($sut);

View File

@@ -0,0 +1,111 @@
<?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\User;
use App\Entity\UserPreference;
use App\Timesheet\UserDateTimeFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
/**
* @covers \App\Timesheet\UserDateTimeFactory
*/
class UserDateTimeFactoryTest extends TestCase
{
public const TEST_TIMEZONE = 'Antarctica/DumontDUrville';
protected function createDateTimeFactory(string $timezone)
{
$user = new User();
$pref = new UserPreference();
$pref->setName('timezone');
$pref->setValue($timezone);
$user->addPreference($pref);
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn($user);
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
return new UserDateTimeFactory($tokenStorage);
}
public function testGetTimezone()
{
$sut = $this->createDateTimeFactory(self::TEST_TIMEZONE);
$this->assertEquals(self::TEST_TIMEZONE, $sut->getTimezone()->getName());
}
public function testGetTimezoneWithFallbackTimezone()
{
$token = $this->getMockBuilder(UsernamePasswordToken::class)->setMethods(['getUser'])->disableOriginalConstructor()->getMock();
$token->expects($this->once())->method('getUser')->willReturn('anonymous');
$tokenStorage = new TokenStorage();
$tokenStorage->setToken($token);
$sut = new UserDateTimeFactory($tokenStorage);
$this->assertEquals(date_default_timezone_get(), $sut->getTimezone()->getName());
}
public function testGetStartOfMonth()
{
$expected = new \DateTime();
$sut = $this->createDateTimeFactory(self::TEST_TIMEZONE);
$dateTime = $sut->getStartOfMonth();
$this->assertEquals(0, $dateTime->format('H'));
$this->assertEquals(0, $dateTime->format('i'));
$this->assertEquals(0, $dateTime->format('s'));
$this->assertEquals(1, $dateTime->format('d'));
$this->assertEquals($expected->format('m'), $dateTime->format('m'));
$this->assertEquals($expected->format('Y'), $dateTime->format('Y'));
$this->assertEquals(self::TEST_TIMEZONE, $dateTime->getTimezone()->getName());
}
public function testGetEndOfMonth()
{
$expected = new \DateTime('last day of this month');
$sut = $this->createDateTimeFactory(self::TEST_TIMEZONE);
$dateTime = $sut->getEndOfMonth();
$this->assertEquals(23, $dateTime->format('H'));
$this->assertEquals(59, $dateTime->format('i'));
$this->assertEquals(59, $dateTime->format('s'));
$this->assertEquals($expected->format('d'), $dateTime->format('d'));
$this->assertEquals($expected->format('m'), $dateTime->format('m'));
$this->assertEquals($expected->format('Y'), $dateTime->format('Y'));
$this->assertEquals(self::TEST_TIMEZONE, $dateTime->getTimezone()->getName());
}
public function testCreateDateTime()
{
$sut = $this->createDateTimeFactory(self::TEST_TIMEZONE);
$dateTime = $sut->createDateTime('2015-07-24 13:45:21');
$this->assertEquals(13, $dateTime->format('H'));
$this->assertEquals(45, $dateTime->format('i'));
$this->assertEquals(21, $dateTime->format('s'));
$this->assertEquals('24', $dateTime->format('d'));
$this->assertEquals('07', $dateTime->format('m'));
$this->assertEquals('2015', $dateTime->format('Y'));
$this->assertEquals(self::TEST_TIMEZONE, $dateTime->getTimezone()->getName());
}
public function testCreateDateTimeWithDefaultValue()
{
$expected = new \DateTime('now', new \DateTimeZone(self::TEST_TIMEZONE));
$sut = $this->createDateTimeFactory(self::TEST_TIMEZONE);
$dateTime = $sut->createDateTime();
$difference = $expected->getTimestamp() - $dateTime->getTimestamp();
// poor test, but there shouldn't be more than 2 seconds between the creation of two DateTime objects
$this->assertTrue(2 >= $difference);
}
}

View File

@@ -40,7 +40,7 @@ class DateExtensionsTest extends TestCase
public function testGetFilters()
{
$filters = ['month_name', 'date_short', 'date_time', 'time'];
$filters = ['month_name', 'date_short', 'date_time', 'date_format', 'time'];
$sut = $this->getSut('de', []);
$twigFilters = $sut->getFilters();
$this->assertCount(count($filters), $twigFilters);
@@ -120,6 +120,13 @@ class DateExtensionsTest extends TestCase
];
}
public function testDateFormat()
{
$date = new \DateTime('7 January 2010 17:43:21', new \DateTimeZone('Europe/Berlin'));
$sut = $this->getSut('en', []);
$this->assertEquals('2010-01-07T17:43:21+01:00', $sut->dateFormat($date, 'c'));
}
public function testTime()
{
$time = new \DateTime('2016-06-23');

View File

@@ -416,6 +416,10 @@
<source>label.timesheet.daily_stats</source>
<target>Tägliche Statistiken im Timesheet anzeigen</target>
</trans-unit>
<trans-unit id="label.now">
<source>label.now</source>
<target>Aktuelle Zeit</target>
</trans-unit>
<!--
User timesheet calendar

View File

@@ -416,6 +416,10 @@
<source>label.timesheet.daily_stats</source>
<target>Show daily stats in timesheet</target>
</trans-unit>
<trans-unit id="label.now">
<source>label.now</source>
<target>Current time</target>
</trans-unit>
<!--
User timesheet calendar