This commit is contained in:
Tim Lappe 2025-04-23 18:55:51 +02:00
parent b870378fae
commit 926a6d7bc1
58 changed files with 5132 additions and 58 deletions

View File

@ -6,4 +6,9 @@ alwaysApply: true
# React components
1. You will always provide a maintainable folder structure when adding or updating react components.
2. You will reuse common components
2. You will reuse common components
# 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

View File

@ -0,0 +1,8 @@
---
description:
globs:
alwaysApply: true
---
# Updating / Creating Entities
When updating or creating entities, always update and the related DTO classes.
DTO Classes are a representation of a JSON Schema

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"phpstan.binPath": "backend/vendor/bin/phpstan",
"phpstan.configFile": "backend/phpstan.dist.neon",
"phpstan.checkValidity": true,
"phpstan.showTypeOnHover": false,
"phpstan.showProgress": true
}

View File

@ -18,3 +18,13 @@
APP_ENV=dev
APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###

View File

4
backend/.gitignore vendored
View File

@ -8,3 +8,7 @@
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

38
backend/README.md Normal file
View File

@ -0,0 +1,38 @@
# Domain-Driven Design Structure
This project follows Domain-Driven Design (DDD) principles with the following structure:
## Application Layer
Contains application-specific logic and serves as the entry point for external requests.
- **Controller**: HTTP controllers that handle web requests
- **DTO**: Data Transfer Objects for API request/response
## Domain Layer
Contains the core business logic and domain models.
- **Model**: Domain entities representing the core business concepts
## Infrastructure Layer
Provides technical capabilities that support the higher layers.
- **Repository**: Data access logic for persisting and retrieving domain objects
- **DataFixtures**: Test data fixtures for development and testing
## Shared
Contains cross-cutting concerns and utilities used across all layers.
## Folder Structure
```
src/
├── Application/
│ ├── Controller/
│ └── DTO/
├── Domain/
│ └── Model/
├── Infrastructure/
│ ├── Repository/
│ └── DataFixtures/
└── Shared/
```

View File

@ -7,14 +7,25 @@
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/annotations": "^2.0",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"nelmio/api-doc-bundle": "^5.0",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/yaml": "6.4.*"
},
"config": {
@ -54,7 +65,9 @@
],
"post-update-cmd": [
"@auto-scripts"
]
],
"phpstan": "phpstan analyse --ansi",
"rebuild-db": "php bin/console doctrine:schema:drop --force && php bin/console doctrine:schema:update --force && php bin/console doctrine:fixtures:load --no-interaction"
},
"conflict": {
"symfony/symfony": "*"
@ -64,5 +77,11 @@
"allow-contrib": false,
"require": "6.4.*"
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-symfony": "^2.0",
"symfony/maker-bundle": "^1.62"
}
}

2275
backend/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,8 @@ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];

View File

@ -0,0 +1,51 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
driver: 'pdo_pgsql'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Domain/Model'
prefix: 'App\Domain\Model'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@ -4,6 +4,10 @@ nelmio_api_doc:
title: Calendi API
description: API Documentation for Calendi
version: 1.0.0
components:
schemas:
directory: "%kernel.project_dir%/public/schema"
type: json
areas:
path_patterns:
- ^/api(?!/doc$)

View File

@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7

View File

@ -0,0 +1,13 @@
framework:
validation:
email_validation_mode: html5
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -1,5 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
path: ../src/Application/Controller/
namespace: App\Application\Controller
type: attribute

10
backend/phpstan.dist.neon Normal file
View File

@ -0,0 +1,10 @@
parameters:
level: 10
paths:
- src
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon

View File

@ -0,0 +1,53 @@
<?php
namespace App\Application\Controller;
use App\Infrastructure\Repository\EventRepository;
use App\Application\DTO\CalendarDTO;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA;
use Nelmio\ApiDocBundle\Attribute\Model;
#[Route('/api/calendar', name: 'api_calendar_')]
class CalendarController extends AbstractController
{
public function __construct(
private readonly EventRepository $eventRepository
) {
}
#[Route('', name: 'get', methods: ['GET'])]
#[OA\Tag(name: 'Calendar')]
#[OA\Response(
response: 200,
description: 'Returns calendar data with events',
content: new OA\JsonContent(
ref: new Model(type: CalendarDTO::class)
)
)]
public function getCalendar(Request $request): JsonResponse
{
$start = $request->query->get('start');
$end = $request->query->get('end');
if (!$start || !$end) {
// Default to current month if not specified
$now = new \DateTimeImmutable();
$start = $now->modify('first day of this month')->setTime(0, 0);
$end = $now->modify('last day of this month')->setTime(23, 59, 59);
} else {
$start = new \DateTimeImmutable($start);
$end = new \DateTimeImmutable($end);
}
$events = $this->eventRepository->findByDateRange($start, $end);
return $this->json([
'events' => $events,
]);
}
}

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\Controller;
namespace App\Application\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;

View File

