feat(KAN-25): build personal dashboard
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:20:27 +02:00
parent 4d6da2c5f5
commit 3914fd5432
10 changed files with 721 additions and 39 deletions
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+18
View File
@@ -118,6 +118,24 @@ export const en = {
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
'A current view of tickets assigned to you in projects you can access.',
'dashboard.summary': 'Work summary',
'dashboard.summary.assignedTickets': 'Assigned tickets',
'dashboard.summary.projects': 'Projects with assigned work',
'dashboard.summary.blockedTickets': 'Blocked tickets',
'dashboard.assignedWork': 'My assigned tickets',
'dashboard.assignedWorkDescription':
'Your work is grouped by project and ordered with the newest tickets first.',
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
'dashboard.openProject': 'Open {name}',
'dashboard.openProjectAction': 'Open project',
'dashboard.empty.title': 'No tickets assigned to you',
'dashboard.empty.description':
'Tickets assigned to you in projects you can access will appear here.',
'dashboard.empty.action': 'Browse projects',
'auth.home': 'KanbanGit home',
'auth.accountAccess': 'Account access',
'auth.backToLogin': 'Return to sign in',
+189 -25
View File
@@ -1,47 +1,211 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types';
import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
{
title: 'Dashboard',
href: dashboard(),
label: t('dashboard.summary.assignedTickets'),
value: assignedTickets.value.length,
icon: ListTodo,
},
];
{
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
<Head :title="t('navigation.dashboard')" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('dashboard.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline break-words">
{{
t('dashboard.greeting', {
name: $page.props.auth.user.name,
})
}}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.description') }}
</p>
</header>
<section :aria-label="t('dashboard.summary')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<PlaceholderPattern />
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
>
<PlaceholderPattern />
<header class="gap-stack grid min-w-0">
<h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<PlaceholderPattern />
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
</div>
</section>
</div>
</AppLayout>
</template>
+9
View File
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
value: string;
};
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = {
projects: ProjectModel[];
status?: string | null;
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\DashboardController;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
})->name('api.docs.openapi');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::get('dashboard', DashboardController::class)->name('dashboard');
require __DIR__.'/web/admin.php';
require __DIR__.'/web/project-templates.php';
+95 -4
View File
@@ -2,26 +2,117 @@
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page()
public function test_guests_are_redirected_to_the_login_page(): void
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_can_visit_the_dashboard()
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
$this->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 0));
}
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$directProject = Project::factory()->create([
'key' => 'WEB',
'name' => 'Web platform',
]);
$teamProject = Project::factory()->create([
'key' => 'OPS',
'name' => 'Operations',
]);
$hiddenProject = Project::factory()->create([
'key' => 'SEC',
'name' => 'Private security',
]);
$team = Team::factory()->create();
$team->users()->attach($user);
$this->syncProjectPermissions($directProject, $user, ['view']);
$this->syncProjectPermissions($teamProject, $team, ['view']);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 1,
'ticket_key' => 'WEB-1',
'title' => 'Older assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 2,
'ticket_key' => 'WEB-2',
'title' => 'Newest assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $teamProject->id,
'sequence_number' => 1,
'ticket_key' => 'OPS-1',
'title' => 'Team-visible assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $otherUser->id,
'project_id' => $directProject->id,
'sequence_number' => 3,
'ticket_key' => 'WEB-3',
'title' => 'Another users ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $hiddenProject->id,
'sequence_number' => 1,
'ticket_key' => 'SEC-1',
'title' => 'Hidden project ticket',
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 2)
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
->has('assignedTicketsByProject.0.tickets', 1)
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
->where('assignedTicketsByProject.1.project.id', $directProject->id)
->has('assignedTicketsByProject.1.tickets', 2)
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
}
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
{
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
}
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4180';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const content = () => page.locator('#main-content');
const focusWithTab = async (link) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await link.evaluate((element) => element === document.activeElement)
) {
assert.equal(
await link.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Dashboard action was not reachable by keyboard');
};
try {
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const [path, state] of [
['/dashboard', 'filled'],
['/dashboard?empty', 'empty'],
]) {
await page.goto(base + path);
await page
.getByRole('heading', {
name: 'Welcome back, Alex Morgan',
exact: true,
level: 1,
})
.waitFor();
await page.evaluate(() => document.fonts.ready);
assert.equal(
await page
.locator('html')
.evaluate((element) =>
element.classList.contains('dark'),
),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${state} dashboard overflows at ${width} in ${theme}`,
);
await page.screenshot({
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await page.goto(`${base}/dashboard`);
const ticket = content().getByRole('link', {
name: 'Open ENG-42: Ship the dashboard with real assigned work',
exact: true,
});
await focusWithTab(ticket);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/tickets/42');
await page
.getByRole('heading', { name: 'Destination reached', exact: true })
.waitFor();
await page.goto(`${base}/dashboard?empty`);
const projects = content().getByRole('link', {
name: 'Browse projects',
exact: true,
});
await focusWithTab(projects);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects');
assert.deepEqual(errors, []);
console.log(
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Dashboard from '../../resources/js/pages/Dashboard.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
({ Dashboard })[name] ?? {
render: () => h('h1', 'Destination reached'),
},
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+248
View File
@@ -0,0 +1,248 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/dashboard-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 8,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
avatar_color: '#22c55e',
avatar_is_generated: true,
email_verified_at: '2026-09-11T00:00:00Z',
must_change_password_on_next_login: false,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
show_author: true,
show_story_points: true,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const secondProject = {
...project,
id: 9,
key: 'OPS',
name: 'Operations',
};
const ticketDefaults = {
acceptance_criteria: [],
assignee: user,
assignee_user_id: user.id,
author: null,
author_user_id: null,
blocked_comment: null,
description: 'Real work loaded from the server.',
is_blocked: false,
parent_ticket_id: null,
story_points: 3,
ticket_priority: {
id: 1,
name: 'Medium',
position: 3,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_priority_id: 1,
ticket_status: {
id: 3,
board_column_id: 2,
name: 'In Progress',
position: 1,
project_id: 7,
slug: 'in-progress',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
board_column: {
id: 2,
name: 'In Progress',
position: 2,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
},
ticket_status_id: 3,
ticket_type: {
id: 1,
is_parent: false,
name: 'Dev task',
position: 1,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_type_id: 1,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const tickets = [
{
...ticketDefaults,
id: 42,
project_id: project.id,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Ship the dashboard with real assigned work',
},
{
...ticketDefaults,
id: 41,
project_id: project.id,
sequence_number: 41,
ticket_key: 'ENG-41',
title: 'Verify keyboard navigation',
},
];
const blockedTicket = {
...ticketDefaults,
id: 12,
blocked_comment: 'Waiting for review.',
is_blocked: true,
project_id: secondProject.id,
sequence_number: 12,
ticket_key: 'OPS-12',
title: 'Resolve the deployment blocker',
};
const assignedTicketsByProject = [
{ project: secondProject, tickets: [blockedTicket] },
{ project, tickets },
];
function makePage(url) {
const parsedUrl = new URL(url, 'http://localhost');
const isDashboard = parsedUrl.pathname === '/dashboard';
return {
component: isDashboard ? 'Dashboard' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
...(isDashboard
? {
assignedTicketsByProject: parsedUrl.searchParams.has(
'empty',
)
? []
: assignedTicketsByProject,
}
: {}),
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/dashboard-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Dashboard } = await server.ssrLoadModule(
'/resources/js/pages/Dashboard.vue',
);
const render = async (url) => {
const response = await createInertiaApp({
page: makePage(url),
resolve: () => Dashboard,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return response.body.split('<main')[1].split('</main>')[0];
};
const dashboard = await render('/dashboard');
assert.match(dashboard, /Welcome back, Alex Morgan/);
assert.match(dashboard, /Assigned tickets/);
assert.match(dashboard, /Projects with assigned work/);
assert.match(dashboard, /Blocked tickets/);
assert.match(dashboard, /Ship the dashboard with real assigned work/);
assert.match(dashboard, /Resolve the deployment blocker/);
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
assert.match(dashboard, /href="\/projects\/9"/);
assert.doesNotMatch(
dashboard,
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
);
const empty = await render('/dashboard?empty');
assert.match(empty, /No tickets assigned to you/);
assert.match(empty, /href="\/projects"/);
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
console.log('Dashboard behavior checks passed.');
} finally {
await server.close();
}
}