Skip to content
46 changes: 38 additions & 8 deletions src/app/(app)/m/[initials]/i/[issueNumber]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { PageShell } from "~/components/layout/PageShell";
import { IssueTimeline } from "~/components/issues/IssueTimeline";
import { IssueSidebar } from "~/components/issues/IssueSidebar";
import { IssueBadgeGrid } from "~/components/issues/IssueBadgeGrid";
import { OwnerBadge } from "~/components/issues/OwnerBadge";
import { getMachineOwnerName } from "~/lib/issues/owner";
import { formatIssueId } from "~/lib/issues/utils";
import type { Issue, IssueWithAllRelations } from "~/lib/types";

Expand Down Expand Up @@ -67,6 +69,20 @@ export default async function IssueDetailPage({
name: true,
initials: true,
},
with: {
owner: {
columns: {
id: true,
name: true,
},
},
invitedOwner: {
columns: {
id: true,
name: true,
},
},
},
},
reportedByUser: {
columns: {
Expand Down Expand Up @@ -141,6 +157,10 @@ export default async function IssueDetailPage({
redirect(`/m/${initials}`);
}

// Cast issue to IssueWithAllRelations for type safety
const issueWithRelations = issue as unknown as IssueWithAllRelations;
const ownerName = getMachineOwnerName(issueWithRelations);

return (
<PageShell className="space-y-8" size="wide">
{/* Back button */}
Expand All @@ -154,12 +174,22 @@ export default async function IssueDetailPage({

{/* Header */}
<div className="space-y-3">
<Link
href={`/m/${initials}`}
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{issue.machine.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/m/${initials}`}
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{issue.machine.name}
</Link>
{ownerName && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span>•</span>
<span>Game Owner:</span>
<span className="font-medium text-foreground">{ownerName}</span>
<OwnerBadge size="sm" />
</div>
)}
</div>
<div className="space-y-3">
<h1 className="flex items-center gap-3">
<span className="text-muted-foreground font-mono text-2xl">
Expand Down Expand Up @@ -187,12 +217,12 @@ export default async function IssueDetailPage({
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Activity
</h2>
<IssueTimeline issue={issue as unknown as IssueWithAllRelations} />
<IssueTimeline issue={issueWithRelations} />
</section>

{/* Sticky Sidebar */}
<IssueSidebar
issue={issue as unknown as IssueWithAllRelations}
issue={issueWithRelations}
allUsers={allUsers}
currentUserId={user.id}
/>
Expand Down
13 changes: 10 additions & 3 deletions src/components/issues/IssueSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Eye } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { SidebarActions } from "~/components/issues/SidebarActions";
import { WatchButton } from "~/components/issues/WatchButton";
import { OwnerBadge } from "~/components/issues/OwnerBadge";
import { isUserMachineOwner } from "~/lib/issues/owner";
import { type IssueWithAllRelations } from "~/lib/types";
import { resolveIssueReporter } from "~/lib/issues/utils";

Expand Down Expand Up @@ -54,9 +56,14 @@ export function IssueSidebar({
{reporter.initial}
</div>
<div className="flex flex-col min-w-0 overflow-hidden">
<span className="truncate text-sm font-medium text-foreground">
{reporter.name}
</span>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{reporter.name}
</span>
{isUserMachineOwner(issue, reporter.id) && (
<OwnerBadge size="sm" />
)}
</div>
{reporter.email && (
<span className="truncate text-xs text-muted-foreground">
{reporter.email}
Expand Down
17 changes: 15 additions & 2 deletions src/components/issues/IssueTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from "react";
import { formatDistanceToNow } from "date-fns";
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
import { AddCommentForm } from "~/components/issues/AddCommentForm";
import { OwnerBadge } from "~/components/issues/OwnerBadge";
import { isUserMachineOwner } from "~/lib/issues/owner";
import { type IssueWithAllRelations } from "~/lib/types";
import { cn } from "~/lib/utils";
import { resolveIssueReporter } from "~/lib/issues/utils";
Expand All @@ -17,6 +19,7 @@ interface TimelineEvent {
id: string;
type: TimelineEventType;
author: {
id?: string | null;
name: string;
avatarFallback: string;
email?: string | null | undefined;
Expand All @@ -29,9 +32,16 @@ interface TimelineEvent {
// Components
// ----------------------------------------------------------------------

function TimelineItem({ event }: { event: TimelineEvent }): React.JSX.Element {
function TimelineItem({
event,
issue,
}: {
event: TimelineEvent;
issue: IssueWithAllRelations;
}): React.JSX.Element {
const isSystem = event.type === "system";
const isIssue = event.type === "issue";
const isOwner = isUserMachineOwner(issue, event.author.id);

return (
<div className="relative flex gap-4">
Expand Down Expand Up @@ -86,6 +96,7 @@ function TimelineItem({ event }: { event: TimelineEvent }): React.JSX.Element {
>
{event.author.name}
</span>
{isOwner && <OwnerBadge size="sm" />}
{event.author.email && (
<span className="text-xs text-muted-foreground font-normal">
{"<"}
Expand Down Expand Up @@ -141,6 +152,7 @@ export function IssueTimeline({
id: `issue-${issue.id}`,
type: "issue",
author: {
id: reporter.id ?? null,
name: reporter.name,
avatarFallback: reporter.initial,
email: reporter.email,
Expand All @@ -156,6 +168,7 @@ export function IssueTimeline({
id: c.id,
type: c.isSystem ? "system" : "comment",
author: {
id: c.author?.id ?? null,
name: authorName,
avatarFallback: authorName.slice(0, 2).toUpperCase(),
email: c.author?.email,
Expand All @@ -180,7 +193,7 @@ export function IssueTimeline({
{/* Events List */}
<div className="relative flex flex-col space-y-6">
{allEvents.map((event) => (
<TimelineItem key={event.id} event={event} />
<TimelineItem key={event.id} event={event} issue={issue} />
))}

{/* Delightful Empty State when no comments yet */}
Expand Down
35 changes: 35 additions & 0 deletions src/components/issues/OwnerBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { OwnerBadge } from "./OwnerBadge";

describe("OwnerBadge", () => {
it("renders the owner badge with crown icon", () => {
render(<OwnerBadge />);

const badge = screen.getByTestId("owner-badge");
expect(badge).toBeInTheDocument();
expect(badge).toHaveTextContent("Game Owner");
});

it("renders with default size", () => {
render(<OwnerBadge />);

const badge = screen.getByTestId("owner-badge");
expect(badge).toHaveClass("gap-1");
});

it("renders with small size", () => {
render(<OwnerBadge size="sm" />);

const badge = screen.getByTestId("owner-badge");
expect(badge).toHaveClass("text-[10px]");
expect(badge).toHaveClass("px-1.5");
});

it("applies custom className", () => {
render(<OwnerBadge className="custom-class" />);

const badge = screen.getByTestId("owner-badge");
expect(badge).toHaveClass("custom-class");
});
});
36 changes: 36 additions & 0 deletions src/components/issues/OwnerBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type React from "react";
import { Crown } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";

interface OwnerBadgeProps {
className?: string;
size?: "sm" | "default";
}

/**
* OwnerBadge Component
*
* Displays a badge indicating that a user is the machine owner.
* Used in issue details to highlight the owner in the timeline,
* comments, and reporter field.
*/
export function OwnerBadge({
className,
size = "default",
}: OwnerBadgeProps): React.JSX.Element {
return (
<Badge
variant="secondary"
className={cn(
"gap-1 font-semibold uppercase tracking-wide",
size === "sm" && "text-[10px] px-1.5 py-0",
className
)}
data-testid="owner-badge"
>
<Crown className="size-3" />
Game Owner
</Badge>
);
}
127 changes: 127 additions & 0 deletions src/lib/issues/owner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { isUserMachineOwner, getMachineOwnerName } from "./owner";
import type { IssueWithAllRelations } from "~/lib/types";

describe("isUserMachineOwner", () => {
const createMockIssue = (
ownerId?: string | null,
invitedOwnerId?: string | null
): IssueWithAllRelations => {
return {
id: "issue-1",
machineInitials: "MM",
issueNumber: 1,
title: "Test Issue",
description: null,
status: "new",
severity: "minor",
priority: "medium",
consistency: "intermittent",
reportedBy: null,
invitedReportedBy: null,
reporterName: null,
reporterEmail: null,
assignedTo: null,
closedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
machine: {
id: "machine-1",
name: "Medieval Madness",
owner: ownerId ? { id: ownerId, name: "Test Owner" } : null,
invitedOwner: invitedOwnerId
? { id: invitedOwnerId, name: "Invited Owner" }
: null,
},
comments: [],
watchers: [],
};
};

it("returns true when user is the registered owner", () => {
const issue = createMockIssue("user-123");
expect(isUserMachineOwner(issue, "user-123")).toBe(true);
});

it("returns true when user is the invited owner", () => {
const issue = createMockIssue(null, "invited-456");
expect(isUserMachineOwner(issue, "invited-456")).toBe(true);
});

it("returns false when user is not the owner", () => {
const issue = createMockIssue("user-123");
expect(isUserMachineOwner(issue, "user-999")).toBe(false);
});

it("returns false when userId is null", () => {
const issue = createMockIssue("user-123");
expect(isUserMachineOwner(issue, null)).toBe(false);
});

it("returns false when userId is undefined", () => {
const issue = createMockIssue("user-123");
expect(isUserMachineOwner(issue, undefined)).toBe(false);
});

it("returns false when there is no owner", () => {
const issue = createMockIssue(null, null);
expect(isUserMachineOwner(issue, "user-123")).toBe(false);
});
});

describe("getMachineOwnerName", () => {
const createMockIssue = (
ownerName?: string | null,
invitedOwnerName?: string | null
): IssueWithAllRelations => {
return {
id: "issue-1",
machineInitials: "MM",
issueNumber: 1,
title: "Test Issue",
description: null,
status: "new",
severity: "minor",
priority: "medium",
consistency: "intermittent",
reportedBy: null,
invitedReportedBy: null,
reporterName: null,
reporterEmail: null,
assignedTo: null,
closedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
machine: {
id: "machine-1",
name: "Medieval Madness",
owner: ownerName ? { id: "owner-id", name: ownerName } : null,
invitedOwner: invitedOwnerName
? { id: "invited-id", name: invitedOwnerName }
: null,
},
comments: [],
watchers: [],
};
};

it("returns registered owner name when present", () => {
const issue = createMockIssue("John Doe");
expect(getMachineOwnerName(issue)).toBe("John Doe");
});

it("returns invited owner name when registered owner is not present", () => {
const issue = createMockIssue(null, "Jane Smith");
expect(getMachineOwnerName(issue)).toBe("Jane Smith");
});

it("prefers registered owner over invited owner", () => {
const issue = createMockIssue("John Doe", "Jane Smith");
expect(getMachineOwnerName(issue)).toBe("John Doe");
});

it("returns null when there is no owner", () => {
const issue = createMockIssue(null, null);
expect(getMachineOwnerName(issue)).toBe(null);
});
});
Loading