Compare commits

..

2 Commits

Author SHA1 Message Date
90d3bf32e6 fix date formatting and parsing logic
- Support time formatting in `formatDate` with `time` option.
- Improve parsing in `ISOToDate` to handle time information or date-only strings.
2025-08-11 15:52:22 +02:00
ebc2921696 Add 45 and 60 minute calendar ranges 2025-08-11 15:23:01 +02:00
20 changed files with 54 additions and 430 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Add 45 and 60 min calendar ranges
time: 2025-08-11T15:21:54.209009751+02:00
custom:
Issue: "409"
SchemaChange: No schema change

View File

@@ -0,0 +1,6 @@
kind: Fixed
body: fix date formatting in calendar range display
time: 2025-08-11T15:52:12.949078671+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -70,6 +70,8 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select

View File

@@ -32,6 +32,8 @@
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select
@@ -102,7 +104,7 @@
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} -
>{{ formatDate(event.startStr) }} - {{ formatDate(event.endStr, 'time') }}:
{{ event.extendedProps.locationName }}</b
>
<b v-else-if="event.extendedProps.is === 'local'">{{
@@ -294,9 +296,26 @@ const nextWeeks = computed((): Weeks[] =>
}),
);
const formatDate = (datetime: string) => {
console.log(typeof datetime);
return ISOToDate(datetime);
const formatDate = (datetime: string, format: null | 'time' = null) => {
const date = ISOToDate(datetime);
if (!date) return '';
if (format === 'time') {
return date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit'
});
}
// French date formatting
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const baseOptions = ref<CalendarOptions>({

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
return null;
}
const [year, month, day] = str.split("-").map((p) => parseInt(p));
// If the string already contains time info, use it directly
if (str.includes('T') || str.includes(' ')) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0);
};

View File

@@ -13,6 +13,7 @@ namespace Chill\TaskBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
@@ -22,7 +23,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType;
@@ -48,6 +48,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class SingleTaskController extends AbstractController
{
public function __construct(
private readonly CenterResolverDispatcherInterface $centerResolverDispatcher,
private readonly PaginatorFactory $paginatorFactory,
private readonly SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository,
private readonly TranslatorInterface $translator,
@@ -168,9 +169,6 @@ final class SingleTaskController extends AbstractController
->setForm($this->setCreateForm($task, TaskVoter::UPDATE));
$this->eventDispatcher->dispatch($event, UIEvent::EDIT_FORM);
// To keep track of specific assignee change
$initialAssignee = $task->getAssignee();
$form = $event->getForm();
$form->handleRequest($request);
@@ -180,13 +178,6 @@ final class SingleTaskController extends AbstractController
$em = $this->managerRegistry->getManager();
$em->persist($task);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, $initialAssignee),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator
@@ -534,13 +525,6 @@ final class SingleTaskController extends AbstractController
$this->eventDispatcher->dispatch(new TaskEvent($task), TaskEvent::PERSIST);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, null),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator->trans('The task is created'));

View File

@@ -42,7 +42,6 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface
$loader->load('services/timeline.yaml');
$loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml');
$loader->load('services/notification.yaml');
}
public function prepend(ContainerBuilder $container)

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Event;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Symfony\Contracts\EventDispatcher\Event;
class AssignTaskEvent extends Event
{
final public const PERSIST = 'chill_task.assign_task';
public function __construct(
private readonly SingleTask $task,
private readonly ?User $initialAssignee,
) {}
public function getTask(): SingleTask
{
return $this->task;
}
public function getInitialAssignee(): ?User
{
return $this->initialAssignee;
}
public function hasAssigneeChanged(): bool
{
return $this->initialAssignee !== $this->task->getAssignee();
}
}

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Event;
use Chill\MainBundle\Entity\Notification;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
readonly class TaskAssignEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private \Twig\Environment $engine,
) {}
public static function getSubscribedEvents(): array
{
return [
AssignTaskEvent::PERSIST => ['onTaskAssigned', 0],
];
}
/**
* Send a notification when a user is assigned to a task.
* Only triggers when the assignee actually changes.
*/
public function onTaskAssigned(AssignTaskEvent $event): void
{
if (!$event->hasAssigneeChanged()) {
return;
}
$task = $event->getTask();
$assignedUser = $task->getAssignee();
$title = $task->getTitle();
$context = [
'task' => $task,
'assignedUser' => $assignedUser,
'title' => $title,
];
$notification = new Notification();
$notification
->setRelatedEntityId($task->getId())
->setRelatedEntityClass(SingleTask::class)
->setTitle($this->engine->render('@ChillTask/Notification/task_assignment_notification_title.txt.twig', $context))
->setMessage($this->engine->render('@ChillTask/Notification/task_assignment_notification_content.txt.twig', $context))
->addAddressee($assignedUser)
->setType(AssignTaskNotificationFlagProvider::FLAG);
$this->entityManager->persist($notification);
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AssignTaskNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'task-assign-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.task_assign');
}
}

View File

@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Repository\SingleTaskRepository;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
final readonly class TaskNotificationHandler implements NotificationHandlerInterface
{
public function __construct(private SingleTaskRepository $taskRepository) {}
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillTask/SingleTask/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return [
'notification' => $notification,
'task' => $this->taskRepository->find($notification->getRelatedEntityId()),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return SingleTask::class === $notification->getRelatedEntityClass();
}
public function getTitle(Notification $notification, array $options = []): TranslatableInterface
{
if (null === $task = $this->getRelatedEntity($notification)) {
return new TranslatableMessage('task.deleted');
}
return new TranslatableMessage('notification.task.title %title%', ['title' => $task->getTitle()]);
}
public function getAssociatedPersons(Notification $notification, array $options = []): array
{
if (null === $task = $this->getRelatedEntity($notification)) {
return [];
}
if (null !== $task->getCourse()) {
return $task->getCourse()->getParticipations()->getValues();
}
return [$task->getPerson()];
}
public function getRelatedEntity(Notification $notification): ?object
{
return $this->taskRepository->find($notification->getRelatedEntityId());
}
}

