added frontend and made it usable

This commit is contained in:
Hendrik Behme committed 2026-08-15 14:50:42 +02:00
1 parent 96408f00d8
commit b9b15152bc
50 files changed
+7974 -1

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
<?php
namespace App\Repository;
use App\Entity\Event;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
class EventRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Event::class);
}
public function findOneNotDeleted(int $id): ?Event
{
return $this->notDeleted()
->andWhere('e.id = :id')
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
}
/** @return Event[] */
public function findRecent(int $limit): array
{
return $this->orderByStartDate($this->notDeleted())
->setMaxResults($limit)
->getQuery()
->getResult();
}
/** @return Event[] */
public function findPage(int $limit, int $offset, ?string $category = null): array
{
$qb = $this->orderByStartDate($this->notDeleted())
->setMaxResults($limit)
->setFirstResult($offset);
$this->filterByCategory($qb, $category);
return $qb->getQuery()->getResult();
}
public function countPage(?string $category = null): int
{
$qb = $this->notDeleted()->select('COUNT(DISTINCT e.id)');
$this->filterByCategory($qb, $category);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/** @return Event[] */
public function findWithCoordinates(int $limit = 500): array
{
return $this->notDeleted()
->andWhere('e.latitude IS NOT NULL AND e.longitude IS NOT NULL')
->andWhere('e.latitude <> 0 OR e.longitude <> 0')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Distance in km to every event within `radiusKm` of (lat, lon),
* nearest first, keyed by event id — the location+range based
* replacement for a flat "first N" cap. No PostGIS here, so distance
* is a plain Haversine formula in raw SQL (DQL has no trig
* functions); a computed column can't be filtered in WHERE directly,
* hence the subquery.
*
* @return array<int, float>
*/
public function distancesWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
{
$sql = 'SELECT id, distance_km FROM (
SELECT e.id,
(6371 * acos(least(1, greatest(-1,
cos(radians(:lat)) * cos(radians(e.latitude)) * cos(radians(e.longitude) - radians(:lon))
+ sin(radians(:lat)) * sin(radians(e.latitude))
)))) AS distance_km
FROM events e
WHERE e.deleted_at IS NULL
AND e.latitude IS NOT NULL AND e.longitude IS NOT NULL
AND (e.latitude <> 0 OR e.longitude <> 0)
) sub
WHERE distance_km <= :radius
ORDER BY distance_km ASC
LIMIT :limit';
$rows = $this->getEntityManager()->getConnection()->executeQuery(
$sql,
['lat' => $lat, 'lon' => $lon, 'radius' => $radiusKm, 'limit' => $limit],
['lat' => ParameterType::STRING, 'lon' => ParameterType::STRING, 'radius' => ParameterType::STRING, 'limit' => ParameterType::INTEGER]
)->fetchAllAssociative();
$distances = [];
foreach ($rows as $row) {
$distances[(int) $row['id']] = (float) $row['distance_km'];
}
return $distances;
}
/**
* Events within `radiusKm` of (lat, lon), nearest first.
*
* @return Event[]
*/
public function findWithinRadius(float $lat, float $lon, float $radiusKm, int $limit = 500): array
{
return $this->hydrateInOrder(array_keys($this->distancesWithinRadius($lat, $lon, $radiusKm, $limit)));
}
/**
* Text/category search, paginated, optionally restricted to (and
* ordered by) a pre-computed set of location-filtered ids — e.g. from
* distancesWithinRadius() — so "markets near Düsseldorf" combines a
* normal LIKE/category filter with the radius query rather than
* needing a second, separate search path. Without $locationIds this
* behaves like a plain text/category search, ordered chronologically.
*
* Distance order only exists in PHP (the $locationIds array), not as
* a sortable DB column, so paging has to slice that ordered id list
* *after* intersecting it with the text/category matches — pushing
* LIMIT/OFFSET into SQL here would paginate an arbitrary DB-ordered
* subset instead of the correct "25th-50th nearest" slice.
*
* @param int[]|null $locationIds
*
* @return Event[]
*/
public function search(?string $q, ?string $category, string $when, int $limit, int $offset, ?array $locationIds = null): array
{
if (null === $locationIds) {
return $this->orderByStartDate($this->baseSearchQb($q, $category, $when))
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
if (!$locationIds) {
return [];
}
$matchingIds = array_map('intval', array_column(
$this->baseSearchQb($q, $category, $when)
->select('e.id')
->andWhere('e.id IN (:locationIds)')
->setParameter('locationIds', $locationIds)
->getQuery()
->getScalarResult(),
'id'
));
$matchingIdSet = array_flip($matchingIds);
$orderedIds = array_values(array_filter($locationIds, static fn ($id) => isset($matchingIdSet[$id])));
return $this->hydrateInOrder(array_slice($orderedIds, $offset, $limit));
}
/**
* Total count matching the same filters as search(), for pagination.
*
* @param int[]|null $locationIds
*/
public function countSearch(?string $q, ?string $category, string $when, ?array $locationIds = null): int
{
if (null !== $locationIds && !$locationIds) {
return 0;
}
$qb = $this->baseSearchQb($q, $category, $when)->select('COUNT(DISTINCT e.id)');
if (null !== $locationIds) {
$qb->andWhere('e.id IN (:locationIds)')->setParameter('locationIds', $locationIds);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function baseSearchQb(?string $q, ?string $category, string $when): QueryBuilder
{
$qb = $this->notDeleted();
if ($q) {
$qb->andWhere('LOWER(e.title) LIKE :q OR LOWER(e.city) LIKE :q OR LOWER(e.street) LIKE :q')
->setParameter('q', '%'.mb_strtolower($q).'%');
}
$this->filterByCategory($qb, $category);
$this->filterByWhen($qb, $when);
return $qb;
}
/**
* startDate is a raw ISO-8601-ish string ("2026-09-12T18:00:00+02:00"),
* consistently formatted across the imported dataset, so a plain
* lexicographic string comparison against a "Y-m-d" boundary sorts
* correctly without needing to parse it as a real date/time column.
* Applied in SQL (not PHP, post-fetch) so it composes correctly with
* pagination and COUNT — filtering after LIMIT/OFFSET would make the
* page size and total count lie.
*/
private function filterByWhen(QueryBuilder $qb, string $when): void
{
if ('any' === $when) {
return;
}
// Explicit timezone, not the container's (UTC): the imported dates
// and the site's audience are both German-local, so "today" needs
// to mean the German calendar day, not whatever day it happens to
// be in UTC — those disagree for two hours after every UTC
// midnight.
$start = new \DateTimeImmutable('today', new \DateTimeZone('Europe/Berlin'));
$end = match ($when) {
'today' => $start->modify('+1 day'),
'week' => $start->modify('+7 days'),
default => null,
};
if (!$end) {
return;
}
$qb->andWhere('e.startDate >= :whenStart AND e.startDate < :whenEnd')
->setParameter('whenStart', $start->format('Y-m-d'))
->setParameter('whenEnd', $end->format('Y-m-d'));
}
/**
* Hydrates entities for the given ids and returns them in that exact
* order — Doctrine's IN(...) doesn't preserve it.
*
* @param int[] $ids
*
* @return Event[]
*/
private function hydrateInOrder(array $ids): array
{
if (!$ids) {
return [];
}
$events = $this->createQueryBuilder('e')
->andWhere('e.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
$order = array_flip($ids);
usort($events, static fn (Event $a, Event $b) => $order[$a->getId()] <=> $order[$b->getId()]);
return $events;
}
private function filterByCategory(QueryBuilder $qb, ?string $category): void
{
if (!$category) {
return;
}
$qb->join('e.categories', 'c')
->andWhere('c.name = :category')
->setParameter('category', $category);
}
private function notDeleted(): QueryBuilder
{
return $this->createQueryBuilder('e')
->andWhere('e.deletedAt IS NULL');
}
/**
* startDate is a raw ISO-ish string column (may be empty when the
* source had no time interval); push undated events to the end
* instead of letting '' sort first.
*/
private function orderByStartDate(QueryBuilder $qb): QueryBuilder
{
return $qb->addSelect("CASE WHEN e.startDate = '' THEN 1 ELSE 0 END AS HIDDEN has_no_date")
->orderBy('has_no_date', 'ASC')
->addOrderBy('e.startDate', 'ASC');
}
}