- );
-}
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
export default function Dashboard() {
const router = useRouter();
@@ -81,25 +25,5 @@ export default function Dashboard() {
if (!user) return null;
- return (
-
-
-
-
-
- {getGreeting()}
-
-
-
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/src/app/design-qa/layout.tsx b/src/app/design-qa/layout.tsx
new file mode 100644
index 0000000..9ce0adb
--- /dev/null
+++ b/src/app/design-qa/layout.tsx
@@ -0,0 +1,26 @@
+import DashboardLayout from "@/app/dashboard/layout";
+
+/**
+ * Design-QA harness route.
+ *
+ * Renders the real dashboard against in-memory fixtures so light mode, empty
+ * states and breakpoints can be reviewed without a Supabase session. It lives
+ * outside `/dashboard` on purpose: the proxy matcher only guards
+ * `/dashboard/:path*` and `/auth/:path*` (src/proxy.ts), so this route needs
+ * no auth bypass of any kind.
+ *
+ * Redirects to the landing page unless NEXT_PUBLIC_QA_MOCK=1, so the harness
+ * is inert in a normal build. `redirect` rather than `notFound` because a
+ * build-time-prerendered `notFound()` was still being served with a 200.
+ */
+// Evaluated per request rather than at build time, so the guard returns a real
+// 404 status instead of a statically prerendered not-found page served as 200.
+export const dynamic = "force-dynamic";
+
+export default function DesignQALayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children};
+}
diff --git a/src/app/design-qa/page.tsx b/src/app/design-qa/page.tsx
new file mode 100644
index 0000000..3b00e87
--- /dev/null
+++ b/src/app/design-qa/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from "next/navigation";
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
+
+// Evaluated per request so the guard cannot be baked out by prerendering.
+export const dynamic = "force-dynamic";
+
+export default function DesignQAPage() {
+ if (process.env.NEXT_PUBLIC_QA_MOCK !== "1") redirect("/");
+
+ return ;
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a31118d..c9b353f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -43,6 +43,11 @@
--gp-success: #22c55e;
/* Semantic — DARK-FIRST (canonical experience) */
+ /* Declared here too, so scrollbars, form controls and autofill match the
+ dark surface by default. Without it the unclassed default rendered dark
+ tokens with light native chrome. `.light` overrides this below. */
+ color-scheme: dark;
+
--gp-bg: var(--gp-zinc-950);
--gp-surface: var(--gp-zinc-900);
--gp-surface-2: var(--gp-zinc-800);
@@ -204,7 +209,10 @@
:root[data-theme="light"] {
color-scheme: light;
- --gp-bg: #ffffff;
+ /* Page sits one step below cards so surfaces read as raised. When both were
+ #ffffff the whole theme was one flat sheet separated only by hairlines.
+ Per design-system-foundation §1.4: page = zinc-50, card = white. */
+ --gp-bg: var(--gp-zinc-50);
--gp-surface: #ffffff;
--gp-surface-2: var(--gp-zinc-100);
--gp-surface-mute: var(--gp-zinc-100);
@@ -359,16 +367,22 @@
--duration-slow: var(--motion-slow);
}
-/* Override Tailwind's dark variant: respond to .dark / .marketing-dark
- classes (forced) AND OS preference when no explicit class is set. */
+/* Override Tailwind's dark variant so it tracks the tokens rather than the OS.
+ The semantic tokens above are dark-first: absent an explicit light opt-in the
+ page IS dark, whatever the OS says. Gating `dark:` on
+ prefers-color-scheme meant that a visitor on an OS set to light, who had
+ chosen no theme, got a dark page rendered with every `dark:` utility switched
+ off — the wordmark resolved to zinc-900 on a zinc-950 background and
+ disappeared. So the condition is "not explicitly light", not "OS is dark".
+
+ The light opt-in has three spellings (see the light block above); all three
+ have to turn the variant off, not just `.light`. */
@variant dark {
&:where(.dark, .dark *, .marketing-dark, .marketing-dark *) {
@slot;
}
- @media (prefers-color-scheme: dark) {
- html:not(.light):not(.dark) & {
- @slot;
- }
+ html:not(.dark):not(.light):not(.gp-allow-light):not([data-theme="light"]) & {
+ @slot;
}
}
diff --git a/src/components/AccountabilityWidget.tsx b/src/components/AccountabilityWidget.tsx
index 4646c24..c8cdf55 100644
--- a/src/components/AccountabilityWidget.tsx
+++ b/src/components/AccountabilityWidget.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Share2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { trpc } from "@/lib/trpc/client";
+import { cn } from "@/lib/utils";
import { AccountabilityScoreRing } from "./AccountabilityScoreRing";
import { StreakBreakBanner } from "./StreakBreakBanner";
import { StreakFlame } from "./StreakFlame";
@@ -118,8 +119,8 @@ export function AccountabilityWidget() {
/>
)}
-
-
+
+
@@ -130,45 +131,70 @@ export function AccountabilityWidget() {
{delta > 0 && (
-
+
+{delta} from last week
)}
- {delta < 0 && (
-
+ {/* A small dip is a number. A collapse is a sentence — quoting
+ "-38 from last week" back at someone is punishment, not
+ accountability. Per design-system-phase-3 §2.2. */}
+ {delta < 0 && delta > -20 && (
+
{delta} from last week
)}
+ {delta <= -20 && (
+ Last week was a write-off. New week starts now.
+ )}
{delta === 0 && No change from last week}
-
-
-
day streak
-
-
-
-
Today
-
- {today.completed}/{today.total} done
+ {/* Below `sm` this wrapper is a single flex item, so the streak and
+ today blocks drop to a second line together instead of each
+ squeezing to two-line wraps. At `sm` and up `contents` dissolves
+ it and the original three-column row returns unchanged. */}
+
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 6f84edc..9694b1b 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -3,9 +3,88 @@
import { useRef, useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useChatContext } from "@/contexts/ChatContext";
+import { useTaskContext } from "@/contexts/TaskContext";
+import { trpc } from "@/lib/trpc/client";
import { AnimatePresence, motion } from "framer-motion";
import { MessageCircle, X, Info } from "lucide-react";
+/**
+ * Opening move for the coach panel.
+ *
+ * The panel used to greet an empty conversation with one generic sentence,
+ * which on desktop left the largest region of the dashboard doing nothing.
+ * These prompts are built from the user's actual score, streak, today's
+ * progress and goal count, so the first thing the coach says already knows
+ * what kind of week this is. Per design-system-phase-3 §6.1.
+ */
+function ChatEmptyState({
+ onPick,
+}: {
+ onPick: (prompt: string) => void;
+}) {
+ const { data } = trpc.accountabilityScore.getScore.useQuery();
+ const { goals } = useTaskContext();
+
+ const prompts: string[] = [];
+
+ if (data) {
+ const { score, tier, currentStreak, delta, today } = data;
+ const openGoals = goals.filter((g) => g.status === "active");
+ const { completed, total } = today;
+
+ // Most specific signal first, so an unusual week leads the conversation.
+ // Every branch below is reachable — a steady mid-week user still gets a
+ // prompt built from their own numbers rather than the generic fallbacks.
+ if (delta <= -20) {
+ prompts.push("What happened last week?");
+ }
+ if (tier?.name === "Slacking") {
+ prompts.push(`Score ${score} — what's actually getting in the way?`);
+ }
+ if (total === 0) {
+ prompts.push("Nothing planned today. What's worth committing to?");
+ } else if (completed === 0) {
+ prompts.push(`0/${total} done today. What should I ship first?`);
+ } else if (completed === total) {
+ prompts.push("Everything's done. What should tomorrow look like?");
+ } else {
+ prompts.push(`${completed}/${total} done today — what do I take next?`);
+ }
+ if (openGoals.length >= 5) {
+ prompts.push(`Help me cut a goal — ${openGoals.length} is too many.`);
+ }
+ if (currentStreak >= 3) {
+ prompts.push(`${currentStreak} days in. How do I not break it?`);
+ }
+ }
+
+ // Always leave the user something to press, including on first load.
+ for (const fallback of [
+ "What should I focus on today?",
+ "Review my plan with me.",
+ ]) {
+ if (prompts.length >= 3) break;
+ prompts.push(fallback);
+ }
+
+ return (
+
+
+ {data ? "I've seen your week. Pick one:" : "Pick one to start:"}
+
+ {/* Two opposite answers used to be pixel-identical until
+ clicked. Each now previews its own outcome on hover
+ and focus, so the choice reads before it is made —
+ without weighting one above the other. */}
handleStatusChange(task.id, "completed")
}
className={cn(
- "rounded-sm px-2 py-1 text-xs transition-colors",
+ "rounded-sm px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50",
reflections[task.id]?.status === "completed"
- ? "bg-green-500/15 text-green-400"
- : "bg-accent text-muted-foreground hover:text-foreground"
+ ? "bg-success/15 text-success ring-1 ring-success/30"
+ : "bg-accent text-muted-foreground hover:bg-success/10 hover:text-success"
)}
>
Done
@@ -202,10 +206,10 @@ export function EveningCheckIn() {
handleStatusChange(task.id, "skipped")}
className={cn(
- "rounded-sm px-2 py-1 text-xs transition-colors",
+ "rounded-sm px-2 py-1 text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50",
reflections[task.id]?.status === "skipped"
- ? "bg-red-500/15 text-red-400"
- : "bg-accent text-muted-foreground hover:text-foreground"
+ ? "bg-error/15 text-error ring-1 ring-error/30"
+ : "bg-accent text-muted-foreground hover:bg-error/10 hover:text-error"
)}
>
Skipped
diff --git a/src/components/StoicQuote.tsx b/src/components/StoicQuote.tsx
index ecf256f..64203ce 100644
--- a/src/components/StoicQuote.tsx
+++ b/src/components/StoicQuote.tsx
@@ -49,21 +49,18 @@ export function StoicQuote() {
if (!quote) return null;
return (
-
-
-
-
-
-
- Today's Flame
-
-
-
“{quote.text}”
- — {quote.author}
+ // The brand accent is the left border and the icon — nothing more. The
+ // amber gradient wash that used to sit behind this block was decorative
+ // depth the surface language (foundation §5) doesn't have a slot for.
+
+
+
+
+ Today's Reminder
+
+
“{quote.text}”
+ — {quote.author}
);
}
diff --git a/src/components/StreakFlame.tsx b/src/components/StreakFlame.tsx
index f11587c..8c18beb 100644
--- a/src/components/StreakFlame.tsx
+++ b/src/components/StreakFlame.tsx
@@ -282,7 +282,12 @@ const TIER_TEXT_COLORS: Record = {
export function StreakFlame({ streak, color, size = 40 }: Props) {
const FlameComponent = FLAME_COMPONENTS[color] ?? NuclearFlame;
- const textColor = TIER_TEXT_COLORS[color] ?? "text-tier-proven";
+ // A zero streak is the absence of a result, not a result worth colouring.
+ // Tier colour is earned from the first day on. Per phase-3 §2.3.
+ const textColor =
+ streak === 0
+ ? "text-muted-foreground"
+ : (TIER_TEXT_COLORS[color] ?? "text-tier-proven");
return (
- {/* Priority strip */}
-
+ {/* Priority strip. This is the only priority encoding on the row — the
+ colour-coded text badge that used to sit beside the title said the
+ same thing a second time. Screen readers get it from the sr-only
+ label below rather than from colour. */}
+
{task.title}
+ , {task.priority} priority
-
- {task.priority}
-
{task.goalId && (
)}
- {task.dueDate && (
- {formatDate(task.dueDate)}
- )}
+ {/* No due date here: the Today view is by definition today, and the Week
+ view already groups every row under a day header. */}
{showDeleteConfirm ? (
-
+ {/* Same rule as the accountability widget: green is earned, not the
+ default. A 57% week is not a success colour. */}
+
{taskStats.completionRate}% done
diff --git a/src/components/dashboard/DashboardShell.tsx b/src/components/dashboard/DashboardShell.tsx
new file mode 100644
index 0000000..c72f894
--- /dev/null
+++ b/src/components/dashboard/DashboardShell.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import Link from "next/link";
+import { Settings } from "lucide-react";
+import { Logo } from "@/components/Logo";
+import { TaskList } from "@/components/TaskList";
+import { GoalList } from "@/components/GoalList";
+import { MorningCheckIn } from "@/components/MorningCheckIn";
+import { EveningCheckIn } from "@/components/EveningCheckIn";
+import { WeeklyRoastCard } from "@/components/WeeklyRoastCard";
+import { StoicQuote } from "@/components/StoicQuote";
+import { ChatPanel } from "@/components/ChatPanel";
+import { AccountabilityWidget } from "@/components/AccountabilityWidget";
+import { Day1Orientation } from "@/components/Day1Orientation";
+import { ReentryBanner } from "@/components/ReentryBanner";
+import { SetupChecklistCard } from "@/components/setup/SetupChecklistCard";
+
+/**
+ * One page width, declared once.
+ *
+ * The header and the content column previously set their own max-widths
+ * (`max-w-xl` vs `max-w-7xl`) and centred independently, which put the logo
+ * 352px right of the first card and stranded 284px of empty container on the
+ * right. Both boxes share this constant so they cannot drift apart again.
+ *
+ * 576 (main) + 24 (lg:gap-6) + 380 (aside) + 32 (px-4 both sides) = 1012
+ */
+const SHELL = "mx-auto w-full max-w-[1012px] px-4";
+
+function getGreeting(): string {
+ const hour = new Date().getHours();
+ if (hour < 12) return "Good morning";
+ if (hour < 17) return "Good afternoon";
+ return "Good evening";
+}
+
+function shouldShowMorning(): boolean {
+ return new Date().getHours() < 11;
+}
+
+function shouldShowEvening(): boolean {
+ return new Date().getHours() >= 17;
+}
+
+function DashboardContent() {
+ return (
+
+ );
+}
+
+/**
+ * The dashboard surface itself, with no auth gate. `/dashboard` wraps this in
+ * its session check; the design-QA harness renders it against fixtures. Both
+ * share this one definition so the surface can't drift between them.
+ */
+export function DashboardShell() {
+ return (
+
+
+
+
+
+ {getGreeting()}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx
index bc66673..2f388d9 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -25,6 +25,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
+ // QA design-review harness: skip the network round-trip and mount a
+ // deterministic user so dashboard surfaces render for visual inspection.
+ if (process.env.NEXT_PUBLIC_QA_MOCK === "1") {
+ setUser({
+ id: "u1",
+ email: "you@example.com",
+ app_metadata: {},
+ user_metadata: {},
+ aud: "authenticated",
+ created_at: new Date().toISOString(),
+ } as User);
+ setIsLoading(false);
+ return;
+ }
+
supabase.auth.getUser().then(({ data: { user } }) => {
setUser(user);
setIsLoading(false);
diff --git a/src/lib/trpc/client.ts b/src/lib/trpc/client.ts
index 3c03cc7..4e0415e 100644
--- a/src/lib/trpc/client.ts
+++ b/src/lib/trpc/client.ts
@@ -5,12 +5,17 @@ import type { AppRouter } from "@/server/trpc/routers/_app";
export const trpc = createTRPCReact();
+// QA design-review harness: swap the network link for in-memory fixtures.
+const QA_MOCK = process.env.NEXT_PUBLIC_QA_MOCK === "1";
+
export const trpcClient = trpc.createClient({
- links: [
- httpBatchLink({
- url: "/api",
- transformer: superjson,
- }),
- ],
+ links: QA_MOCK
+ ? [require("./qa-mock").qaMockLink]
+ : [
+ httpBatchLink({
+ url: "/api",
+ transformer: superjson,
+ }),
+ ],
});
diff --git a/src/lib/trpc/qa-mock.ts b/src/lib/trpc/qa-mock.ts
new file mode 100644
index 0000000..216c005
--- /dev/null
+++ b/src/lib/trpc/qa-mock.ts
@@ -0,0 +1,320 @@
+/**
+ * QA-ONLY tRPC mock link.
+ *
+ * Renders the real dashboard components with deterministic fixture data so a
+ * design review can inspect actual pixels without authenticating against the
+ * hosted Supabase project. Activated only when NEXT_PUBLIC_QA_MOCK === "1".
+ *
+ * Scenario is chosen with the `qa` query param: ?qa=full (default) | empty.
+ */
+
+import { observable } from "@trpc/server/observable";
+import type { TRPCLink } from "@trpc/client";
+
+function scenario(): string {
+ if (typeof window === "undefined") return "full";
+ return new URLSearchParams(window.location.search).get("qa") || "full";
+}
+
+// Anchored to the current day so "today's tasks" fixtures never go stale.
+const now = (() => {
+ const d = new Date();
+ d.setHours(9, 15, 0, 0);
+ return d;
+})();
+
+function at(h: number, m = 0) {
+ const d = new Date(now);
+ d.setHours(h, m, 0, 0);
+ return d;
+}
+
+function daysAgo(n: number) {
+ const d = new Date(now);
+ d.setDate(d.getDate() - n);
+ return d;
+}
+
+const GOALS = [
+ {
+ id: "g1",
+ userId: "u1",
+ title: "Ship GrindProof v1 to 100 paying users",
+ description: "Public launch, billing live, first cohort onboarded.",
+ status: "active" as const,
+ priority: "high" as const,
+ taskTotal: 42,
+ taskCompleted: 27,
+ createdAt: daysAgo(60),
+ updatedAt: daysAgo(1),
+ },
+ {
+ id: "g2",
+ userId: "u1",
+ title: "Run a sub-40 10K",
+ description: "Base building block, then speed work.",
+ status: "active" as const,
+ priority: "medium" as const,
+ taskTotal: 18,
+ taskCompleted: 11,
+ createdAt: daysAgo(45),
+ updatedAt: daysAgo(2),
+ },
+ {
+ id: "g3",
+ userId: "u1",
+ title: "Read 24 books this year",
+ description: null,
+ status: "active" as const,
+ priority: "low" as const,
+ taskTotal: 24,
+ taskCompleted: 9,
+ createdAt: daysAgo(120),
+ updatedAt: daysAgo(5),
+ },
+];
+
+const TASKS = [
+ {
+ id: "t1",
+ userId: "u1",
+ goalId: "g1",
+ title: "Fix the goal progress counter on the dashboard",
+ description: "Counting off a capped page undercounts goals.",
+ dueDate: at(9),
+ startTime: at(9),
+ endTime: at(10, 30),
+ priority: "high" as const,
+ status: "completed" as const,
+ tags: ["eng"],
+ reflection: null,
+ recurrencePattern: null,
+ createdAt: daysAgo(1),
+ updatedAt: now,
+ },
+ {
+ id: "t2",
+ userId: "u1",
+ goalId: "g1",
+ title: "Write launch email to the waitlist",
+ description: null,
+ dueDate: at(11),
+ startTime: at(11),
+ endTime: at(12),
+ priority: "high" as const,
+ status: "pending" as const,
+ tags: ["growth"],
+ reflection: null,
+ recurrencePattern: null,
+ createdAt: daysAgo(1),
+ updatedAt: daysAgo(1),
+ },
+ {
+ id: "t3",
+ userId: "u1",
+ goalId: "g2",
+ title: "Easy 8K recovery run",
+ description: null,
+ dueDate: at(18),
+ startTime: at(18),
+ endTime: at(19),
+ priority: "medium" as const,
+ status: "pending" as const,
+ tags: ["health"],
+ reflection: null,
+ recurrencePattern: null,
+ createdAt: daysAgo(1),
+ updatedAt: daysAgo(1),
+ },
+ {
+ id: "t4",
+ userId: "u1",
+ goalId: null,
+ title: "Review the Q3 accountability metrics doc that finance sent over",
+ description: null,
+ dueDate: at(15),
+ startTime: null,
+ endTime: null,
+ priority: "low" as const,
+ status: "pending" as const,
+ tags: null,
+ reflection: null,
+ recurrencePattern: null,
+ createdAt: daysAgo(2),
+ updatedAt: daysAgo(2),
+ },
+ {
+ id: "t5",
+ userId: "u1",
+ goalId: "g3",
+ title: "Read 30 pages of Meditations",
+ description: null,
+ dueDate: at(21),
+ startTime: null,
+ endTime: null,
+ priority: "low" as const,
+ status: "skipped" as const,
+ tags: null,
+ reflection: "Fell asleep.",
+ recurrencePattern: null,
+ createdAt: daysAgo(1),
+ updatedAt: daysAgo(1),
+ },
+];
+
+const TREND = Array.from({ length: 14 }, (_, i) => {
+ const d = daysAgo(13 - i);
+ const iso = d.toISOString().slice(0, 10);
+ const scores = [41, 44, 40, 52, 58, 55, 61, 64, 60, 66, 71, 68, 72, 68];
+ return {
+ date: iso,
+ score: scores[i],
+ completed: i === 13 ? 1 : 2,
+ total: i === 13 ? 3 : 3,
+ active: i !== 2 && i !== 8,
+ };
+});
+
+function fullFixtures(): Record {
+ return {
+ "profile.getSetupState": { setupState: "completed" },
+ "profile.getCurrent": {
+ id: "u1",
+ email: "you@example.com",
+ timezone: "Africa/Lagos",
+ displayName: "Alfred",
+ createdAt: daysAgo(60),
+ },
+ "retention.getReentryState": {
+ shouldShowReentry: false,
+ isReturningFromBadWeek: false,
+ daysSinceLastActive: 0,
+ },
+ "accountabilityScore.getScore": {
+ score: 68,
+ tier: { name: "Grinding", color: "amber" },
+ currentStreak: 6,
+ streak: 6,
+ delta: -4,
+ today: { completed: 1, total: 3 },
+ drivers: {
+ top: "6-day streak and a 74% completion rate on high-priority tasks",
+ drag: "three evening tasks skipped in the last week",
+ },
+ localDate: now.toISOString().slice(0, 10),
+ active: true,
+ timezone: "Africa/Lagos",
+ },
+ "accountabilityScore.getScoreTrend": { trend: TREND, currentStreak: 6 },
+ "accountabilityScore.getActivityHeatmap": {
+ heatmap: TREND.map((t) => ({ date: t.date, value: t.active ? 0.7 : 0 })),
+ },
+ "accountabilityScore.getRecentEvents": { events: [] },
+ "weeklyRoast.getLatest": {
+ id: "r1",
+ createdAt: daysAgo(2),
+ roastData: {
+ weekSummary:
+ "You planned fourteen things and finished eight. Every single one you dropped was scheduled after 6pm, which means your evenings are where your plans go to die.",
+ insights: [
+ {
+ text: "High-priority work shipped at 86% — your mornings are working.",
+ severity: "positive",
+ },
+ {
+ text: "Every skipped task this week was scheduled after 6pm.",
+ severity: "high",
+ },
+ {
+ text: "You rescheduled 'Read 30 pages' four times.",
+ severity: "medium",
+ },
+ ],
+ recommendations: [
+ "Move the reading block to 7am, before the day gets a vote.",
+ "Cap evening commitments at one task until you close two full weeks.",
+ ],
+ },
+ taskStats: { completionRate: 57, completed: 8, total: 14, skipped: 3 },
+ },
+ "dailyCheck.getMorningSchedule": {
+ alreadySubmitted: false,
+ yesterdayIncomplete: [
+ { id: "y1", title: "Draft the pricing page copy", priority: "high" },
+ { id: "y2", title: "Read 30 pages of Meditations", priority: "low" },
+ ],
+ todayTasks: TASKS.filter((t) => t.status !== "skipped").map((t) => ({
+ id: t.id,
+ title: t.title,
+ startTime: t.startTime,
+ priority: t.priority,
+ })),
+ },
+ "dailyCheck.getEveningSchedule": {
+ alreadySubmitted: false,
+ todayTasks: TASKS.map((t) => ({
+ id: t.id,
+ title: t.title,
+ status: t.status,
+ priority: t.priority,
+ })),
+ },
+ "task.getAll": TASKS,
+ "goal.getAll": GOALS,
+ "notification.getSettings": {
+ pushEnabled: true,
+ morningTime: "07:00",
+ eveningTime: "21:00",
+ },
+ "mcpToken.list": [],
+ };
+}
+
+function emptyFixtures(): Record {
+ const full = fullFixtures();
+ return {
+ ...full,
+ "accountabilityScore.getScore": {
+ score: 0,
+ tier: { name: "Slacking", color: "red" },
+ currentStreak: 0,
+ streak: 0,
+ delta: 0,
+ today: { completed: 0, total: 0 },
+ drivers: { top: "Nothing yet", drag: null },
+ localDate: now.toISOString().slice(0, 10),
+ active: false,
+ timezone: "Africa/Lagos",
+ },
+ "accountabilityScore.getScoreTrend": {
+ trend: TREND.map((t) => ({ ...t, score: 0, active: false })),
+ currentStreak: 0,
+ },
+ "weeklyRoast.getLatest": null,
+ "task.getAll": [],
+ "goal.getAll": [],
+ "dailyCheck.getMorningSchedule": {
+ alreadySubmitted: false,
+ yesterdayIncomplete: [],
+ todayTasks: [],
+ },
+ };
+}
+
+function resolve(path: string): unknown {
+ const table = scenario() === "empty" ? emptyFixtures() : fullFixtures();
+ if (path in table) return table[path];
+ // Unknown query: return null so components fall through to empty states
+ // instead of hanging in a permanent skeleton.
+ return null;
+}
+
+export const qaMockLink: TRPCLink =
+ () =>
+ ({ op }) =>
+ observable((observer) => {
+ // Mutations resolve to null; this harness is for visual review only.
+ const data = op.type === "query" ? resolve(op.path) : null;
+ observer.next({ result: { type: "data", data } });
+ observer.complete();
+ });