View File

@@ -1,15 +0,0 @@
{{ assignedUser.label }},
{{ 'notification.email.task_assigned'|trans({}, null, assignedUser.getLocale) }}
{{ 'notification.email.title_label'|trans({}, null, assignedUser.getLocale) }} "{{ task.title }}".
{% if task.endDate %}
{{ 'notification.email.deadline'|trans({'%date%': task.endDate|format_date('long')}, null, assignedUser.getLocale) }}
{% endif %}
{{ 'notification.email.view_task'|trans({}, null, assignedUser.getLocale) }}
{{ absolute_url(path('chill_task_single_task_show', {'id': task.id, '_locale': assignedUser.getLocale})) }}
{{ 'notification.email.regards'|trans({}, null, assignedUser.getLocale) }},

View File

@@ -1,3 +0,0 @@
{{ 'notification.email.title'|trans({}, null, assignedUser.getLocale) }}

View File

@@ -18,14 +18,14 @@
<div>
{% if task.person is not null %}
<span class="chill-task-list__row__person">
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% elseif task.course is not null %}
<div style="margin-bottom: 1rem;">
{% for part in task.course.currentParticipations %}

View File

@@ -110,5 +110,4 @@
</li>
{% endif %}
</ul>
</div>
</ul></div>

View File

@@ -1,14 +0,0 @@
{% macro recordAction(task) %}
<li>
<a href="{{ path('chill_person_accompanying_course_index', { 'task_id': task }) }}"
class="btn btn-show" title="{{ 'See task'|trans }}"></a>
</li>
{% endmacro %}
{% if task is not null %}
{# <div>Todo : display task? </div>#}
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'You are getting a notification for a task which does not exist any more'|trans }}
</div>
{% endif %}

View File

@@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Tests\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskAssignEventSubscriber;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class TaskAssignEventSubscriberTest extends TestCase
{
private EntityManagerInterface $entityManager;
private Environment $twig;
private TaskAssignEventSubscriber $subscriber;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->twig = $this->createMock(Environment::class);
$this->subscriber = new TaskAssignEventSubscriber($this->entityManager, $this->twig);
}
public function testGetSubscribedEvents(): void
{
$events = TaskAssignEventSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(AssignTaskEvent::PERSIST, $events);
$this->assertEquals(['onTaskAssigned', 0], $events[AssignTaskEvent::PERSIST]);
}
public function testOnTaskAssignedCreatesNotificationWhenAssigneeChanges(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(123);
$task->method('getTitle')->willReturn('Test Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->expects($this->exactly(2))
->method('render')
->with(
$this->logicalOr(
'@ChillTask/Notification/task_assignment_notification_title.txt.twig',
'@ChillTask/Notification/task_assignment_notification_content.txt.twig'
),
$this->isType('array')
)
->willReturnOnConsecutiveCalls('Notification Title', 'Notification Content');
$this->entityManager->expects($this->once())
->method('persist')
->with($this->isInstanceOf(Notification::class));
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testOnTaskAssignedDoesNothingWhenAssigneeDoesNotChange(): void
{
// Arrange
$event = $this->createMock(AssignTaskEvent::class);
$event->method('hasAssigneeChanged')->willReturn(false);
$this->twig->expects($this->never())->method('render');
$this->entityManager->expects($this->never())->method('persist');
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testNotificationHasCorrectProperties(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(456);
$task->method('getTitle')->willReturn('Important Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->method('render')->willReturn('Test Content');
// Capture the persisted notification
$persistedNotification = null;
$this->entityManager->expects($this->once())
->method('persist')
->willReturnCallback(function ($notification) use (&$persistedNotification) {
$persistedNotification = $notification;
});
// Act
$this->subscriber->onTaskAssigned($event);
// Assert
$this->assertInstanceOf(Notification::class, $persistedNotification);
$this->assertEquals(456, $persistedNotification->getRelatedEntityId());
$this->assertEquals(SingleTask::class, $persistedNotification->getRelatedEntityClass());
$this->assertEquals(AssignTaskNotificationFlagProvider::FLAG, $persistedNotification->getType());
$this->assertEquals('Test Content', $persistedNotification->getTitle());
$this->assertEquals('Test Content', $persistedNotification->getMessage());
}
}

View File

@@ -1,13 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
tags:
- { name: kernel.event_subscriber }
Chill\TaskBundle\Event\TaskAssignEventSubscriber: ~

View File

@@ -1,7 +0,0 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Notification\TaskNotificationHandler: ~
Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider: ~

View File

@@ -116,16 +116,3 @@ CHILL_TASK_TASK_UPDATE: Modifier une tâche
CHILL_TASK_TASK_CREATE_FOR_COURSE: Créer une tâche pour un parcours
CHILL_TASK_TASK_CREATE_FOR_PERSON: Créer une tâche pour un usager
notification:
task:
title %title%: "Tâche: title"
flags:
task_assign: Lorsqu'un autre utilisateur m'assigne à une tâche.
email:
title: "Une tâche demande votre attention"
task_assigned: "Une tâche vous a été assignée."
title_label: "Titre de la tâche:"
deadline: "Vous êtes invités à accomplir cette tâche avant le %date%"
view_task: "Vous pouvez visualiser la tâche sur cette page:"
regards: "Cordialement"