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
46 changes: 39 additions & 7 deletions app/Http/Controllers/OverviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,51 @@
namespace App\Http\Controllers;

use App\Domain\Dashboard\Queries\GetOverviewDashboardQuery;
use App\Domain\GitHub\Queries\WorkItemsForUserQuery;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

/**
* Overview dashboard. Delegates the read entirely to
* `GetOverviewDashboardQuery` (roadmap §10.2). The query is responsible
* for distinguishing real DB-backed slices from clearly-marked mock
* placeholders.
* Overview dashboard. Mostly delegates the read to
* `GetOverviewDashboardQuery` (roadmap §10.2) — that query handles the
* single-tenant phase-1 slices that don't need a user (projects,
* hosts, deployments, top repositories, activity heatmap).
*
* The Issues & PRs widget is the one user-scoped slice on this page
* — `WorkItemsForUserQuery` requires a `User` so we run it here and
* merge the result rather than threading the user into
* `GetOverviewDashboardQuery`'s no-arg signature.
*/
class OverviewController extends Controller
{
public function __invoke(GetOverviewDashboardQuery $query): Response
{
return Inertia::render('Overview', $query->handle());
/**
* Cap for the Issues & PRs widget — keeps the card visually
* consistent with the other 4-row stubs around it.
*/
private const TOP_WORK_ITEMS_LIMIT = 4;

public function __invoke(
Request $request,
GetOverviewDashboardQuery $query,
WorkItemsForUserQuery $workItemsQuery,
): Response {
$payload = $query->handle();

// Spec 016 shipped the work-items query; this surfaces the top
// N open items on the Overview's Issues & PRs widget so the
// card reflects real activity instead of fixture data.
$topWorkItems = array_slice(
$workItemsQuery->execute($request->user(), [
'kind' => 'all',
'state' => 'open',
]),
0,
self::TOP_WORK_ITEMS_LIMIT,
);

return Inertia::render('Overview', array_merge($payload, [
'topWorkItems' => $topWorkItems,
]));
}
}
130 changes: 73 additions & 57 deletions resources/js/Pages/Overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,50 +22,29 @@ import {
ShieldCheck,
} from 'lucide-vue-next';

interface TopWorkItem {
id: string;
kind: 'issue' | 'pull_request' | string;
number: number;
title: string;
state: string | null;
author_login: string | null;
updated_at_github: string | null;
html_url: string | null;
repository: { id: number; full_name: string; name: string } | null;
}

defineProps<{
dashboard: DashboardPayload;
activityHeatmap: ActivityHeatmapPayload;
topWorkItems: TopWorkItem[];
}>();

// ----- Hardcoded mock data for the populated stub widgets -----
// These widgets each have their own dedicated spec in later phases. The
// mock data here exists only so the page looks intentional, not skeletal —
// each stub footer points at the phase that will replace it.

const stubIssues = [
{
title: 'Login flow rejects valid 2FA codes intermittently',
repo: 'nexus-web',
time: '12 min',
priority: 'critical' as const,
},
{
title: 'Migrate analytics events to BigQuery sink',
repo: 'nexus-api',
time: '3 h',
priority: 'high' as const,
},
{
title: 'Add dark-mode tokens to email templates',
repo: 'nexus-mail',
time: '1 d',
priority: 'medium' as const,
},
{
title: 'Expose feature-flag SDK in the JS bundle',
repo: 'nexus-flags',
time: '2 d',
priority: 'low' as const,
},
];

const priorityToneMap = {
critical: 'danger',
high: 'warning',
medium: 'info',
low: 'muted',
} as const;

const stubHosts = [
{ name: 'prod-web-01', region: 'us-east', cpu: 0.42, mem: 0.61, status: 'success' as const },
{ name: 'prod-api-02', region: 'us-east', cpu: 0.78, mem: 0.83, status: 'warning' as const },
Expand Down Expand Up @@ -96,12 +75,16 @@ const stubServices = [
},
];

// Stubs only list widgets whose owning phase hasn't shipped yet.
// `Deployment timeline` graduated when Phase 4 (specs 020–022) shipped
// — its real surface is the `Deployments` sidebar entry / `/deployments`
// page. `Website performance` stays here until spec 025 lands the
// dedicated Overview widget on top of the spec-023 monitor data.
const visualizationStubs = [
{ label: 'World map', icon: Globe, phase: 'Phase 8' },
{ label: 'Resource utilization', icon: Activity, phase: 'Phase 6' },
{ label: 'Website performance', icon: LineChart, phase: 'Phase 5' },
{ label: 'Website performance', icon: LineChart, phase: 'Phase 5 · spec 025' },
{ label: 'System metrics', icon: Gauge, phase: 'Phase 8' },
{ label: 'Deployment timeline', icon: Rocket, phase: 'Phase 4' },
] as const;
</script>

Expand Down Expand Up @@ -224,7 +207,10 @@ const visualizationStubs = [
<!-- Each card below is a populated placeholder. The canonical
implementation ships with the phase named in the footer. -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
<!-- Issues & PRs -->
<!-- Issues & PRs — real data from `WorkItemsForUserQuery`
(spec 016). Top 4 open work items across the user's
repos; the wider `/work-items` page hosts the full
queue + filters. -->
<section
aria-label="Issues & PRs"
class="glass-card flex flex-col gap-4 p-5 lg:col-span-7"
Expand All @@ -240,32 +226,62 @@ const visualizationStubs = [
</h2>
</div>
<span class="hidden font-mono text-[11px] text-text-muted sm:inline">
{{ stubIssues.length }} open · mock
{{ topWorkItems.length }} open
</span>
</header>
<ul class="flex flex-col gap-2">
<ul
v-if="topWorkItems.length > 0"
class="flex flex-col gap-2"
>
<li
v-for="issue in stubIssues"
:key="issue.title"
class="flex items-center gap-3 rounded-lg border border-border-subtle bg-background-panel-hover/40 p-3"
v-for="item in topWorkItems"
:key="item.id"
>
<StatusBadge :tone="priorityToneMap[issue.priority]">
{{ issue.priority }}
</StatusBadge>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-text-primary">
{{ issue.title }}
</p>
<p
class="truncate font-mono text-[11px] text-text-muted"
<a
:href="item.html_url ?? '#'"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-border-subtle bg-background-panel-hover/40 p-3 transition hover:border-accent-cyan/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-cyan/60"
>
<StatusBadge
:tone="item.kind === 'pull_request' ? 'info' : 'muted'"
>
{{ issue.repo }} · {{ issue.time }} ago
</p>
</div>
{{ item.kind === 'pull_request' ? 'PR' : 'Issue' }}
</StatusBadge>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-text-primary">
<span class="font-mono text-text-muted">
#{{ item.number }}
</span>
{{ item.title }}
</p>
<p
class="truncate font-mono text-[11px] text-text-muted"
>
<span v-if="item.repository">
{{ item.repository.full_name }}
<span v-if="item.author_login">
· @{{ item.author_login }}
</span>
</span>
<span v-if="item.updated_at_github">
· Updated {{ item.updated_at_github }}
</span>
</p>
</div>
</a>
</li>
</ul>
<p
v-else
class="rounded-lg border border-dashed border-border-subtle bg-background-panel-hover/30 p-4 text-sm text-text-muted"
>
No open issues or pull requests yet — connect a
GitHub repository to start syncing them.
</p>
<footer class="text-[11px] text-text-muted">
Full widget lands with phase 2 — Issues &amp; PRs sync.
Showing the most recent open items. The full queue
lives at <span class="font-mono">/work-items</span>.
</footer>
</section>

Expand Down Expand Up @@ -454,7 +470,7 @@ const visualizationStubs = [
</li>
</ul>
<footer class="text-[11px] text-text-muted">
Full widget lands with phase 5Website Monitoring.
Full widget lands with phase 6Docker Host Agent.
</footer>
</section>

Expand All @@ -474,7 +490,7 @@ const visualizationStubs = [
</h2>
</div>
<span class="hidden font-mono text-[11px] text-text-muted sm:inline">
7 days · 4-hour buckets · mock
7 days · 4-hour buckets · last 90 days
</span>
</header>
<ActivityHeatmap :data="activityHeatmap" />
Expand Down
44 changes: 44 additions & 0 deletions tests/Feature/SmokeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Tests\Feature;

use App\Models\GithubIssue;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
Expand Down Expand Up @@ -46,6 +49,47 @@ public function test_overview_carries_the_mock_dashboard_payload(): void
->has('uptime.overall')
->has('topRepositories')
)
->has('topWorkItems')
);
}

public function test_overview_topworkitems_pulls_from_user_repos_only(): void
{
$user = User::factory()->create([
'email_verified_at' => now(),
]);
$project = Project::factory()->create(['owner_user_id' => $user->id]);
$repository = Repository::factory()->create([
'project_id' => $project->id,
'full_name' => 'mine/repo',
]);
GithubIssue::factory()->create([
'repository_id' => $repository->id,
'number' => 7,
'title' => 'Mine',
'state' => 'open',
]);

// Sibling user's open issue must NOT leak into the widget.
$other = User::factory()->create(['email_verified_at' => now()]);
$otherProject = Project::factory()->create(['owner_user_id' => $other->id]);
$otherRepo = Repository::factory()->create([
'project_id' => $otherProject->id,
'full_name' => 'other/repo',
]);
GithubIssue::factory()->create([
'repository_id' => $otherRepo->id,
'number' => 1,
'title' => 'Sibling',
'state' => 'open',
]);

$this->actingAs($user)
->get('/overview')
->assertInertia(
fn (AssertableInertia $page) => $page
->has('topWorkItems', 1)
->where('topWorkItems.0.title', 'Mine')
);
}

Expand Down
Loading