diff --git a/next.config.ts b/next.config.ts index 7f88605..90184e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,7 +21,9 @@ const nextConfig: NextConfig = { }, { key: "X-Frame-Options", - value: "DENY", + // QA design-review harness renders the app in same-origin iframes + // to audit fixed breakpoints. Production keeps DENY. + value: process.env.NEXT_PUBLIC_QA_MOCK === "1" ? "SAMEORIGIN" : "DENY", }, ], }, diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 01e5354..602fd56 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,65 +1,9 @@ "use client"; -import Link from "next/link"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Settings } from "lucide-react"; -import { Logo } from "@/components/Logo"; import { useAuth } from "@/contexts/AuthContext"; -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"; -import { useEffect } from "react"; - -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 { - const hour = new Date().getHours(); - return hour < 11; -} - -function shouldShowEvening(): boolean { - const hour = new Date().getHours(); - return hour >= 17; -} - -function DashboardContent() { - return ( -
-
- - - - - - - - {shouldShowMorning() && } - {shouldShowEvening() && } - - - - -
- -
- ); -} +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. */} +
+
+ +
+ {currentStreak === 0 ? "Start one today" : "day streak"} +
- - View stats → - - {currentStreak > 0 && ( - - )} + {today.completed}/{today.total} done +
+ + View stats → + + {currentStreak > 0 && ( + + )} +
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:"} +

+ {prompts.slice(0, 3).map((prompt) => ( + + ))} +
+ ); +} + export function ChatPanel({ docked = false }: { docked?: boolean }) { const { user } = useAuth(); const { messages, sendMessage, status, isOpen, setIsOpen, input, setInput } = @@ -22,14 +101,7 @@ export function ChatPanel({ docked = false }: { docked?: boolean }) { const renderMessages = () => ( <> - {messages.length === 0 && ( -
-

- I'm your accountability coach. Ask me anything, or tell me - what you're working on. -

-
- )} + {messages.length === 0 && } {messages.map((message) => { const text = message.parts?.find((p) => p.type === "text")?.text ?? ""; diff --git a/src/components/EveningCheckIn.tsx b/src/components/EveningCheckIn.tsx index 9d9ddbc..89824b0 100644 --- a/src/components/EveningCheckIn.tsx +++ b/src/components/EveningCheckIn.tsx @@ -186,15 +186,19 @@ export function EveningCheckIn() {
+ {/* 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. */} diff --git a/src/components/WeeklyRoastCard.tsx b/src/components/WeeklyRoastCard.tsx index ba3c296..4c0b491 100644 --- a/src/components/WeeklyRoastCard.tsx +++ b/src/components/WeeklyRoastCard.tsx @@ -3,11 +3,12 @@ import { useState } from "react"; import { useAuth } from "@/contexts/AuthContext"; import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { Check, AlertTriangle, Info, type LucideIcon } from "lucide-react"; const SEVERITY_ICON: Record = { - positive: { Icon: Check, color: "text-green-400" }, - high: { Icon: AlertTriangle, color: "text-red-400" }, + positive: { Icon: Check, color: "text-success" }, + high: { Icon: AlertTriangle, color: "text-error" }, medium: { Icon: Info, color: "text-muted-foreground" }, }; @@ -21,7 +22,7 @@ export function WeeklyRoastCard() { if (isLoading) { return ( -
+
); } if (!user || dismissed) return null; @@ -49,7 +50,18 @@ export function WeeklyRoastCard() { {/* Stats bar */} {taskStats && (
- + {/* 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 ( +
+
+ + + + + + + + {shouldShowMorning() && } + {shouldShowEvening() && } + + + + +
+ +
+ ); +} + +/** + * 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(); + });