Skip to content
Closed
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
135 changes: 135 additions & 0 deletions __tests__/dashboards/consultant-home-read-shape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* @jest-environment node
*/

/**
* #1101 — pins the three consultant-Home read regressions that shipped inside a
* perf change and produced quietly-wrong numbers rather than errors.
*
* These are asserted here rather than on the deploy preview because the dev
* database cannot reproduce any of them: its busiest consultant has 10
* appointments (the display cap is 20) and there are 4 pending requests
* platform-wide (the old badge cap was 40). Clicking through the preview
* renders green on both the buggy and the fixed code.
*/

import prisma from "@/lib/prisma";
import { getConsultantDashboard } from "@/lib/data/consultant-dashboard";

jest.mock("../../lib/prisma", () => ({
__esModule: true,
default: {
slotOfAppointment: { findMany: jest.fn(), groupBy: jest.fn() },
appointment: { findMany: jest.fn() },
consultation: { findMany: jest.fn(), count: jest.fn() },
subscription: { findMany: jest.fn(), count: jest.fn() },
activityLog: { findMany: jest.fn() },
consultantEarnings: { aggregate: jest.fn() },
consultantReview: { aggregate: jest.fn() },
trialSession: { groupBy: jest.fn() },
},
}));

const slotFindMany = prisma.slotOfAppointment.findMany as jest.Mock;
const apptFindMany = prisma.appointment.findMany as jest.Mock;
const consultationCount = prisma.consultation.count as jest.Mock;
const subscriptionCount = prisma.subscription.count as jest.Mock;

describe("consultant Home read shape (#1101)", () => {
beforeEach(() => {
jest.clearAllMocks();
slotFindMany.mockResolvedValue([]);
apptFindMany.mockResolvedValue([]);
consultationCount.mockResolvedValue(0);
subscriptionCount.mockResolvedValue(0);
(prisma.slotOfAppointment.groupBy as jest.Mock).mockResolvedValue([]);
(prisma.consultation.findMany as jest.Mock).mockResolvedValue([]);
(prisma.subscription.findMany as jest.Mock).mockResolvedValue([]);
(prisma.activityLog.findMany as jest.Mock).mockResolvedValue([]);
(prisma.trialSession.groupBy as jest.Mock).mockResolvedValue([]);
(prisma.consultantEarnings.aggregate as jest.Mock).mockResolvedValue({
_sum: { consultantSharePaise: null, refundedShareAmount: null },
});
(prisma.consultantReview.aggregate as jest.Mock).mockResolvedValue({
_avg: { rating: null },
_count: { rating: 0 },
});
});

it("ranks Home appointments by slot time anchored at today, not by createdAt", async () => {
await getConsultantDashboard("cp-1").catch(() => undefined);

expect(slotFindMany).toHaveBeenCalledTimes(1);
const args = slotFindMany.mock.calls[0][0];

// Ordering must key off the slot clock. `createdAt` truncated on the wrong
// key: a consultant who booked next month a fortnight ago and then took a
// burst of bookings for last week got 20 all-past rows.
expect(args.orderBy).toEqual({ startsAt: "asc" });

// ...and the window must be anchored at the PRESENT. Ordering ascending
// from a lower bound in the past returns the OLDEST slots, which
// reproduces the same empty Today/Upcoming widgets in a new disguise.
expect(args.where.endsAt?.gte).toBeInstanceOf(Date);
expect(args.where.startsAt).toBeUndefined();
const anchor: Date = args.where.endsAt.gte;
const now = new Date();
expect(anchor.getTime()).toBeLessThanOrEqual(now.getTime());
// Start-of-today, so a session already running today survives.
expect(anchor.getHours()).toBe(0);
expect(anchor.getMinutes()).toBe(0);
expect(now.getTime() - anchor.getTime()).toBeLessThan(24 * 60 * 60 * 1000);

// Tombstoned slots must not steer the ranking.
expect(args.where.deletedAt).toBeNull();
});

it("counts pending requests with count(), uncapped, so the badge cannot disagree with NeedsYou", async () => {
// More pending than any list cap: the old badge read `approvals.length`
// off a capped list and contradicted NeedsYou on the same screen.
consultationCount.mockResolvedValue(37);
subscriptionCount.mockResolvedValue(18);

const result = await getConsultantDashboard("cp-1");

expect(result.pendingRequestsCount).toBe(55);

// NeedsYou counts every pending request regardless of age, so these must
// not inherit the list's 90-day bound or the two numbers drift apart.
for (const call of [
consultationCount.mock.calls[0][0],
subscriptionCount.mock.calls[0][0],
]) {
expect(call.where.status).toBe("PENDING");
expect(call.where.requestedAt).toBeUndefined();
}
});

it("derives active clients from a dedicated query, not the truncated display list", async () => {
// Display list is capped; the active book is not. Deriving counts from the
// capped array under-reported Financial Summary for any real consultant.
const activeBook = Array.from({ length: 64 }, (_, i) => ({
consultation: { requestedBy: { id: `consultee-${i}` } },
subscription: null,
class: null,
}));

apptFindMany.mockImplementation((args: { select?: unknown }) =>
// The active-book read is the `select` one; the display read uses `include`.
Promise.resolve(args.select ? activeBook : []),
);

const result = await getConsultantDashboard("cp-1");

expect(result.financialSummary.activeClients).toBe(64);

const activeBookCall = apptFindMany.mock.calls.find((c) => c[0].select);
expect(activeBookCall).toBeDefined();
// No cap on the counting read.
expect(activeBookCall![0].take).toBeUndefined();
// Soft-deleted slots must not keep an appointment counted as active.
for (const clause of activeBookCall![0].where.AND) {
expect(clause.slotsOfAppointment.some.deletedAt).toBeNull();
}
});
});
6 changes: 5 additions & 1 deletion actions/forms/onboarding.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ export async function updateOnboardingInformationAction(
}