@ -0,0 +1,41 @@
<?php
namespace App\Application\Controller;
use App\Application\DTO\UserDTO;
use App\Infrastructure\Repository\UserRepository;
use Nelmio\ApiDocBundle\Attribute\Model;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use OpenApi\Attributes as OA;
#[Route('/api/user', name: 'api_user_')]
class UserController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository
) {
}
#[Route('', name: 'user_current', methods: ['GET'])]
#[OA\Tag(name: 'User')]
#[OA\Response(
response: 200,
description: 'Returns the current user',
content: new OA\JsonContent(
ref: new Model(type: UserDTO::class)
)
)]
public function getCurrentUser(): JsonResponse
{
$user = $this->userRepository->findOneBy([]);
if (!$user) {
throw new NotFoundHttpException('No user found');
}
return $this->json(UserDTO::fromEntity($user));
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Application\DTO;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class CalendarDTO
{
/**
* @param array<EventDTO> $events
*/
public function __construct(
#[OA\Property(type: 'array', items: new OA\Items(ref: "#/components/schemas/EventDTO"))]
public array $events = []
) {
}
public function addEvent(EventDTO $event): self
{
$events = $this->events;
$events[] = $event;
return new self($events);
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\Event;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class EventDTO
{
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 fromEntity(Event $event): self
{
return new self(
$event->getId(),
$event->getTitle(),
$event->getDescription() ?? '',
$event->getFrom()->format('Y-m-d H:i:s'),
$event->getTo()->format('Y-m-d H:i:s'),
$event->isAllDay()
);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Application\DTO;
use App\Domain\Model\User;
use OpenApi\Attributes as OA;
#[OA\Schema]
final readonly class UserDTO
{
public function __construct(
#[OA\Property(type: 'string')]
public string $id,
#[OA\Property(type: 'string')]
public string $email,
#[OA\Property(type: 'string')]
public string $firstName,
#[OA\Property(type: 'string')]
public string $lastName,
#[OA\Property(type: 'string')]
public string $fullName
) {
}
public static function fromEntity(User $user): self
{
return new self(
$user->getId(),
$user->getEmail(),
$user->getFirstName(),
$user->getLastName(),
$user->getFullName()
);
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Domain\Model;
use App\Infrastructure\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: EventRepository::class)]
#[ORM\Table(name: 'app_event')]
class Event
{
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $id;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_from')]
#[Assert\NotNull]
private \DateTimeImmutable $from;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'event_to')]
#[Assert\NotNull]
#[Assert\GreaterThanOrEqual(propertyPath: 'from')]
private \DateTimeImmutable $to;
#[ORM\Column(type: 'boolean')]
private bool $allDay = false;
public function __construct()
{
$this->id = Uuid::v4()->toRfc4122();
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getStart(): \DateTimeImmutable
{
return $this->from;
}
public function setStart(\DateTimeImmutable $start): self
{
$this->from = $start;
return $this;
}
public function getFrom(): \DateTimeImmutable
{
return $this->from;
}
public function setFrom(\DateTimeImmutable $from): self
{
$this->from = $from;
return $this;
}
public function getEnd(): \DateTimeImmutable
{
return $this->to;
}
public function setEnd(\DateTimeImmutable $end): self
{
$this->to = $end;
return $this;
}
public function getTo(): \DateTimeImmutable
{
return $this->to;
}
public function setTo(\DateTimeImmutable $to): self
{
$this->to = $to;
return $this;
}
public function isAllDay(): bool
{
return $this->allDay;
}
public function setAllDay(bool $allDay): self
{
$this->allDay = $allDay;
return $this;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Domain\Model;
use App\Infrastructure\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'app_user')]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'string')]
private string $id;
#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $firstName;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $lastName;
public function __construct()
{
$this->id = Uuid::v4()->toRfc4122();
}
public function getId(): string
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function setFirstName(string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): string
{
return $this->lastName;
}
public function setLastName(string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getFullName(): string
{
return "$this->firstName $this->lastName";
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Infrastructure\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
// $product = new Product();
// $manager->persist($product);
$manager->flush();
}
public function getDependencies(): array
{
return [
UserFixtures::class,
EventFixtures::class,
];
}
}

View File

@ -0,0 +1,266 @@
<?php
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\Event;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class EventFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
// Health and wellness
$this->createDoctorAppointment($manager);
$this->createDentistAppointment($manager);
$this->createYogaClass($manager);
$this->createGymSession($manager);
// Social events
$this->createDinnerWithFriends($manager);
$this->createMovieNight($manager);
$this->createBirthdayParty($manager);
// Travel and trips
$this->createWeekendGetaway($manager);
$this->createVacation($manager);
// Hobbies and interests
$this->createCookingClass($manager);
$this->createBookClub($manager);
$this->createHiking($manager);
// Recurring events
$this->createWeeklyGroceryShopping($manager);
$this->createMonthlyCleaning($manager);
// Special occasions
$this->createAnniversary($manager);
$this->createConcert($manager);
$manager->flush();
}
private function createDoctorAppointment(ObjectManager $manager): void
{
$event = new Event();
$event->setTitle('Doctor Appointment')
->setDescription('Annual checkup with Dr. Smith')
->setFrom(new DateTimeImmutable('next monday 09:30'))
->setTo(new DateTimeImmutable('next monday 10:30'))
->setAllDay(false);
$manager->persist($event);
}
private function createDentistAppointment(ObjectManager $manager): void
{
$event = new Event();
$event->setTitle('Dentist Appointment')
->setDescription('Teeth cleaning with Dr. Johnson')
->setFrom(new DateTimeImmutable('next friday 14:00'))
->setTo(new DateTimeImmutable('next friday 15:00'))
->setAllDay(false);
$manager->persist($event);
}
private function createYogaClass(ObjectManager $manager): void
{
$tomorrow = new DateTimeImmutable('tomorrow');
$event = new Event();
$event->setTitle('Yoga Class')
->setDescription('Vinyasa flow at Peaceful Mind Studio')
->setFrom($tomorrow->setTime(18, 0))
->setTo($tomorrow->setTime(19, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createGymSession(ObjectManager $manager): void
{
$dayAfterTomorrow = new DateTimeImmutable('today +2 days');
$event = new Event();
$event->setTitle('Gym Workout')
->setDescription('Strength training at Fitness Center')
->setFrom($dayAfterTomorrow->setTime(7, 0))
->setTo($dayAfterTomorrow->setTime(8, 30))
->setAllDay(false);
$manager->persist($event);
}
private function createDinnerWithFriends(ObjectManager $manager): void
{
$thisWeekend = new DateTimeImmutable('next saturday');
$event = new Event();
$event->setTitle('Dinner with Friends')
->setDescription('Dinner at Italiano Restaurant with Alex and Jamie')
->setFrom($thisWeekend->setTime(19, 0))
->setTo($thisWeekend->setTime(22, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createMovieNight(ObjectManager $manager): void
{
$nextFriday = new DateTimeImmutable('next friday');
$event = new Event();
$event->setTitle('Movie Night')
->setDescription('Watching new Marvel movie at Cinema City')
->setFrom($nextFriday->setTime(20, 0))
->setTo($nextFriday->setTime(23, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createBirthdayParty(ObjectManager $manager): void
{
$twoWeeksLater = new DateTimeImmutable('today +14 days');
$event = new Event();
$event->setTitle('Sarah\'s Birthday Party')
->setDescription('Birthday celebration at Rooftop Bar')
->setFrom($twoWeeksLater->setTime(18, 0))
->setTo($twoWeeksLater->setTime(23, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createWeekendGetaway(ObjectManager $manager): void
{
$nextWeekend = new DateTimeImmutable('next saturday');
$nextWeekendEnd = new DateTimeImmutable('next sunday');
$event = new Event();
$event->setTitle('Weekend Getaway')
->setDescription('Short trip to the mountains')
->setFrom($nextWeekend)
->setTo($nextWeekendEnd)
->setAllDay(true);
$manager->persist($event);
}
private function createVacation(ObjectManager $manager): void
{
$vacationStart = new DateTimeImmutable('next month first day');
$vacationEnd = new DateTimeImmutable('next month first day +6 days');
$event = new Event();
$event->setTitle('Summer Vacation')
->setDescription('Beach vacation in Hawaii')
->setFrom($vacationStart)
->setTo($vacationEnd)
->setAllDay(true);
$manager->persist($event);
}
private function createCookingClass(ObjectManager $manager): void
{
$nextSaturday = new DateTimeImmutable('next saturday');
$event = new Event();
$event->setTitle('Italian Cooking Class')
->setDescription('Learn to make pasta from scratch at Culinary Center')
->setFrom($nextSaturday->setTime(14, 0))
->setTo($nextSaturday->setTime(17, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createBookClub(ObjectManager $manager): void
{
$nextThursday = new DateTimeImmutable('next thursday');
$event = new Event();
$event->setTitle('Book Club Meeting')
->setDescription('Discussing "The Midnight Library" at Local Cafe')
->setFrom($nextThursday->setTime(19, 0))
->setTo($nextThursday->setTime(21, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createHiking(ObjectManager $manager): void
{
$inTwoWeeks = new DateTimeImmutable('today +14 days');
$event = new Event();
$event->setTitle('Hiking Trip')
->setDescription('Explore Blue Mountain Trail')
->setFrom($inTwoWeeks->setTime(8, 0))
->setTo($inTwoWeeks->setTime(16, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createWeeklyGroceryShopping(ObjectManager $manager): void
{
$nextSunday = new DateTimeImmutable('next sunday');
$event = new Event();
$event->setTitle('Grocery Shopping')
->setDescription('Weekly grocery run at Farmer\'s Market')
->setFrom($nextSunday->setTime(10, 0))
->setTo($nextSunday->setTime(11, 30))
->setAllDay(false);
$manager->persist($event);
}
private function createMonthlyCleaning(ObjectManager $manager): void
{
$firstOfNextMonth = new DateTimeImmutable('first day of next month');
$event = new Event();
$event->setTitle('Monthly Deep Cleaning')
->setDescription('House deep cleaning day')
->setFrom($firstOfNextMonth)
->setTo($firstOfNextMonth)
->setAllDay(true);
$manager->persist($event);
}
private function createAnniversary(ObjectManager $manager): void
{
$twoMonthsFromNow = new DateTimeImmutable('today +2 months');
$event = new Event();
$event->setTitle('Relationship Anniversary')
->setDescription('Dinner reservation at Sunset Restaurant')
->setFrom($twoMonthsFromNow->setTime(19, 0))
->setTo($twoMonthsFromNow->setTime(22, 0))
->setAllDay(false);
$manager->persist($event);
}
private function createConcert(ObjectManager $manager): void
{
$nextMonth = new DateTimeImmutable('next month');
$event = new Event();
$event->setTitle('Live Concert')
->setDescription('Favorite band performing at Central Arena')
->setFrom($nextMonth->setTime(20, 0))
->setTo($nextMonth->setTime(23, 0))
->setAllDay(false);
$manager->persist($event);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Infrastructure\DataFixtures;
use App\Domain\Model\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class UserFixtures extends Fixture
{
public const DEFAULT_USER_REFERENCE = 'default-user';
public function load(ObjectManager $manager): void
{
$user = new User();
$user->setEmail('user@example.com')
->setFirstName('John')
->setLastName('Doe');
$manager->persist($user);
$manager->flush();
$this->addReference(self::DEFAULT_USER_REFERENCE, $user);
}
}

View File

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

View File

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

View File

@ -1,4 +1,52 @@
{
"doctrine/annotations": {
"version": "2.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.10",
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.10",
"ref": "c170ded8fc587d6bd670550c43dafcf093762245"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"nelmio/api-doc-bundle": {
"version": "5.0",
"recipe": {
@ -8,6 +56,18 @@
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94"
}
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"symfony/console": {
"version": "6.4",
"recipe": {
@ -52,6 +112,15 @@
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.62",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/routing": {
"version": "6.4",
"recipe": {
@ -77,5 +146,29 @@
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
},
"files": [
"config/packages/uid.yaml"
]
},
"symfony/validator": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
},
"files": [
"config/packages/validator.yaml"
]
}
}

View File

@ -29,7 +29,8 @@ RUN apt-get update && apt-get install -y \
php8.4-bcmath \
php8.4-intl \
php8.4-gd \
php8.4-fpm
php8.4-fpm \
php8.4-pgsql
# Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

View File

@ -1,5 +1,8 @@
.App {
text-align: center;
width: 100%;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
.App-logo {

View File

@ -1,14 +1,14 @@
import React from 'react';
import './App.css';
import { TabView } from './components/navigation/TabView';
import Home from './pages/home/Home';
import Profile from './pages/profile/Profile';
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons';
import { faHome } from '@fortawesome/free-solid-svg-icons';
const tabs = [
{ id: 'home', label: 'Home', component: Home, icon: faHome },
{ id: 'calendar', label: 'Calendar', component: Home, icon: faCalendar },
{ id: 'profile', label: 'Profile', component: Home, icon: faUser }
{ id: 'profile', label: 'Profile', component: Profile, icon: faUser }
];
function App() {

View File

@ -3,10 +3,13 @@
flex-direction: column;
height: 100vh;
width: 100%;
max-width: 100%;
position: fixed;
top: 0;
left: 0;
background-color: #f8fafc;
overflow-x: hidden;
box-sizing: border-box;
}
.tab-bar {
@ -60,13 +63,16 @@
.tab-content {
flex: 1;
overflow: auto;
padding: 20px;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
justify-content: start;
align-items: start;
background-color: #ffffff;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
margin-bottom: -20px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}

View File

@ -0,0 +1,200 @@
.calendar {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.calendarContainer {
display: flex;
flex-direction: column;
@media (min-width: 768px) {
flex-direction: row;
}
}
/* Header styles */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
background-color: #fff;
}
.headerTitle {
font-size: 20px;
font-weight: 600;
margin: 0;
color: #333;
}
.navButton {
background-color: transparent;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #666;
transition: background-color 0.2s, color 0.2s;
}
.navButton:hover {
background-color: #f5f5f5;
color: #333;
}
.navButton:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3);
}
/* Calendar grid styles */
.calendarGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 0 1rem 1rem;
flex: 2;
}
.weekDay {
padding: 12px 0;
text-align: center;
font-size: 13px;
font-weight: 600;
color: #777;
border-bottom: 1px solid #f0f0f0;
}
.day, .emptyDay {
aspect-ratio: 1/1;
padding: 4px;
position: relative;
cursor: pointer;
transition: background-color 0.2s;
}
.day:hover {
background-color: #f5f5f5;
}
.dayNumber {
position: absolute;
top: 4px;
left: 6px;
font-size: 14px;
font-weight: 500;
color: #444;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.today .dayNumber {
background-color: #4285f4;
color: white;
}
.selectedDay {
background-color: rgba(66, 133, 244, 0.08);
}
.selectedDay .dayNumber {
font-weight: 700;
}
.eventIndicators {
position: absolute;
bottom: 8px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.eventIndicator {
width: 80%;
height: 4px;
border-radius: 2px;
}
.moreEvents {
font-size: 10px;
color: #777;
margin-top: 2px;
}
/* Events list styles */
.eventsList {
padding: 16px;
border-left: 1px solid #f0f0f0;
flex: 1;
max-height: 460px;
overflow-y: auto;
@media (max-width: 767px) {
border-left: none;
border-top: 1px solid #f0f0f0;
}
}
.eventsListTitle {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
color: #333;
}
.eventsListEmpty {
padding: 20px;
text-align: center;
color: #777;
font-style: italic;
}
.eventsContainer {
display: flex;
flex-direction: column;
gap: 8px;
}
.event {
padding: 12px;
border-radius: 8px;
background-color: #f8f9fa;
border-left: 4px solid #4285f4;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.2s;
}
.event:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.eventTime {
font-size: 12px;
font-weight: 500;
color: #666;
margin-bottom: 4px;
}
.eventTitle {
font-size: 14px;
font-weight: 500;
color: #333;
}

View File

@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { Calendar, CalendarEvent } from './index';
import { useApi } from '../../../lib/api/useApi';
import { getEvents } from '../../../lib/api/endpoints';
export const CalendarDemo = () => {
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
const [fetchEvents, { data: events, loading, error }] = useApi(getEvents, []);
useEffect(() => {
fetchEvents();
}, [fetchEvents]);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
const calendarEvents: CalendarEvent[] = events ? events.map(event => ({
id: event.id,
title: event.title,
date: new Date(event.start),
color: event.allDay ? '#4285F4' : '#34A853'
})) : [];
console.log(calendarEvents);
const handleDateClick = (date: Date) => {
console.log('Date clicked:', date);
};
const handleEventClick = (event: CalendarEvent) => {
setSelectedEvent(event);
};
return (
<div className="calendarDemo">
<Calendar
events={calendarEvents}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
</div>
);
};

View File

@ -0,0 +1,58 @@
import { CalendarEvent } from './index';
import styles from './Calendar.module.css';
export type CalendarEventsListProps = {
events: CalendarEvent[];
date: Date;
onEventClick?: (event: CalendarEvent) => void;
};
export const CalendarEventsList = ({
events,
date,
onEventClick
}: CalendarEventsListProps) => {
if (events.length === 0) {
return (
<div className={styles.eventsListEmpty}>
<p>No events scheduled for this day</p>
</div>
);
}
const handleEventClick = (event: CalendarEvent) => {
if (onEventClick) {
onEventClick(event);
}
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<div className={styles.eventsList}>
<h3 className={styles.eventsListTitle}>
Events for {date.toLocaleDateString([], { month: 'long', day: 'numeric' })}
</h3>
<div className={styles.eventsContainer}>
{events.map(event => (
<div
key={event.id}
className={styles.event}
onClick={() => handleEventClick(event)}
style={{ borderLeftColor: event.color || '#4285f4' }}
>
<div className={styles.eventTime}>
{formatTime(event.date)}
</div>
<div className={styles.eventTitle}>
{event.title}
</div>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,97 @@
import { formatDate } from '../../../lib/date';
import { CalendarEvent } from './index';
import styles from './Calendar.module.css';
export type CalendarGridProps = {
daysInMonth: number[];
firstDay: number;
currentDate: Date;
selectedDate: Date | null;
events: CalendarEvent[];
onDateClick: (day: number) => void;
};
export const CalendarGrid = ({
daysInMonth,
firstDay,
currentDate,
selectedDate,
events,
onDateClick
}: CalendarGridProps) => {
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const today = new Date();
const currentMonth = currentDate.getMonth();
const currentYear = currentDate.getFullYear();
const isCurrentMonth =
today.getMonth() === currentMonth &&
today.getFullYear() === currentYear;
const todayDate = today.getDate();
const getEventsByDay = (day: number) => {
const date = new Date(currentYear, currentMonth, day);
const formattedDate = formatDate(date);
return events.filter(event => formatDate(event.date) === formattedDate);
};
const renderDay = (day: number) => {
const date = new Date(currentYear, currentMonth, day);
const isSelected = selectedDate && formatDate(date) === formatDate(selectedDate);
const isToday = isCurrentMonth && day === todayDate;
const dayEvents = getEventsByDay(day);
return (
<div
key={day}
className={`
${styles.day}
${isSelected ? styles.selectedDay : ''}
${isToday ? styles.today : ''}
`}
onClick={() => onDateClick(day)}
>
<span className={styles.dayNumber}>{day}</span>
{dayEvents.length > 0 && (
<div className={styles.eventIndicators}>
{dayEvents.slice(0, 3).map((event, index) => (
<div
key={event.id}
className={styles.eventIndicator}
style={{ backgroundColor: event.color || '#4285f4' }}
title={event.title}
/>
))}
{dayEvents.length > 3 && (
<div className={styles.moreEvents}>
+{dayEvents.length - 3}
</div>
)}
</div>
)}
</div>
);
};
const renderEmptyDay = (index: number) => (
<div key={`empty-${index}`} className={styles.emptyDay} />
);
return (
<div className={styles.calendarGrid}>
{weekDays.map(day => (
<div key={day} className={styles.weekDay}>
{day}
</div>
))}
{Array(firstDay).fill(null).map((_, index) => renderEmptyDay(index))}
{daysInMonth.map(day => renderDay(day))}
</div>
);
};

View File

@ -0,0 +1,47 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import styles from './Calendar.module.css';
export type CalendarHeaderProps = {
currentDate: Date;
onPrevMonth: () => void;
onNextMonth: () => void;
};
export const CalendarHeader = ({
currentDate,
onPrevMonth,
onNextMonth
}: CalendarHeaderProps) => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const month = monthNames[currentDate.getMonth()];
const year = currentDate.getFullYear();
return (
<div className={styles.header}>
<button
className={styles.navButton}
onClick={onPrevMonth}
aria-label="Previous month"
>
<FontAwesomeIcon icon={faChevronLeft} />
</button>
<h2 className={styles.headerTitle}>
{month} {year}
</h2>
<button
className={styles.navButton}
onClick={onNextMonth}
aria-label="Next month"
>
<FontAwesomeIcon icon={faChevronRight} />
</button>
</div>
);
};

View File

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { CalendarHeader } from './CalendarHeader';
import { CalendarGrid } from './CalendarGrid';
import { CalendarEventsList } from './CalendarEventsList';
import { getDaysInMonth, getFirstDayOfMonth, formatDate } from '../../../lib/date';
import styles from './Calendar.module.css';
export type CalendarEvent = {
id: string;
title: string;
date: Date;
color?: string;
};
export type CalendarProps = {
events?: CalendarEvent[];
onDateClick?: (date: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
initialDate?: Date;
};
export const Calendar = ({
events = [],
onDateClick,
onEventClick,
initialDate = new Date()
}: CalendarProps) => {
const [currentDate, setCurrentDate] = useState<Date>(initialDate);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [daysInMonth, setDaysInMonth] = useState<number[]>([]);
const [firstDay, setFirstDay] = useState<number>(0);
useEffect(() => {
const days = getDaysInMonth(currentDate.getFullYear(), currentDate.getMonth());
const firstDayOfMonth = getFirstDayOfMonth(currentDate.getFullYear(), currentDate.getMonth());
setDaysInMonth(Array.from({ length: days }, (_, i) => i + 1));
setFirstDay(firstDayOfMonth);
}, [currentDate]);
const handlePrevMonth = () => {
setCurrentDate(prevDate => {
const newDate = new Date(prevDate);
newDate.setMonth(newDate.getMonth() - 1);
return newDate;
});
};
const handleNextMonth = () => {
setCurrentDate(prevDate => {
const newDate = new Date(prevDate);
newDate.setMonth(newDate.getMonth() + 1);
return newDate;
});
};
const handleDateClick = (day: number) => {
const newDate = new Date(currentDate);
newDate.setDate(day);
setSelectedDate(newDate);
if (onDateClick) {
onDateClick(newDate);
}
};
const filteredEvents = selectedDate
? events.filter(event => formatDate(event.date) === formatDate(selectedDate))
: [];
return (
<div className={styles.calendar}>
<CalendarHeader
currentDate={currentDate}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
<div className={styles.calendarContainer}>
<CalendarGrid
daysInMonth={daysInMonth}
firstDay={firstDay}
currentDate={currentDate}
selectedDate={selectedDate}
events={events}
onDateClick={handleDateClick}
/>
{selectedDate && (
<CalendarEventsList
events={filteredEvents}
date={selectedDate}
onEventClick={onEventClick}
/>
)}
</div>
</div>
);
};

View File

@ -1,3 +1,13 @@
* {
box-sizing: border-box;
}
html, body {
width: 100%;
max-width: 100%;
overflow-x: hidden;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@ -4,13 +4,16 @@ import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './components/ui/FontAwesomeIcons';
import { UserProvider } from './lib/context';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
<UserProvider>
<App />
</UserProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,101 @@
export type ApiResponse<T> = {
data: T;
success: boolean;
error?: string;
};
export type RequestOptions = {
params?: Record<string, string | number | boolean>;
headers?: Record<string, string>;
};
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:9010';
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
export async function get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
const url = buildUrl(endpoint, options?.params);
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
return handleResponse<T>(response);
}
export async function post<T>(endpoint: string, data: unknown, options?: RequestOptions): Promise<T> {
const url = buildUrl(endpoint, options?.params);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options?.headers
},
body: JSON.stringify(data)
});
return handleResponse<T>(response);
}
export async function put<T>(endpoint: string, data: unknown, options?: RequestOptions): Promise<T> {
const url = buildUrl(endpoint, options?.params);
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...options?.headers
},
body: JSON.stringify(data)
});
return handleResponse<T>(response);
}
export async function del<T>(endpoint: string, options?: RequestOptions): Promise<T> {
const url = buildUrl(endpoint, options?.params);
const response = await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
return handleResponse<T>(response);
}
function buildUrl(endpoint: string, params?: Record<string, string | number | boolean>): string {
const url = new URL(`${API_BASE_URL}${endpoint}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
return url.toString();
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorText = await response.text();
throw new ApiError(errorText || `Request failed with status ${response.status}`, response.status);
}
try {
return await response.json() as T;
} catch (error) {
throw new ApiError('Failed to parse response', 500);
}
}

View File

@ -0,0 +1,65 @@
import { get, post, put, del } from './client';
// Define your data types here
export type User = {
id: number;
name: string;
email: string;
};
export type CreateUserRequest = {
name: string;
email: string;
password: string;
};
// User endpoints
export const getUser = () => get<User>('/api/user');
// Event types
export type Event = {
id: string;
title: string;
description: string;
start: string;
end: string;
allDay: boolean;
};
export type CreateEventRequest = {
title: string;
description?: string;
start: string;
end: string;
allDay?: boolean;
};
// Event endpoints
export const getEvents = () => get<Event[]>('/api/events');
export const getEvent = (id: string) => get<Event>(`/api/events/${id}`);
export const createEvent = (data: CreateEventRequest) => post<Event>('/api/events', data);
export const updateEvent = (id: string, data: Partial<CreateEventRequest>) => put<Event>(`/api/events/${id}`, data);
export const deleteEvent = (id: string) => del<void>(`/api/events/${id}`);
// Calendar types
export type Calendar = {
events: Event[];
};
// Calendar endpoints
export const getCalendar = (start?: string, end?: string) => {
const params = new URLSearchParams();
if (start) params.append('start', start);
if (end) params.append('end', end);
const queryString = params.toString();
return get<Calendar>(`/api/calendar${queryString ? `?${queryString}` : ''}`);
};
// Health check endpoint
export type PingResponse = {
status: string;
timestamp: number;
};
export const ping = () => get<PingResponse>('/api/ping');

View File

@ -0,0 +1,3 @@
export * from './client';
export * from './endpoints';
export * from './useApi';

View File

@ -0,0 +1,67 @@
import { useState, useCallback } from 'react';
import { ApiError } from './client';
export type ApiState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
export type ApiHook<T, P extends unknown[]> = [
(...args: P) => Promise<void>,
ApiState<T>
];
export function useApi<T, P extends unknown[]>(
apiFunction: (...args: P) => Promise<T>,
initialData: T | null = null
): ApiHook<T, P> {
const [state, setState] = useState<ApiState<T>>({
data: initialData,
loading: false,
error: null,
});
const execute = useCallback(
async (...args: P) => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const data = await apiFunction(...args);
setState({ data, loading: false, error: null });
} catch (err) {
const error = err instanceof ApiError
? err.message
: 'An unexpected error occurred';
console.error(err);
setState((prev) => ({ ...prev, loading: false, error }));
}
},
[apiFunction]
);
return [execute, state];
}
// Example usage:
//
// function UserList() {
// const [fetchUsers, { data: users, loading, error }] = useApi(getUsers, []);
//
// useEffect(() => {
// fetchUsers();
// }, [fetchUsers]);
//
// if (loading) return <div>Loading...</div>;
// if (error) return <div>Error: {error}</div>;
//
// return (
// <ul>
// {users?.map(user => (
// <li key={user.id}>{user.name}</li>
// ))}
// </ul>
// );
// }

View File

@ -0,0 +1,140 @@
import { CalendarEvent, RecurrenceRule } from '../models/CalendarEvent';
export class CalendarService {
static getEventsForMonth(events: CalendarEvent[], year: number, month: number): CalendarEvent[] {
const startOfMonth = new Date(year, month, 1);
const endOfMonth = new Date(year, month + 1, 0);
return events.filter(event =>
(event.start <= endOfMonth && event.end >= startOfMonth) ||
this.isRecurringInRange(event, startOfMonth, endOfMonth)
);
}
static getEventsForDay(events: CalendarEvent[], date: Date): CalendarEvent[] {
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59);
return events.filter(event =>
(event.start <= dayEnd && event.end >= dayStart) ||
this.isRecurringInRange(event, dayStart, dayEnd)
);
}
static getEventsForWeek(events: CalendarEvent[], date: Date): CalendarEvent[] {
const dayOfWeek = date.getDay();
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - dayOfWeek);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return events.filter(event =>
(event.start <= weekEnd && event.end >= weekStart) ||
this.isRecurringInRange(event, weekStart, weekEnd)
);
}
static isRecurringInRange(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
if (!event.recurrence) {
return false;
}
if (event.recurrence.until && event.recurrence.until < rangeStart) {
return false;
}
// Simplified recurrence check - a complete implementation would be more complex
switch (event.recurrence.frequency) {
case 'daily':
return this.checkDailyRecurrence(event, rangeStart, rangeEnd);
case 'weekly':
return this.checkWeeklyRecurrence(event, rangeStart, rangeEnd);
case 'monthly':
return this.checkMonthlyRecurrence(event, rangeStart, rangeEnd);
case 'yearly':
return this.checkYearlyRecurrence(event, rangeStart, rangeEnd);
default:
return false;
}
}
private static checkDailyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
const interval = event.recurrence?.interval || 1;
const eventDuration = event.end.getTime() - event.start.getTime();
// Check if any occurrence falls within range
let currentDate = new Date(event.start);
const rangeEndTime = rangeEnd.getTime();
while (currentDate.getTime() <= rangeEndTime) {
if (currentDate.getTime() + eventDuration >= rangeStart.getTime()) {
return true;
}
currentDate.setDate(currentDate.getDate() + interval);
}
return false;
}
private static checkWeeklyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
const interval = event.recurrence?.interval || 1;
const eventDuration = event.end.getTime() - event.start.getTime();
// Check if any occurrence falls within range
let currentDate = new Date(event.start);
const rangeEndTime = rangeEnd.getTime();
while (currentDate.getTime() <= rangeEndTime) {
if (currentDate.getTime() + eventDuration >= rangeStart.getTime()) {
return true;
}
currentDate.setDate(currentDate.getDate() + (interval * 7));
}
return false;
}
private static checkMonthlyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
const interval = event.recurrence?.interval || 1;
const eventDay = event.start.getDate();
// Simple check - a complete implementation would handle more complex patterns
for (let year = rangeStart.getFullYear(); year <= rangeEnd.getFullYear(); year++) {
const startMonth = year === rangeStart.getFullYear() ? rangeStart.getMonth() : 0;
const endMonth = year === rangeEnd.getFullYear() ? rangeEnd.getMonth() : 11;
for (let month = startMonth; month <= endMonth; month++) {
if ((month - event.start.getMonth()) % interval === 0) {
const checkDate = new Date(year, month, eventDay);
if (checkDate >= rangeStart && checkDate <= rangeEnd) {
return true;
}
}
}
}
return false;
}
private static checkYearlyRecurrence(event: CalendarEvent, rangeStart: Date, rangeEnd: Date): boolean {
const interval = event.recurrence?.interval || 1;
const eventMonth = event.start.getMonth();
const eventDay = event.start.getDate();
for (let year = rangeStart.getFullYear(); year <= rangeEnd.getFullYear(); year++) {
if ((year - event.start.getFullYear()) % interval === 0) {
const checkDate = new Date(year, eventMonth, eventDay);
if (checkDate >= rangeStart && checkDate <= rangeEnd) {
return true;
}
}
}
return false;
}
}

View File

@ -0,0 +1,57 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, getUser } from '../api/endpoints';
type UserContextType = {
user: User | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
};
const UserContext = createContext<UserContextType | undefined>(undefined);
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 fetchUser = async () => {
try {
setLoading(true);
setError(null);
const userData = await getUser();
setUser(userData);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch user'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUser();
}, []);
return (
<UserContext.Provider
value={{
user,
loading,
error,
refetch: fetchUser
}}
>
{children}
</UserContext.Provider>
);
};
export const useUser = (): UserContextType => {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
};

View File

@ -0,0 +1 @@
export * from './UserContext';

69
frontend/src/lib/date.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* Get the number of days in a month
*/
export const getDaysInMonth = (year: number, month: number): number => {
return new Date(year, month + 1, 0).getDate();
};
/**
* Get the day of the week for the first day of a month (0 = Sunday, 6 = Saturday)
*/
export const getFirstDayOfMonth = (year: number, month: number): number => {
return new Date(year, month, 1).getDay();
};
/**
* Format a date as YYYY-MM-DD for comparison
*/
export const formatDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Get an array of dates for the current week containing the given date
*/
export const getDatesForWeek = (date: Date): Date[] => {
const day = date.getDay();
const diff = date.getDate() - day;
return Array(7)
.fill(null)
.map((_, index) => {
const newDate = new Date(date);
newDate.setDate(diff + index);
return newDate;
});
};
/**
* Get the list of dates for a given month
*/
export const getDatesForMonth = (year: number, month: number): Date[] => {
const daysInMonth = getDaysInMonth(year, month);
return Array(daysInMonth)
.fill(null)
.map((_, index) => new Date(year, month, index + 1));
};
/**
* Check if two dates are the same day
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};
/**
* Check if a date is today
*/
export const isToday = (date: Date): boolean => {
return isSameDay(date, new Date());
};

11
frontend/src/lib/index.ts Normal file
View File

@ -0,0 +1,11 @@
// Models
export * from './models/CalendarEvent';
// Calendar utilities
export * from './calendar/CalendarService';
// Date utilities
export * from './utils/DateUtils';
// API client
export * as api from './api';

View File

@ -0,0 +1,37 @@
export interface CalendarEvent {
id: string;
title: string;
description?: string;
start: Date;
end: Date;
allDay?: boolean;
color?: string;
location?: string;
organizer?: string;
attendees?: Attendee[];
recurrence?: RecurrenceRule;
}
export interface Attendee {
id: string;
name: string;
email: string;
status: 'accepted' | 'declined' | 'tentative' | 'pending';
}
export interface RecurrenceRule {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
interval?: number;
count?: number;
until?: Date;
byDay?: string[];
byMonth?: number[];
byMonthDay?: number[];
exceptions?: Date[];
}
export enum EventStatus {
CONFIRMED = 'confirmed',
TENTATIVE = 'tentative',
CANCELLED = 'cancelled'
}

View File

@ -0,0 +1,89 @@
export class DateUtils {
static getWeekNumber(date: Date): number {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
static getMonthName(month: number): string {
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return months[month];
}
static getShortMonthName(month: number): string {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return months[month];
}
static getDayName(day: number): string {
const days = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday',
'Thursday', 'Friday', 'Saturday'
];
return days[day];
}
static getShortDayName(day: number): string {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return days[day];
}
static areDatesEqual(date1: Date, date2: Date): boolean {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
}
static areDateTimesEqual(date1: Date, date2: Date): boolean {
return date1.getTime() === date2.getTime();
}
static formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
const year = date.getFullYear().toString();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds);
}
static getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
static getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
static addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
static addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
static addYears(date: Date, years: number): Date {
const result = new Date(date);
result.setFullYear(result.getFullYear() + years);
return result;
}
}

View File

@ -0,0 +1,162 @@
.home-container {
display: flex;
flex-direction: column;
padding: 1.25rem;
background-color: #ffffff;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.greeting {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1.875rem;
color: #000000;
}
.events-section {
margin-bottom: 2.5rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: #000000;
}
.events-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tomorrow-events {
display: flex;
flex-direction: row;
gap: 1rem;
padding-bottom: 0.5rem;
width: 100%;
flex-wrap: wrap;
}
.tomorrow-events .event-item {
min-width: 200px;
width: 250px;
flex-shrink: 0;
max-width: 100%;
}
.week-events {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
width: 100%;
}
.event-item {
background-color: #ec6a5e;
border-radius: 1rem;
padding: 1.125rem 1.25rem;
color: #ffffff;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(236, 106, 94, 0.3);
display: flex;
flex-direction: column;
min-height: 80px;
}
.event-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(236, 106, 94, 0.4);
}
.event-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
word-break: break-word;
}
.event-time {
font-size: 0.875rem;
opacity: 0.9;
}
.no-events {
color: #888;
font-style: italic;
padding: 1rem 0;
}
.loading, .error {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 1.125rem;
color: #555;
}
.error {
color: #ec6a5e;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.greeting {
font-size: 1.75rem;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 1.25rem;
}
.events-section {
margin-bottom: 2rem;
width: 100%;
max-width: 100%;
}
.tomorrow-events {
flex-direction: column;
}
.tomorrow-events .event-item {
width: 100%;
min-width: auto;
}
}
@media (max-width: 480px) {
.home-container {
padding: 1rem;
}
.greeting {
font-size: 1.5rem;
margin-bottom: 1.25rem;
}
.tomorrow-events .event-item {
width: 100%;
}
.week-events {
grid-template-columns: 1fr;
}
}
/* Ensure scrolling works properly within the TabView */
@media (max-height: 700px) {
.home-container {
padding-bottom: 6.25rem;
}
}

View File

@ -1,53 +1,126 @@
import React from 'react';
import { FontAwesomeIcon } from '../../components/ui/FontAwesomeIcons';
import React, { useEffect, useState } from 'react';
import { getEvents, Event } from '../../lib/api/endpoints';
import './Home.css';
const Home: React.FC = () => {
const [todayEvents, setTodayEvents] = useState<Event[]>([]);
const [tomorrowEvents, setTomorrowEvents] = useState<Event[]>([]);
const [weekEvents, setWeekEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userName, setUserName] = useState('John');
useEffect(() => {
const fetchEvents = async () => {
try {
setLoading(true);
const response = await getEvents();
// Get today, tomorrow, and this week's date ranges
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const weekStart = new Date(today);
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
// Filter events for each time period
const todayEvts = response.filter(event => {
const eventDate = new Date(event.start);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === today.getTime();
});
const tomorrowEvts = response.filter(event => {
const eventDate = new Date(event.start);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === tomorrow.getTime();
});
const weekEvts = response.filter(event => {
const eventDate = new Date(event.start);
eventDate.setHours(0, 0, 0, 0);
return eventDate > today && eventDate <= weekEnd &&
eventDate.getTime() !== today.getTime() &&
eventDate.getTime() !== tomorrow.getTime();
});
setTodayEvents(todayEvts);
setTomorrowEvents(tomorrowEvts);
setWeekEvents(weekEvts);
setLoading(false);
} catch (err) {
setError('Failed to load events');
setLoading(false);
}
};
fetchEvents();
}, []);
const EventItem = ({ event }: { event: Event }) => (
<div className="event-item">
<div className="event-title">{event.title}</div>
<div className="event-time">
{new Date(event.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{!event.allDay && event.end && ` - ${new Date(event.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
{event.allDay && ' (All day)'}
</div>
</div>
);
if (loading) {
return <div className="loading">Loading events...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
return (
<div className="home-container">
<header className="home-header">
<h1>Welcome to Our Application</h1>
<p>Your centralized dashboard for all features</p>
</header>
<h1 className="greeting">Hallo {userName}!</h1>
<section className="features-section">
<div className="feature-card">
<div className="feature-icon">
<FontAwesomeIcon icon={['fas', 'plus']} />
</div>
<h2>Create New</h2>
<p>Start a new project or task</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<FontAwesomeIcon icon={['far', 'calendar']} />
</div>
<h2>Schedule</h2>
<p>Manage your upcoming events</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<FontAwesomeIcon icon={['fas', 'edit']} />
</div>
<h2>Edit</h2>
<p>Modify your existing content</p>
<section className="events-section">
<h2 className="section-title">Heute</h2>
<div className="events-list">
{todayEvents.length > 0 ? (
todayEvents.map(event => (
<EventItem key={event.id} event={event} />
))
) : (
<div className="no-events">Keine Termine für heute</div>
)}
</div>
</section>
<section className="quick-links">
<h2>Quick Links</h2>
<ul>
<li>
<a href="#dashboard">Dashboard</a>
</li>
<li>
<a href="#profile">Profile Settings</a>
</li>
<li>
<a href="#help">Help & Support</a>
</li>
</ul>
<section className="events-section">
<h2 className="section-title">Morgen</h2>
<div className="events-list tomorrow-events">
{tomorrowEvents.length > 0 ? (
tomorrowEvents.map(event => (
<EventItem key={event.id} event={event} />
))
) : (
<div className="no-events">Keine Termine für morgen</div>
)}
</div>
</section>
<section className="events-section">
<h2 className="section-title">Diese Woche</h2>
<div className="events-list week-events">
{weekEvents.length > 0 ? (
weekEvents.map(event => (
<EventItem key={event.id} event={event} />
))
) : (
<div className="no-events">Keine Termine für diese Woche</div>
)}
</div>
</section>
</div>
);

View File

@ -0,0 +1,41 @@
import { useUser } from '../../lib/context';
const Profile = () => {
const { user, loading, error, refetch } = useUser();
if (loading) {
return <div>Loading user data...</div>;
}
if (error) {
return (
<div>
<p>Error loading user data: {error.message}</p>
<button onClick={refetch}>Try Again</button>
</div>
);
}
if (!user) {
return (
<div>
<p>No user data available</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}
return (
<div>
<h1>Profile</h1>
<div>
<strong>Name:</strong> {user.name}
</div>
<div>
<strong>Email:</strong> {user.email}
</div>
</div>
);
};
export default Profile;