Use postgresql and improved visuals

This commit is contained in:
Tim Lappe 2025-05-30 07:04:14 +02:00
parent 3160d60eaf
commit 65ef2ed89c
67 changed files with 3055 additions and 699 deletions

28
.cursor/rules/project.mdc Normal file
View File

@ -0,0 +1,28 @@
---
description:
globs:
alwaysApply: true
---
# EV Wiki
The EV Wiki project is a website for searching, finding and comparison of ev vehicles.
It works like a search engine and aggregates the data from the database to show the user the requested information in a tile based view.
## Structue
The project is structured in domain driven design manner. Inside the src-Folder, you find:
- Application (Controllers, Commands and other symfony related application stuff)
- Domain: The core business logic of the project. This is 100% framework agnostic code
- Infrastructure: Implementations of Interfaces provided by the Domain layer.
The dependency is going from Application -> Domain -> Infrastrcture, but not in the opposite direction.
The Domain does not know about the existence of the application folder, and also the infrastructure doesn't know anything about the Domain and Application folder.
## Concepts
### Tile based results
One core concept of the search results in the ev wiki project is the tile based search result.
The Tile Classes e.g. are holding view data for specific tile types. These tiles are displayed by custom twig template, one template for one tile type.
The Tiles were aggregated and built by the Engine class that is responsible for building the tile view.
### Project dependencies
We will barely use external dependencies. Some depencies are explicitly avoided:
- No ORM: The project follows the philosophy, that orms destroy the needed abstraction between model (Domain) and database layer (Infrastrcture).
Since SQL is already a human readable language, there is no need to introduce heavy coupling between the model and the database layer.

5
.gitignore vendored
View File

@ -49,3 +49,8 @@ next-env.d.ts
/var/
/vendor/
###< symfony/framework-bundle ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###

View File

@ -2,7 +2,7 @@ FROM php:8.4-fpm-alpine
WORKDIR /app
# Install system dependencies including zsh
# Install system dependencies
RUN apk add --no-cache \
git \
curl \
@ -14,14 +14,15 @@ RUN apk add --no-cache \
autoconf \
g++ \
make \
wget
wget \
postgresql-dev \
zsh
# Install Oh My Zsh
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_mysql
# Install MongoDB PHP extension
RUN pecl install mongodb \
&& docker-php-ext-enable mongodb
RUN docker-php-ext-install pdo pdo_pgsql pgsql
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

117
README.md
View File

@ -8,22 +8,22 @@ A modern Symfony application for browsing and searching electric vehicle informa
- **Brand Directory**: Browse popular electric vehicle manufacturers
- **Vehicle Database**: Comprehensive information about electric car models and revisions
- **RESTful API**: JSON API endpoints for integration
- **MongoDB Integration**: NoSQL database for flexible data storage
- **PostgreSQL Integration**: Relational database for structured data storage
- **Responsive Design**: Mobile-friendly interface without external CSS frameworks
## Technology Stack
- **Backend**: Symfony 7.2 (PHP 8.2+)
- **Database**: MongoDB with Doctrine ODM
- **Backend**: Symfony 7.2 (PHP 8.3+)
- **Database**: PostgreSQL with native PDO
- **Frontend**: Vanilla JavaScript with modern CSS
- **Architecture**: Clean Architecture with SOLID principles
## Requirements
- PHP 8.2 or higher
- MongoDB 4.4 or higher
- PHP 8.3 or higher
- PostgreSQL 13 or higher
- Composer
- MongoDB PHP Extension
- PostgreSQL PHP Extension (pdo_pgsql)
## Installation
@ -43,19 +43,21 @@ A modern Symfony application for browsing and searching electric vehicle informa
cp .env.local.example .env.local
```
Edit `.env.local` and set your MongoDB connection:
Edit `.env.local` and set your PostgreSQL connection:
```
APP_ENV=dev
APP_SECRET=your-secret-key-here
MONGODB_URI=mongodb://localhost:27017
DATABASE_DSN="pgsql:host=localhost;port=5432;dbname=evwiki"
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
```
4. **Start MongoDB**
Make sure MongoDB is running on your system.
4. **Start PostgreSQL**
Make sure PostgreSQL is running on your system.
5. **Seed the database**
5. **Initialize the database**
```bash
php bin/console app:seed-data
php bin/console app:database:init
```
6. **Start the development server**
@ -68,26 +70,48 @@ A modern Symfony application for browsing and searching electric vehicle informa
php -S localhost:8000 -t public/
```
## Docker Setup
You can also run the application using Docker:
1. **Start the services**
```bash
docker-compose up -d
```
2. **Initialize the database in the container**
```bash
docker-compose exec app php bin/console app:database:init
```
## Project Structure
```
src/
├── Command/ # Console commands
├── Controller/ # HTTP controllers
├── Document/ # MongoDB document models
├── Repository/ # Data access layer
├── Service/ # Business logic layer
└── Kernel.php # Application kernel
├── Command/ # Console commands
├── Application/
│ └── Controller/ # HTTP controllers
├── Domain/
│ ├── Model/ # Domain models
│ └── Repository/ # Repository interfaces
├── Infrastructure/
│ └── PostgreSQL/ # PostgreSQL implementation
│ ├── PostgreSQLClient.php
│ └── Repository/ # Concrete repositories
└── Kernel.php # Application kernel
templates/
├── base.html.twig # Base template
└── home/ # Home page templates
├── base.html.twig # Base template
└── home/ # Home page templates
database/
└── schema.sql # PostgreSQL schema
config/
├── bundles.php # Bundle configuration
├── packages/ # Package configurations
├── routes.yaml # Route definitions
└── services.yaml # Service container
├── bundles.php # Bundle configuration
├── packages/ # Package configurations
├── routes.yaml # Route definitions
└── services.yaml # Service container
```
## API Endpoints
@ -107,50 +131,63 @@ config/
This application follows clean architecture principles:
### Domain Layer
- **Documents**: MongoDB document models (`Brand`, `CarModel`, `CarRevision`)
- **Models**: Core domain models (`Brand`, `CarModel`, `CarRevision`)
- **Repositories**: Data access interfaces
### Application Layer
- **Services**: Business logic (`CarSearchService`)
- **Controllers**: HTTP request handlers
- **Commands**: Console commands for data management
### Infrastructure Layer
- **Controllers**: HTTP request handlers
- **PostgreSQL**: Database implementation with native PDO
- **Templates**: Twig templates for rendering
### Key Design Principles
1. **SOLID Principles**: Each class has a single responsibility
2. **Dependency Injection**: All dependencies are injected via constructor
3. **No Else Statements**: Code uses early returns for better readability
4. **Readable Names**: Self-documenting code with descriptive names
3. **Clean Architecture**: Domain logic is independent of infrastructure
4. **Readable Code**: Self-documenting code with descriptive names
## Development
## Database Commands
### Adding New Data
Use the seed command to populate the database:
### Initialize Database
Initialize the database with schema and sample data:
```bash
php bin/console app:seed-data
php bin/console app:database:init
```
### Console Commands
List all available commands:
```bash
php bin/console list
```
### Database Operations
## Database Schema
The application uses MongoDB with Doctrine ODM. All database operations are handled through repositories following the Repository pattern.
The application uses PostgreSQL with the following main tables:
- **brands**: Electric vehicle manufacturers
- **car_models**: Vehicle models belonging to brands
- **car_revisions**: Specific revisions of car models with detailed specifications
All database operations are handled through repositories following the Repository pattern, using native PDO for optimal performance.
## Styling Guidelines
- **No External CSS Frameworks**: Pure CSS following modern standards
- **Responsive Design**: Mobile-first approach
- **Modern UI**: Clean, minimalist design inspired by search engines
- **Accessibility**: Semantic HTML and proper contrast ratios
- **Mobile-First**: Responsive design approach
- **Clean Design**: Minimalist interface focusing on content
## Environment Variables
Required environment variables:
- `APP_ENV`: Application environment (dev/prod)
- `APP_SECRET`: Secret key for Symfony
- `DATABASE_DSN`: PostgreSQL DSN connection string
- `DATABASE_USER`: PostgreSQL username
- `DATABASE_PASSWORD`: PostgreSQL password
## Contributing

17
assets/app.js Normal file
View File

@ -0,0 +1,17 @@
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
// Import Font Awesome CSS
import '@fortawesome/fontawesome-free/css/fontawesome.min.css';
import '@fortawesome/fontawesome-free/css/solid.min.css';
import '@fortawesome/fontawesome-free/css/brands.min.css';
import '@fortawesome/fontawesome-free/css/regular.min.css';
// Import Font Awesome JavaScript (optional - for advanced features)
import '@fortawesome/fontawesome-free';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

3
bin/docker Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
docker compose exec app /bin/zsh

View File

