From d6315ef685aa919cd757dc7ca013535fd9500ab7 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 13 May 2026 21:01:45 -0700 Subject: [PATCH] feat(dashboard): M6 page shell and stat cards - 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. --- client/eslint.config.js | 6 ++++ client/src/api/dashboard.ts | 38 +++++++++++++++++++++++ client/src/api/people.ts | 13 ++++++++ client/src/components/StatCard.tsx | 34 +++++++++++++++++++++ client/src/dashboard/hooks.ts | 24 +++++++++++++++ client/src/routes/dashboard.tsx | 48 ++++++++++++++++++++++++++---- client/src/test/App.test.tsx | 4 +-- client/src/test/handlers.ts | 12 ++++++++ 8 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 client/src/api/dashboard.ts create mode 100644 client/src/api/people.ts create mode 100644 client/src/components/StatCard.tsx create mode 100644 client/src/dashboard/hooks.ts diff --git a/client/eslint.config.js b/client/eslint.config.js index ef614d2..4d90b67 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -19,4 +19,10 @@ export default defineConfig([ globals: globals.browser, }, }, + { + files: ['src/components/ui/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ]) diff --git a/client/src/api/dashboard.ts b/client/src/api/dashboard.ts new file mode 100644 index 0000000..e77732f --- /dev/null +++ b/client/src/api/dashboard.ts @@ -0,0 +1,38 @@ +import { apiFetch } from "./client"; + +export type Ring = "inner_circle" | "network" | "community" | "acquaintances"; + +export type DashboardPerson = { + id: number; + name: string; + ring: Ring; + last_connected_at: string | null; +}; + +export type ReconnectReminder = { + id: number; + due_at: string; + reason: string; + snoozed_until: string | null; + person: DashboardPerson; +}; + +export type UpcomingDate = { + name: string; + month: number; + day: number; + days_until: number; +}; + +export type UpcomingGroup = { + person: DashboardPerson; + upcoming_dates: UpcomingDate[]; +}; + +export function fetchReconnectReminders(): Promise { + return apiFetch("/api/dashboard/reconnect"); +} + +export function fetchUpcomingGroups(): Promise { + return apiFetch("/api/dashboard/upcoming"); +} diff --git a/client/src/api/people.ts b/client/src/api/people.ts new file mode 100644 index 0000000..82ca9b5 --- /dev/null +++ b/client/src/api/people.ts @@ -0,0 +1,13 @@ +import { apiFetch } from "./client"; +import type { Ring } from "./dashboard"; + +export type Person = { + id: number; + name: string; + ring: Ring; + last_connected_at: string | null; +}; + +export function fetchPeople(): Promise { + return apiFetch("api/people"); +} diff --git a/client/src/components/StatCard.tsx b/client/src/components/StatCard.tsx new file mode 100644 index 0000000..b5b6667 --- /dev/null +++ b/client/src/components/StatCard.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; + +const TONE_STYLES = { + default: "text-sapphire", + orange: "text-orange-dark", +} as const; + +type Tone = keyof typeof TONE_STYLES; + +export function StatCard({ + label, + value, + tone = "default", + className, +}: { + label: string; + value: number | string; + tone?: Tone; + className?: string; +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/client/src/dashboard/hooks.ts b/client/src/dashboard/hooks.ts new file mode 100644 index 0000000..33b5317 --- /dev/null +++ b/client/src/dashboard/hooks.ts @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchReconnectReminders, fetchUpcomingGroups } from "@/api/dashboard"; +import { fetchPeople } from "@/api/people"; + +export function useReconnectReminders() { + return useQuery({ + queryKey: ["dashboard", "reconnect"], + queryFn: fetchReconnectReminders, + }); +} + +export function useUpcomingGroups() { + return useQuery({ + queryKey: ["dashboard", "upcoming"], + queryFn: fetchUpcomingGroups, + }); +} + +export function usePeople() { + return useQuery({ + queryKey: ["people"], + queryFn: fetchPeople, + }); +} diff --git a/client/src/routes/dashboard.tsx b/client/src/routes/dashboard.tsx index 23da722..d46f7b5 100644 --- a/client/src/routes/dashboard.tsx +++ b/client/src/routes/dashboard.tsx @@ -1,10 +1,48 @@ +import { useCurrentUser } from "@/auth/hooks"; +import { + useReconnectReminders, + useUpcomingGroups, + usePeople, +} from "@/dashboard/hooks"; +import { useDocumentTitle } from "@/lib/use-document-title"; +import { getGreeting } from "@/lib/greeting"; +import { StatCard } from "@/components/StatCard"; + +function displayNameFromEmail(email: string | undefined): string { + if (!email) return ""; + const localPart = email.split("@")[0] ?? ""; + return localPart.charAt(0).toUpperCase() + localPart.slice(1); +} + export default function DashboardPage() { + useDocumentTitle("Dashboard"); + + const { data: user } = useCurrentUser(); + const reconnectQuery = useReconnectReminders(); + const upcomingQuery = useUpcomingGroups(); + const peopleQuery = usePeople(); + + const displayName = displayNameFromEmail(user?.email); + const greeting = getGreeting(); + const heading = displayName ? `${greeting}, ${displayName}` : greeting; + + const reconnectCount = reconnectQuery.data?.length ?? 0; + const upcomingCount = upcomingQuery.data?.length ?? 0; + const peopleCount = peopleQuery.data?.length ?? 0; + return ( -
-

Dashboard

-

- Reconnect prompts and upcoming events land here in M6. -

+
+

{heading}

+ +
+ + + +
); } diff --git a/client/src/test/App.test.tsx b/client/src/test/App.test.tsx index 26ff0bb..4f6c963 100644 --- a/client/src/test/App.test.tsx +++ b/client/src/test/App.test.tsx @@ -60,9 +60,7 @@ describe("App auth shell", () => { await user.click(screen.getByRole("button", { name: /sign in/i })); await waitFor(() => { - expect( - screen.getByRole("heading", { name: /dashboard/i }), - ).toBeInTheDocument(); + expect(screen.getByText(/time to reconnect/i)).toBeInTheDocument(); }); }); diff --git a/client/src/test/handlers.ts b/client/src/test/handlers.ts index bb632a9..0f10feb 100644 --- a/client/src/test/handlers.ts +++ b/client/src/test/handlers.ts @@ -39,4 +39,16 @@ export const handlers = [ authState.user = null; return new HttpResponse(null, { status: 204 }); }), + + http.get(`${API_BASE_URL}/api/dashboard/reconnect`, () => { + return HttpResponse.json([]); + }), + + http.get(`${API_BASE_URL}/api/dashboard/upcoming`, () => { + return HttpResponse.json([]); + }), + + http.get(`${API_BASE_URL}/api/people`, () => { + return HttpResponse.json([]); + }), ];