Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/web/src/components/TaskDueDatePalette.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TaskDueDatePalette } from './TaskDueDatePalette'

function paletteInput(): HTMLInputElement {
return screen.getByTestId('task-due-palette').querySelector('input') as HTMLInputElement
}

describe('TaskDueDatePalette', () => {
it('commits a typed natural-language date and closes', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
render(<TaskDueDatePalette onSelect={onSelect} onClose={onClose} />)

fireEvent.change(paletteInput(), { target: { value: '2026-07-01' } })
expect(screen.getByText(/Set due/)).toBeTruthy()
fireEvent.keyDown(paletteInput(), { key: 'Enter' })

expect(onSelect).toHaveBeenCalledWith(Date.UTC(2026, 6, 1))
expect(onClose).toHaveBeenCalledTimes(1)
})

it('clears the due date via the preset', () => {
const onSelect = vi.fn()
render(<TaskDueDatePalette onSelect={onSelect} onClose={() => {}} />)

fireEvent.click(screen.getByText('Clear due date'))
expect(onSelect).toHaveBeenCalledWith(null)
})

it('navigates presets with arrow keys and commits on Enter', () => {
const onSelect = vi.fn()
render(<TaskDueDatePalette onSelect={onSelect} onClose={() => {}} />)

// Default list starts at "Today"; ArrowDown→Tomorrow, ArrowUp→Today.
fireEvent.keyDown(paletteInput(), { key: 'ArrowDown' })
fireEvent.keyDown(paletteInput(), { key: 'ArrowUp' })
fireEvent.keyDown(paletteInput(), { key: 'Enter' })
expect(onSelect).toHaveBeenCalledTimes(1)
expect(typeof onSelect.mock.calls[0][0]).toBe('number')
})

it('closes on Escape and backdrop click without selecting', () => {
const onSelect = vi.fn()
const onClose = vi.fn()
render(<TaskDueDatePalette onSelect={onSelect} onClose={onClose} />)

fireEvent.keyDown(paletteInput(), { key: 'Escape' })
fireEvent.click(screen.getByTestId('task-due-palette'))
expect(onClose).toHaveBeenCalled()
expect(onSelect).not.toHaveBeenCalled()
})

it('filters presets by the typed query when it is not a date', () => {
render(<TaskDueDatePalette onSelect={() => {}} onClose={() => {}} />)

fireEvent.change(paletteInput(), { target: { value: 'week' } })
expect(screen.getByText('Next week')).toBeTruthy()
expect(screen.queryByText('Today')).toBeNull()
})
})
125 changes: 125 additions & 0 deletions apps/web/src/components/TaskDueDatePalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* TaskDueDatePalette - keyboard-first due-date entry for a focused task.
*
* Opened by the `d` verb on the Tasks surface. Typing a natural-language date
* ("next friday", "in 3 days", "2026-07-01") surfaces a live "Set due …" row;
* Enter commits the highlighted option. Presets and a clear action round out
* the list. Mirrors TaskMiniPalette so the registry's single-key verbs stay
* suppressed while its input is focused.
*/
import { parseDueDate, utcDayFromNow } from '@xnetjs/ui'
import { useMemo, useRef, useState, type JSX } from 'react'

export interface TaskDueDatePaletteProps {
/** Commit a due date (UTC-midnight ms) or null to clear. */
onSelect: (dueDate: number | null) => void
onClose: () => void
}

interface DueOption {
id: string
label: string
ms: number | null
}

function formatDue(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC'
})
}

export function TaskDueDatePalette({ onSelect, onClose }: TaskDueDatePaletteProps): JSX.Element {
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)

const options = useMemo<DueOption[]>(() => {
const trimmed = query.trim()
const parsed = trimmed ? parseDueDate(trimmed) : null
const presets: DueOption[] = [
{ id: 'today', label: 'Today', ms: utcDayFromNow(0) },
{ id: 'tomorrow', label: 'Tomorrow', ms: utcDayFromNow(1) },
{
id: 'weekend',
label: 'This weekend',
ms: parseDueDate('this weekend')?.ms ?? utcDayFromNow(0)
},
{ id: 'nextweek', label: 'Next week', ms: utcDayFromNow(7) },
{ id: 'clear', label: 'Clear due date', ms: null }
]
const filtered = trimmed
? presets.filter((option) => option.label.toLowerCase().includes(trimmed.toLowerCase()))
: presets
return parsed
? [{ id: 'parsed', label: `Set due ${formatDue(parsed.ms)}`, ms: parsed.ms }, ...filtered]
: filtered
}, [query])

const commit = (option: DueOption | undefined) => {
if (!option) return
onSelect(option.ms)
onClose()
}