@ -9,7 +9,10 @@
"php": "^8.3",
"ext-ctype": "*",
"ext-iconv": "*",
"mongodb/mongodb": "*",
"ext-pdo": "*",
"ext-pgsql": "*",
"doctrine/doctrine-migrations-bundle": "^3.4",
"symfony/asset-mapper": "^7.3",
"symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
@ -46,7 +49,8 @@
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
@ -55,4 +59,4 @@
"@auto-scripts"
]
}
}
}

1634
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,4 +5,6 @@ return [
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
];

View File

@ -0,0 +1,11 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn

View File

@ -0,0 +1,24 @@
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
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
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

@ -9,10 +9,5 @@ services:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Document/'
- '../src/Kernel.php'
App\Infrastructure\MongoDB\MongoDBClient:
arguments:
$dsl: '%env(MONGODB_DSL)%'
$databaseName: '%env(MONGODB_DATABASE)%'
- '../src/Entity/'
- '../src/Kernel.php'

View File

@ -1,5 +1,3 @@
version: '3'
services:
app:
build:
@ -9,9 +7,7 @@ services:
volumes:
- .:/app
depends_on:
- mongodb
environment:
- MONGODB_URI=mongodb://mongodb:27017/evwiki
- database
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.entrypoints=web"
@ -20,41 +16,44 @@ services:
networks:
- proxy
mongodb:
image: mongo:latest
hostname: mongodb.evwiki.test
volumes:
- mongodb_data:/data/db
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.mongodb.entrypoints=mongodb"
- "traefik.http.routers.mongodb.rule=Host(`mongodb.evwiki.test`)"
- "traefik.http.services.mongodb.loadbalancer.server.port=27017"
mongo-express:
image: mongo-express:latest
hostname: mongo-express.evwiki.test
depends_on:
- mongodb
database:
image: postgres:17-alpine
hostname: database.evwiki.test
environment:
- ME_CONFIG_MONGODB_SERVER=mongodb
- ME_CONFIG_MONGODB_PORT=27017
- ME_CONFIG_MONGODB_ENABLE_ADMIN=true
- ME_CONFIG_BASICAUTH_USERNAME=admin
- ME_CONFIG_BASICAUTH_PASSWORD=pass
networks:
- proxy
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=evwiki
volumes:
- postgres_evwiki:/var/lib/postgresql/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.mongo-express.entrypoints=web"
- "traefik.http.routers.mongo-express.rule=Host(`mongo-express.evwiki.test`)"
- "traefik.http.services.mongo-express.loadbalancer.server.port=8081"
- "traefik.tcp.routers.database.entrypoints=postgres"
- "traefik.tcp.routers.database.rule=HostSNI(`database.evwiki.test`)"
- "traefik.tcp.routers.database.tls=true"
- "traefik.tcp.routers.database.tls.passthrough=true"
- "traefik.tcp.services.database.loadbalancer.server.port=5432"
networks:
- proxy
adminer:
image: adminer
hostname: adminer.evwiki.test
environment:
- ADMINER_DEFAULT_SERVER=database.evwiki.test
- ADMINER_DEFAULT_DB=evwiki
- ADMINER_DESIGN=darkly
- ADMINER_USER=postgres_evwiki
- ADMINER_PASSWORD=postgres_evwiki
labels:
- "traefik.enable=true"
- "traefik.http.routers.adminer.rule=Host(`adminer.evwiki.test`)"
- "traefik.http.services.adminer.loadbalancer.server.port=8080"
networks:
- proxy
networks:
proxy:
external: true
volumes:
mongodb_data:
postgres_evwiki:

38
importmap.php Normal file
View File

@ -0,0 +1,38 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@fortawesome/fontawesome-free' => [
'version' => '6.7.2',
],
'@fortawesome/fontawesome-free/css/fontawesome.min.css' => [
'version' => '6.7.2',
'type' => 'css',
],
'@fortawesome/fontawesome-free/css/solid.min.css' => [
'version' => '6.7.2',
'type' => 'css',
],
'@fortawesome/fontawesome-free/css/brands.min.css' => [
'version' => '6.7.2',
'type' => 'css',
],
'@fortawesome/fontawesome-free/css/regular.min.css' => [
'version' => '6.7.2',
'type' => 'css',
],
];

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250529155930 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create base tables';
}
public function up(Schema $schema): void
{
$this->addSql(<<<SQL
CREATE TABLE brands (
id VARCHAR(255) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL
)
SQL);
$this->addSql(<<<SQL
CREATE TABLE car_models (
id VARCHAR(255) NOT NULL PRIMARY KEY,
brand_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL,
FOREIGN KEY (brand_id) REFERENCES brands(id)
)
SQL);
$this->addSql(<<<SQL
CREATE TABLE car_revisions (
id VARCHAR(255) NOT NULL PRIMARY KEY,
car_model_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
content JSON NOT NULL,
FOREIGN KEY (car_model_id) REFERENCES car_models(id)
)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE car_revisions');
$this->addSql('DROP TABLE car_models');
$this->addSql('DROP TABLE brands');
}
}

View File