// Use the central processing function
return await processOnboardingData(userId, body);
// No cookie-cache refresh here: requireOnboarded() reads force-fresh, so it
// already sees onboardingCompleted / profile ids. A refresh would also be the
// wrong tool — getSession reads the CALLER's headers, and this action lets an
// ADMIN/STAFF update someone else, whose session it could not refresh anyway.
return processOnboardingData(userId, body);
}
// #endregion

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
"use client";

import dynamic from "next/dynamic";
import { UrlTabs } from "@/components/dashboard/UrlTabs";
import AnalyticsPageClient from "../analytics/AnalyticsPageClient";
import { EarningsSummaryPanel } from "./EarningsSummaryPanel";

const AnalyticsPageClient = dynamic(
() => import("../analytics/AnalyticsPageClient"),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
Loading analytics…
</div>
),
},
);

/**
* Earnings, with Analytics as its second panel.
*
Expand All @@ -13,6 +25,9 @@ import { EarningsSummaryPanel } from "./EarningsSummaryPanel";
* is the pattern the rule exists to stop. Both panels keep their own filter and
* pagination state, deliberately: they answer different questions and resetting
* one when the other moves would be surprising.
*
* Analytics (recharts) is code-split so the Summary tab does not pay for the
* charting library on first paint.
*/
export function EarningsTabs({
consultantId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Inbox } from "lucide-react";
import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary";
import { HomeSkeleton } from "@/components/dashboard/DashboardSkeletons";
Expand All @@ -13,12 +13,6 @@ import type { TConsultantDashboardResponse } from "@/types/consultant-events";
export default function HomePageClient({
consultantId,
}: Readonly<{ consultantId: string }>) {
const queryClient = useQueryClient();

// Read consultant name from the cached profile (already fetched by layout)
const consultantProfile = queryClient.getQueryData<{ user?: { name?: string } }>(["consultant-data", consultantId]);
const consultantName = consultantProfile?.user?.name;

// Use the centralized query configuration with optimized settings for immediate rendering
const dashboardQuery = {
...createConsultantQueries(consultantId).dashboard,
Expand All @@ -37,7 +31,8 @@ export default function HomePageClient({

// Show skeleton only for initial load when no data exists
if (isLoading && !dashboardData) {
return <HomeSkeleton />;
// Header is owned by the server page now — see its comment on FCP.
return <HomeSkeleton withHeader={false} />;
}

if (error && !dashboardData) {
Expand Down Expand Up @@ -83,8 +78,7 @@ export default function HomePageClient({
<HomeTab
appointments={dashboardData.appointments}
consultantId={consultantId}
consultantName={consultantName}
pendingRequestsCount={dashboardData.approvals?.length ?? 0}
pendingRequestsCount={dashboardData.pendingRequestsCount ?? 0}
performanceSnapshot={dashboardData.performanceSnapshot}
financialSummary={dashboardData.financialSummary}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import {
DashboardHeader,
DashboardContent,
} from "@/components/dashboard/PageScaffold";
import { DataCard, EmptyState } from "@/components/dashboard/DataCard";
Expand Down Expand Up @@ -71,7 +70,6 @@ import type {
interface HomeTabProps {
appointments: TAppointment[];
consultantId: string;
consultantName?: string;
pendingRequestsCount?: number;
performanceSnapshot?: TPerformanceSnapshot;
financialSummary?: TFinancialSummary;
Expand All @@ -93,7 +91,6 @@ const fadeInUp = {
export function HomeTab({
appointments,
consultantId,
consultantName,
pendingRequestsCount = 0,
performanceSnapshot,
financialSummary,
Expand Down Expand Up @@ -164,7 +161,6 @@ export function HomeTab({
.slice(0, 5);
}, [allUpcomingAppointments]);

const firstName = consultantName?.split(" ")[0];

// "Needs you now" — derived from data already on the page, so no extra
// fetch. The rows go over whole, ids and ends included: these are raw
Expand All @@ -190,11 +186,9 @@ export function HomeTab({

return (
<>
<DashboardHeader
title={firstName ? `Welcome back, ${firstName}` : "Welcome back"}
subtitle="Here's what's happening with your appointments today"
/>

{/* The header is rendered by the server page, outside the Suspense
boundary, so it can paint as real text while this tab is still
waiting on data. Keeping a copy here would double it up. */}
<DashboardContent>
<motion.div
variants={staggerChildren}
Expand Down
Loading
Loading