Skip to content

Commit c674541

Browse files
authored
Feature/dashboard redesign (#1205)
1 parent af25c1c commit c674541

89 files changed

Lines changed: 10647 additions & 9793 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ cd docker/development
4545

4646
## Development Guidelines
4747

48+
### Comments — hard rule for all code (backend, frontend, SCSS)
49+
- **DON'T** add explanatory comments. The code must speak for itself.
50+
- This includes "why" comments justifying a design choice ("X is intentionally omitted because…", "matches the rest of the section rhythm…", "Views live on the per-event-per-day table…", "Online events satisfy the where requirement…"). If you'd write that, rename a variable / extract a function / restructure the code instead, or just leave it implicit.
51+
- Functional annotations are fine: PHPDoc `@throws` / `@return` / `@param`, `// TODO(handle:owner)` linked to a tracked task, schema comments inside SQL migrations that future migrations depend on.
52+
- Never restate what the next line does. If a reviewer can read the diff and understand it, the comment is noise.
53+
- If you're tempted to leave a comment "for the next agent", **don't** — write it as a CLAUDE.md note instead.
54+
4855
### Backend
4956

5057
#### Architecture Flow
@@ -58,8 +65,8 @@ cd docker/development
5865
- **ALWAYS** wrap all translatable strings in `__()` helper
5966
- Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually
6067
- **Always** create unit tests for new features in `backend/tests/Unit/`
68+
- **DON'T** add comments — see the comments rule above. No exceptions for "this seems useful context".
6169
- **NEVER leave dead code.** Code that has no production callers — unused methods, unused DTO fields, unused constants, columns that are written but never read, classes only called from tests — must be deleted, not left "for future use". This applies to both backend and frontend. If you add a method speculatively, wire it to a real caller in the same change or remove it. The same rule applies after refactors: if something becomes unreferenced, it goes. Confirm with grep before claiming a method or class is reachable.
62-
- **DON'T** add comments unless absolutely necessary
6370
- **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML
6471

6572
#### DTOs
@@ -94,6 +101,7 @@ cd docker/development
94101
- **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead
95102
- Unit tests extend Laravel's TestCase, not PHPUnit's TestCase
96103
- Use Mockery for mocking
104+
- **Unit suite (`tests/Unit/`) is for pure isolation tests** — no DB, no HTTP, no real container resolution. If a test uses `DatabaseTransactions`, hits the DB (raw `DB::` calls, factories that persist, repository methods that query), or boots significant framework state, it's an integration test and belongs in `tests/Feature/` (mirror the path, e.g. `tests/Feature/Repository/Eloquent/`). Running `--testsuite=Unit` must stay fast and DB-free.
97105
- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'`
98106
- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass.
99107

backend/VERSION

Whitespace-only changes.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<?php
2+
3+
namespace HiEvents\Console\Commands;
4+
5+
use Carbon\CarbonImmutable;
6+
use HiEvents\Helper\IdHelper;
7+
use Illuminate\Console\Command;
8+
use Illuminate\Support\Facades\DB;
9+
10+
class SeedDevDashboardDataCommand extends Command
11+
{
12+
protected $signature = 'dev:seed-dashboard
13+
{eventId : Event ID to seed dummy historical data for}
14+
{--days=60 : Days of historical data to generate}
15+
{--force : Skip the non-production environment check}';
16+
17+
protected $description = 'Seed dummy historical orders and daily statistics so the dashboard renders with realistic data. Idempotent — re-running replaces previously seeded rows for the event.';
18+
19+
private const SEED_NOTE = '[dev-seed]';
20+
21+
private const FIRST_NAMES = [
22+
'Sarah', 'Liam', 'Aoife', 'Noah', 'Mia', 'Oisin', 'Emma', 'Conor',
23+
'Sophie', 'Sean', 'Ella', 'Cian', 'Ava', 'Patrick', 'Saoirse', 'Diarmuid',
24+
'Niamh', 'Eoin', 'Caoimhe', 'Ciaran',
25+
];
26+
27+
private const LAST_NAMES = [
28+
'O\'Connor', 'Murphy', 'Kelly', 'Byrne', 'Walsh', 'O\'Brien', 'Ryan',
29+
'O\'Sullivan', 'Doyle', 'Kennedy', 'Lynch', 'Quinn', 'McCarthy', 'Brady',
30+
'Reilly',
31+
];
32+
33+
public function handle(): int
34+
{
35+
if (app()->environment('production') && !$this->option('force')) {
36+
$this->error('Refusing to run in production. Pass --force to override.');
37+
return self::FAILURE;
38+
}
39+
40+
$eventId = (int)$this->argument('eventId');
41+
$days = (int)$this->option('days');
42+
43+
$event = DB::table('events')->where('id', $eventId)->first();
44+
if ($event === null) {
45+
$this->error("Event {$eventId} not found.");
46+
return self::FAILURE;
47+
}
48+
49+
$occurrence = DB::table('event_occurrences')
50+
->where('event_id', $eventId)
51+
->whereNull('deleted_at')
52+
->first();
53+
54+
if ($occurrence === null) {
55+
$this->error("Event {$eventId} has no event_occurrence rows. Cannot seed daily statistics.");
56+
return self::FAILURE;
57+
}
58+
59+
$this->info("Seeding {$days} days of dummy data for event {$eventId} ({$event->title}, {$event->currency})");
60+
61+
DB::transaction(function () use ($eventId, $days, $event, $occurrence) {
62+
$this->cleanup($eventId);
63+
64+
$today = CarbonImmutable::today();
65+
$aggregateGross = 0.0;
66+
$aggregateTax = 0.0;
67+
$aggregateFee = 0.0;
68+
$aggregateRefunded = 0.0;
69+
$aggregateOrders = 0;
70+
$aggregateProducts = 0;
71+
$aggregateAttendees = 0;
72+
$aggregateViews = 0;
73+
$aggregateCancelled = 0;
74+
75+
$bar = $this->output->createProgressBar($days);
76+
$bar->start();
77+
78+
for ($i = $days - 1; $i >= 0; $i--) {
79+
$date = $today->subDays($i);
80+
81+
$isWeekend = in_array($date->dayOfWeek, [0, 6], true);
82+
$orderCount = $this->randomOrderCount($i, $isWeekend);
83+
84+
$dayProducts = 0;
85+
$dayAttendees = 0;
86+
$dayGross = 0.0;
87+
$dayTax = 0.0;
88+
$dayFee = 0.0;
89+
$dayRefunded = 0.0;
90+
$dayOrdersCreated = 0;
91+
$dayOrdersCancelled = 0;
92+
$dayViews = random_int(20, 180) + ($isWeekend ? 50 : 0);
93+
94+
for ($n = 0; $n < $orderCount; $n++) {
95+
$items = random_int(1, 4);
96+
$unitPrice = $this->randomChoice([15.00, 25.00, 35.00, 45.00, 75.00]);
97+
$beforeAdditions = round($unitPrice * $items, 2);
98+
$tax = round($beforeAdditions * 0.135, 2);
99+
$fee = round($beforeAdditions * 0.025, 2);
100+
$gross = round($beforeAdditions + $tax + $fee, 2);
101+
102+
$isCancelled = random_int(1, 100) <= 8;
103+
$refundedAmount = 0.0;
104+
if (!$isCancelled && random_int(1, 100) <= 6) {
105+
$refundedAmount = $gross;
106+
}
107+
108+
$createdAt = $date->setTime(random_int(8, 22), random_int(0, 59), random_int(0, 59));
109+
110+
$status = $isCancelled ? 'CANCELLED' : 'COMPLETED';
111+
$paymentStatus = $isCancelled ? null : 'PAYMENT_RECEIVED';
112+
$refundStatus = $refundedAmount > 0 ? 'REFUNDED' : null;
113+
114+
DB::table('orders')->insert([
115+
'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX),
116+
'public_id' => IdHelper::publicId(IdHelper::ORDER_PREFIX),
117+
'event_id' => $eventId,
118+
'currency' => $event->currency,
119+
'first_name' => $this->randomChoice(self::FIRST_NAMES),
120+
'last_name' => $this->randomChoice(self::LAST_NAMES),
121+
'email' => 'seed' . random_int(1000, 9999) . '@example.com',
122+
'status' => $status,
123+
'payment_status' => $paymentStatus,
124+
'refund_status' => $refundStatus,
125+
'total_before_additions' => $beforeAdditions,
126+
'total_gross' => $gross,
127+
'total_tax' => $tax,
128+
'total_fee' => $fee,
129+
'total_refunded' => $refundedAmount,
130+
'is_manually_created' => false,
131+
'notes' => self::SEED_NOTE,
132+
'locale' => 'en',
133+
'payment_provider' => 'STRIPE',
134+
'created_at' => $createdAt,
135+
'updated_at' => $createdAt,
136+
]);
137+
138+
if ($isCancelled) {
139+
$dayOrdersCancelled++;
140+
continue;
141+
}
142+
143+
$dayOrdersCreated++;
144+
$dayProducts += $items;
145+
$dayAttendees += $items;
146+
$dayGross += $gross;
147+
$dayTax += $tax;
148+
$dayFee += $fee;
149+
$dayRefunded += $refundedAmount;
150+
}
151+
152+
DB::table('event_occurrence_daily_statistics')->upsert(
153+
[
154+
[
155+
'event_id' => $eventId,
156+
'event_occurrence_id' => $occurrence->id,
157+
'date' => $date->toDateString(),
158+
'products_sold' => $dayProducts,
159+
'attendees_registered' => $dayAttendees,
160+
'sales_total_gross' => $dayGross,
161+
'sales_total_before_additions' => round($dayGross - $dayTax - $dayFee, 2),
162+
'total_tax' => $dayTax,
163+
'total_fee' => $dayFee,
164+
'orders_created' => $dayOrdersCreated,
165+
'orders_cancelled' => $dayOrdersCancelled,
166+
'total_refunded' => $dayRefunded,
167+
'version' => 0,
168+
'created_at' => $date,
169+
'updated_at' => $date,
170+
],
171+
],
172+
['event_occurrence_id', 'date'],
173+
[
174+
'products_sold', 'attendees_registered',
175+
'sales_total_gross', 'sales_total_before_additions',
176+
'total_tax', 'total_fee',
177+
'orders_created', 'orders_cancelled', 'total_refunded',
178+
'updated_at',
179+
],
180+
);
181+
182+
$aggregateGross += $dayGross;
183+
$aggregateTax += $dayTax;
184+
$aggregateFee += $dayFee;
185+
$aggregateRefunded += $dayRefunded;
186+
$aggregateOrders += $dayOrdersCreated;
187+
$aggregateProducts += $dayProducts;
188+
$aggregateAttendees += $dayAttendees;
189+
$aggregateViews += $dayViews;
190+
$aggregateCancelled += $dayOrdersCancelled;
191+
192+
$bar->advance();
193+
}
194+
195+
$bar->finish();
196+
$this->newLine();
197+
198+
DB::table('event_statistics')
199+
->where('event_id', $eventId)
200+
->update([
201+
'sales_total_gross' => $aggregateGross,
202+
'sales_total_before_additions' => round($aggregateGross - $aggregateTax - $aggregateFee, 2),
203+
'total_tax' => $aggregateTax,
204+
'total_fee' => $aggregateFee,
205+
'total_refunded' => $aggregateRefunded,
206+
'orders_created' => $aggregateOrders,
207+
'orders_cancelled' => $aggregateCancelled,
208+
'products_sold' => $aggregateProducts,
209+
'attendees_registered' => $aggregateAttendees,
210+
'total_views' => $aggregateViews,
211+
'unique_views' => (int)round($aggregateViews * 0.65),
212+
'updated_at' => now(),
213+
]);
214+
215+
$this->table(
216+
['Metric', 'Total over period'],
217+
[
218+
['Orders (completed)', $aggregateOrders],
219+
['Orders (cancelled)', $aggregateCancelled],
220+
['Products sold', $aggregateProducts],
221+
['Attendees', $aggregateAttendees],
222+
['Gross sales', number_format($aggregateGross, 2) . ' ' . $event->currency],
223+
['Tax', number_format($aggregateTax, 2) . ' ' . $event->currency],
224+
['Fees', number_format($aggregateFee, 2) . ' ' . $event->currency],
225+
['Refunded', number_format($aggregateRefunded, 2) . ' ' . $event->currency],
226+
['Page views', $aggregateViews],
227+
],
228+
);
229+
});
230+
231+
$this->info('Done.');
232+
return self::SUCCESS;
233+
}
234+
235+
private function cleanup(int $eventId): void
236+
{
237+
$deletedOrders = DB::table('orders')
238+
->where('event_id', $eventId)
239+
->where('notes', self::SEED_NOTE)
240+
->delete();
241+
242+
$this->line("Cleaned up {$deletedOrders} prior seed orders. Daily statistics will be upserted for the seeded window.");
243+
}
244+
245+
private function randomOrderCount(int $daysAgo, bool $isWeekend): int
246+
{
247+
$base = $isWeekend ? random_int(4, 12) : random_int(1, 7);
248+
249+
if ($daysAgo > 30) {
250+
$base = (int)round($base * 0.6);
251+
}
252+
if (random_int(1, 100) <= 4) {
253+
$base += random_int(8, 20);
254+
}
255+
return max(0, $base);
256+
}
257+
258+
private function randomChoice(array $items)
259+
{
260+
return $items[array_rand($items)];
261+
}
262+
}

backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
1818
final public const TITLE = 'title';
1919
final public const DESCRIPTION = 'description';
2020
final public const STATUS = 'status';
21+
final public const LOCATION_DETAILS = 'location_details';
2122
final public const CURRENCY = 'currency';
2223
final public const TIMEZONE = 'timezone';
2324
final public const ATTRIBUTES = 'attributes';
@@ -39,6 +40,7 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
3940
protected string $title;
4041
protected ?string $description = null;
4142
protected ?string $status = null;
43+
protected array|string|null $location_details = null;
4244
protected string $currency = 'USD';
4345
protected ?string $timezone = null;
4446
protected array|string|null $attributes = null;
@@ -63,6 +65,7 @@ public function toArray(): array
6365
'title' => $this->title ?? null,
6466
'description' => $this->description ?? null,
6567
'status' => $this->status ?? null,
68+
'location_details' => $this->location_details ?? null,
6669
'currency' => $this->currency ?? null,
6770
'timezone' => $this->timezone ?? null,
6871
'attributes' => $this->attributes ?? null,
@@ -166,6 +169,17 @@ public function getStatus(): ?string
166169
return $this->status;
167170
}
168171

172+
public function setLocationDetails(array|string|null $location_details): self
173+
{
174+
$this->location_details = $location_details;
175+
return $this;
176+
}
177+
178+
public function getLocationDetails(): array|string|null
179+
{
180+
return $this->location_details;
181+
}
182+
169183
public function setCurrency(string $currency): self
170184
{
171185
$this->currency = $currency;

0 commit comments

Comments
 (0)