Skip to content

Commit 094609f

Browse files
authored
feat(dashboard): M6 page shell and stat cards (#37)
- Time-of-day greeting using `getGreeting` with display name derived from the current user's email. - Three `StatCard`s wired through TanStack Query: `Time to reconnect` (orange) from `/api/dashboard/reconnect`, `Coming up soon` from `/api/dashboard/upcoming`, `In your circle` from `/api/people`. - New API modules `api/dashboard.ts` and `api/people.ts`; query hooks live in `dashboard/hooks.ts` next to the route. - MSW handlers stub the three dashboard endpoints so the auth-shell smoke test still passes; assertion now keys off `Time to reconnect` since the dashboard heading is now the greeting. - ESLint: skip `react-refresh/only-export-components` for vendored `src/components/ui/**` (shadcn `Button` co-exports `buttonVariants`). Lands piece 1 of three for M6 Dashboard. Next pieces are the reconnect card list and its expand-to-log interactions.
1 parent b1e60ef commit 094609f

8 files changed

Lines changed: 171 additions & 8 deletions

File tree

client/eslint.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,10 @@ export default defineConfig([
1919
globals: globals.browser,
2020
},
2121
},
22+
{
23+
files: ['src/components/ui/**/*.{ts,tsx}'],
24+
rules: {
25+
'react-refresh/only-export-components': 'off',
26+
},
27+
},
2228
])

client/src/api/dashboard.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { apiFetch } from "./client";
2+
3+
export type Ring = "inner_circle" | "network" | "community" | "acquaintances";
4+
5+
export type DashboardPerson = {
6+
id: number;
7+
name: string;
8+
ring: Ring;
9+
last_connected_at: string | null;
10+
};
11+
12+
export type ReconnectReminder = {
13+
id: number;
14+
due_at: string;
15+
reason: string;
16+
snoozed_until: string | null;
17+
person: DashboardPerson;
18+
};
19+
20+
export type UpcomingDate = {
21+
name: string;
22+
month: number;
23+
day: number;
24+
days_until: number;
25+
};
26+
27+
export type UpcomingGroup = {
28+
person: DashboardPerson;
29+
upcoming_dates: UpcomingDate[];
30+
};
31+
32+
export function fetchReconnectReminders(): Promise<ReconnectReminder[]> {
33+
return apiFetch<ReconnectReminder[]>("/api/dashboard/reconnect");
34+
}
35+
36+
export function fetchUpcomingGroups(): Promise<UpcomingGroup[]> {
37+
return apiFetch<UpcomingGroup[]>("/api/dashboard/upcoming");
38+
}

client/src/api/people.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { apiFetch } from "./client";
2+
import type { Ring } from "./dashboard";
3+
4+
export type Person = {
5+
id: number;
6+
name: string;
7+
ring: Ring;
8+
last_connected_at: string | null;
9+
};
10+
11+
export function fetchPeople(): Promise<Person[]> {
12+
return apiFetch<Person[]>("api/people");
13+
}

client/src/components/StatCard.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { cn } from "@/lib/utils";
2+
3+
const TONE_STYLES = {
4+
default: "text-sapphire",
5+
orange: "text-orange-dark",
6+
} as const;
7+
8+
type Tone = keyof typeof TONE_STYLES;
9+
10+
export function StatCard({
11+
label,
12+
value,
13+
tone = "default",
14+
className,
15+
}: {
16+
label: string;
17+
value: number | string;
18+
tone?: Tone;
19+
className?: string;
20+
}) {
21+
return (
22+
<div
23+
className={cn(
24+
"rounded-md border bg-card px-3.5 py-3",
25+
className,
26+
)}
27+
>
28+
<p className="text-meta text-muted-foreground mb-1">{label}</p>
29+
<p className={cn("text-stat font-medium tabular-nums", TONE_STYLES[tone])}>
30+
{value}
31+
</p>
32+
</div>
33+
);
34+
}