@ -0,0 +1,513 @@
<?php
namespace App\Application\Commands;
use App\Domain\Model\Brand;
use App\Domain\Model\CarModel;
use App\Domain\Model\CarRevision;
use App\Domain\Model\DrivingCharacteristics;
use App\Domain\Model\Image;
use App\Domain\Model\Value\Date;
use App\Domain\Model\Value\Price;
use App\Domain\Model\Value\Currency;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Battery\CellChemistry;
use App\Domain\Model\Charging\ChargingProperties;
use App\Domain\Model\RangeProperties;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Value\Energy;
use App\Domain\Model\Value\Power;
use App\Domain\Model\Value\Acceleration;
use App\Domain\Model\Value\Speed;
use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Range;
use App\Domain\Repository\BrandRepository;
use App\Domain\Repository\CarModelRepository;
use App\Domain\Repository\CarRevisionRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:load-fixtures',
description: 'Load fixture data for EV brands, car models and car revisions'
)]
class LoadFixtures extends Command
{
private array $brandIds = [];
private array $carModelIds = [];
public function __construct(
private readonly BrandRepository $brandRepository,
private readonly CarModelRepository $carModelRepository,
private readonly CarRevisionRepository $carRevisionRepository,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Loading EV Wiki Fixtures');
// Load brands
$brands = $this->getFixtureBrands();
$io->section('Loading Brands');
$io->progressStart(count($brands));
foreach ($brands as $brand) {
$persistedBrand = $this->brandRepository->create($brand);
$this->brandIds[$brand->name] = $persistedBrand->id;
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf('Successfully loaded %d brands', count($brands)));
// Load car models
$carModels = $this->getFixtureCarModels();
$io->section('Loading Car Models');
$io->progressStart(count($carModels));
foreach ($carModels as $carModelData) {
$persistedCarModel = $this->carModelRepository->create(
$carModelData['model'],
$this->brandIds[$carModelData['brand']]
);
$this->carModelIds[$carModelData['model']->name] = $persistedCarModel->id;
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf('Successfully loaded %d car models', count($carModels)));
// Load car revisions
$carRevisions = $this->getFixtureCarRevisions();
$io->section('Loading Car Revisions');
$io->progressStart(count($carRevisions));
foreach ($carRevisions as $carRevisionData) {
$this->carRevisionRepository->create(
$carRevisionData['revision'],
$this->carModelIds[$carRevisionData['model']]
);
$io->progressAdvance();
}
$io->progressFinish();
$io->success(sprintf('Successfully loaded %d car revisions', count($carRevisions)));
$io->success('All fixtures loaded successfully!');
return Command::SUCCESS;
}
/**
* @return Brand[]
*/
private function getFixtureBrands(): array
{
return [
new Brand(
name: 'Tesla',
logo: 'https://logo.clearbit.com/tesla.com',
description: 'American electric vehicle and clean energy company founded by Elon Musk',
foundedYear: 2003,
headquarters: 'Austin, Texas, USA',
website: 'https://tesla.com',
),
new Brand(
name: 'BMW',
logo: 'https://logo.clearbit.com/bmw.com',
description: 'German multinational corporation producing luxury vehicles with strong EV lineup',
foundedYear: 1916,
headquarters: 'Munich, Germany',
website: 'https://bmw.com',
),
new Brand(
name: 'Audi',
logo: 'https://logo.clearbit.com/audi.com',
description: 'German automotive manufacturer of luxury vehicles with e-tron electric series',
foundedYear: 1909,
headquarters: 'Ingolstadt, Germany',
website: 'https://audi.com',
),
new Brand(
name: 'Mercedes-Benz',
logo: 'https://logo.clearbit.com/mercedes-benz.com',
description: 'German luxury automotive brand with EQS, EQC and other electric models',
foundedYear: 1926,
headquarters: 'Stuttgart, Germany',
website: 'https://mercedes-benz.com',
),
new Brand(
name: 'Volkswagen',
logo: 'https://logo.clearbit.com/vw.com',
description: 'German motor vehicle manufacturer with ID series electric vehicles',
foundedYear: 1937,
headquarters: 'Wolfsburg, Germany',
website: 'https://vw.com',
),
new Brand(
name: 'Porsche',
logo: 'https://logo.clearbit.com/porsche.com',
description: 'German sports car manufacturer with Taycan electric sports cars',
foundedYear: 1931,
headquarters: 'Stuttgart, Germany',
website: 'https://porsche.com',
),
new Brand(
name: 'Lucid Motors',
logo: 'https://logo.clearbit.com/lucidmotors.com',
description: 'American electric vehicle manufacturer focused on luxury sedans',
foundedYear: 2007,
headquarters: 'Newark, California, USA',
website: 'https://lucidmotors.com',
),
new Brand(
name: 'Rivian',
logo: 'https://logo.clearbit.com/rivian.com',
description: 'American electric vehicle manufacturer focusing on electric trucks and vans',
foundedYear: 2009,
headquarters: 'Irvine, California, USA',
website: 'https://rivian.com',
),
new Brand(
name: 'NIO',
logo: 'https://logo.clearbit.com/nio.com',
description: 'Chinese electric vehicle manufacturer with innovative battery swapping technology',
foundedYear: 2014,
headquarters: 'Shanghai, China',
website: 'https://nio.com',
),
new Brand(
name: 'BYD',
logo: 'https://logo.clearbit.com/byd.com',
description: 'Chinese electric vehicle and battery manufacturer, world leader in EV sales',
foundedYear: 1995,
headquarters: 'Shenzhen, China',
website: 'https://byd.com',
)
];
}
/**
* @return array<array{brand: string, model: CarModel}>
*/
private function getFixtureCarModels(): array
{
return [
// Tesla Models
[
'brand' => 'Tesla',
'model' => new CarModel('Model S'),
],
[
'brand' => 'Tesla',
'model' => new CarModel('Model 3'),
],
[
'brand' => 'Tesla',
'model' => new CarModel('Model X'),
],
[
'brand' => 'Tesla',
'model' => new CarModel('Model Y'),
],
// BMW Models
[
'brand' => 'BMW',
'model' => new CarModel('iX'),
],
[
'brand' => 'BMW',
'model' => new CarModel('i4'),
],
[
'brand' => 'BMW',
'model' => new CarModel('iX3'),
],
// Audi Models
[
'brand' => 'Audi',
'model' => new CarModel('e-tron GT'),
],
[
'brand' => 'Audi',
'model' => new CarModel('Q4 e-tron'),
],
[
'brand' => 'Audi',
'model' => new CarModel('e-tron'),
],
// Mercedes-Benz Models
[
'brand' => 'Mercedes-Benz',
'model' => new CarModel('EQS'),
],
[
'brand' => 'Mercedes-Benz',
'model' => new CarModel('EQC'),
],
[
'brand' => 'Mercedes-Benz',
'model' => new CarModel('EQA'),
],
// Volkswagen Models
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.4'),
],
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.3'),
],
[
'brand' => 'Volkswagen',
'model' => new CarModel('ID.Buzz'),
],
// Porsche Models
[
'brand' => 'Porsche',
'model' => new CarModel('Taycan'),
],
[
'brand' => 'Porsche',
'model' => new CarModel('Macan Electric'),
],
];
}
/**
* @return array<array{model: string, revision: CarRevision}>
*/
private function getFixtureCarRevisions(): array
{
return [
// Tesla Model S Plaid
[
'model' => 'Model S',
'revision' => new CarRevision(
name: 'Plaid',
productionBegin: new Date(1, 1, 2021),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(750),
acceleration: new Acceleration(2.1),
topSpeed: new Speed(322),
consumption: new Consumption(new Energy(19.3))
),
battery: new BatteryProperties(
usableCapacity: new Energy(95.0),
totalCapacity: new Energy(100.0),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: '4680',
manufacturer: 'Tesla'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(250)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(628)),
nefz: new NefzRange(new Range(652))
),
catalogPrice: new Price(129990, Currency::euro()),
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-S-Main-Hero-Desktop-LHD.jpg')
),
],
// Tesla Model 3 Long Range
[
'model' => 'Model 3',
'revision' => new CarRevision(
name: 'Long Range',
productionBegin: new Date(1, 1, 2020),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(366),
acceleration: new Acceleration(4.4),
topSpeed: new Speed(233),
consumption: new Consumption(new Energy(14.9))
),
battery: new BatteryProperties(
usableCapacity: new Energy(75.0),
totalCapacity: new Energy(82.0),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: '2170',
manufacturer: 'Tesla'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(250)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(602)),
nefz: new NefzRange(new Range(614))
),
catalogPrice: new Price(49990, Currency::euro()),
image: new Image('https://digitalassets.tesla.com/tesla-contents/image/upload/f_auto,q_auto/Model-3-Main-Hero-Desktop-LHD.jpg')
),
],
// BMW iX xDrive50
[
'model' => 'iX',
'revision' => new CarRevision(
name: 'xDrive50',
productionBegin: new Date(1, 1, 2021),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(385),
acceleration: new Acceleration(4.6),
topSpeed: new Speed(200),
consumption: new Consumption(new Energy(19.8))
),
battery: new BatteryProperties(
usableCapacity: new Energy(71.2),
totalCapacity: new Energy(76.6),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'BMW Gen5',
manufacturer: 'CATL'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(195)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(630)),
nefz: new NefzRange(new Range(680))
),
catalogPrice: new Price(77300, Currency::euro()),
image: new Image('https://www.bmw.de/content/dam/bmw/common/all-models/x-series/ix/2021/highlights/bmw-ix-sp-desktop.jpg')
),
],
// Audi e-tron GT RS
[
'model' => 'e-tron GT',
'revision' => new CarRevision(
name: 'RS',
productionBegin: new Date(1, 1, 2021),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(475),
acceleration: new Acceleration(3.3),
topSpeed: new Speed(250),
consumption: new Consumption(new Energy(19.6))
),
battery: new BatteryProperties(
usableCapacity: new Energy(83.7),
totalCapacity: new Energy(93.4),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'PPE Platform',
manufacturer: 'LG Energy Solution'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(270)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(472)),
nefz: new NefzRange(new Range(487))
),
catalogPrice: new Price(142900, Currency::euro()),
image: new Image('https://www.audi.de/content/dam/nemo/models/e-tron-gt/my-2021/1920x1080-gallery/1920x1080_AudiRS_e-tron_GT_19.jpg')
),
],
// Mercedes EQS 450+
[
'model' => 'EQS',
'revision' => new CarRevision(
name: '450+',
productionBegin: new Date(1, 1, 2021),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(245),
acceleration: new Acceleration(6.2),
topSpeed: new Speed(210),
consumption: new Consumption(new Energy(15.7))
),
battery: new BatteryProperties(
usableCapacity: new Energy(90.0),
totalCapacity: new Energy(107.8),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'EVA Platform',
manufacturer: 'CATL'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(200)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(756)),
nefz: new NefzRange(new Range(770))
),
catalogPrice: new Price(106374, Currency::euro()),
image: new Image('https://www.mercedes-benz.de/content/germany/de/mercedes-benz/vehicles/passenger-cars/eqs/sedan/overview/_jcr_content/root/responsivegrid/simple_stage.component.damq2.3318859008536.jpg')
),
],
// Volkswagen ID.4 Pro
[
'model' => 'ID.4',
'revision' => new CarRevision(
name: 'Pro',
productionBegin: new Date(1, 1, 2020),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(150),
acceleration: new Acceleration(8.5),
topSpeed: new Speed(160),
consumption: new Consumption(new Energy(16.3))
),
battery: new BatteryProperties(
usableCapacity: new Energy(77.0),
totalCapacity: new Energy(82.0),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'MEB Platform',
manufacturer: 'LG Energy Solution'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(125)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(520)),
nefz: new NefzRange(new Range(549))
),
catalogPrice: new Price(51515, Currency::euro()),
image: new Image('https://www.volkswagen.de/content/dam/vw-ngw/vw_pkw/importers/de/models/id-4/gallery/id4-gallery-exterior-01-16x9.jpg')
),
],
// Porsche Taycan Turbo S
[
'model' => 'Taycan',
'revision' => new CarRevision(
name: 'Turbo S',
productionBegin: new Date(1, 1, 2019),
drivingCharacteristics: new DrivingCharacteristics(
power: new Power(560),
acceleration: new Acceleration(2.8),
topSpeed: new Speed(260),
consumption: new Consumption(new Energy(23.7))
),
battery: new BatteryProperties(
usableCapacity: new Energy(83.7),
totalCapacity: new Energy(93.4),
cellChemistry: CellChemistry::LithiumNickelManganeseOxide,
model: 'J1 Platform',
manufacturer: 'LG Energy Solution'
),
chargingProperties: new ChargingProperties(
topChargingSpeed: new Power(270)
),
rangeProperties: new RangeProperties(
wltp: new WltpRange(new Range(440)),
nefz: new NefzRange(new Range(452))
),
catalogPrice: new Price(185456, Currency::euro()),
image: new Image('https://www.porsche.com/germany/models/taycan/taycan-models/turbo-s/_jcr_content/par/twocolumnlayout/par_left/image.transform.porsche-model-desktop-xl.jpg')
),
],
];
}
}

