Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions app/Demo/DemoSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Demo;

use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
use Modules\TasksProjects\Application\ProjectMemberService;
use Modules\TasksProjects\Application\ProjectService;
use Modules\TasksProjects\Application\TaskService;
use Modules\TasksProjects\Application\TaskStatusService;
use Modules\TasksProjects\Application\TimeEntryService;
use Modules\TasksProjects\Models\Project;
use Modules\TasksProjects\Models\Task;
use Modules\TasksProjects\Models\TaskStatus;

/**
* Sample projects, tasks and logged time for the public demo.
*
* The host's demo reset calls run() once the demo company, its customers and
* its owner exist, passing the company to fill. Everything goes through the
* module's own services, and every date is relative to today, so the board
* and the timesheets always look current. The time is left unbilled, which
* is what invoicing from time needs to show anything.
*
* A company that already has projects is left alone.
*/
final class DemoSeeder extends Seeder
{
/**
* Three projects, filed under the company's first three customers by name;
* a project with no customer left to take is internal. Rates are minor
* units per hour.
*
* @var list<array{name: string, identifier: string, colour: string, default_rate: int, budget_minutes: int|null, due_in_days: int|null, description: string}>
*/
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<array{project: int, name: string, status: string, priority: string, due_in_days: int, estimated_minutes: int, billable?: bool, entries: list<array{int, int, string}>}>
*/
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;
}
}
9 changes: 6 additions & 3 deletions tests/Support/MemoryCompanyDataReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -77,7 +77,10 @@ public function findCustomer(int $companyId, int $customerId): ?array
/** @return array<string, mixed> */
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<string, mixed> */
Expand Down
82 changes: 82 additions & 0 deletions tests/Unit/DemoSeederTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Tests\Unit;

use Illuminate\Support\Carbon;
use Modules\TasksProjects\Demo\DemoSeeder;
use Modules\TasksProjects\Models\Project;
use Modules\TasksProjects\Models\Task;
use Modules\TasksProjects\Models\TaskStatus;
use Modules\TasksProjects\Models\TimeEntry;
use Modules\TasksProjects\Tests\TestCase;

final class DemoSeederTest extends TestCase
{
private const COMPANY = 9;

private const OWNER = 7;

protected function setUp(): void
{
parent::setUp();

Carbon::setTestNow('2026-09-24 12:00:00');
$this->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());
}
}
Loading