return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 pt-32"
onClick={onClose}
data-testid="task-due-palette"
>
<div
className="w-full max-w-xs overflow-hidden rounded-lg border border-border bg-background shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<input
ref={inputRef}
autoFocus
type="text"
value={query}
placeholder="Set due date… (e.g. next friday)"
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, options.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
commit(options[activeIndex])
} else if (event.key === 'Escape') {
Comment on lines +87 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp keyboard index when the options list is empty.

With zero options, ArrowDown currently drives activeIndex to -1. Keep the index at 0 when the list is empty so keyboard state stays valid.

Suggested fix
           onKeyDown={(event) => {
             if (event.key === 'ArrowDown') {
               event.preventDefault()
-              setActiveIndex((index) => Math.min(index + 1, options.length - 1))
+              setActiveIndex((index) =>
+                options.length > 0 ? Math.min(index + 1, options.length - 1) : 0
+              )
             } else if (event.key === 'ArrowUp') {
               event.preventDefault()
-              setActiveIndex((index) => Math.max(index - 1, 0))
+              setActiveIndex((index) => (options.length > 0 ? Math.max(index - 1, 0) : 0))
             } else if (event.key === 'Enter') {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, options.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
commit(options[activeIndex])
} else if (event.key === 'Escape') {
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) =>
options.length > 0 ? Math.min(index + 1, options.length - 1) : 0
)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => (options.length > 0 ? Math.max(index - 1, 0) : 0))
} else if (event.key === 'Enter') {
event.preventDefault()
commit(options[activeIndex])
} else if (event.key === 'Escape') {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/TaskDueDatePalette.tsx` around lines 87 - 97, The
ArrowDown branch can set activeIndex to -1 when options.length is 0; fix by
computing a safe maxIndex = Math.max(0, options.length - 1) and use that when
updating via setActiveIndex in the onKeyDown handler (replace the current
Math.min(index + 1, options.length - 1) logic), keep the ArrowUp clamp as
Math.max(index - 1, 0), and also guard the Enter branch (commit) to only call
commit(options[activeIndex]) when options.length > 0 to avoid indexing into an
empty array; reference: onKeyDown, setActiveIndex, options, activeIndex, commit.

event.preventDefault()
event.stopPropagation()
onClose()
}
}}
className="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<ul className="max-h-60 list-none overflow-y-auto p-1">
{options.map((option, index) => (
<li
key={option.id}
className={`flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground ${
index === activeIndex ? 'bg-secondary' : 'hover:bg-secondary'
}`}
onClick={() => commit(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.label}
</li>
))}
{options.length === 0 && (
<li className="px-2 py-1.5 text-sm text-muted-foreground">No matches</li>
)}
</ul>
</div>
</div>
)
}
77 changes: 72 additions & 5 deletions apps/web/src/components/TasksView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ import { useIdentity, useMutate, useQuery, useTasks } from '@xnetjs/react'
import {
DIDAvatar,
MentionTextInput,
formatDueDate,
getTaskStatusMeta,
taskPersonLabel,
type TaskDisplayData
} from '@xnetjs/ui'
import { TaskBoard, TaskListGrouped, type TaskBoardStatusChange } from '@xnetjs/views'
import { Inbox, KanbanSquare, List, Plus, User, X } from 'lucide-react'
import { CalendarDays, Hash, Inbox, KanbanSquare, List, Plus, User, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type JSX } from 'react'
import { useWorkspacePeople } from '../hooks/useWorkspacePeople'
import { useWorkspaceTags } from '../hooks/useWorkspaceTags'
import { useContextPanel, type ContextPanelSection } from '../workbench/context-panel'
import { TaskDueDatePalette } from './TaskDueDatePalette'
import { TaskInlineEditor } from './TaskInlineEditor'
import { TaskMiniPalette } from './TaskMiniPalette'

Expand Down Expand Up @@ -74,13 +77,16 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
const { did } = useIdentity()
const { create, update } = useMutate()
const people = useWorkspacePeople()
const { suggestions: tagOptions, getOrCreateTag } = useWorkspaceTags()
const [tab, setTab] = useState<TasksTab>('all')
const [mode, setMode] = useState<TasksMode>('list')
const [draft, setDraft] = useState('')
const [draftAssignees, setDraftAssignees] = useState<string[]>([])
const [draftTags, setDraftTags] = useState<string[]>([])
const [draftDue, setDraftDue] = useState<number | null>(null)
const [focusedTaskId, setFocusedTaskId] = useState<string | null>(null)
const [editingTaskId, setEditingTaskId] = useState<string | null>(null)
const [miniPalette, setMiniPalette] = useState<'status' | 'priority' | null>(null)
const [miniPalette, setMiniPalette] = useState<'status' | 'priority' | 'dueDate' | null>(null)
const quickAddRef = useRef<HTMLInputElement>(null)

const { data: tasks, loading } = useTasks({ includeCompleted: true })
Expand Down Expand Up @@ -273,6 +279,13 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
key: 'p',
run: withFocused(() => setMiniPalette('priority'))
}),
registry.register({
id: 'task.setDueDate',
title: 'Set task due date…',
scope: 'task-focused',
key: 'd',
run: withFocused(() => setMiniPalette('dueDate'))
}),
registry.register({
id: 'task.edit',
title: 'Edit task',
Expand Down Expand Up @@ -328,6 +341,8 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp

setDraft('')
setDraftAssignees([])
setDraftTags([])
setDraftDue(null)
const status: TaskStatusId = tab === 'triage' ? 'triage' : 'todo'
const assignees =
tab === 'mine' && did && !draftAssignees.includes(did)
Expand All @@ -342,7 +357,9 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
status,
source: 'api',
...(projectId ? { project: projectId } : {}),
...(firstAssignee ? { assignee: firstAssignee as DID, assignees: assignees as DID[] } : {})
...(firstAssignee ? { assignee: firstAssignee as DID, assignees: assignees as DID[] } : {}),
...(draftTags.length ? { tags: draftTags } : {}),
...(draftDue != null ? { dueDate: draftDue } : {})
},
generateTaskId()
)
Expand Down Expand Up @@ -431,13 +448,54 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
onChange={setDraft}
people={people.filter((person) => !draftAssignees.includes(person.did))}
onMention={(mentioned) => setDraftAssignees((current) => [...current, mentioned])}
tags={tagOptions.filter((tag) => !draftTags.includes(tag.id))}
onTag={(tagId) => setDraftTags((current) => [...current, tagId])}
onCreateTag={(name) => {
void getOrCreateTag(name).then((tag) => {
if (tag) setDraftTags((current) => [...current, tag.id])
})
}}
onDueDate={(ms) => setDraftDue(ms)}
onSubmit={() => void handleCreate()}
inputRef={quickAddRef}
placeholder={
tab === 'triage' ? 'Add to triage… (@ to assign)' : 'Add a task… (@ to assign)'
tab === 'triage'
? 'Add to triage… (@ assign · # tag · due date)'
: 'Add a task… (@ assign · # tag · type a due date)'
}
data-testid="task-quick-add"
/>
{draftDue != null && (
<span className="flex shrink-0 items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-xs text-foreground">
<CalendarDays size={12} className="text-muted-foreground" />
{formatDueDate(draftDue).label}
<button
type="button"
aria-label="Remove pending due date"
onClick={() => setDraftDue(null)}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X size={10} />
</button>
</span>
)}
{draftTags.map((tagId) => (
<span
key={tagId}
className="flex shrink-0 items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-xs text-foreground"
>
<Hash size={11} className="text-muted-foreground" />
{tagOptions.find((tag) => tag.id === tagId)?.name ?? 'tag'}
<button
type="button"
aria-label="Remove pending tag"
onClick={() => setDraftTags((current) => current.filter((id) => id !== tagId))}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<X size={10} />
</button>
</span>
))}
{draftAssignees.map((assignee) => (
<span
key={assignee}
Expand Down Expand Up @@ -508,7 +566,7 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
</div>
)}

{miniPalette && focusedTaskId && (
{(miniPalette === 'status' || miniPalette === 'priority') && focusedTaskId && (
<TaskMiniPalette
title={miniPalette === 'status' ? 'Change status…' : 'Change priority…'}
kind={miniPalette}
Expand All @@ -529,6 +587,15 @@ export function TasksView({ openTaskId = null, projectId = null }: TasksViewProp
onClose={() => setMiniPalette(null)}
/>
)}

{miniPalette === 'dueDate' && focusedTaskId && (
<TaskDueDatePalette
onSelect={(dueDate) =>
void update(TaskSchema, focusedTaskId, { dueDate: dueDate ?? undefined })
}
onClose={() => setMiniPalette(null)}
/>
)}
</div>
)
}
8 changes: 5 additions & 3 deletions apps/web/src/components/task-node-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Kept free of React so the host-authority rules stay unit-testable.
*/
import type { UseTasksResult } from '@xnetjs/react'
import type { TaskDisplayData } from '@xnetjs/ui'
import { dueDateMsToIso as msToIso, type TaskDisplayData } from '@xnetjs/ui'

export type TaskNode = UseTasksResult['data'][number]

Expand Down Expand Up @@ -54,9 +54,11 @@ export function diffAssignees(
}
}

/** UTC ms → YYYY-MM-DD for the editor's due-date chips; null clears. */
/** UTC ms → YYYY-MM-DD for the editor's due-date chips; null clears.
* Delegates to the canonical converter (@xnetjs/ui) so the node↔doc
* round-trip shares one timezone-safe contract (exploration 0172). */
export function dueDateMsToIso(dueDate: number | null): string | null {
return dueDate == null ? null : new Date(dueDate).toISOString().slice(0, 10)
return dueDate == null ? null : msToIso(dueDate)
}

/** Compact fallback label when presence has no entry for a DID. */
Expand Down
Loading
Loading