From 2d81f2b449d8f98f5dde00e83fc12907502ca32e Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:56:38 -0600 Subject: [PATCH 1/6] feat(tasks): add createTask mutation and users query documents The mutation selects the same fields as the tasks query so the created task matches the board's shape. The users query feeds the assignee picker in the create modal. Generated types committed per the repo's codegen convention. --- src/features/tasks/queries.ts | 29 +++++++++++++++++++++++++++++ src/graphql/generated/gql.ts | 12 ++++++++++++ src/graphql/generated/graphql.ts | 25 ++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/features/tasks/queries.ts b/src/features/tasks/queries.ts index be02d45..39ca572 100644 --- a/src/features/tasks/queries.ts +++ b/src/features/tasks/queries.ts @@ -18,3 +18,32 @@ export const TasksDocument = graphql(` } } `) + +export const CreateTaskDocument = graphql(` + mutation CreateTask($input: CreateTaskInput!) { + createTask(input: $input) { + id + name + dueDate + pointEstimate + position + status + tags + assignee { + id + fullName + avatar + } + } + } +`) + +export const UsersDocument = graphql(` + query Users { + users { + id + fullName + avatar + } + } +`) diff --git a/src/graphql/generated/gql.ts b/src/graphql/generated/gql.ts index b075a30..51281a0 100644 --- a/src/graphql/generated/gql.ts +++ b/src/graphql/generated/gql.ts @@ -15,9 +15,13 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- */ 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, + "\n mutation CreateTask($input: CreateTaskInput!) {\n createTask(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.CreateTaskDocument, + "\n query Users {\n users {\n id\n fullName\n avatar\n }\n }\n": typeof types.UsersDocument, }; 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, + "\n mutation CreateTask($input: CreateTaskInput!) {\n createTask(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.CreateTaskDocument, + "\n query Users {\n users {\n id\n fullName\n avatar\n }\n }\n": types.UsersDocument, }; /** @@ -38,6 +42,14 @@ 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"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation CreateTask($input: CreateTaskInput!) {\n createTask(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 mutation CreateTask($input: CreateTaskInput!) {\n createTask(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"]; +/** + * 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 Users {\n users {\n id\n fullName\n avatar\n }\n }\n"): (typeof documents)["\n query Users {\n users {\n id\n fullName\n avatar\n }\n }\n"]; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/src/graphql/generated/graphql.ts b/src/graphql/generated/graphql.ts index 16ddda5..88858c1 100644 --- a/src/graphql/generated/graphql.ts +++ b/src/graphql/generated/graphql.ts @@ -4,6 +4,15 @@ 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 CreateTaskInput = { + assigneeId?: string | null | undefined; + dueDate: string; + name: string; + pointEstimate: PointEstimate; + status: Status; + tags: Array; +}; + export type FilterTaskInput = { assigneeId?: string | null | undefined; dueDate?: string | null | undefined; @@ -45,5 +54,19 @@ export type TasksQueryVariables = Exact<{ 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 type CreateTaskMutationVariables = Exact<{ + input: CreateTaskInput; +}>; + + +export type CreateTaskMutation = { createTask: { id: string, name: string, dueDate: string, pointEstimate: PointEstimate, position: number, status: Status, tags: Array, assignee: { id: string, fullName: string, avatar: string | null } | null } }; + +export type UsersQueryVariables = Exact<{ [key: string]: never; }>; + + +export type UsersQuery = { users: Array<{ id: string, fullName: string, avatar: string | 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 +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; +export const CreateTaskDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateTask"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateTaskInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createTask"},"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; +export const UsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"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 From e0b1fa8aed4d7d86b87f7b7eb37f1aafcb318a27 Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:56:38 -0600 Subject: [PATCH 2/6] feat(tasks): open a create task modal from the add buttons The two add-task affordances become real buttons (aria-haspopup=dialog) that open a Figma-matched modal: title input, estimate/assignee/label dropdown chips, and a due-date chip backed by an invisible native date input. Create stays disabled until name, estimate, and due date are set because CreateTaskInput requires them; status defaults to BACKLOG since the design has no status field. On success the tasks query is invalidated so the board refetches; on failure a role=alert banner keeps the modal open with the form intact for retry. The due date is sent as noon UTC so the calendar day survives any timezone. The existing toolbar test assertion moves from img to button role to match the upgrade. Verified live: task created through the UI appears in Backlog with the chosen points, date, tag, and assignee; error path verified by forcing fetch to fail. --- src/app/router.test.tsx | 2 +- src/features/tasks/CreateTaskModal.tsx | 242 +++++++++++++++++++++++++ src/features/tasks/Toolbar.tsx | 30 ++- src/features/tasks/icons.tsx | 36 ++++ src/features/tasks/task-display.ts | 2 +- 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/features/tasks/CreateTaskModal.tsx diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index cf2c7b3..7be1d1c 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -109,7 +109,7 @@ describe('dashboard main content', () => { renderAt('/') expect(screen.getByRole('img', { name: /grid view/i })).toBeInTheDocument() expect(screen.getByRole('img', { name: /list view/i })).toBeInTheDocument() - expect(screen.getAllByRole('img', { name: /add task/i }).length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: /add task/i }).length).toBeGreaterThan(0) }) it('marks the correct mobile tab active per route', () => { diff --git a/src/features/tasks/CreateTaskModal.tsx b/src/features/tasks/CreateTaskModal.tsx new file mode 100644 index 0000000..68f9771 --- /dev/null +++ b/src/features/tasks/CreateTaskModal.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import avatarUrl from '@/assets/avatar.png' +import { graphqlClient } from '@/lib/graphql-client' +import { CreateTaskDocument, UsersDocument } from '@/features/tasks/queries' +import { POINTS, TAG_META } from '@/features/tasks/task-display' +import { CalendarIcon, EstimateIcon, LabelIcon } from '@/features/tasks/icons' +import type { CreateTaskInput, PointEstimate, TaskTag } from '@/graphql/generated/graphql' + +const ESTIMATES: PointEstimate[] = ['ZERO', 'ONE', 'TWO', 'FOUR', 'EIGHT'] +const ALL_TAGS = Object.keys(TAG_META) as TaskTag[] + +type MenuName = 'estimate' | 'assignee' | 'label' | null + +export function CreateTaskModal({ onClose }: { onClose: () => void }) { + const [name, setName] = useState('') + const [estimate, setEstimate] = useState(null) + const [assigneeId, setAssigneeId] = useState(null) + const [tags, setTags] = useState([]) + const [dueDate, setDueDate] = useState('') + const [openMenu, setOpenMenu] = useState(null) + const queryClient = useQueryClient() + + const users = useQuery({ + queryKey: ['users'], + queryFn: () => graphqlClient.request(UsersDocument), + }) + + const createTask = useMutation({ + mutationFn: (input: CreateTaskInput) => graphqlClient.request(CreateTaskDocument, { input }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['tasks'] }) + onClose() + }, + }) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + } + }, [onClose]) + + const selectedAssignee = users.data?.users.find((user) => user.id === assigneeId) + const canSubmit = name.trim().length > 0 && estimate !== null && dueDate !== '' + + const submit = () => { + if (!canSubmit || createTask.isPending) return + createTask.mutate({ + name: name.trim(), + pointEstimate: estimate, + dueDate: new Date(`${dueDate}T12:00:00Z`).toISOString(), + status: 'BACKLOG', + tags, + assigneeId, + }) + } + + const toggleMenu = (menu: MenuName) => { + setOpenMenu((current) => (current === menu ? null : menu)) + } + + return ( +
+ + {openMenu === 'estimate' && ( +
+ {ESTIMATES.map((option) => ( + + ))} +
+ )} +
+
+ + {openMenu === 'assignee' && ( +
+ {(users.data?.users ?? []).map((user) => ( + + ))} +
+ )} +
+
+ + {openMenu === 'label' && ( +
+ {ALL_TAGS.map((tag) => ( + + ))} +
+ )} +
+ + + {createTask.isError && ( +
+ The task could not be created. Check your connection and try again. +
+ )} +
+ + +
+ + + ) +} diff --git a/src/features/tasks/Toolbar.tsx b/src/features/tasks/Toolbar.tsx index 22edf26..ba9bf06 100644 --- a/src/features/tasks/Toolbar.tsx +++ b/src/features/tasks/Toolbar.tsx @@ -1,9 +1,12 @@ +import { useState } from 'react' import { useLocation } from 'react-router' import { GridIcon, ListIcon } from '@/components/ui/icons' import { PlusIcon } from '@/features/tasks/icons' +import { CreateTaskModal } from '@/features/tasks/CreateTaskModal' export function Toolbar() { const { pathname } = useLocation() + const [createOpen, setCreateOpen] = useState(false) const onMyTask = pathname === '/my-task' return (
@@ -42,21 +45,36 @@ export function Toolbar() {
- { + setCreateOpen(true) + }} className="flex size-10 items-center justify-center rounded-lg bg-primary-4" > - + - { + setCreateOpen(true) + }} className="fixed right-4 bottom-4 z-20 flex size-16 items-center justify-center rounded-full bg-primary-4 lg:hidden" > - + + {createOpen && ( + { + setCreateOpen(false) + }} + /> + )} ) } diff --git a/src/features/tasks/icons.tsx b/src/features/tasks/icons.tsx index 309be83..bf676a0 100644 --- a/src/features/tasks/icons.tsx +++ b/src/features/tasks/icons.tsx @@ -67,3 +67,39 @@ export function ChatIcon(props: IconProps) { ) } + +export function EstimateIcon(props: IconProps) { + return ( + + ) +} + +export function LabelIcon(props: IconProps) { + return ( + + ) +} + +export function CalendarIcon(props: IconProps) { + return ( + + ) +} diff --git a/src/features/tasks/task-display.ts b/src/features/tasks/task-display.ts index 11fcaa2..2d65873 100644 --- a/src/features/tasks/task-display.ts +++ b/src/features/tasks/task-display.ts @@ -20,7 +20,7 @@ export function groupTasksByStatus(tasks: ApiTask[]): BoardColumn[] { })) } -const POINTS: Record = { +export const POINTS: Record = { ZERO: 0, ONE: 1, TWO: 2, From 7fce7ef7efa905295ff821246032f19686c837cf Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:57:16 -0600 Subject: [PATCH 3/6] fix(tasks): align task cards with what the api actually provides --- README.md | 2 +- src/features/tasks/TaskCard.tsx | 19 ++--- src/features/tasks/icons.tsx | 117 +++++++++++++++++++++++------ src/features/tasks/task-display.ts | 5 ++ 4 files changed, 112 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1098753..eed715d 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ src/ - **Generated GraphQL code is committed on purpose.** `src/graphql/generated/` (output of `npm run codegen`) is checked into git so CI can typecheck and build without holding the API token. Regenerate after changing any query/mutation; never edit by hand. - **Quality gates are CI-enforced, not hook-enforced.** There are deliberately no git hooks (husky/lint-staged): CI runs format check, lint, typecheck, tests, and build on every PR, and the same scripts run locally on demand. Hooks can be added later if commit-time enforcement proves necessary. -- **Card reaction icons are decorative.** The Figma shows attachment/fork/comment metrics on task cards, but the API exposes no such fields — the icons are kept for design fidelity without fabricating counts. +- **Card attachment/fork/comment icons are omitted.** The Figma shows those metrics on task cards, but the API's Task type exposes no fields for them. Per mentor guidance not to expose non-working UI, the icons are removed until the schema provides the data; the SVGs live in git history for easy reintroduction. - **Node 24 is a hard requirement** — `.npmrc` sets `engine-strict=true`, so `npm install` fails fast on older Node instead of warning. - **A11y deviation from the design, flagged and recommended per mentor guidance:** the Figma's active-tab red (`primary-4`, `#da584b`) on the dark surface measures ≈3.5:1 below WCAG AA's 4.5:1 for 15px text. Following the design team's process (flag + recommend), the active label uses `primary-3` (`#e27d73`) one step up the design system's own red scale measuring ≈4.7:1 (≈4.5:1 worst-case over the 5% gradient wash). The indicator bar stays `primary-4` (non-text graphic, 3:1 rule, passes). The active state is also conveyed non-visually via `aria-current="page"`. diff --git a/src/features/tasks/TaskCard.tsx b/src/features/tasks/TaskCard.tsx index b05b399..010a5c3 100644 --- a/src/features/tasks/TaskCard.tsx +++ b/src/features/tasks/TaskCard.tsx @@ -1,6 +1,12 @@ import avatarUrl from '@/assets/avatar.png' -import { AlarmIcon, AttachIcon, ChatIcon, DotsIcon, ForkIcon } from '@/features/tasks/icons' -import { dueInfo, pointsLabel, TAG_META, type TagTone } from '@/features/tasks/task-display' +import { AlarmIcon, DotsIcon } from '@/features/tasks/icons' +import { + avatarSrc, + dueInfo, + pointsLabel, + TAG_META, + type TagTone, +} from '@/features/tasks/task-display' import type { ApiTask } from '@/features/tasks/types' const tagToneClasses: Record = { @@ -44,17 +50,12 @@ export function TaskCard({ task }: { task: ApiTask }) { ))} -
+
{task.assignee?.fullName -
- - - -
) diff --git a/src/features/tasks/icons.tsx b/src/features/tasks/icons.tsx index bf676a0..239dbf7 100644 --- a/src/features/tasks/icons.tsx +++ b/src/features/tasks/icons.tsx @@ -32,74 +32,149 @@ export function AlarmIcon(props: IconProps) { ) } -export function AttachIcon(props: IconProps) { +export function EstimateIcon(props: IconProps) { return ( -
+
+ +
{ setName(event.target.value) }} className="w-full bg-transparent text-body-xl font-semibold text-neutral-1 placeholder:text-neutral-2" /> -
-
+
+
{openMenu === 'estimate' && ( -
+
+ + Estimate + {ESTIMATES.map((option) => ( ))}
)}
-
+
- {openMenu === 'assignee' && ( -
- {(users.data?.users ?? []).map((user) => ( + {openMenu === 'label' && ( +
+ + Tag Title + + {ALL_TAGS.map((tag) => ( ))}
)}
-
+
- {openMenu === 'label' && ( -
- {ALL_TAGS.map((tag) => ( + {openMenu === 'assignee' && ( +
+ + Assign To... + + {(users.data?.users ?? []).map((user) => ( ))}
)}
-
- {createTask.isError && ( + {(createTask.isError || (showValidation && !canSubmit)) && (
- The task could not be created. Check your connection and try again. + {canSubmit + ? 'The task could not be created. Check your connection and try again.' + : 'An estimate and a due date are required to create the task.'}
)} -
+
+ +
+ {monthLabel} +
+ + +
+
+
+
+
+ {WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} +
+ {Array.from({ length: 6 }, (_, row) => ( +
+ {cells.slice(row * 7, row * 7 + 7).map((day) => { + const iso = toIso(day) + return ( +
+ +
+ ) + })} +
+ ))} +
+
+
+ +
+
+ ) +} + +const DIALOG_WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] + +const dialogDayTone = (selected: boolean, isToday: boolean) => { + if (selected) return 'bg-primary-4' + if (isToday) return 'border-2 border-primary-3' + return '' +} + +interface DatePickerDialogProps { + value: string + onCancel: () => void + onConfirm: (iso: string) => void +} + +export function DatePickerDialog({ value, onCancel, onConfirm }: DatePickerDialogProps) { + const today = new Date() + const initial = value === '' ? today : new Date(`${value}T12:00:00`) + const [pending, setPending] = useState(toIso(initial)) + const [viewYear, setViewYear] = useState(initial.getFullYear()) + const [viewMonth, setViewMonth] = useState(initial.getMonth()) + + const moveView = (deltaMonths: number) => { + const next = new Date(viewYear, viewMonth + deltaMonths, 1) + setViewYear(next.getFullYear()) + setViewMonth(next.getMonth()) + } + + const pendingDate = new Date(`${pending}T12:00:00`) + const headline = pendingDate.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }) + const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }) + const firstWeekday = new Date(viewYear, viewMonth, 1).getDay() + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + const rowCount = Math.ceil((firstWeekday + daysInMonth) / 7) + const todayIso = toIso(today) + + return ( +
+
+ + {pendingDate.getFullYear()} + + {headline} +
+
+
+ + + {monthLabel} + + +
+
+ {DIALOG_WEEKDAYS.map((letter, index) => ( + + {letter} + + ))} +
+ {Array.from({ length: rowCount }, (_, row) => ( +
+ {Array.from({ length: 7 }, (_, column) => { + const dayNumber = row * 7 + column + 1 - firstWeekday + if (dayNumber < 1 || dayNumber > daysInMonth) { + return + } + const iso = toIso(new Date(viewYear, viewMonth, dayNumber)) + return ( + + ) + })} +
+ ))} +
+ + +
+
+
+ ) +} diff --git a/src/index.css b/src/index.css index ab7d682..edacf80 100644 --- a/src/index.css +++ b/src/index.css @@ -252,6 +252,14 @@ --text-body-s--line-height: 1.25rem; /* 20px */ --text-body-s--letter-spacing: 0.25px; + --text-picker: 0.875rem; /* 14px */ + --text-picker--line-height: 1.375rem; /* 22px */ + --text-picker--letter-spacing: 0px; + + --text-date-display: 1.75rem; /* 28px */ + --text-date-display--line-height: 2.5rem; /* 40px */ + --text-date-display--letter-spacing: 1px; + /* ============================================================ SHADOWS — [S] Depth scale + [P] Drop Shadow Large. Usage: `shadow-depth-2`, `shadow-drop-large`, `inset-shadow-depth` From 9c03b33b81622b3164c671a8b153cde1304de46e Mon Sep 17 00:00:00 2001 From: Julio Quezada <95459740+Alejandroq12@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:31:06 -0600 Subject: [PATCH 5/6] fix: focus inside modal --- src/features/tasks/CreateTaskModal.tsx | 49 ++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/features/tasks/CreateTaskModal.tsx b/src/features/tasks/CreateTaskModal.tsx index 29c9420..17adeda 100644 --- a/src/features/tasks/CreateTaskModal.tsx +++ b/src/features/tasks/CreateTaskModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import avatarUrl from '@/assets/avatar.png' import { graphqlClient } from '@/lib/graphql-client' @@ -50,15 +50,46 @@ export function CreateTaskModal({ onClose }: { onClose: () => void }) { }, }) + const containerRef = useRef(null) + const isPending = createTask.isPending + + const dismiss = () => { + if (!isPending) onClose() + } + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose() + if (event.key === 'Escape' && !isPending) onClose() } window.addEventListener('keydown', onKeyDown) return () => { window.removeEventListener('keydown', onKeyDown) } - }, [onClose]) + }, [onClose, isPending]) + + useEffect(() => { + const previous = document.activeElement + return () => { + if (previous instanceof HTMLElement) previous.focus() + } + }, []) + + const trapFocus = (event: React.KeyboardEvent) => { + if (event.key !== 'Tab' || containerRef.current === null) return + const focusables = [...containerRef.current.querySelectorAll('button, input')] + .filter((el) => el.tabIndex !== -1 && !el.hasAttribute('disabled')) + .filter((el) => el.getClientRects().length > 0) + const first = focusables.at(0) + const last = focusables.at(-1) + if (first === undefined || last === undefined) return + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } const selectedAssignee = users.data?.users.find((user) => user.id === assigneeId) const canSubmit = name.trim().length > 0 && estimate !== null && dueDate !== '' @@ -90,11 +121,15 @@ export function CreateTaskModal({ onClose }: { onClose: () => void }) { } return ( -
+