From 39f0bec164f3d64283d7363e14ae8f41e89193ab Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 24 Sep 2026 01:03:31 +0200 Subject: [PATCH] feat: sample projects, tasks and time for the public demo The host's demo reset calls Demo\DemoSeeder::run($companyId) once the demo company exists: three projects under its customers, a dozen tasks across the board and unbilled time from the last few weeks, all through the module's services and dated relative to today. --- app/Demo/DemoSeeder.php | 189 ++++++++++++++++++++++ tests/Support/MemoryCompanyDataReader.php | 9 +- tests/Unit/DemoSeederTest.php | 82 ++++++++++ 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 app/Demo/DemoSeeder.php create mode 100644 tests/Unit/DemoSeederTest.php diff --git a/app/Demo/DemoSeeder.php b/app/Demo/DemoSeeder.php new file mode 100644 index 0000000..bcce4f9 --- /dev/null +++ b/app/Demo/DemoSeeder.php @@ -0,0 +1,189 @@ + + */ + private const PROJECTS = [ + [ + 'name' => 'Website redesign', + 'identifier' => 'WEB', + 'colour' => '#6366f1', + 'default_rate' => 9500, + 'budget_minutes' => 80 * 60, + 'due_in_days' => 30, + 'description' => 'New marketing site: design system, home and pricing pages, blog migration.', + ], + [ + 'name' => 'Mobile app launch', + 'identifier' => 'APP', + 'colour' => '#0ea5e9', + 'default_rate' => 11000, + 'budget_minutes' => 120 * 60, + 'due_in_days' => 60, + 'description' => 'First release of the ordering app on iOS and Android.', + ], + [ + 'name' => 'Monthly support', + 'identifier' => 'SUP', + 'colour' => '#f59e0b', + 'default_rate' => 8500, + 'budget_minutes' => null, + 'due_in_days' => null, + 'description' => 'Hosting, maintenance and small fixes, billed monthly.', + ], + ]; + + /** + * Tasks by project (an index into PROJECTS). `status` is a default column + * name; `entries` are [days ago, minutes, description]. + * + * @var list}> + */ + private const TASKS = [ + ['project' => 0, 'name' => 'Audit the current site', 'status' => 'Done', 'priority' => Task::PRIORITY_NORMAL, 'due_in_days' => -18, 'estimated_minutes' => 240, 'entries' => [ + [22, 120, 'Crawled the site and listed broken pages'], + [21, 90, 'Analytics review: top pages and exits'], + ]], + ['project' => 0, 'name' => 'Wireframes for the home and pricing pages', 'status' => 'Done', 'priority' => Task::PRIORITY_HIGH, 'due_in_days' => -10, 'estimated_minutes' => 480, 'entries' => [ + [16, 180, 'Home page wireframe, two directions'], + [15, 150, 'Pricing page wireframe'], + [14, 60, 'Review call with the client'], + ]], + ['project' => 0, 'name' => 'Design system: colours, type and buttons', 'status' => 'Review', 'priority' => Task::PRIORITY_HIGH, 'due_in_days' => 2, 'estimated_minutes' => 360, 'entries' => [ + [8, 150, 'Colour and type scales'], + [7, 120, 'Buttons, fields and cards'], + ]], + ['project' => 0, 'name' => 'Build the new home page', 'status' => 'In Progress', 'priority' => Task::PRIORITY_HIGH, 'due_in_days' => 7, 'estimated_minutes' => 600, 'entries' => [ + [3, 210, 'Hero and feature sections'], + [1, 165, 'Responsive layout and testimonials'], + ]], + ['project' => 0, 'name' => 'Migrate blog posts', 'status' => 'Backlog', 'priority' => Task::PRIORITY_LOW, 'due_in_days' => 21, 'estimated_minutes' => 300, 'entries' => []], + ['project' => 1, 'name' => 'Sign-in with email and Apple', 'status' => 'In Progress', 'priority' => Task::PRIORITY_URGENT, 'due_in_days' => 4, 'estimated_minutes' => 480, 'entries' => [ + [4, 240, 'Email sign-in and password reset'], + [2, 135, 'Sign in with Apple'], + ]], + ['project' => 1, 'name' => 'Offline cart', 'status' => 'Review', 'priority' => Task::PRIORITY_NORMAL, 'due_in_days' => 3, 'estimated_minutes' => 420, 'entries' => [ + [9, 180, 'Local storage for the cart'], + [6, 120, 'Sync when the connection returns'], + ]], + ['project' => 1, 'name' => 'Push notifications for order updates', 'status' => 'Backlog', 'priority' => Task::PRIORITY_NORMAL, 'due_in_days' => 25, 'estimated_minutes' => 360, 'entries' => []], + ['project' => 1, 'name' => 'App Store screenshots and listing', 'status' => 'Backlog', 'priority' => Task::PRIORITY_LOW, 'due_in_days' => 35, 'estimated_minutes' => 180, 'entries' => []], + ['project' => 2, 'name' => 'Renew the SSL certificate', 'status' => 'Done', 'priority' => Task::PRIORITY_URGENT, 'due_in_days' => -5, 'estimated_minutes' => 30, 'entries' => [ + [6, 30, 'Renewed and installed the certificate'], + ]], + ['project' => 2, 'name' => 'Fix checkout emails landing in spam', 'status' => 'In Progress', 'priority' => Task::PRIORITY_HIGH, 'due_in_days' => 1, 'estimated_minutes' => 120, 'entries' => [ + [2, 75, 'SPF and DKIM records'], + ]], + ['project' => 2, 'name' => 'Update the internal runbook', 'status' => 'Backlog', 'priority' => Task::PRIORITY_LOW, 'due_in_days' => 12, 'estimated_minutes' => 90, 'billable' => false, 'entries' => [ + [5, 45, 'Restore steps for the database'], + ]], + ]; + + public function run( + int $companyId, + CompanyDataReader $companyData, + TaskStatusService $statuses, + ProjectService $projects, + ProjectMemberService $members, + TaskService $tasks, + TimeEntryService $entries, + ): void { + if (Project::query()->forCompany($companyId)->exists()) { + return; + } + + $owner = $companyData->companyMembers($companyId)[0]['id'] ?? null; + + if ($owner === null) { + return; + } + + $statuses->ensureDefaults($companyId); + $columns = $statuses->listFor($companyId)->keyBy(fn (TaskStatus $status): string => $status->name); + $customers = array_values($companyData->searchCustomers($companyId, null, count(self::PROJECTS))); + $today = Carbon::today(); + + $created = []; + + foreach (self::PROJECTS as $index => $project) { + $created[$index] = $projects->create($companyId, [ + 'customer_id' => $customers[$index]['id'] ?? null, + 'name' => $project['name'], + 'identifier' => $project['identifier'], + 'description' => $project['description'], + 'colour' => $project['colour'], + 'default_rate' => $project['default_rate'], + 'budget_minutes' => $project['budget_minutes'], + 'due_date' => $project['due_in_days'] === null ? null : $today->copy()->addDays($project['due_in_days'])->toDateString(), + 'creator_id' => $owner, + ]); + + $members->attach($companyId, (int) $created[$index]->id, (int) $owner); + } + + foreach (self::TASKS as $definition) { + $task = $tasks->create($companyId, [ + 'project_id' => $created[$definition['project']]->id, + 'task_status_id' => $columns->get($definition['status'])?->id, + 'name' => $definition['name'], + 'priority' => $definition['priority'], + 'due_date' => $today->copy()->addDays($definition['due_in_days'])->toDateString(), + 'estimated_minutes' => $definition['estimated_minutes'], + 'billable' => $definition['billable'] ?? true, + 'assignee_id' => $owner, + 'creator_id' => $owner, + ]); + + foreach ($definition['entries'] as [$daysAgo, $minutes, $description]) { + $startedAt = $this->workday($today->copy()->subDays($daysAgo))->setTime(9, 30); + + $entries->create($companyId, [ + 'task_id' => $task->id, + 'user_id' => $owner, + 'started_at' => $startedAt->toDateTimeString(), + 'ended_at' => $startedAt->copy()->addMinutes($minutes)->toDateTimeString(), + 'description' => $description, + ]); + } + } + } + + /** Weekend time looks odd on a timesheet; it moves back to the Friday. */ + private function workday(Carbon $day): Carbon + { + return $day->isWeekend() ? $day->previous(Carbon::FRIDAY) : $day; + } +} diff --git a/tests/Support/MemoryCompanyDataReader.php b/tests/Support/MemoryCompanyDataReader.php index b518a79..e960d03 100644 --- a/tests/Support/MemoryCompanyDataReader.php +++ b/tests/Support/MemoryCompanyDataReader.php @@ -12,8 +12,8 @@ * Only the surfaces this module actually uses carry state: the invoice ids * that still exist, which decide whether a stamped entry counts as billed, the * company member list, which supplies names for the member grouping, and the - * customers, which lend a new project its currency. The rest satisfy the - * contract and return nothing. + * customers, which lend a new project its currency and give the demo seeder + * its projects. The rest satisfy the contract and return nothing. */ final class MemoryCompanyDataReader implements CompanyDataReader { @@ -77,7 +77,10 @@ public function findCustomer(int $companyId, int $customerId): ?array /** @return array */ public function searchCustomers(int $companyId, ?string $query, int $limit): array { - return []; + $customers = array_values($this->customers[$companyId] ?? []); + usort($customers, fn (array $a, array $b): int => [$a['name'], $a['id']] <=> [$b['name'], $b['id']]); + + return array_slice($customers, 0, $limit); } /** @return array */ diff --git a/tests/Unit/DemoSeederTest.php b/tests/Unit/DemoSeederTest.php new file mode 100644 index 0000000..96a1517 --- /dev/null +++ b/tests/Unit/DemoSeederTest.php @@ -0,0 +1,82 @@ +companyData->withMember(self::COMPANY, self::OWNER, 'Demo Owner'); + } + + private function seedDemo(int $companyId = self::COMPANY): void + { + $seeder = new DemoSeeder; + $seeder->setContainer($this->app)->__invoke(['companyId' => $companyId]); + } + + public function test_the_demo_gets_projects_under_its_customers_with_a_board_and_unbilled_time(): void + { + $this->companyData->withCustomer(self::COMPANY, 31, 3)->withCustomer(self::COMPANY, 32)->withCustomer(self::COMPANY, 33); + + $this->seedDemo(); + + $projects = Project::query()->forCompany(self::COMPANY)->orderBy('id')->get(); + self::assertSame([31, 32, 33], $projects->pluck('customer_id')->all()); + self::assertSame(3, $projects->first()->currency_id); + + $columns = Task::query()->forCompany(self::COMPANY)->get() + ->groupBy('task_status_id') + ->map->count(); + self::assertCount(4, TaskStatus::query()->forCompany(self::COMPANY)->get()); + self::assertCount(4, $columns, 'every column of the board has tasks'); + self::assertSame(12, $columns->sum()); + + $entries = TimeEntry::query()->forCompany(self::COMPANY)->get(); + self::assertGreaterThan(10, $entries->count()); + self::assertTrue($entries->every(fn (TimeEntry $entry): bool => $entry->invoice_id === null && (int) $entry->user_id === self::OWNER)); + self::assertGreaterThan(0, $entries->where('billable', true)->sum('amount')); + self::assertTrue($entries->contains('billable', false)); + self::assertTrue($entries->every(fn (TimeEntry $entry): bool => ! $entry->started_at->isWeekend() && $entry->started_at->lessThan(Carbon::now()))); + } + + public function test_a_company_without_customers_gets_internal_projects(): void + { + $this->seedDemo(); + + self::assertSame([null, null, null], Project::query()->forCompany(self::COMPANY)->pluck('customer_id')->all()); + } + + public function test_a_company_that_already_has_projects_is_left_alone(): void + { + $this->seedDemo(); + $this->seedDemo(); + + self::assertSame(3, Project::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, Project::query()->forCompany(self::COMPANY + 1)->count()); + } + + public function test_a_company_with_no_one_to_log_time_gets_nothing(): void + { + $this->seedDemo(self::COMPANY + 1); + + self::assertSame(0, Project::query()->count()); + } +}