diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php
index de2faf1..15d052a 100644
--- a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php
+++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
+ 'acceptanceCriteria',
'assignee',
- 'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php
new file mode 100644
index 0000000..63b887a
--- /dev/null
+++ b/app/Http/Controllers/DashboardController.php
@@ -0,0 +1,24 @@
+user();
+
+ return Inertia::render('Dashboard', [
+ 'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
+ ]);
+ }
+}
diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts
index eb32394..5e11e4a 100644
--- a/resources/js/locales/en.ts
+++ b/resources/js/locales/en.ts
@@ -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',
diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue
index 7b3a935..611d124 100644
--- a/resources/js/pages/Dashboard.vue
+++ b/resources/js/pages/Dashboard.vue
@@ -1,47 +1,211 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ -
+
+
+
+
+ {{ item.label }}
+
+
+ {{ item.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.project.key }}
+
+
+
+ {{ group.project.name }}
+
+
+ {{
+ t('tickets.count', {
+ count: group.tickets.length,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts
index 00e8ee2..e6fb2f9 100644
--- a/resources/js/types/page-props.ts
+++ b/resources/js/types/page-props.ts
@@ -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;
diff --git a/routes/web.php b/routes/web.php
index 7e87613..ffb392a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,6 @@
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';
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
index 10a9671..bc4033c 100644
--- a/tests/Feature/DashboardTest.php
+++ b/tests/Feature/DashboardTest.php
@@ -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());
}
}
diff --git a/tests/Fixtures/dashboard-browser.mjs b/tests/Fixtures/dashboard-browser.mjs
new file mode 100644
index 0000000..67a98af
--- /dev/null
+++ b/tests/Fixtures/dashboard-browser.mjs
@@ -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();
+}
diff --git a/tests/Fixtures/dashboard-preview.mjs b/tests/Fixtures/dashboard-preview.mjs
new file mode 100644
index 0000000..ae81432
--- /dev/null
+++ b/tests/Fixtures/dashboard-preview.mjs
@@ -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),
+});
diff --git a/tests/Fixtures/dashboard.mjs b/tests/Fixtures/dashboard.mjs
new file mode 100644
index 0000000..8a9b8b3
--- /dev/null
+++ b/tests/Fixtures/dashboard.mjs
@@ -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('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<');
+ response.setHeader('Content-Type', 'text/html');
+ response.end(
+ await server.transformIndexHtml(
+ request.url,
+ '
',
+ ),
+ );
+ });
+ 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('')[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|