View File

@ -5,13 +5,11 @@ namespace App\Domain\Model;
final readonly class Brand
{
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $logo,
public readonly string $description,
public readonly int $foundedYear,
public readonly string $headquarters,
public readonly string $website,
public readonly array $carModels,
public readonly ?string $logo = null,
public readonly ?string $description = null,
public readonly ?int $foundedYear = null,
public readonly ?string $headquarters = null,
public readonly ?string $website = null,
) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Model;
class CarModelCollection
{
public function __construct(
private readonly array $carModels,
) {
}
public function array(): array
{
return $this->carModels;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Domain\Model;
class CarRevisionCollection
{
public function __construct(
private readonly array $carRevisions,
) {
}
public function array(): array
{
return $this->carRevisions;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\Brand;
class PersistedBrand
{
public function __construct(
public readonly string $id,
public readonly Brand $brand,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\CarModel;
class PersistedCarModel
{
public function __construct(
public readonly string $id,
public readonly CarModel $carModel,
) {}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Domain\Model\Persistence;
use App\Domain\Model\CarRevision;
class PersistedCarRevision
{
public function __construct(
public readonly string $id,
public readonly CarRevision $carRevision,
) {}
}

View File

@ -14,7 +14,7 @@ class Acceleration
return $this->secondsFrom0To100 . ' sec (0-100 km/h)';
}
public function getSeconds(): float
public function seconds(): float
{
return $this->secondsFrom0To100;
}

View File

@ -5,17 +5,22 @@ namespace App\Domain\Model\Value;
class Consumption
{
public function __construct(
public readonly Energy $energyPerKm,
public readonly Energy $energyPer100Km,
) {
}
public function __toString(): string
{
return $this->energyPerKm->kwh() . ' kWh/100km';
return $this->energyPer100Km->kwh() . ' ' . $this->unit();
}
public function energyPerKm(): Energy
public function energyPer100Km(): Energy
{
return $this->energyPerKm;
return $this->energyPer100Km;
}
public function unit(): string
{
return 'kWh/100km';
}
}

View File

@ -15,21 +15,6 @@ class Currency
return $this->symbol;
}
public function symbol(): string
{
return $this->symbol;
}
public function currency(): string
{
return $this->currency;
}
public function name(): string
{
return $this->name;
}
public static function euro(): self
{
return new self('€', 'EUR', 'Euro');

View File

@ -12,6 +12,11 @@ class Price
public function __toString(): string
{
return number_format($this->price, 0, ',', '.') . ' ' . $this->currency->symbol();
return $this->formattedPrice() . ' ' . $this->currency->symbol;
}
public function formattedPrice(): string
{
return number_format($this->price, 0, ',', '.');
}
}

View File

@ -2,9 +2,15 @@
namespace App\Domain\Repository;
use App\Domain\Model\Brand;
use App\Domain\Model\BrandCollection;
use App\Domain\Model\Persistence\PersistedBrand;
interface BrandRepository
{
public function findAll(): BrandCollection;
}
public function create(Brand $brand): PersistedBrand;
public function update(PersistedBrand $persistedBrand): void;
}

View File

@ -1,98 +1,22 @@
<?php
namespace App\Repository;
namespace App\Domain\Repository;
use App\Domain\Model\CarModel;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use App\Domain\Model\CarModelCollection;
use App\Domain\Model\Persistence\PersistedCarModel;
class CarModelRepository extends DocumentRepository
interface CarModelRepository
{
public function __construct(DocumentManager $dm)
{
parent::__construct($dm, $dm->getUnitOfWork(), $dm->getClassMetadata(CarModel::class));
}
public function findAll(): CarModelCollection;
public function findById(string $id): ?PersistedCarModel;
public function findByBrandId(string $brandId): CarModelCollection;
public function create(CarModel $carModel, string $brandId): PersistedCarModel;
public function findAllCarModels(): array
{
return $this->createQueryBuilder()
->sort('name', 'asc')
->getQuery()
->execute()
->toArray();
}
public function findCarModelById(string $id): ?CarModel
{
return $this->find($id);
}
public function findCarModelsByBrandId(string $brandId): array
{
return $this->createQueryBuilder()
->field('brand.$id')->equals(new \MongoDB\BSON\ObjectId($brandId))
->sort('name', 'asc')
->getQuery()
->execute()
->toArray();
}
public function findCarModelsByName(string $name): array
{
return $this->createQueryBuilder()
->field('name')->equals(new \MongoDB\BSON\Regex($name, 'i'))
->sort('name', 'asc')
->getQuery()
->execute()
->toArray();
}
public function findCarModelsByCategory(string $category): array
{
return $this->createQueryBuilder()
->field('category')->equals($category)
->sort('name', 'asc')
->getQuery()
->execute()
->toArray();
}
public function findCarModelsByYearRange(int $startYear, int $endYear): array
{
return $this->createQueryBuilder()
->field('productionStartYear')->gte($startYear)
->field('productionStartYear')->lte($endYear)
->sort('productionStartYear', 'asc')
->getQuery()
->execute()
->toArray();
}
public function searchCarModels(string $query): array
{
$regex = new \MongoDB\BSON\Regex($query, 'i');
return $this->createQueryBuilder()
->addOr(
$this->createQueryBuilder()->field('name')->equals($regex)->getQueryArray(),
$this->createQueryBuilder()->field('description')->equals($regex)->getQueryArray(),
$this->createQueryBuilder()->field('category')->equals($regex)->getQueryArray()
)
->sort('name', 'asc')
->getQuery()
->execute()
->toArray();
}
public function saveCarModel(CarModel $carModel): void
{
$this->getDocumentManager()->persist($carModel);
$this->getDocumentManager()->flush();
}
public function deleteCarModel(CarModel $carModel): void
{
$this->getDocumentManager()->remove($carModel);
$this->getDocumentManager()->flush();
}
public function update(PersistedCarModel $persistedCarModel): void;
public function delete(PersistedCarModel $persistedCarModel): void;
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Domain\Repository;
use App\Domain\Model\CarRevision;
use App\Domain\Model\CarRevisionCollection;
use App\Domain\Model\Persistence\PersistedCarRevision;
interface CarRevisionRepository
{
public function findAll(): CarRevisionCollection;
public function findById(string $id): ?PersistedCarRevision;
public function findByCarModelId(string $carModelId): CarRevisionCollection;
public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision;
public function update(PersistedCarRevision $persistedCarRevision): void;
public function delete(PersistedCarRevision $persistedCarRevision): void;
}

View File

@ -17,6 +17,7 @@ use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Speed;
use App\Domain\Model\Value\Drivetrain;
use App\Domain\Model\Value\Energy;
use App\Domain\Model\Value\ChargingSpeed;
use App\Domain\Model\Charging\ChargingProperties;
use App\Domain\Model\Charging\ChargeCurve;
use App\Domain\Model\Charging\ChargeTimeProperties;
@ -41,21 +42,15 @@ use App\Domain\Search\Tiles\AvailabilityTile;
use App\Domain\Search\Tiles\CarTile;
use App\Domain\Search\Tiles\TopSpeedTile;
use App\Domain\Search\Tiles\DrivetrainTile;
// New tiles
use App\Domain\Search\Tiles\ChargeCurveTile;
use App\Domain\Search\Tiles\ChargeTimeTile;
use App\Domain\Search\Tiles\ChargingConnectivityTile;
use App\Domain\Search\Tiles\BatteryDetailsTile;
use App\Domain\Search\Tiles\ProductionPeriodTile;
use App\Domain\Search\Tiles\RangeComparisonTile;
use App\Domain\Search\Tiles\RealRangeTile;
use App\Domain\Search\Tiles\PerformanceOverviewTile;
class Engine
{
public function search(string $query): TileCollection
{
// Create comprehensive test data showing all the new features
$batteryProperties = new BatteryProperties(
usableCapacity: new Energy(77.0),
totalCapacity: new Energy(82.0),
@ -123,7 +118,7 @@ class Engine
$skodaElroq85 = new CarRevision(
name: 'Skoda Enyaq iV 85',
productionBegin: new Date(1, 1, 2020),
productionEnd: null, // Still in production
productionEnd: null,
drivingCharacteristics: $drivingCharacteristics,
battery: $batteryProperties,
chargingProperties: $chargingProperties,
@ -132,25 +127,30 @@ class Engine
image: new Image('https://www.scherer-gruppe.de/media/f3b72d42-4b26-4606-8df4-d840efeff017/01_elroc.jpg?w=1920&h=758&action=crop&scale=both&anchor=middlecenter')
);
$chargingSpeed = new ChargingSpeed(
dcMaxKw: new Power(175),
acMaxKw: new Power(11)
);
return new TileCollection([
new SectionTile('Skoda Enyaq iV 85', [
new CarTile($skodaElroq85->image, [
new BrandTile('Skoda'),
new PriceTile($skodaElroq85->catalogPrice),
new ProductionPeriodTile($skodaElroq85->productionBegin, $skodaElroq85->productionEnd),
new AvailabilityTile('Verfügbar', new Date(1, 1, 2020)),
new RangeTile($wltpRange->range),
new ConsumptionTile($drivingCharacteristics->consumption),
new AccelerationTile($drivingCharacteristics->acceleration),
]),
new SubSectionTile('Performance', [
new PowerTile($drivingCharacteristics->power),
new AccelerationTile($drivingCharacteristics->acceleration),
new TopSpeedTile($drivingCharacteristics->topSpeed),
new DrivetrainTile(new Drivetrain('rear')),
new ConsumptionTile($drivingCharacteristics->consumption),
], 'Individual performance metrics'),
new SubSectionTile('Reichweite', [
new RangeTile($wltpRange->range),
new RangeComparisonTile($skodaElroq85->rangeProperties),
new RealRangeTile($realRangeTests),
], 'Range data from different sources'),
@ -160,9 +160,9 @@ class Engine
], 'Battery capacity and technology'),
new SubSectionTile('Laden', [
new ChargingTile($chargingSpeed),
new ChargeTimeTile($chargingProperties->chargeTimeProperties),
new ChargingConnectivityTile($chargingProperties->chargingConnectivity),
new ChargeCurveTile($chargingProperties->chargeCurve),
], 'Charging capabilities and compatibility'),
]),
]);

View File

@ -2,10 +2,12 @@
namespace App\Domain\Search\Tiles;
use App\Domain\Model\Value\Date;
class AvailabilityTile
{
public function __construct(
public readonly string $status,
public readonly ?string $availableSince = null,
public readonly ?Date $availableSince = null,
) {}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Infrastructure\MongoDB;
use MongoDB\Client;
use MongoDB\Database;
class MongoDBClient
{
private Client $client;
public function __construct(
private readonly string $dsl,
private readonly string $databaseName,
) {
$this->client = new Client($this->dsl);
}
public function getClient(): Client
{
return $this->client;
}
public function getDatabase(): Database
{
return $this->client->selectDatabase($this->databaseName);
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
use App\Domain\Model\Brand;
class ModelMapper
{
public function map(array $data): Brand
{
$id = $data['_id'] ?? null;
if ($id !== null) {
$id = (string) $id;
}
return new Brand(
id: $id,
name: $data['name'] ?? null,
logo: $data['logo'] ?? null,
description: $data['description'] ?? null,
foundedYear: $data['foundedYear'] ?? null,
headquarters: $data['headquarters'] ?? null,
website: $data['website'] ?? null,
carModels: $data['carModels'] ?? [],
);
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Infrastructure\MongoDB\Repository\BrandRepository;
use App\Domain\Model\BrandCollection;
use App\Domain\Repository\BrandRepository;
use App\Infrastructure\MongoDB\MongoDBClient;
final class MongoDBBrandRepository implements BrandRepository
{
public function __construct(
private readonly MongoDBClient $client,
) {
}
public function findAll(): BrandCollection
{
$result = $this->client->getDatabase()->selectCollection('brands')->find();
$brands = [];
$mapper = new ModelMapper();
/** @var \MongoDB\Model\BSONDocument $brand */
foreach ($result as $brand) {
$brands[] = $mapper->map($brand->getArrayCopy());
}
return new BrandCollection($brands);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand;
class ModelMapper
{
public function map(array $data): Brand
{
return new Brand(
id: (string) ($data['id'] ?? null),
name: $data['name'] ?? '',
logo: $data['logo'] ?? '',
description: $data['description'] ?? '',
foundedYear: (int) ($data['founded_year'] ?? 0),
headquarters: $data['headquarters'] ?? '',
website: $data['website'] ?? '',
carModels: json_decode($data['car_models'] ?? '[]', true),
);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\BrandRepository;
use App\Domain\Model\Brand;
use App\Domain\Model\BrandCollection;
use App\Domain\Model\Persistence\PersistedBrand;
use App\Domain\Repository\BrandRepository;
use Doctrine\DBAL\Connection;
final class PostgreSQLBrandRepository implements BrandRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
public function findAll(): BrandCollection
{
$sql = 'SELECT * FROM brands ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$brands = [];
$mapper = new ModelMapper();
foreach ($result as $brand) {
$brands[] = $mapper->map($brand);
}
return new BrandCollection($brands);
}
public function create(Brand $brand): PersistedBrand
{
// Generate an ID for the brand since Brand model doesn't have one
$brandId = uniqid('brand_', true);
$sql = <<<'SQL'
INSERT INTO brands (id, name, content)
VALUES (?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
content = EXCLUDED.content
SQL;
$content = json_encode([
'logo' => $brand->logo,
'description' => $brand->description,
'founded_year' => $brand->foundedYear,
'headquarters' => $brand->headquarters,
'website' => $brand->website,
]);
$this->connection->executeStatement($sql, [
$brandId,
$brand->name,
$content,
]);
return new PersistedBrand($brandId, $brand);
}
public function update(PersistedBrand $persistedBrand): void
{
$brand = $persistedBrand->brand;
$sql = <<<'SQL'
UPDATE brands SET
name = ?,
content = ?
WHERE id = ?
SQL;
$content = json_encode([
'logo' => $brand->logo,
'description' => $brand->description,
'founded_year' => $brand->foundedYear,
'headquarters' => $brand->headquarters,
'website' => $brand->website,
]);
$this->connection->executeStatement($sql, [
$brand->name,
$content,
$persistedBrand->id,
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
use App\Domain\Model\CarModel;
use App\Domain\Model\Brand;
class ModelMapper
{
public function map(array $data): CarModel
{
$content = json_decode($data['content'] ?? '{}', true);
$brand = null;
if (!empty($content['brand'])) {
$brand = new Brand($content['brand']);
}
return new CarModel(
name: $data['name'] ?? '',
brand: $brand,
);
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\CarModelRepository;
use App\Domain\Model\CarModel;
use App\Domain\Model\CarModelCollection;
use App\Domain\Model\Persistence\PersistedCarModel;
use App\Domain\Repository\CarModelRepository;
use Doctrine\DBAL\Connection;
final class PostgreSQLCarModelRepository implements CarModelRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
public function findAll(): CarModelCollection
{
$sql = 'SELECT * FROM car_models ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$carModels = [];
$mapper = new ModelMapper();
foreach ($result as $carModel) {
$carModels[] = $mapper->map($carModel);
}
return new CarModelCollection($carModels);
}
public function findById(string $id): ?PersistedCarModel
{
$sql = 'SELECT * FROM car_models WHERE id = ?';
$result = $this->connection->executeQuery($sql, [$id]);
$data = $result->fetchAssociative();
if (!$data) {
return null;
}
$mapper = new ModelMapper();
$carModel = $mapper->map($data);
return new PersistedCarModel($data['id'], $carModel);
}
public function findByBrandId(string $brandId): CarModelCollection
{
$sql = 'SELECT * FROM car_models WHERE brand_id = ? ORDER BY name ASC';
$result = $this->connection->executeQuery($sql, [$brandId]);
$carModels = [];
$mapper = new ModelMapper();
foreach ($result as $carModel) {
$carModels[] = $mapper->map($carModel);
}
return new CarModelCollection($carModels);
}
public function create(CarModel $carModel, string $brandId): PersistedCarModel
{
// Generate an ID for the car model
$carModelId = uniqid('carmodel_', true);
$sql = <<<'SQL'
INSERT INTO car_models (id, brand_id, name, content)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
brand_id = EXCLUDED.brand_id,
name = EXCLUDED.name,
content = EXCLUDED.content
SQL;
$content = json_encode([
'brand' => $carModel->brand?->name ?? null,
]);
$this->connection->executeStatement($sql, [
$carModelId,
$brandId,
$carModel->name,
$content,
]);
return new PersistedCarModel($carModelId, $carModel);
}
public function update(PersistedCarModel $persistedCarModel): void
{
$carModel = $persistedCarModel->carModel;
$sql = <<<'SQL'
UPDATE car_models SET
name = ?,
content = ?
WHERE id = ?
SQL;
$content = json_encode([
'brand' => $carModel->brand?->name ?? null,
]);
$this->connection->executeStatement($sql, [
$carModel->name,
$content,
$persistedCarModel->id,
]);
}
public function delete(PersistedCarModel $persistedCarModel): void
{
$sql = 'DELETE FROM car_models WHERE id = ?';
$this->connection->executeStatement($sql, [$persistedCarModel->id]);
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision;
use App\Domain\Model\DrivingCharacteristics;
use App\Domain\Model\Image;
use App\Domain\Model\Value\Date;
use App\Domain\Model\Value\Price;
use App\Domain\Model\Value\Currency;
use App\Domain\Model\Battery\BatteryProperties;
use App\Domain\Model\Battery\CellChemistry;
use App\Domain\Model\Charging\ChargingProperties;
use App\Domain\Model\RangeProperties;
use App\Domain\Model\Range\WltpRange;
use App\Domain\Model\Range\NefzRange;
use App\Domain\Model\Value\Energy;
use App\Domain\Model\Value\Power;
use App\Domain\Model\Value\Acceleration;
use App\Domain\Model\Value\Speed;
use App\Domain\Model\Value\Consumption;
use App\Domain\Model\Value\Range;
class ModelMapper
{
public function map(array $data): CarRevision
{
$content = json_decode($data['content'] ?? '{}', true);
$productionBegin = null;
if (!empty($content['production_begin'])) {
$productionBegin = new Date(1, 1, $content['production_begin']);
}
$productionEnd = null;
if (!empty($content['production_end'])) {
$productionEnd = new Date(1, 1, $content['production_end']);
}
$catalogPrice = null;
if (!empty($content['catalog_price']) && !empty($content['catalog_price_currency'])) {
$currency = match($content['catalog_price_currency']) {
'EUR' => Currency::euro(),
'USD' => Currency::usd(),
default => Currency::euro(), // fallback to euro
};
$catalogPrice = new Price(
$content['catalog_price'],
$currency
);
}
$image = null;
if (!empty($content['image_url'])) {
$image = new Image($content['image_url']);
}
$drivingCharacteristics = null;
if (!empty($content['driving_characteristics'])) {
$dc = $content['driving_characteristics'];
$drivingCharacteristics = new DrivingCharacteristics(
power: !empty($dc['power_kw']) ? new Power($dc['power_kw']) : null,
acceleration: !empty($dc['acceleration_0_100']) ? new Acceleration($dc['acceleration_0_100']) : null,
topSpeed: !empty($dc['top_speed_kmh']) ? new Speed($dc['top_speed_kmh']) : null,
consumption: !empty($dc['consumption_kwh_100km']) ? new Consumption(new Energy($dc['consumption_kwh_100km'])) : null,
);
}
$battery = null;
if (!empty($content['battery'])) {
$b = $content['battery'];
if (!empty($b['usable_capacity_kwh']) && !empty($b['total_capacity_kwh'])) {
$battery = new BatteryProperties(
usableCapacity: new Energy($b['usable_capacity_kwh']),
totalCapacity: new Energy($b['total_capacity_kwh']),
cellChemistry: !empty($b['cell_chemistry']) ? CellChemistry::from($b['cell_chemistry']) : CellChemistry::LithiumIronPhosphate,
model: $b['model'] ?? '',
manufacturer: $b['manufacturer'] ?? '',
);
}
}
$chargingProperties = null;
if (!empty($content['charging']['top_charging_speed_kw'])) {
$chargingProperties = new ChargingProperties(
topChargingSpeed: new Power($content['charging']['top_charging_speed_kw'])
);
}
$rangeProperties = null;
if (!empty($content['range'])) {
$r = $content['range'];
$wltp = !empty($r['wltp_km']) ? new WltpRange(new Range($r['wltp_km'])) : null;
$nefz = !empty($r['nefz_km']) ? new NefzRange(new Range($r['nefz_km'])) : null;
if ($wltp || $nefz) {
$rangeProperties = new RangeProperties(
wltp: $wltp,
nefz: $nefz,
);
}
}
return new CarRevision(
name: $data['name'] ?? '',
productionBegin: $productionBegin,
productionEnd: $productionEnd,
drivingCharacteristics: $drivingCharacteristics,
battery: $battery,
chargingProperties: $chargingProperties,
rangeProperties: $rangeProperties,
catalogPrice: $catalogPrice,
carModel: null, // CarModel would need to be loaded separately if needed
image: $image,
);
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Infrastructure\PostgreSQL\Repository\CarRevisionRepository;
use App\Domain\Model\CarRevision;
use App\Domain\Model\CarRevisionCollection;
use App\Domain\Model\Persistence\PersistedCarRevision;
use App\Domain\Repository\CarRevisionRepository;
use Doctrine\DBAL\Connection;
final class PostgreSQLCarRevisionRepository implements CarRevisionRepository
{
public function __construct(
private readonly Connection $connection,
) {
}
public function findAll(): CarRevisionCollection
{
$sql = 'SELECT * FROM car_revisions ORDER BY name ASC';
$result = $this->connection->executeQuery($sql);
$carRevisions = [];
$mapper = new ModelMapper();
foreach ($result as $carRevision) {
$carRevisions[] = $mapper->map($carRevision);
}
return new CarRevisionCollection($carRevisions);
}
public function findById(string $id): ?PersistedCarRevision
{
$sql = 'SELECT * FROM car_revisions WHERE id = ?';
$result = $this->connection->executeQuery($sql, [$id]);
$data = $result->fetchAssociative();
if (!$data) {
return null;
}
$mapper = new ModelMapper();
$carRevision = $mapper->map($data);
return new PersistedCarRevision($data['id'], $carRevision);
}
public function findByCarModelId(string $carModelId): CarRevisionCollection
{
$sql = 'SELECT * FROM car_revisions WHERE car_model_id = ? ORDER BY name ASC';
$result = $this->connection->executeQuery($sql, [$carModelId]);
$carRevisions = [];
$mapper = new ModelMapper();
foreach ($result as $carRevision) {
$carRevisions[] = $mapper->map($carRevision);
}
return new CarRevisionCollection($carRevisions);
}
public function create(CarRevision $carRevision, string $carModelId): PersistedCarRevision
{
// Generate an ID for the car revision
$carRevisionId = uniqid('carrevision_', true);
$sql = <<<'SQL'
INSERT INTO car_revisions (id, car_model_id, name, content)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
car_model_id = EXCLUDED.car_model_id,
name = EXCLUDED.name,
content = EXCLUDED.content
SQL;
$content = json_encode([
'production_begin' => $carRevision->productionBegin?->year ?? null,
'production_end' => $carRevision->productionEnd?->year ?? null,
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
'image_url' => $carRevision->image?->url ?? null,
'driving_characteristics' => [
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
],
'battery' => [
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
'model' => $carRevision->battery?->model ?? null,
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
],
'charging' => [
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
],
'range' => [
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,
'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null,
],
]);
$this->connection->executeStatement($sql, [
$carRevisionId,
$carModelId,
$carRevision->name,
$content,
]);
return new PersistedCarRevision($carRevisionId, $carRevision);
}
public function update(PersistedCarRevision $persistedCarRevision): void
{
$carRevision = $persistedCarRevision->carRevision;
$sql = <<<'SQL'
UPDATE car_revisions SET
name = ?,
content = ?
WHERE id = ?
SQL;
$content = json_encode([
'production_begin' => $carRevision->productionBegin?->year ?? null,
'production_end' => $carRevision->productionEnd?->year ?? null,
'catalog_price' => $carRevision->catalogPrice?->price ?? null,
'catalog_price_currency' => $carRevision->catalogPrice?->currency->currency ?? null,
'image_url' => $carRevision->image?->url ?? null,
'driving_characteristics' => [
'power_kw' => $carRevision->drivingCharacteristics?->power?->kilowatts ?? null,
'acceleration_0_100' => $carRevision->drivingCharacteristics?->acceleration?->secondsFrom0To100 ?? null,
'top_speed_kmh' => $carRevision->drivingCharacteristics?->topSpeed?->kmh ?? null,
'consumption_kwh_100km' => $carRevision->drivingCharacteristics?->consumption?->energyPer100Km->kwh() ?? null,
],
'battery' => [
'usable_capacity_kwh' => $carRevision->battery?->usableCapacity->kwh() ?? null,
'total_capacity_kwh' => $carRevision->battery?->totalCapacity->kwh() ?? null,
'cell_chemistry' => $carRevision->battery?->cellChemistry->value ?? null,
'model' => $carRevision->battery?->model ?? null,
'manufacturer' => $carRevision->battery?->manufacturer ?? null,
],
'charging' => [
'top_charging_speed_kw' => $carRevision->chargingProperties?->topChargingSpeed?->kilowatts ?? null,
],
'range' => [
'wltp_km' => $carRevision->rangeProperties?->wltp?->range->kilometers ?? null,
'nefz_km' => $carRevision->rangeProperties?->nefz?->range->kilometers ?? null,
],
]);
$this->connection->executeStatement($sql, [
$carRevision->name,
$content,
$persistedCarRevision->id,
]);
}
public function delete(PersistedCarRevision $persistedCarRevision): void
{
$sql = 'DELETE FROM car_revisions WHERE id = ?';
$this->connection->executeStatement($sql, [$persistedCarRevision->id]);
}
}

View File

@ -1,4 +1,55 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"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"
]
},
"symfony/asset-mapper": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "7.2",
"recipe": {

View File

@ -1,6 +1,8 @@
<div class="search-container" id="searchForm">
<input type="text" id="searchInput" class="search-input" placeholder="Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
<button type="button" id="searchButton" class="search-button">Search</button>
<input type="text" id="searchInput" class="search-input" placeholder="🔍 Search for electric vehicles, brands, or models..." value="{{ query|default('') }}">
<button type="button" id="searchButton" class="search-button">
<i class="fas fa-search"></i> Search
</button>
</div>
<script>

View File

@ -89,7 +89,7 @@
/* Tiles Grid Layout */
.tiles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
gap: 1px;
padding: 0;
overflow: hidden;
@ -159,9 +159,9 @@
}
.tile-subtitle {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.75rem;
font-size: 0.8rem;
color: #999;
margin-bottom: 1.2rem;
font-weight: 500;
}
@ -326,5 +326,6 @@
</div>
{% block javascripts %}{% endblock %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
</body>
</html>

View File

@ -4,27 +4,27 @@
{% block body %}
<div class="header">
<h1 class="title">E-WIKI</h1>
<h1 class="title"><i class="fas fa-car-battery"></i> E-WIKI</h1>
{% include '_components/search.html.twig' %}
</div>
<div id="brandsSection">
<h2 class="section-title">Popular Electric Vehicle Brands</h2>
<h2 class="section-title"><i class="fas fa-industry"></i> Popular Electric Vehicle Brands</h2>
<div class="brands-grid">
{% for brand in brands %}
<div class="brand-card" data-brand-id="{{ brand.id }}">
<div class="brand-name">{{ brand.name }}</div>
<div class="brand-name"><i class="fas fa-car"></i> {{ brand.name }}</div>
{% if brand.description %}
<div class="brand-description">{{ brand.description }}</div>
{% endif %}
<div class="brand-year">Founded: {{ brand.foundedYear }}</div>
<div class="brand-year"><i class="fas fa-calendar-alt"></i> Founded: {{ brand.foundedYear }}</div>
{% if brand.headquarters %}
<div class="brand-year">{{ brand.headquarters }}</div>
<div class="brand-year"><i class="fas fa-map-marker-alt"></i> {{ brand.headquarters }}</div>
{% endif %}
</div>
{% else %}
<div class="no-results">No brands available</div>
<div class="no-results"><i class="fas fa-exclamation-triangle"></i> No brands available</div>
{% endfor %}
</div>
</div>

View File

@ -1,4 +1,8 @@
<div class="tile">
<div class="tile-title">{{ tile.acceleration }}</div>
<div class="tile-title">
<i class="fas fa-rocket" style="color: #28a745; margin-right: 8px;"></i>
{{ tile.acceleration.seconds() }} s
</div>
<div class="tile-subtitle">0 auf 100 km/h</div>
<small>Beschleunigung</small>
</div>

View File

@ -1,9 +1,10 @@
<div class="tile">
<div class="tile-content">
<div class="tile-title">{{ tile.status }}</div>
{% if tile.availableSince %}
<div class="tile-subtitle">seit {{ tile.availableSince }}</div>
{% endif %}
<div class="tile-title">
<i class="fas fa-calendar-alt" style="color: #007acc; margin-right: 8px;"></i>
{{ tile.availableSince.year }}
</div>
<small>Verfügbarkeit</small>
{% if tile.availableSince.month %}
<div class="tile-subtitle">{{ tile.availableSince.month }}</div>
{% endif %}
<small>Verfügbar ab</small>
</div>

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.battery }}</div>
<div class="tile-title">
<i class="fas fa-car-battery" style="color: #007acc; margin-right: 8px;"></i>
{{ tile.battery }}
</div>
<small>Batterie</small>
</div>

View File

@ -1,21 +1,19 @@
<div class="tile battery-details-tile">
<div class="tile-title">Zellchemie</div>
<div class="battery-info" style="margin-top: 12px; text-align: center;">
<!-- Chemistry Badge -->
<div class="tile-title">
<i class="fas fa-car-battery" style="color: #007acc; margin-right: 8px;"></i>
Batterie Details
</div>
<div class="chemistry-info" style="margin-top: 12px; text-align: center;">
{% set chemistryColor = tile.batteryProperties.cellChemistry.value == 'LFP' ? '#28a745' : (tile.batteryProperties.cellChemistry.value == 'NMC' ? '#007acc' : '#6f42c1') %}
<div style="margin-bottom: 12px;">
<span style="background: {{ chemistryColor }}; color: white; padding: 6px 12px; border-radius: 12px; font-size: 14px; font-weight: bold;">
{{ tile.batteryProperties.cellChemistry.value }}
</span>
<div style="font-weight: bold; font-size: 24px; color: {{ chemistryColor }}; margin-bottom: 4px;">
{{ tile.batteryProperties.cellChemistry.value }}
</div>
<!-- Manufacturer & Model -->
{% if tile.batteryProperties.manufacturer != 'Tesla' or tile.batteryProperties.model != '4680' %}
<div style="font-size: 12px; color: #666;">
{{ tile.batteryProperties.manufacturer }}<br>
{{ tile.batteryProperties.model }}
{% if tile.batteryProperties.cellChemistry.value == 'LFP' %}Lithium-Eisenphosphat
{% elseif tile.batteryProperties.cellChemistry.value == 'NMC' %}Lithium-Nickel-Mangan
{% else %}{{ tile.batteryProperties.cellChemistry.value }}
{% endif %}
</div>
{% endif %}
</div>
<small style="color: #666;">Batterietechnologie</small>
</div>

View File

@ -3,7 +3,10 @@
<img src="{{ tile.logo }}" alt="{{ tile.name }}" class="tile-logo">
{% endif %}
<div class="tile-content">
<div class="tile-title">{{ tile.name }}</div>
<div class="tile-title">
<i class="fas fa-building" style="color: #083d77; margin-right: 8px;"></i>
{{ tile.name }}
</div>
</div>
<small>Marke</small>
</div>

View File

@ -1,4 +1,4 @@
<div style="grid-column: span 2; grid-row: span 4;">
<div style="grid-column: span 2; grid-row: span 2">
{% if tile.image %}
<img src="{{ tile.image.externalPublicUrl }}" style="width: 100%; height: 100%; object-fit: cover;">
{% endif %}

View File

@ -1,5 +1,8 @@
<div class="tile charge-curve-tile" style="grid-column: span 3; grid-row: span 2;">
<div class="tile-title">Ladekurve</div>
<div class="tile-title">
<i class="fas fa-chart-area" style="color: #007acc; margin-right: 8px;"></i>
Ladekurve
</div>
<div class="charge-curve-container" style="position: relative; height: 120px; margin-top: 10px;">
<svg width="100%" height="100%" viewBox="0 0 300 100" style="overflow: visible;">
<!-- Grid lines -->

View File

@ -1,19 +1,16 @@
<div class="tile charge-time-tile">
<div class="tile-title">Schnellladen</div>
<div class="charge-times" style="margin-top: 12px;">
<div class="tile-title">
<i class="fas fa-clock" style="color: #007acc; margin-right: 8px;"></i>
Ladedauer
</div>
<div class="charge-time" style="margin-top: 12px; text-align: center;">
{% if tile.chargeTimeProperties.minutesFrom10To80 %}
<div style="text-align: center; margin-bottom: 12px;">
<div style="font-weight: bold; font-size: 24px; color: #007acc;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
<small style="color: #666; font-weight: 600;">Minuten 10-80%</small>
</div>
{% endif %}
{% if tile.chargeTimeProperties.minutesFrom20To80 %}
<div style="text-align: center;">
<div style="font-weight: bold; font-size: 18px; color: #6c757d;">{{ tile.chargeTimeProperties.minutesFrom20To80 }}</div>
<small style="color: #666;">Minuten 20-80%</small>
</div>
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom10To80 }}</div>
<div style="font-size: 12px; color: #666;">Minuten (10-80%)</div>
{% elseif tile.chargeTimeProperties.minutesFrom20To80 %}
<div style="font-weight: bold; font-size: 28px; color: #007acc; margin-bottom: 4px;">{{ tile.chargeTimeProperties.minutesFrom20To80 }}</div>
<div style="font-size: 12px; color: #666;">Minuten (20-80%)</div>
{% endif %}
</div>
<small style="color: #666;">DC Laden</small>
<small style="color: #666;">DC Schnellladen</small>
</div>

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.chargingSpeed }}</div>
<small>Laden</small>
<div class="tile-title">
<i class="fas fa-charging-station" style="color: #28a745; margin-right: 8px;"></i>
{{ tile.chargingSpeed.dcMax.kilowatts }} kW
</div>
<small>DC Laden</small>
</div>

View File

@ -1,35 +1,25 @@
<div class="tile charging-connectivity-tile">
<div class="tile-title">Ladekompatibilität</div>
<div class="connectivity-features" style="margin-top: 8px;">
<!-- Voltage Support -->
<div style="display: flex; gap: 6px; margin-bottom: 6px;">
{% if tile.chargingConnectivity.is400v %}
<span style="background: #28a745; color: white; padding: 2px 6px; border-radius: 12px; font-size: 10px; font-weight: bold;">400V</span>
{% endif %}
{% if tile.chargingConnectivity.is800v %}
<span style="background: #007acc; color: white; padding: 2px 6px; border-radius: 12px; font-size: 10px; font-weight: bold;">800V</span>
{% endif %}
</div>
<!-- Plug & Charge -->
{% if tile.chargingConnectivity.plugAndCharge %}
<div style="margin-bottom: 6px;">
<span style="background: rgba(40, 167, 69, 0.1); color: #28a745; padding: 3px 8px; border-radius: 4px; font-size: 11px;">
⚡ Plug & Charge
</span>
</div>
{% endif %}
<!-- Connector Types -->
<div class="tile-title">
<i class="fas fa-plug" style="color: #007acc; margin-right: 8px;"></i>
Stecker
</div>
<div class="connector-info" style="margin-top: 8px; text-align: center;">
<!-- Main Connector Types -->
{% if tile.chargingConnectivity.connectorTypes|length > 0 %}
<div class="connector-types" style="display: flex; gap: 4px; flex-wrap: wrap;">
<div style="margin-bottom: 8px;">
{% for connector in tile.chargingConnectivity.connectorTypes %}
<span style="background: rgba(108,117,125,0.1); color: #6c757d; padding: 2px 6px; border-radius: 8px; font-size: 10px; font-weight: 500;">
{{ connector.value }}
</span>
<div style="font-weight: bold; font-size: 18px; color: #007acc; margin-bottom: 2px;">{{ connector.value }}</div>
{% endfor %}
</div>
{% endif %}
<!-- Voltage Support -->
{% if tile.chargingConnectivity.is400v or tile.chargingConnectivity.is800v %}
<div style="font-size: 12px; color: #666;">
{% if tile.chargingConnectivity.is400v %}400V{% endif %}
{% if tile.chargingConnectivity.is800v %}{% if tile.chargingConnectivity.is400v %} / {% endif %}800V{% endif %}
</div>
{% endif %}
</div>
<small style="color: #666;">Ladeschnittstellen & Features</small>
<small style="color: #666;">Ladeanschluss</small>
</div>

View File

@ -1,4 +1,8 @@
<div class="tile">
<div class="tile-title">{{ tile.consumption }}</div>
<div class="tile-title">
<i class="fas fa-leaf" style="color: #28a745; margin-right: 8px;"></i>
{{ tile.consumption.energyPer100Km.kwh|round(1) }}
</div>
<div class="tile-subtitle">{{ tile.consumption.unit }}</div>
<small>Verbrauch</small>
</div>
</div>

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.drivetrain }}</div>
<div class="tile-title">
<i class="fas fa-cogs" style="color: #6c757d; margin-right: 8px;"></i>
{{ tile.drivetrain }}
</div>
<small>Antrieb</small>
</div>

View File

@ -1,5 +1,8 @@
<div class="tile performance-overview-tile" style="grid-column: span 3; grid-row: span 2;">
<div class="tile-title">Performance-Übersicht</div>
<div class="tile-title">
<i class="fas fa-trophy" style="color: #ffc107; margin-right: 8px;"></i>
Performance-Übersicht
</div>
<div class="performance-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 12px; height: calc(100% - 40px);">
<!-- Power -->
{% if tile.drivingCharacteristics.power %}

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.power }}</div>
<div class="tile-title">
<i class="fas fa-bolt" style="color: #ffc107; margin-right: 8px;"></i>
{{ tile.power }}
</div>
<small>Leistung</small>
</div>

View File

@ -1,6 +1,8 @@
<div class="tile">
<div class="tile-content">
<div class="tile-title">{{ tile.price }}</div>
<div class="tile-title">
<i class="fas fa-euro-sign" style="color: #28a745; margin-right: 8px;"></i>
{{ tile.price }}
</div>
<div class="tile-subtitle">{{ tile.price.currency.name }}</div>
<small>Preis</small>
</div>

View File

@ -1,5 +1,8 @@
<div class="tile production-period-tile">
<div class="tile-title">Produktionszeitraum</div>
<div class="tile-title">
<i class="fas fa-industry" style="color: #6c757d; margin-right: 8px;"></i>
Produktionszeitraum
</div>
<div class="production-timeline" style="margin-top: 8px;">
{% if tile.productionBegin or tile.productionEnd %}
<div style="display: flex; align-items: center; gap: 8px;">

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.range }}</div>
<div class="tile-title">
<i class="fas fa-road" style="color: #6f42c1; margin-right: 8px;"></i>
{{ tile.range }}
</div>
<small>Reichweite</small>
</div>

View File

@ -1,5 +1,8 @@
<div class="tile range-comparison-tile">
<div class="tile-title">Reichweiten-Vergleich</div>
<div class="tile-title">
<i class="fas fa-balance-scale" style="color: #007acc; margin-right: 8px;"></i>
Reichweiten-Vergleich
</div>
<div class="range-comparison" style="margin-top: 8px;">
<!-- WLTP Range -->
{% if tile.rangeProperties.wltp %}

View File

@ -1,18 +1,16 @@
<div class="tile real-range-tile">
<div class="tile-title">Praxis-Tests</div>
<div class="real-tests" style="margin-top: 8px;">
{% for realTest in tile.realRangeTests|slice(0, 3) %}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; padding: 4px 8px; background: rgba(40,167,69,0.1); border-radius: 4px; font-size: 13px;">
<span style="color: #28a745;">
{% if realTest.season %}{{ realTest.season.value }}{% endif %}
{% if realTest.averageSpeed %}{{ realTest.averageSpeed }}{% endif %}
</span>
<span style="font-weight: bold;">{{ realTest.range.kilometers }} km</span>
</div>
{% endfor %}
{% if tile.realRangeTests|length > 3 %}
<small style="color: #666; font-style: italic; text-align: center; display: block;">+{{ tile.realRangeTests|length - 3 }} weitere</small>
{% endif %}
<div class="tile-title">
<i class="fas fa-chart-line" style="color: #28a745; margin-right: 8px;"></i>
Praxis-Test
</div>
{% if tile.realRangeTests|length > 0 %}
<div class="best-test" style="margin-top: 8px; text-align: center;">
<div style="font-weight: bold; font-size: 24px; color: #28a745; margin-bottom: 4px;">{{ tile.realRangeTests[0].range.kilometers }} km</div>
<div style="font-size: 12px; color: #666;">
{% if tile.realRangeTests[0].season %}{{ tile.realRangeTests[0].season.value }}{% endif %}
{% if tile.realRangeTests[0].averageSpeed %}{{ tile.realRangeTests[0].averageSpeed.kmh }} km/h{% endif %}
</div>
</div>
{% endif %}
<small style="color: #666;">Real-World Reichweite</small>
</div>

View File

@ -1,3 +1,3 @@
<h1 class="section-title" style="grid-column: span 4;">{{ tile.title }}</h1>
<h1 class="section-title" style="grid-column: span 5;">{{ tile.title }}</h1>
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}

View File

@ -1,2 +1,2 @@
<h2 class="subsection-title" style="grid-column: span 4">{{ tile.title }}</h2>
<h2 class="subsection-title" style="grid-column: span 5">{{ tile.title }}</h2>
{% include 'result/tiles/collection.html.twig' with { tiles: tile.tiles } %}

View File

@ -1,4 +1,7 @@
<div class="tile">
<div class="tile-title">{{ tile.topSpeed }}</div>
<div class="tile-title">
<i class="fas fa-tachometer-alt" style="color: #ffc107; margin-right: 8px;"></i>
{{ tile.topSpeed }}
</div>
<small>Höchstgeschwindigkeit</small>
</div>