client/src/dashboard/hooks.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { fetchReconnectReminders, fetchUpcomingGroups } from "@/api/dashboard";
3+
import { fetchPeople } from "@/api/people";
4+
5+
export function useReconnectReminders() {
6+
return useQuery({
7+
queryKey: ["dashboard", "reconnect"],
8+
queryFn: fetchReconnectReminders,
9+
});
10+
}
11+
12+
export function useUpcomingGroups() {
13+
return useQuery({
14+
queryKey: ["dashboard", "upcoming"],
15+
queryFn: fetchUpcomingGroups,
16+
});
17+
}
18+
19+
export function usePeople() {
20+
return useQuery({
21+
queryKey: ["people"],
22+
queryFn: fetchPeople,
23+
});
24+
}

client/src/routes/dashboard.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,48 @@
1+
import { useCurrentUser } from "@/auth/hooks";
2+
import {
3+
useReconnectReminders,
4+
useUpcomingGroups,
5+
usePeople,
6+
} from "@/dashboard/hooks";
7+
import { useDocumentTitle } from "@/lib/use-document-title";
8+
import { getGreeting } from "@/lib/greeting";
9+
import { StatCard } from "@/components/StatCard";
10+
11+
function displayNameFromEmail(email: string | undefined): string {
12+
if (!email) return "";
13+
const localPart = email.split("@")[0] ?? "";
14+
return localPart.charAt(0).toUpperCase() + localPart.slice(1);
15+
}
16+
117
export default function DashboardPage() {
18+
useDocumentTitle("Dashboard");
19+
20+
const { data: user } = useCurrentUser();
21+
const reconnectQuery = useReconnectReminders();
22+
const upcomingQuery = useUpcomingGroups();
23+
const peopleQuery = usePeople();
24+
25+
const displayName = displayNameFromEmail(user?.email);
26+
const greeting = getGreeting();
27+
const heading = displayName ? `${greeting}, ${displayName}` : greeting;
28+
29+
const reconnectCount = reconnectQuery.data?.length ?? 0;
30+
const upcomingCount = upcomingQuery.data?.length ?? 0;
31+
const peopleCount = peopleQuery.data?.length ?? 0;
32+
233
return (
3-
<div className="space-y-2">
4-
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
5-
<p className="text-sm text-muted-foreground">
6-
Reconnect prompts and upcoming events land here in M6.
7-
</p>
34+
<div className="space-y-6">
35+
<h1 className="text-stat font-medium tracking-tight">{heading}</h1>
36+
37+
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
38+
<StatCard
39+
label="Time to reconnect"
40+
value={reconnectCount}
41+
tone="orange"
42+
/>
43+
<StatCard label="Coming up soon" value={upcomingCount} />
44+
<StatCard label="In your cirle" value={peopleCount} />
45+
</div>
846
</div>
947
);
1048
}

client/src/test/App.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ describe("App auth shell", () => {
6060
await user.click(screen.getByRole("button", { name: /sign in/i }));
6161

6262
await waitFor(() => {
63-
expect(
64-
screen.getByRole("heading", { name: /dashboard/i }),
65-
).toBeInTheDocument();
63+
expect(screen.getByText(/time to reconnect/i)).toBeInTheDocument();
6664
});
6765
});
6866

client/src/test/handlers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,16 @@ export const handlers = [
3939
authState.user = null;
4040
return new HttpResponse(null, { status: 204 });
4141
}),
42+
43+
http.get(`${API_BASE_URL}/api/dashboard/reconnect`, () => {
44+
return HttpResponse.json([]);
45+
}),
46+
47+
http.get(`${API_BASE_URL}/api/dashboard/upcoming`, () => {
48+
return HttpResponse.json([]);
49+
}),
50+
51+
http.get(`${API_BASE_URL}/api/people`, () => {
52+
return HttpResponse.json([]);
53+
}),
4254
];

0 commit comments

Comments
 (0)