Compare commits

..

No commits in common. "a86890da2bc7be01d0aec2334eb66b5a95778132" and "4448740277a9db1fcee9c515f4a3bf9ff7f8df75" have entirely different histories.

57 changed files with 297 additions and 2723 deletions

View File

@ -11,6 +11,4 @@ alwaysApply: true
# Folders
- components: general reusable components
- pages: specific components related to the domain and the application
- lib: plain typescript for building the domain logic, services etc. you will never put react code in here
Every component must be in its own folder (together with optional custom css)
- lib: plain typescript for building the domain logic, services etc. you will never put react code in here

View File

@ -11,7 +11,4 @@ The Symfony project is designed in a domain driven design pattern with 3 Main Fo
# Updating / Creating Entities
When updating or creating entities, always update and the related DTO classes.
DTO Classes are a representation of a JSON Schema
# Code style
You will always write perfect and strict PHP 8.4 code that aligns to phpstan level 10 rules
DTO Classes are a representation of a JSON Schema

View File

@ -19,7 +19,6 @@
"symfony/dotenv": "6.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",

173
backend/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f41287711c3c1d476ebbca47f5b529b5",
"content-hash": "58ca9f6d53632372fae9dee2c6c72aa7",
"packages": [
{
"name": "doctrine/annotations",
@ -3123,177 +3123,6 @@
],
"time": "2025-03-23T16:46:24+00:00"
},
{
"name": "symfony/http-client",
"version": "v6.4.19",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "3294a433fc9d12ae58128174896b5b1822c28dad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/3294a433fc9d12ae58128174896b5b1822c28dad",
"reference": "3294a433fc9d12ae58128174896b5b1822c28dad",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"php-http/discovery": "<1.15",
"symfony/http-foundation": "<6.3"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/amp": "^2.5",
"amphp/http-client": "^4.2.1",
"amphp/http-tunnel": "^1.0",
"amphp/socket": "^1.1",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0",
"symfony/messenger": "^5.4|^6.0|^7.0",
"symfony/process": "^5.4|^6.0|^7.0",
"symfony/stopwatch": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v6.4.19"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-02-13T09:55:13+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.5.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645",
"reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.5-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-12-07T08:49:48+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v6.4.18",

View File

@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Application\Command;
use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatSession;
use App\Domain\Chat\MessageCollection;
use App\Domain\Chat\ToolCollection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:test-chat-session',
description: 'Test the chat session interface with sample interactions'
)]
final class TestChatSessionCommand extends Command
{
public function __construct(
private readonly ChatProviderInterface $chatProvider,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Initial message to send to the chat session', 'Hello, this is a test message.')
->addOption('max-steps', null, InputOption::VALUE_REQUIRED, 'Maximum number of steps to run', 3)
->addOption('system-prompt', 's', InputOption::VALUE_REQUIRED, 'System prompt to use', 'You are a helpful assistant. Keep your answers brief.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$message = $input->getOption('message');
$maxSteps = (int) $input->getOption('max-steps');
$systemPrompt = $input->getOption('system-prompt');
$io->title('Testing Chat Session Interface');
try {
$chatSession = new ChatSession(
$this->chatProvider,
new ToolCollection([])
);
// Add a chat listener for real-time messaging
$chatSession->addChatListener(function (MessageCollection $messages) use ($io) {
$lastMessage = $messages->getLastMessage();
$role = $lastMessage->getRole();
$content = $lastMessage->getContent();
if ($content !== null) {
$io->section(ucfirst($role) . ' Message');
$io->writeln($content);
}
$toolCalls = $lastMessage->getToolCalls();
if (count($toolCalls) > 0) {
$io->section('Tool Calls');
foreach ($toolCalls as $toolCall) {
$io->writeln('- ' . $toolCall->getName() . ': ' . json_encode($toolCall->getArguments()));
}
}
$toolResult = $lastMessage->getToolResult();
if ($toolResult !== null) {
$io->section('Tool Result');
$io->writeln('Tool: ' . $toolResult->getToolName());
$io->writeln('ID: ' . $toolResult->getToolCallId());
}
});
// Set system prompt
$io->section('Setting System Prompt');
$io->writeln($systemPrompt);
$chatSession->system($systemPrompt);
// Send user message
$io->section('Sending User Message');
$io->writeln($message);
$chatSession->user($message);
// Commit the conversation for the specified number of steps
$io->section('Committing Conversation');
$chatSession->commit($maxSteps);
// Summary
$io->success('Chat session test completed successfully.');
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error('Error during chat session test: ' . $e->getMessage());
return Command::FAILURE;
}
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Application\Controller\Calendar;
namespace App\Application\Controller;
use App\Infrastructure\Repository\EventRepository;
use App\Application\DTO\CalendarDTO;

View File

@ -1,36 +0,0 @@
<?php
namespace App\Application\Controller\Event;
use App\Application\DTO\EventDraftDTO;
use App\Application\DTO\GenerateDraftDTO;
use App\Domain\Event\GenerateDraftHandler;
use App\Domain\Event\GenerateDraft;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA;
use Nelmio\ApiDocBundle\Attribute\Model;
#[Route('/api/events', name: 'generate_draft', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
class GenerateDraftController extends AbstractController
{
public function __construct(
private readonly GenerateDraftHandler $generateDraftHandler
) {
}
#[Route('/generation', name: 'generate_draft', methods: ['POST'])]
#[OA\RequestBody(content: new OA\JsonContent(ref: new Model(type: GenerateDraftDTO::class)))]
#[OA\Response(
response: 200,
description: 'Returns the generated draft',
content: new OA\JsonContent(ref: new Model(type: EventDraftDTO::class))
)]
public function generateDraft(#[MapRequestPayload] GenerateDraftDTO $generateDraftDTO): JsonResponse
{
$draft = $this->generateDraftHandler->handle(new GenerateDraft($generateDraftDTO->input));
return $this->json(EventDraftDTO::fromDraft($draft));
}
}

View File

@ -1,69 +0,0 @@
<?php
namespace App\Application\Controller\Event;
use App\Domain\Event\ReadEventsHandler;
use App\Domain\Event\ReadEvents;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Application\DTO\EventDTO;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')]
class GetEventsController extends AbstractController
{
public function __construct(
private readonly ReadEventsHandler $readEventsHandler
) {
}
#[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response(
response: 200,
description: 'Returns list of events',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
public function list(): JsonResponse
{
/** @var PersistedEvent[] $events */
$events = $this->readEventsHandler->handle(new ReadEvents(null));
$eventDTOs = array_map(static fn (PersistedEvent $event) => EventDTO::fromEntity($event), $events);
return $this->json($eventDTOs);
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 200,
description: 'Returns event details',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function get(string $id): JsonResponse
{
$events = $this->readEventsHandler->handle(new ReadEvents((int)$id));
if (count($events) === 0) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
return $this->json(EventDTO::fromEntity($events[0] ?? throw new NotFoundHttpException('Event not found')));
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace App\Application\Controller;
use App\Domain\Model\Event;
use App\Infrastructure\Repository\EventRepository;
use App\Application\DTO\EventDTO;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;
#[Route('/api/events', name: 'api_events_')]
class EventController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ValidatorInterface $validator,
private readonly EventRepository $eventRepository
) {
}
#[Route('', name: 'list', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Response(
response: 200,
description: 'Returns list of events',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
public function list(): JsonResponse
{
/** @var Event[] $events */
$events = $this->eventRepository->findAll();
$eventDTOs = array_map(static fn (Event $event) => EventDTO::fromEntity($event), $events);
return $this->json($eventDTOs);
}
#[Route('/{id}', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 200,
description: 'Returns event details',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function get(string $id): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
return $this->json(EventDTO::fromEntity($event));
}
#[Route('', name: 'create', methods: ['POST'])]
#[OA\Tag(name: 'Events')]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 201,
description: 'Event created',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 400,
description: 'Invalid input'
)]
public function create(Request $request): JsonResponse
{
/** @var array<string, mixed>|null $data */
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
}
$event = new Event();
$event->setTitle((string)$data['title']);
if (array_key_exists('description', $data)) {
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
}
try {
$event->setStart(new \DateTimeImmutable($data['start'] ?? 'now'));
$event->setEnd(new \DateTimeImmutable($data['end'] ?? 'now'));
} catch (\Exception) {
return $this->json(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
}
$event->setAllDay(isset($data['allDay']) ? (bool)$data['allDay'] : false);
$errors = $this->validator->validate($event);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
}
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
}
$this->entityManager->persist($event);
$this->entityManager->flush();
return $this->json(EventDTO::fromEntity($event), Response::HTTP_CREATED);
}
#[Route('/{id}', name: 'update', methods: ['PUT'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 200,
description: 'Event updated',
content: new OA\JsonContent(ref: new Model(type: EventDTO::class))
)]
#[OA\Response(
response: 400,
description: 'Invalid input'
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function update(string $id, Request $request): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
/** @var array<string, mixed>|null $data */
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->json(['error' => 'Invalid JSON'], Response::HTTP_BAD_REQUEST);
}
if (isset($data['title'])) {
$event->setTitle((string)$data['title']);
}
if (array_key_exists('description', $data)) {
$event->setDescription($data['description'] !== null ? (string)$data['description'] : null);
}
if (isset($data['start'])) {
try {
$event->setStart(new \DateTimeImmutable((string)$data['start']));
} catch (\Exception) {
return $this->json(['error' => 'Invalid start date format'], Response::HTTP_BAD_REQUEST);
}
}
if (isset($data['end'])) {
try {
$event->setEnd(new \DateTimeImmutable((string)$data['end']));
} catch (\Exception) {
return $this->json(['error' => 'Invalid end date format'], Response::HTTP_BAD_REQUEST);
}
}
if (isset($data['allDay'])) {
$event->setAllDay((bool)$data['allDay']);
}
$errors = $this->validator->validate($event);
if (count($errors) > 0) {
$errorMessages = [];
foreach ($errors as $error) {
$errorMessages[$error->getPropertyPath()] = $error->getMessage();
}
return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST);
}
$this->entityManager->flush();
return $this->json(EventDTO::fromEntity($event));
}
#[Route('/{id}', name: 'delete', methods: ['DELETE'])]
#[OA\Tag(name: 'Events')]
#[OA\Parameter(
name: 'id',
description: 'Event ID',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string')
)]
#[OA\Response(
response: 204,
description: 'Event deleted'
)]
#[OA\Response(
response: 404,
description: 'Event not found'
)]
public function delete(string $id): JsonResponse
{
$event = $this->eventRepository->find($id);
if (!$event instanceof Event) {
return $this->json(['error' => 'Event not found'], Response::HTTP_NOT_FOUND);
}
$this->entityManager->remove($event);
$this->entityManager->flush();
return $this->json(null, Response::HTTP_NO_CONTENT);
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Application\Controller\User;
namespace App\Application\Controller;
use App\Application\DTO\UserDTO;
use App\Infrastructure\Repository\UserRepository;

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Domain\Model\Event;
use OpenApi\Attributes as OA;
#[OA\Schema]
@ -26,7 +26,7 @@ final readonly class EventDTO
) {
}
public static function fromEntity(PersistedEvent $event): self
public static function fromEntity(Event $event): self
{
return new self(
$event->getId(),

View File

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\EventDraft;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class EventDraftDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public string $id,
#[OA\Property(type: 'string')]
public string $title,
#[OA\Property(type: 'string')]
public string $description,
#[OA\Property(type: 'string')]
public ?string $start,
#[OA\Property(type: 'string')]
public ?string $end,
#[OA\Property(type: 'boolean')]
public bool $allDay
) {
}
public static function fromDraft(EventDraft $draft): self
{
return new self(
$draft->title(),
$draft->description(),
$draft->location(),
$draft->start()?->format('Y-m-d H:i:s'),
$draft->end()?->format('Y-m-d H:i:s'),
$draft->allDay()
);
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Application\DTO;
use OpenApi\Attributes as OA;
#[OA\Schema]
class GenerateDraftDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public readonly string $input,
) {
}
}

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\Persisted\PersistedUser;
use App\Domain\Model\User;
use OpenApi\Attributes as OA;
#[OA\Schema]
@ -24,7 +24,7 @@ final readonly class UserDTO
) {
}
public static function fromEntity(PersistedUser $user): self
public static function fromEntity(User $user): self
{
return new self(
$user->getId(),

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
interface ChatProviderInterface
{
public function chat(MessageCollection $messages, ToolCollection $tools, bool $forceToolCalls = false, bool $reasoning = true): ChatResult;
}

View File

@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
final class ChatResult
{
/**
* @param list<Choice> $choices
* @param list<Message> $messageHistory
*/
public function __construct(
private readonly array $choices = [],
private readonly array $messageHistory = [],
) {
}
/**
* @return list<Choice>
*/
public function getChoices(): array
{
return $this->choices;
}
/**
* @return list<Message>
*/
public function getMessageHistory(): array
{
return $this->messageHistory;
}
public function getFirstChoice(): ?Choice
{
if (empty($this->choices)) {
return null;
}
return $this->choices[0];
}
public function getContent(): ?string
{
$firstChoice = $this->getFirstChoice();
if ($firstChoice === null) {
return null;
}
return $firstChoice->getContent();
}
/**
* @return list<ToolCall>
*/
public function getToolCalls(): array
{
$toolCalls = [];
foreach ($this->choices as $choice) {
if (!empty($choice->getToolCalls())) {
foreach ($choice->getToolCalls() as $toolCall) {
$toolCalls[] = $toolCall;
}
}
}
return $toolCalls;
}
}

View File

@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
use Exception;
final class ChatSession
{
private MessageCollection $messages;
/**
* @var array<callable(MessageCollection): void>
*/
private array $chatListeners = [];
public function __construct(
private readonly ChatProviderInterface $chatProvider,
private readonly ToolCollection $toolCollection = new ToolCollection([]),
) {
$this->messages = new MessageCollection();
}
public function addChatListener(callable $listener): void
{
$this->chatListeners[] = $listener;
}
public function system(string $message): void
{
$this->addMessage(Message::fromSystem($message));
}
public function user(string $message): void
{
$this->addMessage(Message::fromUser($message));
}
public function getMessages(): MessageCollection
{
return $this->messages;
}
public function commit(int $maxSteps = 10, int $forcedToolCalls = 0, bool $reasoning = true): void
{
if ($maxSteps <= 0) {
throw new Exception('Max steps reached');
}
$result = $this->chatProvider->chat($this->messages, $this->toolCollection, $forcedToolCalls > 0, $reasoning);
$choices = $result->getChoices();
if (count($choices) === 0) {
throw new Exception('No choices found');
}
$this->addMessage(Message::fromAssistant($result->getContent(), $result->getToolCalls()));
if (count($result->getToolCalls()) === 0) {
return;
}
foreach ($result->getToolCalls() as $toolCall) {
$tool = $this->toolCollection->findTool($toolCall->getName());
if ($tool === null) {
continue;
}
$toolResult = $tool->execute($toolCall->getArguments(), []);
$this->addMessage(Message::fromToolResult(
$toolResult,
$toolCall->getId(),
$toolCall->getName(),
));
}
$this->commit($maxSteps - 1, $forcedToolCalls - 1);
}
private function notifyChatListeners(MessageCollection $messages): void
{
foreach ($this->chatListeners as $listener) {
$listener($messages);
}
}
private function addMessage(Message $message): void
{
$this->messages->addMessage($message);
$this->notifyChatListeners($this->messages);
}
}

View File

@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
final class Choice
{
/**
* @param array<string, mixed> $contentFilterResults
* @param array<ToolCall> $toolCalls
* @param array<string, mixed>|null $logprobs
* @param array<string, mixed>|null $toolResult
*/
public function __construct(
private readonly array $contentFilterResults,
private readonly string $finishReason,
private readonly int $index,
private readonly ?array $logprobs,
private readonly ?string $content,
private readonly ?string $refusal,
private readonly string $role,
private readonly array $toolCalls,
private readonly ?array $toolResult = null,
) {
}
/**
* @return array<string, mixed>
*/
public function getContentFilterResults(): array
{
return $this->contentFilterResults;
}
public function getFinishReason(): string
{
return $this->finishReason;
}
public function getIndex(): int
{
return $this->index;
}
/**
* @return array<string, mixed>|null
*/
public function getLogprobs(): ?array
{
return $this->logprobs;
}
public function getContent(): ?string
{
return $this->content;
}
public function getRefusal(): ?string
{
return $this->refusal;
}
public function getRole(): string
{
return $this->role;
}
/**
* @return array<ToolCall>
*/
public function getToolCalls(): array
{
return $this->toolCalls;
}
/**
* @return array<string, mixed>|null
*/
public function getToolResult(): ?array
{
return $this->toolResult;
}
/**
* @param array{
* content_filter_results?: array<string, mixed>,
* finish_reason?: string,
* index?: int,
* logprobs?: ?array<string, mixed>,
* message: array{
* content: ?string,
* refusal: ?string,
* role: string,
* tool_calls: array<array{
* function: array{arguments: string, name: string},
* id: string,
* type: string
* }>
* }
* } $data
*/
public static function fromArray(array $data): self
{
$toolCalls = array_map(
static fn (array $toolCall): ToolCall => ToolCall::fromArray($toolCall),
$data['message']['tool_calls'] ?? []
);
$toolResult = $data['message']['tool_result'] ?? null;
return new self(
contentFilterResults: $data['content_filter_results'] ?? [],
finishReason: $data['finish_reason'] ?? '',
index: $data['index'] ?? 0,
logprobs: $data['logprobs'] ?? null,
content: $data['message']['content'] ?? null,
refusal: $data['message']['refusal'] ?? null,
role: $data['message']['role'] ?? '',
toolCalls: $toolCalls,
toolResult: $toolResult,
);
}
}

View File

@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
use DateTimeImmutable;
use DateTimeInterface;
final class Message
{
/**
* @param array<ToolCall>|null $toolCalls
*/
public function __construct(
private readonly string $role,
private readonly ?string $content,
private readonly ?array $toolCalls = null,
private readonly ?ToolResult $toolResult = null,
private readonly DateTimeInterface $createdAt = new DateTimeImmutable(),
) {
}
public function getRole(): string
{
return $this->role;
}
public function getContent(): ?string
{
return $this->content;
}
public function getTwig(): ?string
{
$content = $this->getContent() ?? '```twig\n{}\n```';
$matches = [];
if (preg_match('/```twig\s*([\s\S]*?)\s*```/m', $content, $matches) > 0) {
return $matches[1];
}
return null;
}
/**
* @return array<string, mixed>|null
*/
public function getJson(): ?array
{
$content = $this->getContent() ?? '```json\n{}\n```';
$matches = [];
if (preg_match('/```json\s*([\s\S]*?)\s*```/m', $content, $matches) > 0) {
$decoded = json_decode($matches[1], true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
public function getCreatedAt(): DateTimeInterface
{
return $this->createdAt;
}
/**
* @return array<ToolCall>
*/
public function getToolCalls(): array
{
return $this->toolCalls ?? [];
}
public function getToolResult(): ?ToolResult
{
if (isset($this->toolResult)) {
return $this->toolResult;
}
return null;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$result = [
'role' => $this->role,
];
if ($this->content !== null) {
$result['content'] = $this->content;
}
if ($this->toolResult !== null) {
$result['tool_call_id'] = $this->toolResult->getToolCallId();
}
if ($this->toolCalls !== null && $this->toolCalls !== []) {
$result['tool_calls'] = array_map(
fn (ToolCall $toolCall) => [
'id' => $toolCall->getId(),
'type' => $toolCall->getType(),
'function' => [
'name' => $toolCall->getName(),
'arguments' => json_encode($toolCall->getArguments()),
],
],
$this->toolCalls
);
}
return $result;
}
public static function fromUser(string $content): self
{
return new self(role: 'user', content: $content);
}
public static function fromSystem(string $content): self
{
return new self(role: 'system', content: $content);
}
/**
* @param array<ToolCall>|null $toolCalls
*/
public static function fromAssistant(?string $content = null, ?array $toolCalls = null): self
{
return new self(role: 'assistant', content: $content, toolCalls: $toolCalls);
}
public static function fromToolResult(string $content, string $toolCallId, string $toolName): self
{
return new self(role: 'tool', content: $content, toolResult: new ToolResult($toolCallId, $toolName));
}
}

View File

@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
use ArrayIterator;
use Countable;
use IteratorAggregate;
use function array_filter;
/**
* @implements IteratorAggregate<int, Message>
*/
final class MessageCollection implements IteratorAggregate, Countable
{
/**
* @var list<Message>
*/
private array $messages = [];
/**
* @param list<Message> $messages
*/
public function __construct(array $messages = [])
{
$this->messages = $messages;
}
/**
* @return list<Message>
*/
public function getMessagesSortedByCreatedAtDesc(): array
{
$messages = $this->messages;
usort($messages, fn (Message $a, Message $b) => $b->getCreatedAt()->getTimestamp() <=> $a->getCreatedAt()->getTimestamp());
return $messages;
}
public function getLastMessage(): Message
{
$messages = $this->getMessagesSortedByCreatedAtDesc();
return $messages[0];
}
/**
* @return list<Message>
*/
public function getLastMessages(int $count): array
{
$messages = $this->getMessagesSortedByCreatedAtDesc();
return array_slice($messages, 0, $count);
}
public function addMessage(Message $message): void
{
$this->messages[] = $message;
}
/**
* @return ArrayIterator<int, Message>
*/
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->messages);
}
/**
* @return list<Message>
*/
public function toArray(): array
{
return $this->messages;
}
/**
* @return list<ToolCall>
*/
public function getToolCalls(): array
{
$toolCalls = [];
foreach ($this->messages as $message) {
foreach ($message->getToolCalls() as $toolCall) {
$toolCalls[] = $toolCall;
}
}
return $toolCalls;
}
/**
* @return list<ToolCall>
*/
public function getToolCallsByToolName(string $toolName): array
{
return array_values(array_filter($this->getToolCalls(), fn (ToolCall $toolCall) => $toolCall->getName() === $toolName));
}
public function getToolCallsById(string $toolCallId): ?ToolCall
{
foreach ($this->getToolCalls() as $toolCall) {
if ($toolCall->getId() === $toolCallId) {
return $toolCall;
}
}
return null;
}
/**
* @return array<string, mixed>|null
*/
public function getToolResultById(string $toolCallId): ?array
{
foreach ($this->getToolCalls() as $toolCall) {
if ($toolCall->getId() === $toolCallId) {
return $toolCall->getArguments();
}
}
return null;
}
/**
* @return list<Message>
*/
public function getMessagesWithToolCallByToolName(string $toolName): array
{
$result = [];
foreach ($this->getMessagesSortedByCreatedAtDesc() as $message) {
$toolCalls = $message->getToolCalls();
foreach ($toolCalls as $toolCall) {
if ($toolCall->getName() === $toolName) {
$result[] = $message;
break;
}
}
}
return $result;
}
/**
* @return list<Message>
*/
public function getMessagesWithToolResults(): array
{
$result = [];
foreach ($this->getMessagesSortedByCreatedAtDesc() as $message) {
if ($message->getToolResult() !== null) {
$result[] = $message;
}
}
return $result;
}
/**
* @return list<Message>
*/
public function getMessagesWithToolResultByToolName(string $toolName): array
{
$result = [];
foreach ($this->getMessagesWithToolResults() as $message) {
if ($message->getToolResult()?->getToolName() === $toolName) {
$result[] = $message;
}
}
return $result;
}
public function getLatestMessageWithToolResultByToolName(string $toolName): ?Message
{
$toolResults = $this->getMessagesWithToolResultByToolName($toolName);
if (count($toolResults) === 0) {
return null;
}
return $toolResults[0];
}
public function getLatestMessageWithToolCallByToolName(string $toolName): ?Message
{
$messagesWithToolCall = $this->getMessagesWithToolCallByToolName($toolName);
if (count($messagesWithToolCall) === 0) {
return null;
}
return $messagesWithToolCall[0];
}
public function getLatestToolCallByToolName(string $toolName): ?ToolCall
{
$messageWithToolCall = $this->getLatestMessageWithToolCallByToolName($toolName);
if ($messageWithToolCall === null) {
return null;
}
$toolCalls = array_filter($messageWithToolCall->getToolCalls(), fn (ToolCall $toolCall) => $toolCall->getName() === $toolName);
return !empty($toolCalls) ? array_values($toolCalls)[0] : null;
}
public function count(): int
{
return count($this->messages);
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
final class ShouldStopResult
{
public function __construct(
private readonly bool $shouldStop,
private readonly ?string $errorPrompt = null,
) {
}
public function shouldStop(): bool
{
return $this->shouldStop;
}
public function getErrorPrompt(): ?string
{
return $this->errorPrompt;
}
}

View File

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
final class ToolCall
{
/**
* @param array<string, mixed> $arguments
*/
public function __construct(
private readonly string $id,
private readonly string $type,
private readonly string $name,
private readonly array $arguments,
) {
}
public function getId(): string
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getName(): string
{
return $this->name;
}
/**
* @return array<string, mixed>
*/
public function getArguments(): array
{
return $this->arguments;
}
/**
* @param array{
* id: string,
* type: string,
* function: array{
* name: string,
* arguments: string
* }
* } $data
*/
public static function fromArray(array $data): self
{
$decodedArgs = json_decode($data['function']['arguments'], true);
/** @var array<string, mixed> $arguments */
$arguments = is_array($decodedArgs) ? $decodedArgs : [];
return new self(
id: $data['id'],
type: $data['type'],
name: $data['function']['name'],
arguments: $arguments,
);
}
}

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
/**
* @extends \ArrayObject<int, ToolInterface>
*/
final class ToolCollection extends \ArrayObject
{
/**
* @param list<ToolInterface> $tools
*/
public function __construct(
private readonly iterable $tools,
) {
}
/**
* @return iterable<ToolInterface>
*/
public function toArray(): array
{
return $this->tools;
}
public function count(): int
{
return count($this->tools);
}
public function findTool(string $name): ?ToolInterface
{
foreach ($this->tools as $tool) {
if ($tool->getName() === $name) {
return $tool;
}
}
return null;
}
}

View File

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
interface ToolInterface
{
/**
* Get the name of the tool.
*/
public function getName(): string;
/**
* Get the description of the tool.
*/
public function getDescription(): string;
/**
* Get the arguments of the tool.
*
* @return array<string, array<string, mixed>>
*/
public function getArguments(): array;
/**
* Get the required arguments of the tool.
*
* @return array<int, string>
*/
public function getRequiredArguments(): array;
/**
* Execute the tool with the given arguments.
*
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function execute(array $arguments, array $context = []): string;
}

View File

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ToolProvider
{
/**
* @param list<ToolInterface> $tools
*/
public function __construct(
#[AutowireIterator(tag: 'app.ai_proxy.tool')]
private readonly iterable $tools,
) {
}
public function getTools(): ToolCollection
{
$toolsArray = iterator_to_array($this->tools);
// Ensure it's a list (consecutive integer keys starting from 0)
$toolsList = array_values($toolsArray);
return new ToolCollection($toolsList);
}
public function getToolByClass(string $class): ?ToolInterface
{
foreach ($this->tools as $tool) {
if ($tool instanceof $class) {
return $tool;
}
}
return null;
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Chat;
final class ToolResult
{
public function __construct(
private readonly string $toolCallId,
private readonly string $toolName,
) {
}
public function getToolCallId(): string
{
return $this->toolCallId;
}
public function getToolName(): string
{
return $this->toolName;
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace App\Domain\Event;
class GenerateDraft
{
public function __construct(
private readonly string $input,
) {
}
public function input(): string
{
return $this->input;
}
}

View File

@ -1,75 +0,0 @@
<?php
namespace App\Domain\Event;
use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatSession;
use App\Domain\Model\EventDraft;
use App\Domain\User\UserContextProvider;
class GenerateDraftHandler
{
public function __construct(
private readonly ChatProviderInterface $chatProvider,
private readonly UserContextProvider $userContextProvider,
) {
}
public function handle(GenerateDraft $generateDraft): EventDraft
{
$userContext = $this->userContextProvider->getUserContext();
$chat = new ChatSession($this->chatProvider);
$chat->system(<<<PROMPT
You are a helpful assistant that generates event drafts based on user input.
The user input can be anything from a very detailed description to a simple sentence or just a snippet from another source (Chat, email, etc.).
You should always generate a draft even if the user input is not very detailed.
Go Step by Step by step:
- First, analyze the user input and analyze the context of the user input.
- Then, generate a draft of the event.
- Finally, return the draft in the following format:
The event draft should be in the following format:
```json
{
"title": "Event title",
"description": "Event description",
"location": "Event location",
"start": "Event start date (YYYY-MM-DD HH:MM:SS)",
"end": "Event end date (YYYY-MM-DD HH:MM:SS)",
"allDay": "Event all day (true or false)"
}
```
This is the current context:
- Time: {$userContext->now()->format('Y-m-d H:i:s')}
You will only respond with the JSON object in ```json``` tags, nothing else.
PROMPT);
$chat->user($generateDraft->input());
$chat->commit(reasoning: false);
$response = $chat->getMessages()->getLastMessage()->getJson() ?? throw new \Exception('JSON response is required: ' . $chat->getMessages()->getLastMessage()->getContent());
if (!isset($response['title']) || !is_string($response['title'])) {
throw new \Exception('Title is required and must be a string: ' . json_encode($response));
}
if (!isset($response['description']) || !is_string($response['description'])) {
throw new \Exception('Description is required and must be a string: ' . json_encode($response));
}
if (!isset($response['location']) || !is_string($response['location'])) {
throw new \Exception('Location is required and must be a string: ' . json_encode($response));
}
return new EventDraft(
$response['title'],
$response['description'],
$response['location'],
isset($response['start']) && is_string($response['start']) ? new \DateTime($response['start']) : null,
isset($response['end']) && is_string($response['end']) ? new \DateTime($response['end']) : null,
isset($response['allDay']) ? (bool)$response['allDay'] : false,
);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\PersistedEvent;
class ReadEvents
{
public function __construct(
private readonly ?int $id = null
) {
}
public function id(): ?int
{
return $this->id;
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Domain\Event;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Infrastructure\Repository\EventRepository;
class ReadEventsHandler
{
public function __construct(
private readonly EventRepository $eventRepository,
) {
}
/**
* @return PersistedEvent[]
*/
public function handle(ReadEvents $readEvents): array
{
return $this->eventRepository->findAll();
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Model\Persisted;
namespace App\Domain\Model;
use App\Infrastructure\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
@ -10,7 +10,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: EventRepository::class)]
#[ORM\Table(name: 'app_event')]
class PersistedEvent
class Event
{
#[ORM\Id]
#[ORM\Column(type: 'string')]

View File

@ -1,48 +0,0 @@
<?php
namespace App\Domain\Model;
use DateTimeInterface;
class EventDraft
{
public function __construct(
private readonly string $title,
private readonly string $description,
private readonly string $location,
private readonly ?DateTimeInterface $start,
private readonly ?DateTimeInterface $end,
private readonly bool $allDay,
) {
}
public function title(): string
{
return $this->title;
}
public function description(): string
{
return $this->description;
}
public function location(): string
{
return $this->location;
}
public function start(): ?DateTimeInterface
{
return $this->start;
}
public function end(): ?DateTimeInterface
{
return $this->end;
}
public function allDay(): bool
{
return $this->allDay;
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Domain\Model\Persisted;
namespace App\Domain\Model;
use App\Infrastructure\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
@ -9,7 +9,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'app_user')]
class PersistedUser
class User
{
#[ORM\Id]
#[ORM\Column(type: 'string')]

View File

@ -1,18 +0,0 @@
<?php
namespace App\Domain\Model;
use DateTimeInterface;
final readonly class UserContext
{
public function __construct(
private readonly DateTimeInterface $now,
) {
}
public function now(): DateTimeInterface
{
return $this->now;
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace App\Domain\User;
use App\Domain\Model\UserContext;
use DateTimeImmutable;
final readonly class UserContextProvider
{
public function getUserContext(): UserContext
{
return new UserContext(new DateTimeImmutable());
}
}

View File

@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Chat;
use App\Domain\Chat\ChatProviderInterface;
use App\Domain\Chat\ChatResult;
use App\Domain\Chat\Choice;
use App\Domain\Chat\Message;
use App\Domain\Chat\MessageCollection;
use App\Domain\Chat\ToolCollection;
use App\Domain\Chat\ToolInterface;
use Exception;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final readonly class OpenAIChatProvider implements ChatProviderInterface
{
public function __construct(
private HttpClientInterface $httpClient,
#[Autowire('%env(OPENAI_API_KEY)%')]
private string $openAiApiKey,
) {
}
/**
* @throws Exception When the OpenAI API returns an error
*/
public function chat(MessageCollection $messages, ToolCollection $tools, bool $forceToolCalls = false, bool $reasoning = true): ChatResult
{
$payload = [
'model' => $reasoning ? 'o4-mini' : 'gpt-4o-mini',
'messages' => array_map(function (Message $message) {
return $message->toArray();
}, $messages->toArray()),
];
if ($tools->count() > 0) {
$payload['tool_choice'] = $forceToolCalls ? 'required' : 'auto';
$payload['tools'] = [];
/** @var ToolInterface $tool */
foreach ($tools->toArray() as $tool) {
$payload['tools'][] = [
'type' => 'function',
'function' => [
'name' => $tool->getName(),
'description' => $tool->getDescription(),
'parameters' => [
'type' => 'object',
'properties' => $tool->getArguments(),
'required' => $tool->getRequiredArguments(),
],
],
];
}
}
try {
/** @var ResponseInterface $response */
$response = $this->httpClient->request('POST', "https://api.openai.com/v1/chat/completions", [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->openAiApiKey,
],
'json' => $payload,
'timeout' => 60 * 10,
'max_duration' => 60 * 10,
]);
$responseArray = $response->toArray(false);
if (isset($responseArray['choices']) && is_array($responseArray['choices']) && count($responseArray['choices']) > 0) {
$choices = [];
foreach ($responseArray['choices'] as $choice) {
$choices[] = Choice::fromArray($choice);
}
return new ChatResult(
choices: $choices,
messageHistory: $messages->toArray(),
);
}
throw new Exception('Error: ' . $response->getContent(false));
} catch (ClientExceptionInterface | DecodingExceptionInterface | RedirectionExceptionInterface |
ServerExceptionInterface | TransportExceptionInterface $e) {
throw new Exception('API Error: ' . $e->getMessage(), 0, $e);
}
}
}

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\PersistedEvent;
use App\Domain\Model\Event;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@ -44,7 +44,7 @@ final class EventFixtures extends Fixture
private function createDoctorAppointment(ObjectManager $manager): void
{
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Doctor Appointment')
->setDescription('Annual checkup with Dr. Smith')
->setFrom(new DateTimeImmutable('next monday 09:30'))
@ -56,7 +56,7 @@ final class EventFixtures extends Fixture
private function createDentistAppointment(ObjectManager $manager): void
{
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Dentist Appointment')
->setDescription('Teeth cleaning with Dr. Johnson')
->setFrom(new DateTimeImmutable('next friday 14:00'))
@ -70,7 +70,7 @@ final class EventFixtures extends Fixture
{
$tomorrow = new DateTimeImmutable('tomorrow');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Yoga Class')
->setDescription('Vinyasa flow at Peaceful Mind Studio')
->setFrom($tomorrow->setTime(18, 0))
@ -84,7 +84,7 @@ final class EventFixtures extends Fixture
{
$dayAfterTomorrow = new DateTimeImmutable('today +2 days');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Gym Workout')
->setDescription('Strength training at Fitness Center')
->setFrom($dayAfterTomorrow->setTime(7, 0))
@ -98,7 +98,7 @@ final class EventFixtures extends Fixture
{
$thisWeekend = new DateTimeImmutable('next saturday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Dinner with Friends')
->setDescription('Dinner at Italiano Restaurant with Alex and Jamie')
->setFrom($thisWeekend->setTime(19, 0))
@ -112,7 +112,7 @@ final class EventFixtures extends Fixture
{
$nextFriday = new DateTimeImmutable('next friday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Movie Night')
->setDescription('Watching new Marvel movie at Cinema City')
->setFrom($nextFriday->setTime(20, 0))
@ -126,7 +126,7 @@ final class EventFixtures extends Fixture
{
$twoWeeksLater = new DateTimeImmutable('today +14 days');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Sarah\'s Birthday Party')
->setDescription('Birthday celebration at Rooftop Bar')
->setFrom($twoWeeksLater->setTime(18, 0))
@ -141,7 +141,7 @@ final class EventFixtures extends Fixture
$nextWeekend = new DateTimeImmutable('next saturday');
$nextWeekendEnd = new DateTimeImmutable('next sunday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Weekend Getaway')
->setDescription('Short trip to the mountains')
->setFrom($nextWeekend)
@ -156,7 +156,7 @@ final class EventFixtures extends Fixture
$vacationStart = new DateTimeImmutable('next month first day');
$vacationEnd = new DateTimeImmutable('next month first day +6 days');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Summer Vacation')
->setDescription('Beach vacation in Hawaii')
->setFrom($vacationStart)
@ -170,7 +170,7 @@ final class EventFixtures extends Fixture
{
$nextSaturday = new DateTimeImmutable('next saturday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Italian Cooking Class')
->setDescription('Learn to make pasta from scratch at Culinary Center')
->setFrom($nextSaturday->setTime(14, 0))
@ -184,7 +184,7 @@ final class EventFixtures extends Fixture
{
$nextThursday = new DateTimeImmutable('next thursday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Book Club Meeting')
->setDescription('Discussing "The Midnight Library" at Local Cafe')
->setFrom($nextThursday->setTime(19, 0))
@ -198,7 +198,7 @@ final class EventFixtures extends Fixture
{
$inTwoWeeks = new DateTimeImmutable('today +14 days');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Hiking Trip')
->setDescription('Explore Blue Mountain Trail')
->setFrom($inTwoWeeks->setTime(8, 0))
@ -212,7 +212,7 @@ final class EventFixtures extends Fixture
{
$nextSunday = new DateTimeImmutable('next sunday');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Grocery Shopping')
->setDescription('Weekly grocery run at Farmer\'s Market')
->setFrom($nextSunday->setTime(10, 0))
@ -226,7 +226,7 @@ final class EventFixtures extends Fixture
{
$firstOfNextMonth = new DateTimeImmutable('first day of next month');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Monthly Deep Cleaning')
->setDescription('House deep cleaning day')
->setFrom($firstOfNextMonth)
@ -240,7 +240,7 @@ final class EventFixtures extends Fixture
{
$twoMonthsFromNow = new DateTimeImmutable('today +2 months');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Relationship Anniversary')
->setDescription('Dinner reservation at Sunset Restaurant')
->setFrom($twoMonthsFromNow->setTime(19, 0))
@ -254,7 +254,7 @@ final class EventFixtures extends Fixture
{
$nextMonth = new DateTimeImmutable('next month');
$event = new PersistedEvent();
$event = new Event();
$event->setTitle('Live Concert')
->setDescription('Favorite band performing at Central Arena')
->setFrom($nextMonth->setTime(20, 0))

View File

@ -2,7 +2,7 @@
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\PersistedUser;
use App\Domain\Model\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
@ -12,7 +12,7 @@ final class UserFixtures extends Fixture
public function load(ObjectManager $manager): void
{
$user = new PersistedUser();
$user = new User();
$user->setEmail('user@example.com')
->setFirstName('John')
->setLastName('Doe');

View File

@ -2,26 +2,26 @@
namespace App\Infrastructure\Repository;
use App\Domain\Model\Persisted\PersistedEvent;
use App\Domain\Model\Event;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PersistedEvent>
* @extends ServiceEntityRepository<Event>
*/
class EventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PersistedEvent::class);
parent::__construct($registry, Event::class);
}
/**
* @return array<PersistedEvent>
* @return array<Event>
*/
public function findByDateRange(\DateTimeInterface $start, \DateTimeInterface $end): array
{
/** @var array<PersistedEvent> $result */
/** @var array<Event> $result */
$result = $this->createQueryBuilder('e')
->andWhere('e.from >= :start AND e.from <= :end')
->orWhere('e.to >= :start AND e.to <= :end')

View File

@ -2,27 +2,27 @@
namespace App\Infrastructure\Repository;
use App\Domain\Model\Persisted\PersistedUser;
use App\Domain\Model\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PersistedUser>
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PersistedUser::class);
parent::__construct($registry, User::class);
}
public function save(PersistedUser $user): void
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
public function remove(PersistedUser $user): void
public function remove(User $user): void
{
$this->getEntityManager()->remove($user);
$this->getEntityManager()->flush();

View File

@ -21,7 +21,6 @@
"@types/react-dom": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
@ -14036,54 +14035,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz",
"integrity": "sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.2.tgz",
"integrity": "sha512-yk1XW8Fj7gK7flpYBXF3yzd2NbX6P7Kxjvs2b5nu1M04rb5pg/Zc4fGdBNTeT4eDYL2bvzWNyKaIMJX/RKHTTg==",
"license": "MIT",
"dependencies": {
"react-router": "7.5.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@ -14999,12 +14950,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -16383,12 +16328,6 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -15,7 +15,6 @@
"@types/react-dom": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@ -2,12 +2,8 @@ import './App.css';
import { TabView } from './components/navigation/TabView';
import Home from './pages/home/Home';
import Profile from './pages/profile/Profile';
import EditEvent from './pages/edit-event/EditEvent';
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
import { faHome } from '@fortawesome/free-solid-svg-icons';
import { useUser } from './lib/context';
import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
const tabs = [
{ id: 'home', label: 'Home', component: Home, icon: faHome },
@ -16,26 +12,11 @@ const tabs = [
];
function App() {
const { initialLoad } = useUser();
const [renderContent, setRenderContent] = useState(false);
useEffect(() => {
// Only render content when initial loading is done or after a timeout
if (!initialLoad) {
setRenderContent(true);
}
}, [initialLoad]);
return (
<div className="App">
{renderContent && (
<BrowserRouter>
<Routes>
<Route path="/edit-event/:id" element={<EditEvent />} />
<Route path="*" element={<TabView tabs={tabs} />} />
</Routes>
</BrowserRouter>
)}
<TabView
tabs={tabs}
/>
</div>
);
}

View File

@ -70,8 +70,7 @@ export const TabView: React.FC<TabBarProps> = ({
className="tab-content"
ref={contentRef}
style={{
transition: 'opacity 0.4s ease, transform 0.4s ease',
opacity: isTransitioning ? 0 : 1,
transition: 'opacity 0.3s ease, transform 0.3s ease',
}}
>
{ActiveComponent && <ActiveComponent />}

View File

@ -1,164 +0,0 @@
.event-draft-card-container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 2000;
display: flex;
justify-content: center;
padding: 16px;
pointer-events: none;
transform: translateY(-100%);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
padding-top: 0;
width: 100%;
}
.event-draft-card-container.visible {
transform: translateY(0);
pointer-events: auto;
}
.event-draft-card-container.closing {
transform: translateY(-100%);
}
@keyframes cardDisappear {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
}
.event-draft-card {
background-color: #ffffff;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2), 0 6px 12px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 100%;
padding: 24px;
position: relative;
margin: 0 16px;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.event-draft-card-container.closing .event-draft-card {
animation: cardDisappear 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.draft-close-button {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #666;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.draft-close-button:hover {
background-color: #f0f0f0;
color: #000;
}
.draft-card-content {
width: 100%;
}
.draft-title {
font-size: 22px;
font-weight: 600;
margin: 0 0 16px 0;
line-height: 1.2;
color: #000000;
}
.draft-description {
font-size: 16px;
line-height: 1.4;
margin-bottom: 20px;
color: #333;
white-space: pre-line;
}
.draft-details {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
font-size: 15px;
}
.draft-time {
display: flex;
align-items: flex-start;
color: #555;
}
.draft-label {
font-weight: 600;
margin-right: 6px;
min-width: 60px;
display: inline-block;
}
.draft-all-day {
display: inline-block;
background-color: #F2ADAD;
color: #000;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
margin-top: 6px;
width: fit-content;
}
.draft-actions {
display: flex;
gap: 16px;
width: 100%;
}
.draft-save-button, .draft-edit-button {
padding: 12px 20px;
border-radius: 10px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
flex: 1;
border: none;
}
.draft-save-button {
background-color: #F2ADAD;
color: #000000;
}
.draft-save-button:hover {
background-color: #f09e9e;
transform: translateY(-1px);
}
.draft-edit-button {
background-color: #e8f0fe;
color: #1a73e8;
}
.draft-edit-button:hover {
background-color: #d2e3fc;
transform: translateY(-1px);
}

View File

@ -1,118 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { EventDraft } from '../../lib/api/endpoints';
import { useNavigate } from 'react-router-dom';
import './EventDraftCard.css';
interface EventDraftCardProps {
draft: EventDraft;
onClose: () => void;
onSave?: () => void;
onEdit?: (draft: EventDraft) => void;
}
const EventDraftCard: React.FC<EventDraftCardProps> = ({
draft,
onClose,
onSave,
onEdit
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
setIsVisible(true);
// Add click event listener to detect clicks outside the card
const handleClickOutside = (event: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(event.target as Node)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
setIsVisible(false);
onClose();
}, 400); // Match animation duration
};
const handleEdit = () => {
if (onEdit) {
onEdit(draft);
} else {
// Default behavior if no onEdit handler is provided
console.log('Navigate to edit page for draft:', draft);
sessionStorage.setItem('editingEventDraft', JSON.stringify(draft));
navigate(`/edit-event/${draft.id}`);
}
};
const formatDateTime = (dateStr: string | null) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className={`event-draft-card-container ${isVisible ? 'visible' : ''} ${isClosing ? 'closing' : ''}`}>
<div className="event-draft-card" ref={cardRef}>
<div className="draft-card-content">
<h2 className="draft-title">{draft.title}</h2>
{draft.description && (
<div className="draft-description">
{draft.description}
</div>
)}
<div className="draft-details">
{draft.start && (
<div className="draft-time">
<span className="draft-label">Start:</span> {formatDateTime(draft.start)}
</div>
)}
{draft.end && (
<div className="draft-time">
<span className="draft-label">Ende:</span> {formatDateTime(draft.end)}
</div>
)}
{draft.allDay && (
<div className="draft-all-day">Ganztägig</div>
)}
</div>
<div className="draft-actions">
<button className="draft-edit-button" onClick={handleEdit}>
Bearbeiten
</button>
{onSave && (
<button className="draft-save-button" onClick={onSave}>
Speichern
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default EventDraftCard;

View File

@ -1,44 +0,0 @@
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
animation: fadeInOverlay 0.5s ease-in-out;
transition: opacity 0.5s ease;
}
.loading-overlay.transparent-background {
background-color: rgba(255, 255, 255, 0.5);
}
.loading-overlay-content {
padding: 2rem;
border-radius: 1rem;
background-color: white;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
animation: scaleInOverlay 0.5s ease-in-out;
}
@keyframes fadeInOverlay {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleInOverlay {
from {
transform: scale(0.95);
}
to {
transform: scale(1);
}
}

View File

@ -1,31 +0,0 @@
import React from 'react';
import LoadingSpinner from './LoadingSpinner';
import './LoadingOverlay.css';
interface LoadingOverlayProps {
isVisible: boolean;
message?: string;
transparentBackground?: boolean;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
isVisible,
message = 'Loading...',
transparentBackground = false,
}) => {
if (!isVisible) return null;
const overlayClass = transparentBackground
? 'loading-overlay transparent-background'
: 'loading-overlay';
return (
<div className={overlayClass}>
<div className="loading-overlay-content">
<LoadingSpinner size="large" message={message} />
</div>
</div>
);
};
export default LoadingOverlay;

View File

@ -1,85 +0,0 @@
.loading-spinner-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
transition: all 0.5s ease;
min-height: 200px;
opacity: 1;
}
.loading-spinner-container.full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 1000;
}
.loading-spinner-content {
display: flex;
flex-direction: column;
align-items: center;
animation: fadeIn 0.6s ease-out forwards;
}
.loading-spinner {
color: #F2ADAD;
opacity: 0;
transform: scale(0.9);
animation: scaleIn 0.6s ease-out forwards 0.2s;
}
.loading-spinner-small {
font-size: 1.5rem;
}
.loading-spinner-medium {
font-size: 2.5rem;
}
.loading-spinner-large {
font-size: 4rem;
}
.loading-spinner-message {
margin-top: 1rem;
font-size: 1.2rem;
color: #555;
font-weight: 500;
opacity: 0;
animation: slideUp 0.6s ease-out forwards 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,35 +0,0 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import './LoadingSpinner.css';
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
message?: string;
fullScreen?: boolean;
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'medium',
message = 'Loading...',
fullScreen = false,
}) => {
const containerClass = fullScreen
? 'loading-spinner-container full-screen'
: 'loading-spinner-container';
const sizeClass = `loading-spinner-${size}`;
return (
<div className={containerClass}>
<div className="loading-spinner-content">
<div className={`loading-spinner ${sizeClass}`}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
{message && <p className="loading-spinner-message">{message}</p>}
</div>
</div>
);
};
export default LoadingSpinner;

View File

@ -41,24 +41,6 @@ export const createEvent = (data: CreateEventRequest) => post<Event>('/api/event
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data);
export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
// Event draft types
export type GenerateDraftRequest = {
input: string;
};
export type EventDraft = {
id: string;
title: string;
description: string;
start: string | null;
end: string | null;
allDay: boolean;
};
// Event draft endpoints
export const generateDraft = (data: GenerateDraftRequest) =>
post<EventDraft>('/api/events/generation', data);
// Calendar types
export type Calendar = {
events: Event[];

View File

@ -1,12 +1,10 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, getUser } from '../api/endpoints';
import LoadingOverlay from '../../components/ui/LoadingOverlay';
type UserContextType = {
user: User | null;
loading: boolean;
error: Error | null;
initialLoad: boolean;
refetch: () => Promise<void>;
};
@ -16,7 +14,6 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [initialLoad, setInitialLoad] = useState(true);
const fetchUser = async () => {
try {
@ -32,10 +29,7 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
};
useEffect(() => {
fetchUser().finally(() => {
// Longer delay to ensure child components are ready before removing overlay
setTimeout(() => setInitialLoad(false), 800);
});
fetchUser();
}, []);
return (
@ -44,14 +38,9 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
user,
loading,
error,
initialLoad,
refetch: fetchUser
}}
>
<LoadingOverlay
isVisible={initialLoad}
message="Welcome to Calendi..."
/>
{children}
</UserContext.Provider>
);

View File

@ -1,132 +0,0 @@
.edit-event-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
min-height: 100vh;
}
.edit-event-title {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 2rem;
color: #000000;
}
.edit-event-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
font-size: 1rem;
color: #333;
}
.form-group input[type="text"],
.form-group textarea,
.form-group input[type="date"],
.form-group input[type="time"] {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background-color: #f9f9f9;
transition: border-color 0.2s ease;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus,
.form-group input[type="date"]:focus,
.form-group input[type="time"]:focus {
outline: none;
border-color: #F2ADAD;
box-shadow: 0 0 0 2px rgba(242, 173, 173, 0.2);
}
.form-checkbox {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #F2ADAD;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.edit-event-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
}
.save-button, .cancel-button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
border: none;
}
.save-button {
background-color: #F2ADAD;
color: #000000;
}
.save-button:hover {
background-color: #f09e9e;
transform: translateY(-1px);
}
.cancel-button {
background-color: #f0f0f0;
color: #555;
}
.cancel-button:hover {
background-color: #e5e5e5;
transform: translateY(-1px);
}
@media (max-width: 768px) {
.edit-event-container {
padding: 1.5rem;
}
.form-row {
flex-direction: column;
gap: 1.5rem;
}
.edit-event-actions {
flex-direction: column-reverse;
gap: 0.75rem;
}
.save-button, .cancel-button {
width: 100%;
padding: 1rem;
}
}

View File

@ -1,218 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { EventDraft, createEvent } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import './EditEvent.css';
interface EditEventParams {
id: string;
}
const EditEvent: React.FC = () => {
const params = useParams<{ id: string }>();
const id = params.id;
const navigate = useNavigate();
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('');
const [endDate, setEndDate] = useState('');
const [endTime, setEndTime] = useState('');
const [allDay, setAllDay] = useState(false);
useEffect(() => {
// Try to load draft data from sessionStorage
const draftData = sessionStorage.getItem('editingEventDraft');
if (draftData) {
try {
const draft = JSON.parse(draftData) as EventDraft;
setEventDraft(draft);
// Set form values
setTitle(draft.title);
setDescription(draft.description || '');
if (draft.start) {
const startDateTime = new Date(draft.start);
setStartDate(formatDateForInput(startDateTime));
setStartTime(formatTimeForInput(startDateTime));
}
if (draft.end) {
const endDateTime = new Date(draft.end);
setEndDate(formatDateForInput(endDateTime));
setEndTime(formatTimeForInput(endDateTime));
}
setAllDay(draft.allDay);
} catch (error) {
console.error('Error parsing draft data:', error);
}
} else {
console.error('No event draft data found in session storage');
// Redirect back to home if no draft data is found
setTimeout(() => navigate('/'), 500);
}
setLoading(false);
}, [id, navigate]);
const formatDateForInput = (date: Date): string => {
return date.toISOString().split('T')[0];
};
const formatTimeForInput = (date: Date): string => {
return date.toTimeString().slice(0, 5);
};
const formatForServer = (date: string, time: string): string => {
return `${date}T${time}:00`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title) {
alert('Bitte geben Sie einen Titel ein');
return;
}
try {
setLoading(true);
const eventData = {
title,
description,
start: startDate ? formatForServer(startDate, startTime || '00:00') : new Date().toISOString(),
end: endDate ? formatForServer(endDate, endTime || '00:00') : new Date().toISOString(),
allDay
};
const savedEvent = await createEvent(eventData);
console.log('Event created:', savedEvent);
// Clear the draft from sessionStorage
sessionStorage.removeItem('editingEventDraft');
// Redirect back to home page after successful save
navigate('/');
} catch (error) {
console.error('Error saving event:', error);
setLoading(false);
alert('Fehler beim Speichern des Termins');
}
};
const handleCancel = () => {
// Clear the draft data when canceling
sessionStorage.removeItem('editingEventDraft');
// Navigate back to home
navigate('/');
};
if (loading) {
return <LoadingSpinner message="Lade Termin..." size="large" />;
}
return (
<div className="edit-event-container">
<h1 className="edit-event-title">Termin bearbeiten</h1>
<form className="edit-event-form" onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">Titel</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Beschreibung</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
<div className="form-group form-checkbox">
<input
type="checkbox"
id="allDay"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
/>
<label htmlFor="allDay">Ganztägig</label>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="startDate">Startdatum</label>
<input
type="date"
id="startDate"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
{!allDay && (
<div className="form-group">
<label htmlFor="startTime">Startzeit</label>
<input
type="time"
id="startTime"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
)}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="endDate">Enddatum</label>
<input
type="date"
id="endDate"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{!allDay && (
<div className="form-group">
<label htmlFor="endTime">Endzeit</label>
<input
type="time"
id="endTime"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
)}
</div>
<div className="edit-event-actions">
<button type="button" className="cancel-button" onClick={handleCancel}>
Abbrechen
</button>
<button type="submit" className="save-button">
Speichern
</button>
</div>
</form>
</div>
);
};
export default EditEvent;

View File

@ -238,20 +238,6 @@
color: #F2ADAD;
}
.draft-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
backdrop-filter: blur(3px);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.greeting {

View File

@ -1,14 +1,8 @@
import React, { useEffect, useState, useRef, KeyboardEvent } from 'react';
import { getEvents, Event, generateDraft, EventDraft, GenerateDraftRequest } from '../../lib/api/endpoints';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import EventDraftCard from '../../components/ui/EventDraftCard';
import { useUser } from '../../lib/context';
import { useNavigate } from 'react-router-dom';
import React, { useEffect, useState, useRef } from 'react';
import { getEvents, Event } from '../../lib/api/endpoints';
import './Home.css';
const Home: React.FC = () => {
const { initialLoad } = useUser();
const navigate = useNavigate();
const [todayEvents, setTodayEvents] = useState<Event[]>([]);
const [tomorrowEvents, setTomorrowEvents] = useState<Event[]>([]);
const [weekEvents, setWeekEvents] = useState<Event[]>([]);
@ -17,9 +11,6 @@ const Home: React.FC = () => {
const [userName, setUserName] = useState('John');
const [isTextboxOpen, setIsTextboxOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [inputText, setInputText] = useState('');
const [isDraftLoading, setIsDraftLoading] = useState(false);
const [eventDraft, setEventDraft] = useState<EventDraft | null>(null);
const textInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -89,52 +80,9 @@ const Home: React.FC = () => {
setTimeout(() => {
setIsTextboxOpen(false);
setIsClosing(false);
setInputText('');
}, 300); // Match animation duration
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
};
const handleKeyDown = async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputText.trim()) {
await generateEventDraft();
} else if (e.key === 'Escape') {
handleCloseTextbox();
}
};
const generateEventDraft = async () => {
if (!inputText.trim()) return;
try {
setIsDraftLoading(true);
handleCloseTextbox();
const request: GenerateDraftRequest = { input: inputText.trim() };
const draft = await generateDraft(request);
setEventDraft(draft);
setIsDraftLoading(false);
} catch (err) {
setError('Failed to generate event draft');
setIsDraftLoading(false);
}
};
const handleDraftClose = () => {
setEventDraft(null);
};
const handleEditDraft = (draft: EventDraft) => {
// Store the draft data in sessionStorage for use on the edit page
sessionStorage.setItem('editingEventDraft', JSON.stringify(draft));
// Navigate to the edit page using React Router
navigate(`/edit-event/${draft.id}`);
};
const EventItem = ({ event }: { event: Event }) => (
<div className="event-item">
<div className="event-title">{event.title}</div>
@ -146,8 +94,8 @@ const Home: React.FC = () => {
</div>
);
if (loading && !initialLoad) {
return <LoadingSpinner message="Loading events..." size="large" />;
if (loading) {
return <div className="loading">Loading events...</div>;
}
if (error) {
@ -156,33 +104,6 @@ const Home: React.FC = () => {
return (
<div className="home-container">
{isDraftLoading && (
<div className="draft-loading-overlay">
<LoadingSpinner message="Termin wird erstellt..." size="medium" />
</div>
)}
{eventDraft && (
<EventDraftCard
draft={eventDraft}
onClose={handleDraftClose}
onEdit={handleEditDraft}
onSave={() => {
// For now we just close the card and log the data
const eventData = {
title: eventDraft.title,
description: eventDraft.description,
start: eventDraft.start || new Date().toISOString(),
end: eventDraft.end || new Date().toISOString(),
allDay: eventDraft.allDay
};
console.log('Saving event:', eventData);
// Add actual implementation for saving the event
handleDraftClose();
}}
/>
)}
<div className="header-container">
<h1 className="greeting">Hallo {userName}</h1>
<button className="add-button" onClick={handleOpenTextbox}>
@ -238,9 +159,6 @@ const Home: React.FC = () => {
className="large-textbox"
type="text"
placeholder="Neuen Termin eingeben..."
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</div>
</div>

View File

@ -1,11 +1,10 @@
import { useUser } from '../../lib/context';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
const Profile = () => {
const { user, loading, error, refetch, initialLoad } = useUser();
const { user, loading, error, refetch } = useUser();
if (loading && !initialLoad) {
return <LoadingSpinner message="Loading user data..." />;
if (loading) {
return <div>Loading user data...</div>;
}
if (error) {