Compare commits

..

No commits in common. "926a6d7bc12066a4d3e075cac6aeb3d728570771" and "cad4564a084e98e612d05a14c0363339eb56dcdb" have entirely different histories.

83 changed files with 11 additions and 27624 deletions

View File

@ -1,14 +0,0 @@
---
description:
globs:
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
# 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

@ -1,8 +0,0 @@
---
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

View File

@ -1,7 +1,4 @@
You are an expert React and PHP Symfony developer. You are an expert React and PHP Symfony developer.
You will build very clean and abstract code to provide a clean and extendable codebase. You will build very clean and abstract code to provide a clean and extendable codebase.
You are using PHP 8.4 with the latest symfony in the backend directory. You are using PHP 8.4 with the latest symfony in the backend directory.
You are using the latest React version in the frontend directory You are using the latest React version in the frontend directory
You will never use react native components
You will rarely install new packages

3
.gitignore vendored
View File

@ -1,2 +1 @@
var var
node_modules

View File

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

View File

@ -18,13 +18,3 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8 APP_SECRET=71bf50bfb778d456b3a376ff60d5dcd8
###< symfony/framework-bundle ### ###< 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 ###

0
backend/.env.dev Normal file
View File

4
backend/.gitignore vendored
View File

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

View File

@ -1,38 +0,0 @@
# 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,25 +7,14 @@
"php": ">=8.1", "php": ">=8.1",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "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", "nelmio/api-doc-bundle": "^5.0",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"symfony/asset": "6.4.*", "symfony/asset": "6.4.*",
"symfony/console": "6.4.*", "symfony/console": "6.4.*",
"symfony/dotenv": "6.4.*", "symfony/dotenv": "6.4.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/twig-bundle": "6.4.*", "symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/yaml": "6.4.*" "symfony/yaml": "6.4.*"
}, },
"config": { "config": {
@ -65,9 +54,7 @@
], ],
"post-update-cmd": [ "post-update-cmd": [
"@auto-scripts" "@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": { "conflict": {
"symfony/symfony": "*" "symfony/symfony": "*"
@ -77,11 +64,5 @@
"allow-contrib": false, "allow-contrib": false,
"require": "6.4.*" "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,8 +4,4 @@ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::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

@ -1,51 +0,0 @@
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

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

View File

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

View File

@ -1,13 +0,0 @@
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: controllers:
resource: resource:
path: ../src/Application/Controller/ path: ../src/Controller/
namespace: App\Application\Controller namespace: App\Controller
type: attribute type: attribute

View File

@ -1,10 +0,0 @@
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

@ -1,53 +0,0 @@
<?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

@ -1,243 +0,0 @@
<?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,41 +0,0 @@
<?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

@ -1,28 +0,0 @@
<?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

@ -1,40 +0,0 @@
<?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

@ -1,37 +0,0 @@
<?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

@ -1,6 +1,6 @@
<?php <?php
namespace App\Application\Controller; namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;

View File

@ -1,131 +0,0 @@
<?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

@ -1,81 +0,0 @@
<?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

@ -1,26 +0,0 @@
<?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

@ -1,266 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?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

@ -1,37 +0,0 @@
<?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

@ -1,30 +0,0 @@
<?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,52 +1,4 @@
{ {
"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": { "nelmio/api-doc-bundle": {
"version": "5.0", "version": "5.0",
"recipe": { "recipe": {
@ -56,18 +8,6 @@
"ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94" "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": { "symfony/console": {
"version": "6.4", "version": "6.4",
"recipe": { "recipe": {
@ -112,15 +52,6 @@
"src/Kernel.php" "src/Kernel.php"
] ]
}, },
"symfony/maker-bundle": {
"version": "1.62",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/routing": { "symfony/routing": {
"version": "6.4", "version": "6.4",
"recipe": { "recipe": {
@ -146,29 +77,5 @@
"config/packages/twig.yaml", "config/packages/twig.yaml",
"templates/base.html.twig" "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,8 +29,7 @@ RUN apt-get update && apt-get install -y \
php8.4-bcmath \ php8.4-bcmath \
php8.4-intl \ php8.4-intl \
php8.4-gd \ php8.4-gd \
php8.4-fpm \ php8.4-fpm
php8.4-pgsql
# Install Composer # Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

View File

@ -5,7 +5,7 @@ set -e
service php8.4-fpm start service php8.4-fpm start
# Change to frontend directory and start npm in background # Change to frontend directory and start npm in background
cd /var/www/html/frontend && npm run start & cd /var/www/html/frontend && npm start &
# Start Nginx in foreground to keep container running # Start Nginx in foreground to keep container running
exec nginx -g 'daemon off;' exec nginx -g 'daemon off;'

1
frontend Submodule

@ -0,0 +1 @@
Subproject commit 86e8a69d32ddae9135ab05b253782ae644720eb2

23
frontend/.gitignore vendored
View File

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -1,46 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

17644
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +0,0 @@
{
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "export PORT=9010 && react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -1,41 +0,0 @@
.App {
width: 100%;
max-width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,24 +0,0 @@
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: Profile, icon: faUser }
];
function App() {
return (
<div className="App">
<TabView
tabs={tabs}
/>
</div>
);
}
export default App;

View File

@ -1,78 +0,0 @@
.tab-container {
display: flex;
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 {
display: flex;
background-color: #ffffff;
border-top: 1px solid #e2e8f0;
margin-top: auto;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
border-radius: 16px 16px 0 0;
padding: 5px 10px;
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 0;
background: none;
border: none;
cursor: pointer;
color: #64748b;
transition: all 0.3s ease;
border-radius: 12px;
margin: 0 4px;
}
.tab-bar-item:hover {
color: #334155;
background-color: #f1f5f9;
}
.tab-bar-item.active {
color: #3182ce;
background-color: #ebf8ff;
box-shadow: 0 2px 6px rgba(49, 130, 206, 0.15);
transform: translateY(-2px);
}
.tab-icon {
font-size: 22px;
margin-bottom: 6px;
}
.tab-label {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.tab-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
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

@ -1,74 +0,0 @@
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import './TabView.css';
interface TabBarProps {
tabs: {
id: string;
label: string;
icon?: React.ReactNode | IconDefinition;
component: React.ComponentType<any>;
}[];
activeTab?: string;
onTabChange?: (tabId: string) => void;
}
export const TabView: React.FC<TabBarProps> = ({
tabs,
activeTab,
onTabChange
}) => {
const [active, setActive] = useState(activeTab || tabs[0]?.id || '');
const [ActiveComponent, setActiveComponent] = useState<React.ComponentType<any> | null>(null);
useEffect(() => {
if (activeTab && activeTab !== active) {
setActive(activeTab);
}
}, [activeTab, active]);
useEffect(() => {
const activeTabData = tabs.find(tab => tab.id === active);
if (activeTabData) {
setActiveComponent(() => activeTabData.component);
}
}, [active, tabs]);
const handleTabClick = (tabId: string) => {
setActive(tabId);
onTabChange?.(tabId);
};
return (
<div className="tab-container">
<div className="tab-content">
{ActiveComponent && <ActiveComponent />}
</div>
<div className="tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-bar-item ${active === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
aria-selected={active === tab.id}
role="tab"
>
{tab.icon && (
<span className="tab-icon">
{React.isValidElement(tab.icon) ? (
tab.icon
) : (
<FontAwesomeIcon icon={tab.icon as IconDefinition} />
)}
</span>
)}
<span className="tab-label">{tab.label}</span>
</button>
))}
</div>
</div>
);
};
export default TabView;

View File

@ -1 +0,0 @@
export { default as TabView } from './TabView';

View File

@ -1,200 +0,0 @@
.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

@ -1,49 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,97 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,99 +0,0 @@
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,52 +0,0 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import {
faCheck,
faTimes,
faSpinner,
faEdit,
faTrash,
faPlus
} from '@fortawesome/free-solid-svg-icons';
import {
faCalendar,
faCircle
} from '@fortawesome/free-regular-svg-icons';
import {
faGithub,
faTwitter
} from '@fortawesome/free-brands-svg-icons';
import { IconName, IconPrefix } from '@fortawesome/fontawesome-svg-core';
// Add icons to library
library.add(
faCheck,
faTimes,
faSpinner,
faEdit,
faTrash,
faPlus,
faCalendar,
faCircle,
faGithub,
faTwitter
);
export { FontAwesomeIcon };
export const iconNames: Record<string, [IconPrefix, IconName]> = {
// Solid
check: ['fas', 'check'],
times: ['fas', 'times'],
spinner: ['fas', 'spinner'],
edit: ['fas', 'edit'],
trash: ['fas', 'trash'],
plus: ['fas', 'plus'],
// Regular
calendar: ['far', 'calendar'],
circle: ['far', 'circle'],
// Brands
github: ['fab', 'github'],
twitter: ['fab', 'twitter']
};

View File

@ -1,23 +0,0 @@
* {
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',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -1,23 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
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>
<UserProvider>
<App />
</UserProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@ -1,101 +0,0 @@
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

@ -1,65 +0,0 @@
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

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

View File

@ -1,67 +0,0 @@
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

@ -1,140 +0,0 @@
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

@ -1,57 +0,0 @@
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

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

View File

@ -1,69 +0,0 @@
/**
* 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());
};

View File

@ -1,11 +0,0 @@
// 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

@ -1,37 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,162 +0,0 @@
.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,129 +0,0 @@
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">
<h1 className="greeting">Hallo {userName}!</h1>
<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="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>
);
};
export default Home;

View File

@ -1,41 +0,0 @@
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;

View File

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

4281
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +0,0 @@
{
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6"
}
}