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
231 changes: 128 additions & 103 deletions components/features/activity-feed.tsx
Original file line number Diff line number Diff line change
@@ -1,134 +1,159 @@
import {
getMyRecentActivity,
getRecentApplications,
} from '@/prisma/data/applications';
import { getActivityGroups } from '@/prisma/data/activity';

import {
APPLICATION_STATUS_BADGE_VARIANT,
APPLICATION_STATUS_LABELS,
ACTIVITY_FEED_COPY,
ACTIVITY_MINE_TITLE,
STATUS_BADGE_VARIANT_TO_DOT,
} from '@/lib/constants';
import { CONCEPT_ICONS } from '@/lib/icons';
import { type ActivityItem, type Reviewer } from '@/lib/types';
import { getDisplayName, getRenamedTo } from '@/lib/utils';
import { type ActivityItem, type ActivityScope } from '@/lib/types';

import { LocalTime } from '@/components/ui/local-time';
import { SectionCard, SectionCardEmpty } from '@/components/ui/section-card';
import { SectionCardEmpty } from '@/components/ui/section-card';
import { Skeleton } from '@/components/ui/skeleton';

// ─── Presentational leaf ─────────────────────────────────────────────────────
export function ActivityFeedList({ items }: { items: ActivityItem[] }) {
return (
<ol>
{items.map((item) => {
const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant];

return (
<li
key={item.id}
className="flex items-start gap-3 border-b px-4 py-3 last:border-0"
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<p className="line-clamp-3 min-w-0 flex-1 text-sm">
{item.sentence}
</p>
<LocalTime
date={item.timestamp}
precision="relative"
className="text-muted-foreground ml-auto shrink-0 text-xs tabular-nums"
/>
</li>
);
})}
</ol>
);
}

