Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 6 additions & 2 deletions apps/web/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,15 +423,19 @@ test('reorders tasks and subtasks by drag only and persists local order', async
const secondSubtask = reorderedTask.locator('.subtask-row').filter({
hasText: 'Second subtask',
})
await expect(reorderedTask.locator('.subtask-title')).toHaveText([
'Second subtask',
'First subtask',
])
await dragHandleOnto(
page,
secondSubtask.locator('.subtask-drag-handle'),
firstSubtask.locator('.subtask-drag-handle'),
)

await expect(reorderedTask.locator('.subtask-title')).toHaveText([
'Second subtask',
'First subtask',
'Second subtask',
])
await expect(reorderedTask.locator('.subtask-row .task-num')).toHaveText([
'1.1.1',
Expand All @@ -444,8 +448,8 @@ test('reorders tasks and subtasks by drag only and persists local order', async
await persistedTask.getByRole('button', { name: 'Expand task' }).click()
}
await expect(persistedTask.locator('.subtask-title')).toHaveText([
'Second subtask',
'First subtask',
'Second subtask',
])
await expect(persistedTask.locator('.subtask-row .task-num')).toHaveText([
'1.1.1',
Expand Down
49 changes: 26 additions & 23 deletions apps/web/src/components/roadmap/MarkdownDescription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,32 @@ function safeMarkdownUrl(url: string): string | undefined {

export function MarkdownDescription({ value }: MarkdownDescriptionProps) {
return (
<div className="desc markdown-description">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
urlTransform={safeMarkdownUrl}
components={{
a: ({ href, children, ...props }) => {
if (!href) return <>{children}</>
<section className="task-description-block">
<h4 className="section-label task-description-label">Description:</h4>
<div className="desc markdown-description">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
urlTransform={safeMarkdownUrl}
components={{
a: ({ href, children, ...props }) => {
if (!href) return <>{children}</>

const external = /^https?:/i.test(href)
return (
<a
href={href}
{...props}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
}}
>
{value}
</ReactMarkdown>
</div>
const external = /^https?:/i.test(href)
return (
<a
href={href}
{...props}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
}}
>
{value}
</ReactMarkdown>
</div>
</section>
)
}
55 changes: 49 additions & 6 deletions apps/web/src/components/roadmap/TaskDetailMeta.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
'use client'

import { Icon } from '@/components/ui/Icon'
import { useTaskTagDisplayPreferences } from '@/hooks/useTaskTagDisplayPreferences'
import { resolveTagDisplay } from '@/lib/tag-registry'
import type { Task, TagDefinition } from '@/types/roadmap'
import { TagChip } from './TagChip'

Expand All @@ -18,6 +21,14 @@ export function TaskDetailMeta({
visibleTags,
registry = [],
}: TaskDetailMetaProps) {
const {
userTags,
favoriteTagId,
showAutomaticStatus,
selectFavoriteTag,
toggleAutomaticStatus,
} = useTaskTagDisplayPreferences(task.id, visibleTags)

return (
<dl className="task-meta-stack">
{!isNested && (
Expand All @@ -44,12 +55,44 @@ export function TaskDetailMeta({
</div>
<div className="task-meta-group is-tags">
<dt className="task-meta-label">Tags</dt>
<dd className="task-meta-value tags">
{visibleTags.length > 0
? visibleTags.map((tagId) => (
<TagChip key={tagId} tagId={tagId} registry={registry} />
))
: <span className="task-meta-empty">No tags</span>}
<dd className="task-meta-value tags task-tag-preferences">
<button
type="button"
className={`automatic-tag-toggle${showAutomaticStatus ? ' selected' : ''}`}
aria-pressed={showAutomaticStatus}
onClick={toggleAutomaticStatus}
title={showAutomaticStatus
? 'Hide the automatic status tag from this task header'
: 'Show the automatic status tag in this task header'}
>
<Icon name={showAutomaticStatus ? 'eye' : 'eye-off'} size={12} />
Auto status
</button>
{userTags.map((tagId) => {
const selected = tagId === favoriteTagId
const label = resolveTagDisplay(tagId, registry).label
return (
<button
key={tagId}
type="button"
className={`favorite-tag-option${selected ? ' selected' : ''}`}
aria-pressed={selected}
aria-label={selected
? `${label} is the featured task tag`
: `Feature ${label} beside the task title`}
title={selected
? 'Featured beside the task title'
: 'Show this tag beside the task title'}
onClick={() => selectFavoriteTag(tagId)}
>
<TagChip tagId={tagId} registry={registry} />
{selected && <Icon name="spark" size={11} />}
</button>
)
})}
{userTags.length === 0 && (
<span className="task-meta-empty">No custom tags</span>
)}
</dd>
</div>
</dl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ afterEach(() => {
})

describe('MarkdownDescription', () => {
it('labels the expanded task description', () => {
render('Task details')

expect(container.querySelector('.task-description-label')?.textContent).toBe('Description:')
expect(container.querySelector('.markdown-description')?.textContent).toBe('Task details')
})

it('renders the roadmap fixture headings, lists, and bold labels', () => {
render(`### Objective

Expand Down
35 changes: 25 additions & 10 deletions apps/web/src/components/roadmap/task-row/TaskRowHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Icon } from '@/components/ui/Icon'
import { TagChip } from '@/components/roadmap/TagChip'
import { useTaskTagDisplayPreferences } from '@/hooks/useTaskTagDisplayPreferences'
import {
TASK_STATUS_LABELS,
type DerivedTaskStatus,
Expand Down Expand Up @@ -47,6 +48,16 @@ export function TaskRowHeader({
onCheck,
onToggle,
}: TaskRowHeaderProps) {
const {
userTags,
favoriteTagId,
showAutomaticStatus,
} = useTaskTagDisplayPreferences(task.id, visibleTags)
const remainingTagCount = Math.max(
0,
userTags.length - (favoriteTagId ? 1 : 0),
)

return (
<div className="task-row">
<div
Expand Down Expand Up @@ -78,19 +89,23 @@ export function TaskRowHeader({
}}
/>
<span className="title">{task.title}</span>
<span className={`task-status-badge is-${status}`} title={statusTitle}>
{TASK_STATUS_LABELS[status]}
</span>
{visibleTags.slice(0, 2).map((tagId) => (
{showAutomaticStatus && (
<span
className={`task-status-badge is-${status} is-automatic-tag`}
title={`Automatic status: ${statusTitle}`}
>
{TASK_STATUS_LABELS[status]}
</span>
)}
{favoriteTagId && (
<TagChip
key={tagId}
tagId={tagId}
tagId={favoriteTagId}
registry={registry}
className="task-row-tag"
className="task-row-tag is-favorite"
/>
))}
{visibleTags.length > 2 && (
<span className="meta-pill">+{visibleTags.length - 2}</span>
)}
{remainingTagCount > 0 && (
<span className="meta-pill">+{remainingTagCount}</span>
)}
{lockedByOther && (
<span className="meta-pill meta-pill-lock">
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/hooks/__tests__/useTaskTagDisplayPreferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import {
getUserDisplayTags,
isAutomaticTaskTag,
} from '../useTaskTagDisplayPreferences'

describe('task tag display preferences', () => {
it('recognizes status namespace tags as automatic', () => {
expect(isAutomaticTaskTag('status:done')).toBe(true)
expect(isAutomaticTaskTag(' STATUS:in-progress ')).toBe(true)
expect(isAutomaticTaskTag('priority:P0')).toBe(false)
})

it('keeps custom tags in their authored order', () => {
expect(getUserDisplayTags([
'status:done',
'priority:P0',
'area:coordination',
'status:planned',
])).toEqual([
'priority:P0',
'area:coordination',
])
})
})
132 changes: 132 additions & 0 deletions apps/web/src/hooks/useTaskTagDisplayPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'use client'

import { useCallback, useEffect, useMemo, useState } from 'react'
import { storage } from '@/lib/storage'

interface TaskTagDisplayPreferences {
favoriteTagId: string | null
showAutomaticStatus: boolean
}

interface TaskTagPreferenceEventDetail {
storageKey: string
preferences: TaskTagDisplayPreferences
}

const STORAGE_PREFIX = 'roadforge.task-tag-display.v1'
const PREFERENCE_EVENT = 'roadforge:task-tag-display-change'
const DEFAULT_PREFERENCES: TaskTagDisplayPreferences = {
favoriteTagId: null,
showAutomaticStatus: true,
}

export function isAutomaticTaskTag(tagId: string): boolean {
return /^status:/i.test(tagId.trim())
}

export function getUserDisplayTags(tagIds: string[]): string[] {
return tagIds.filter((tagId) => !isAutomaticTaskTag(tagId))
}

function readPreferences(storageKey: string): TaskTagDisplayPreferences {
if (typeof window === 'undefined') return DEFAULT_PREFERENCES

try {
const stored = window.localStorage.getItem(storageKey)
if (!stored) return DEFAULT_PREFERENCES

const parsed = JSON.parse(stored) as Partial<TaskTagDisplayPreferences>
return {
favoriteTagId: typeof parsed.favoriteTagId === 'string'
? parsed.favoriteTagId
: null,
showAutomaticStatus: parsed.showAutomaticStatus !== false,
}
} catch {
return DEFAULT_PREFERENCES
}
}

function writePreferences(
storageKey: string,
preferences: TaskTagDisplayPreferences,
): void {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(storageKey, JSON.stringify(preferences))
} catch {
// The preference remains active for this session when browser storage is blocked.
}
}

export function useTaskTagDisplayPreferences(taskId: string, tagIds: string[]) {
const activeRoadmapId = storage.getActiveRoadmapId()
const userTags = useMemo(() => getUserDisplayTags(tagIds), [tagIds])
const storageKey = `${STORAGE_PREFIX}:${activeRoadmapId ?? 'local'}:${taskId}`
const [preferences, setPreferences] = useState<TaskTagDisplayPreferences>(
DEFAULT_PREFERENCES,
)

useEffect(() => {
setPreferences(readPreferences(storageKey))

const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) {
setPreferences(readPreferences(storageKey))
}
}
const handlePreferenceChange = (event: Event) => {
const detail = (event as CustomEvent<TaskTagPreferenceEventDetail>).detail
if (detail?.storageKey === storageKey) {
setPreferences(detail.preferences)
}
}

window.addEventListener('storage', handleStorage)
window.addEventListener(PREFERENCE_EVENT, handlePreferenceChange)
return () => {
window.removeEventListener('storage', handleStorage)
window.removeEventListener(PREFERENCE_EVENT, handlePreferenceChange)
}
}, [storageKey])

const updatePreferences = useCallback((
updater: (current: TaskTagDisplayPreferences) => TaskTagDisplayPreferences,
) => {
setPreferences((current) => {
const next = updater(current)
writePreferences(storageKey, next)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<TaskTagPreferenceEventDetail>(PREFERENCE_EVENT, {
detail: { storageKey, preferences: next },
}))
}
return next
})
}, [storageKey])

const selectFavoriteTag = useCallback((tagId: string) => {
if (!userTags.includes(tagId)) return
updatePreferences((current) => ({ ...current, favoriteTagId: tagId }))
}, [updatePreferences, userTags])

const toggleAutomaticStatus = useCallback(() => {
updatePreferences((current) => ({
...current,
showAutomaticStatus: !current.showAutomaticStatus,
}))
}, [updatePreferences])

const favoriteTagId = preferences.favoriteTagId
&& userTags.includes(preferences.favoriteTagId)
? preferences.favoriteTagId
: userTags[0] ?? null

return {
userTags,
favoriteTagId,
showAutomaticStatus: preferences.showAutomaticStatus,
selectFavoriteTag,
toggleAutomaticStatus,
}
}
Loading
Loading