From cd8332084a8063d940395eb97db9e2a46820f4de Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:53 -0600 Subject: [PATCH 1/5] feat(api): add the tasks query with generated types First real codegen run against the introspected schema. DateTime is mapped to string so dueDate types honestly instead of unknown. Generated --- codegen.ts | 2 +- src/features/tasks/queries.ts | 20 ++++++ src/graphql/generated/fragment-masking.ts | 87 +++++++++++++++++++++++ src/graphql/generated/gql.ts | 46 ++++++++++++ src/graphql/generated/graphql.ts | 49 +++++++++++++ src/graphql/generated/index.ts | 2 + 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/features/tasks/queries.ts create mode 100644 src/graphql/generated/fragment-masking.ts create mode 100644 src/graphql/generated/gql.ts create mode 100644 src/graphql/generated/graphql.ts create mode 100644 src/graphql/generated/index.ts diff --git a/codegen.ts b/codegen.ts index 92fbc08..fbb98b8 100644 --- a/codegen.ts +++ b/codegen.ts @@ -27,7 +27,7 @@ const config: CodegenConfig = { './src/graphql/generated/': { preset: 'client', // Required by verbatimModuleSyntax: emit `import type` for type-only symbols. - config: { useTypeImports: true }, + config: { useTypeImports: true, scalars: { DateTime: 'string' } }, }, }, } diff --git a/src/features/tasks/queries.ts b/src/features/tasks/queries.ts new file mode 100644 index 0000000..be02d45 --- /dev/null +++ b/src/features/tasks/queries.ts @@ -0,0 +1,20 @@ +import { graphql } from '@/graphql/generated' + +export const TasksDocument = graphql(` + query Tasks($input: FilterTaskInput!) { + tasks(input: $input) { + id + name + dueDate + pointEstimate + position + status + tags + assignee { + id + fullName + avatar + } + } + } +`) diff --git a/src/graphql/generated/fragment-masking.ts b/src/graphql/generated/fragment-masking.ts new file mode 100644 index 0000000..743a364 --- /dev/null +++ b/src/graphql/generated/fragment-masking.ts @@ -0,0 +1,87 @@ +/* eslint-disable */ +import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; +import type { FragmentDefinitionNode } from 'graphql'; +import type { Incremental } from './graphql'; + + +export type FragmentType> = TDocumentType extends DocumentTypeDecoration< + infer TType, + any +> + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null; +// return nullable if `fragmentType` is nullable or undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> | null | undefined +): Array | null | undefined; +// return readonly array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return readonly array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | Array>> | ReadonlyArray>> | null | undefined +): TType | Array | ReadonlyArray | null | undefined { + return fragmentType as any; +} + + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} +export function isFragmentReady( + queryNode: DocumentTypeDecoration, + fragmentNode: TypedDocumentNode, + data: FragmentType, any>> | null | undefined +): data is FragmentType { + const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ + ?.deferredFields; + + if (!deferredFields) return true; + + const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; + const fragName = fragDef?.name?.value; + + const fields = (fragName && deferredFields[fragName]) || []; + return fields.length > 0 && fields.every(field => data && field in data); +} diff --git a/src/graphql/generated/gql.ts b/src/graphql/generated/gql.ts new file mode 100644 index 0000000..b075a30 --- /dev/null +++ b/src/graphql/generated/gql.ts @@ -0,0 +1,46 @@ +/* eslint-disable */ +import * as types from './graphql'; +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + "\n query Tasks($input: FilterTaskInput!) {\n tasks(input: $input) {\n id\n name\n dueDate\n pointEstimate\n position\n status\n tags\n assignee {\n id\n fullName\n avatar\n }\n }\n }\n": typeof types.TasksDocument, +}; +const documents: Documents = { + "\n query Tasks($input: FilterTaskInput!) {\n tasks(input: $input) {\n id\n name\n dueDate\n pointEstimate\n position\n status\n tags\n assignee {\n id\n fullName\n avatar\n }\n }\n }\n": types.TasksDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query Tasks($input: FilterTaskInput!) {\n tasks(input: $input) {\n id\n name\n dueDate\n pointEstimate\n position\n status\n tags\n assignee {\n id\n fullName\n avatar\n }\n }\n }\n"): (typeof documents)["\n query Tasks($input: FilterTaskInput!) {\n tasks(input: $input) {\n id\n name\n dueDate\n pointEstimate\n position\n status\n tags\n assignee {\n id\n fullName\n avatar\n }\n }\n }\n"]; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; \ No newline at end of file diff --git a/src/graphql/generated/graphql.ts b/src/graphql/generated/graphql.ts new file mode 100644 index 0000000..16ddda5 --- /dev/null +++ b/src/graphql/generated/graphql.ts @@ -0,0 +1,49 @@ +/* eslint-disable */ +/** Internal type. DO NOT USE DIRECTLY. */ +type Exact = { [K in keyof T]: T[K] }; +/** Internal type. DO NOT USE DIRECTLY. */ +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type FilterTaskInput = { + assigneeId?: string | null | undefined; + dueDate?: string | null | undefined; + name?: string | null | undefined; + ownerId?: string | null | undefined; + pointEstimate?: PointEstimate | null | undefined; + status?: Status | null | undefined; + tags?: Array | null | undefined; +}; + +/** Estimate point for a task */ +export type PointEstimate = + | 'EIGHT' + | 'FOUR' + | 'ONE' + | 'TWO' + | 'ZERO'; + +/** Status for Task */ +export type Status = + | 'BACKLOG' + | 'CANCELLED' + | 'DONE' + | 'IN_PROGRESS' + | 'TODO'; + +/** Enum for tags for tasks */ +export type TaskTag = + | 'ANDROID' + | 'IOS' + | 'NODE_JS' + | 'RAILS' + | 'REACT'; + +export type TasksQueryVariables = Exact<{ + input: FilterTaskInput; +}>; + + +export type TasksQuery = { tasks: Array<{ id: string, name: string, dueDate: string, pointEstimate: PointEstimate, position: number, status: Status, tags: Array, assignee: { id: string, fullName: string, avatar: string | null } | null }> }; + + +export const TasksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Tasks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"FilterTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tasks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"dueDate"}},{"kind":"Field","name":{"kind":"Name","value":"pointEstimate"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"assignee"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/src/graphql/generated/index.ts b/src/graphql/generated/index.ts new file mode 100644 index 0000000..f515991 --- /dev/null +++ b/src/graphql/generated/index.ts @@ -0,0 +1,2 @@ +export * from "./fragment-masking"; +export * from "./gql"; \ No newline at end of file From 4b4bdd7009937227cf550d8474dbcb1eefb41b99 Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:50:14 -0600 Subject: [PATCH 2/5] fix(api): use an absolute endpoint URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graphql-request v7 constructs new URL(endpoint) without a base, so the relative '/graphql' threw TypeError: Invalid URL synchronously — no request ever reached the network. Resolving against window.location.origin keeps the same-origin proxy design in dev, preview, and any future deploy. --- src/lib/graphql-client.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/graphql-client.ts b/src/lib/graphql-client.ts index 73d96aa..afe7597 100644 --- a/src/lib/graphql-client.ts +++ b/src/lib/graphql-client.ts @@ -1,9 +1,5 @@ import { GraphQLClient } from 'graphql-request' -// Transport layer: every query/mutation goes through this client. -// It calls the relative /graphql path; the dev server's proxy (vite.config.ts) -// forwards the request to the real API and attaches the Authorization header -// in Node, and the token never enters the browser bundle. -// Typed documents from `npm run codegen` (TypedDocumentNode) infer both the -// response and variable types when passed to graphqlClient.request(). -export const graphqlClient = new GraphQLClient('/graphql') +export const graphqlClient = new GraphQLClient( + new URL('/graphql', window.location.origin).toString(), +) From dba0f9ee83a2cc2dcff11936dc2112ee2484f471 Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:50:55 -0600 Subject: [PATCH 3/5] feat(tasks): fetch tasks into status columns with loading, error, and empty states TasksView owns the four query states: skeleton board while pending, role=alert with retry on failure, empty state, and the board grouped by the Status enum onto the five required columns, position-sorted. task-display.ts translates schema values (point enums, tags, due dates) into the design's vocabulary. Tests mock the transport seam with API-shaped fixtures; the three state tests pin the three requirement bullets. Sample data retired. --- README.md | 2 +- src/app/router.test.tsx | 52 +++++++++++--- src/features/tasks/Dashboard.tsx | 5 +- src/features/tasks/MyTask.tsx | 5 +- src/features/tasks/TaskCard.tsx | 33 +++++---- src/features/tasks/TasksView.tsx | 54 +++++++++++++++ src/features/tasks/sample-tasks.ts | 108 ----------------------------- src/features/tasks/task-display.ts | 55 +++++++++++++++ src/features/tasks/types.ts | 15 +--- src/features/tasks/useTasks.ts | 10 +++ src/test/fixtures.ts | 62 +++++++++++++++++ 11 files changed, 253 insertions(+), 148 deletions(-) create mode 100644 src/features/tasks/TasksView.tsx delete mode 100644 src/features/tasks/sample-tasks.ts create mode 100644 src/features/tasks/task-display.ts create mode 100644 src/features/tasks/useTasks.ts create mode 100644 src/test/fixtures.ts diff --git a/README.md b/README.md index 621889f..c8793aa 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ src/ - [x] Initial setup (folder structure, routing, styles solution, linting/formatting, error boundary, tests, CI) - [x] Dashboard UI (static): sidebar with mobile drawer, header, toolbar, five status columns, task cards -- [ ] API connection — fetch tasks, loading/error/empty states +- [x] API connection — fetch tasks into their status columns, with loading skeleton, failure alert + retry, and empty state - [ ] Create task - [ ] Update task - [ ] Delete task diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index 55f9d8f..2cfd9a8 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -1,9 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createMemoryRouter, RouterProvider } from 'react-router' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { routes } from '@/app/router' +import { fixtureTasks, tasksRequestMock } from '@/test/fixtures' + +vi.mock('@/lib/graphql-client', () => ({ + graphqlClient: { request: vi.fn() }, +})) + +beforeEach(() => { + tasksRequestMock().mockReset() + tasksRequestMock().mockResolvedValue({ tasks: fixtureTasks }) +}) // Fresh QueryClient per test: no cache leaks between tests, no retries // slowing failure cases down. @@ -64,25 +74,28 @@ describe('sidebar navigation', () => { }) describe('dashboard main content', () => { - it('renders the five required status columns', () => { + it('renders the five required status columns with fetched tasks', async () => { renderAt('/') - for (const title of ['Backlog', 'To Do', 'In Progress', 'Done', 'Cancelled']) { + expect(await screen.findByRole('heading', { level: 2, name: /backlog/i })).toBeInTheDocument() + for (const title of ['To Do', 'In Progress', 'Done', 'Cancelled']) { expect( screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') }), ).toBeInTheDocument() } + expect(screen.getByRole('heading', { level: 3, name: /slack/i })).toBeInTheDocument() }) - it('renders a task card with its required fields', () => { + it('renders a task card with its required fields', async () => { renderAt('/') - const card = screen.getByRole('heading', { level: 3, name: /twitter/i }).closest('article') + const card = (await screen.findByRole('heading', { level: 3, name: /twitter/i })).closest( + 'article', + ) if (!card) throw new Error('expected the Twitter card to render inside an
') const scoped = within(card) - expect(scoped.getByText(/3 pts/i)).toBeInTheDocument() + expect(scoped.getByText(/8 pts/i)).toBeInTheDocument() expect(scoped.getByText(/yesterday/i)).toBeInTheDocument() - expect(scoped.getByText(/ios app/i)).toBeInTheDocument() - expect(scoped.getByText(/android/i)).toBeInTheDocument() - expect(scoped.getByRole('img', { name: /assignee/i })).toBeInTheDocument() + expect(scoped.getByText('REACT')).toBeInTheDocument() + expect(scoped.getByRole('img', { name: /unassigned/i })).toBeInTheDocument() expect(scoped.getByRole('img', { name: /task options/i })).toBeInTheDocument() }) @@ -103,6 +116,27 @@ describe('dashboard main content', () => { }) }) +describe('tasks query states', () => { + it('shows a loading indicator while fetching', () => { + tasksRequestMock().mockImplementation(() => new Promise(() => undefined)) + renderAt('/') + expect(screen.getByRole('status')).toHaveTextContent(/loading tasks/i) + }) + + it('indicates when the query has failed and offers a retry', async () => { + tasksRequestMock().mockRejectedValue(new Error('network down')) + renderAt('/') + expect(await screen.findByRole('alert')).toHaveTextContent(/something went wrong/i) + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument() + }) + + it('shows an empty state when there are no results', async () => { + tasksRequestMock().mockResolvedValue({ tasks: [] }) + renderAt('/') + expect(await screen.findByText(/no tasks found/i)).toBeInTheDocument() + }) +}) + describe('header', () => { it('renders a controlled search input', async () => { const user = userEvent.setup() diff --git a/src/features/tasks/Dashboard.tsx b/src/features/tasks/Dashboard.tsx index 21168eb..c84f96c 100644 --- a/src/features/tasks/Dashboard.tsx +++ b/src/features/tasks/Dashboard.tsx @@ -1,13 +1,12 @@ import { Toolbar } from '@/features/tasks/Toolbar' -import { TaskBoard } from '@/features/tasks/TaskBoard' -import { sampleColumns } from '@/features/tasks/sample-tasks' +import { TasksView } from '@/features/tasks/TasksView' export function Dashboard() { return (

Dashboard

- +
) } diff --git a/src/features/tasks/MyTask.tsx b/src/features/tasks/MyTask.tsx index d8483db..6776a7e 100644 --- a/src/features/tasks/MyTask.tsx +++ b/src/features/tasks/MyTask.tsx @@ -1,13 +1,12 @@ import { Toolbar } from '@/features/tasks/Toolbar' -import { TaskBoard } from '@/features/tasks/TaskBoard' -import { sampleColumns } from '@/features/tasks/sample-tasks' +import { TasksView } from '@/features/tasks/TasksView' export function MyTask() { return (

My Task

- +
) } diff --git a/src/features/tasks/TaskCard.tsx b/src/features/tasks/TaskCard.tsx index e271353..4621d78 100644 --- a/src/features/tasks/TaskCard.tsx +++ b/src/features/tasks/TaskCard.tsx @@ -1,13 +1,16 @@ import avatarUrl from '@/assets/avatar.png' import { AlarmIcon, AttachIcon, ChatIcon, DotsIcon, ForkIcon } from '@/features/tasks/icons' -import type { Task, TagTone } from '@/features/tasks/types' +import { dueInfo, pointsLabel, TAG_META, type TagTone } from '@/features/tasks/task-display' +import type { ApiTask } from '@/features/tasks/types' const tagToneClasses: Record = { secondary: 'bg-secondary-4/10 text-secondary-4', tertiary: 'bg-tertiary-4/10 text-tertiary-4', + neutral: 'bg-neutral-2/10 text-neutral-1', } -export function TaskCard({ task }: { task: Task }) { +export function TaskCard({ task }: { task: ApiTask }) { + const due = dueInfo(task.dueDate) return (
@@ -19,36 +22,42 @@ export function TaskCard({ task }: { task: Task }) {
- {task.points} Pts + + {pointsLabel(task.pointEstimate)} + - {task.dueLabel} + {due.label}
- {task.tags.map(({ label, tone }) => ( + {task.tags.map((tag) => ( - {label} + {TAG_META[tag].label} ))}
- Assignee + {task.assignee?.fullName
- {task.forks} + 5 - {task.comments} + 3
diff --git a/src/features/tasks/TasksView.tsx b/src/features/tasks/TasksView.tsx new file mode 100644 index 0000000..431eea1 --- /dev/null +++ b/src/features/tasks/TasksView.tsx @@ -0,0 +1,54 @@ +import { TaskBoard } from '@/features/tasks/TaskBoard' +import { groupTasksByStatus } from '@/features/tasks/task-display' +import { useTasks } from '@/features/tasks/useTasks' + +function TaskBoardSkeleton() { + return ( + <> +

+ Loading tasks… +

+
diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 77f841f..91cc46b 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -8,48 +8,50 @@ function daysFromNow(days: number): string { return date.toISOString() } -export const fixtureTasks: TasksQuery['tasks'] = [ - { - id: 'task-1', - name: 'Slack', - dueDate: daysFromNow(0), - pointEstimate: 'FOUR', - position: 1, - status: 'BACKLOG', - tags: ['IOS', 'ANDROID'], - assignee: { id: 'user-1', fullName: 'Jane Doe', avatar: null }, - }, - { - id: 'task-2', - name: 'Twitter', - dueDate: daysFromNow(-1), - pointEstimate: 'EIGHT', - position: 1, - status: 'TODO', - tags: ['REACT'], - assignee: null, - }, - { - id: 'task-3', - name: 'Samsung', - dueDate: daysFromNow(5), - pointEstimate: 'TWO', - position: 2, - status: 'IN_PROGRESS', - tags: ['NODE_JS', 'RAILS'], - assignee: { id: 'user-2', fullName: 'Sam Lee', avatar: null }, - }, - { - id: 'task-4', - name: 'Tesla', - dueDate: daysFromNow(3), - pointEstimate: 'ONE', - position: 1, - status: 'IN_PROGRESS', - tags: ['ANDROID'], - assignee: null, - }, -] +export function makeFixtureTasks(): TasksQuery['tasks'] { + return [ + { + id: 'task-1', + name: 'Slack', + dueDate: daysFromNow(0), + pointEstimate: 'FOUR', + position: 1, + status: 'BACKLOG', + tags: ['IOS', 'ANDROID'], + assignee: { id: 'user-1', fullName: 'Jane Doe', avatar: null }, + }, + { + id: 'task-2', + name: 'Twitter', + dueDate: daysFromNow(-1), + pointEstimate: 'EIGHT', + position: 1, + status: 'TODO', + tags: ['REACT'], + assignee: null, + }, + { + id: 'task-3', + name: 'Samsung', + dueDate: daysFromNow(5), + pointEstimate: 'TWO', + position: 2, + status: 'IN_PROGRESS', + tags: ['NODE_JS', 'RAILS'], + assignee: { id: 'user-2', fullName: 'Sam Lee', avatar: null }, + }, + { + id: 'task-4', + name: 'Tesla', + dueDate: daysFromNow(3), + pointEstimate: 'ONE', + position: 1, + status: 'IN_PROGRESS', + tags: ['ANDROID'], + assignee: null, + }, + ] +} type TasksRequest = () => Promise From 8764477c2629f32975b1ad2ba35b6b6d0adee5ab Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:31:43 -0600 Subject: [PATCH 5/5] test: freeze the clock so date fixtures are fully deterministic --- src/app/router.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index f550425..cf2c7b3 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createMemoryRouter, RouterProvider } from 'react-router' @@ -11,10 +11,16 @@ vi.mock('@/lib/graphql-client', () => ({ })) beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-08-06T12:00:00Z')) tasksRequestMock().mockReset() tasksRequestMock().mockResolvedValue({ tasks: makeFixtureTasks() }) }) +afterEach(() => { + vi.useRealTimers() +}) + // Fresh QueryClient per test: no cache leaks between tests, no retries // slowing failure cases down. function renderAt(path: string) {