interface ActivityFeedListProps {
function ActivityFeedGroup({
id,
title,
items,
}: {
id: string;
title: string;
items: ActivityItem[];
emptyDescription: string;
}) {
return (
<section aria-labelledby={id}>
<h3
id={id}
className="text-muted-foreground px-4 pt-4 pb-1 text-xs font-medium"
>
{title}
</h3>
<ActivityFeedList items={items} />
</section>
);
}

function ActivityFeedList({ items, emptyDescription }: ActivityFeedListProps) {
return (
<SectionCard
title="Recent Activity"
icon={CONCEPT_ICONS.activity}
sectionLabel="Recent activity"
>
{items.length === 0 ? (
interface ActivityFeedProps {
userId: string;
isAdmin: boolean;
}

export async function ActivityFeed({ userId, isAdmin }: ActivityFeedProps) {
let groups;
try {
groups = await getActivityGroups(userId, isAdmin);
} catch (error) {
console.error('getActivityGroups failed', error);
Comment thread
cielbellerose marked this conversation as resolved.
return (
<SectionCardEmpty
variant="compact"
message="Couldn't load recent activity."
/>
);
}

const { scope, mine, reviewed } = groups;
const copy = ACTIVITY_FEED_COPY[scope];

if (mine.length === 0 && reviewed.length === 0)
return (
<div className="px-4">
<SectionCardEmpty
icon={CONCEPT_ICONS.activity}
title="No recent activity"
description={emptyDescription}
description={copy.emptyDescription}
/>
) : (
<ol>
{items.map((item) => {
const dotClass = STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant];
</div>
);

return (
<li
key={item.id}
className="flex items-start gap-3 border-b px-4 py-3 last:border-0"
>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${dotClass}`}
aria-hidden="true"
/>
<p className="line-clamp-2 min-w-0 flex-1 text-sm">
{item.sentence}
</p>
<LocalTime
date={item.timestamp}
precision="relative"
className="text-muted-foreground ml-auto shrink-0 text-xs tabular-nums"
/>
</li>
);
})}
</ol>
if (scope === 'none') return <ActivityFeedList items={mine} />;

return (
<>
{mine.length > 0 && (
<ActivityFeedGroup
id="activity-mine"
title={ACTIVITY_MINE_TITLE}
items={mine}
/>
)}
</SectionCard>
{reviewed.length > 0 && copy.reviewedTitle && (
<ActivityFeedGroup
id="activity-reviewed"
title={copy.reviewedTitle}
items={reviewed}
/>
)}
</>
);
}

// ─── Applicant feed wrapper ───────────────────────────────────────────────────

interface ApplicantActivityFeedProps {
userId: string;
}

// States the current status only — no status-history table, so no from-state to assert.
export async function ApplicantActivityFeed({
userId,
}: ApplicantActivityFeedProps) {
const applications = await getMyRecentActivity(userId, 10);

const items: ActivityItem[] = applications.map((app) => {
const statusLabel = APPLICATION_STATUS_LABELS[app.status];
const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status];
return {
id: app.id,
statusVariant: variant,
sentence: `Your application for ${app.position.title} is ${statusLabel}`,
timestamp: app.submittedAt,
};
});

function ActivityFeedRowsSkeleton({ count }: { count: number }) {
return (
<ActivityFeedList
items={items}
emptyDescription="Updates to your applications will show up here."
/>
<ol>
{Array.from({ length: count }).map((_, i) => (
<li
key={i}
className="flex items-center gap-3 border-b px-4 py-3 last:border-0"
>
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-12" />
</li>
))}
</ol>
);
}

// ─── Reviewer feed wrapper ─────────────────────────────────────────────────────

interface ReviewerActivityFeedProps {
reviewer: Reviewer;
function ActivityFeedGroupSkeleton() {
return (
<div>
<div className="px-4 pt-4 pb-1">
<Skeleton className="h-3 w-28" />
</div>
<ActivityFeedRowsSkeleton count={5} />
</div>
);
}

// Ordered by submittedAt (a provable event stream); cross-user data, reviewer-gated only.
export async function ReviewerActivityFeed({
reviewer,
}: ReviewerActivityFeedProps) {
const applications = await getRecentApplications(reviewer, 10);

const items: ActivityItem[] = applications.map((app) => {
const applicantLabel = getDisplayName(app);
const renamedTo = getRenamedTo(app);
const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status];
return {
id: app.id,
statusVariant: variant,
sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`,
timestamp: app.submittedAt,
};
});
export function ActivityFeedListSkeleton({ scope }: { scope: ActivityScope }) {
if (scope === 'none') return <ActivityFeedRowsSkeleton count={10} />;

return (
<ActivityFeedList
items={items}
emptyDescription={
reviewer.isAdmin
? 'New applications across all positions will show up here.'
: 'New applications to the positions you manage will show up here.'
}
/>
<>
<ActivityFeedGroupSkeleton />
<ActivityFeedGroupSkeleton />
</>
);
}
42 changes: 42 additions & 0 deletions components/features/activity-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { type ReactNode, useState } from 'react';

import { CONCEPT_ICONS } from '@/lib/icons';

import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';

interface ActivityPanelProps {
children: ReactNode;
}

export function ActivityPanel({ children }: ActivityPanelProps) {
const [open, setOpen] = useState(false);
const ActivityIcon = CONCEPT_ICONS.activity;

return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Open recent activity">
<ActivityIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0"
aria-describedby={undefined}
>
<div className="border-b px-4 py-3 pr-12">
<SheetTitle className="text-base">Recent activity</SheetTitle>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
</SheetContent>
</Sheet>
);
}
9 changes: 0 additions & 9 deletions components/features/admin-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Suspense } from 'react';

import { type Reviewer } from '@/lib/types';

import { ReviewerActivityFeed } from '@/components/features/activity-feed';
import { OpenPositionsSummary } from '@/components/features/open-positions-summary';
import {
PipelineSummary,
Expand Down Expand Up @@ -36,14 +35,6 @@ export function AdminDashboard({ reviewer }: AdminDashboardProps) {
<Suspense fallback={<SectionCardSkeleton rowShape="meta" />}>
<OpenPositionsSummary take={3} />
</Suspense>

<Suspense
fallback={
<SectionCardSkeleton rowShape="timeline" rows={10} hasLink={false} />
}
>
<ReviewerActivityFeed reviewer={reviewer} />
</Suspense>
</div>
);
}
9 changes: 0 additions & 9 deletions components/features/manager-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Suspense } from 'react';

import { type Reviewer } from '@/lib/types';

import { ReviewerActivityFeed } from '@/components/features/activity-feed';
import {
ManagedPositionsWidget,
ManagedPositionsWidgetSkeleton,
Expand Down Expand Up @@ -41,14 +40,6 @@ export function ManagerDashboard({ user }: ManagerDashboardProps) {
<ManagedPositionsWidget userId={user.id} take={3} />
</Suspense>

<Suspense
fallback={
<SectionCardSkeleton rowShape="timeline" rows={10} hasLink={false} />
}
>
<ReviewerActivityFeed reviewer={user} />
</Suspense>

<Suspense
fallback={<SectionCardSkeleton rowShape="badge-meta" hasSubtitle />}
>
Expand Down
9 changes: 0 additions & 9 deletions components/features/user-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Suspense } from 'react';

import { getFirstName } from '@/lib/utils';

import { ApplicantActivityFeed } from '@/components/features/activity-feed';
import {
ApplicantSummary,
ApplicantSummarySkeleton,
Expand Down Expand Up @@ -48,14 +47,6 @@ export function UserDashboard({ userId, userName }: UserDashboardProps) {
<Suspense fallback={<SectionCardSkeleton rowShape="stacked-action" />}>
<OpenPositionsWidget limit={3} />
</Suspense>

<Suspense
fallback={
<SectionCardSkeleton rowShape="timeline" rows={10} hasLink={false} />
}
>
<ApplicantActivityFeed userId={userId} />
</Suspense>
</div>
);
}
Loading
Loading