feat: complete task management workflow
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterTasks, groupTaskTree, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
tags?: { name: string }[]
|
||||
list_name?: string
|
||||
}
|
||||
|
||||
const tasks: SearchTask[] = [
|
||||
{ id: '1', title: 'Write release notes', description: 'mention **API**', parent_id: null, completed: false },
|
||||
{ id: '2', title: 'Check links', description: '', parent_id: '1', completed: false },
|
||||
{ id: '3', title: 'Buy milk', description: '', parent_id: null, completed: true },
|
||||
]
|
||||
|
||||
describe('task utilities', () => {
|
||||
it('filters task titles and markdown descriptions case-insensitively', () => {
|
||||
expect(filterTasks(tasks, 'api').map((task) => task.id)).toEqual(['1'])
|
||||
expect(filterTasks(tasks, 'WRITE').map((task) => task.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps a one-level subtask tree without duplicating children', () => {
|
||||
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('filters titles, descriptions, tags, and list names', () => {
|
||||
const searchable: SearchTask[] = [
|
||||
...tasks,
|
||||
{ id: '4', title: 'Plan', tags: [{ name: '重要' }], list_name: '工作清单' },
|
||||
]
|
||||
expect(filterTasks(searchable, '重要').map((task) => task.id)).toEqual(['4'])
|
||||
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
|
||||
})
|
||||
|
||||
it('formats API dates in local wall-clock time', () => {
|
||||
const original = process.env.TZ
|
||||
process.env.TZ = 'Asia/Shanghai'
|
||||
expect(toDateTimeLocal('2026-09-05T12:30:00Z')).toBe('2026-09-05T20:30')
|
||||
process.env.TZ = original
|
||||
expect(toDateTimeLocal(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
export type MinimalTask = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
tags?: { name: string }[]
|
||||
list_name?: string
|
||||
}
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const renderInline = (value: string) => {
|
||||
let out = escapeHtml(value)
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
export function renderMarkdown(markdown = '') {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
const html: string[] = []
|
||||
let inList = false
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>')
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
closeList()
|
||||
html.push(`<h1>${renderInline(line.slice(2))}</h1>`)
|
||||
} else if (line.startsWith('## ')) {
|
||||
closeList()
|
||||
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
||||
} else if (/^[-*] /.test(line)) {
|
||||
if (!inList) {
|
||||
html.push('<ul>')
|
||||
inList = true
|
||||
}
|
||||
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
||||
} else {
|
||||
closeList()
|
||||
html.push(`<p>${renderInline(line)}</p>`)
|
||||
}
|
||||
}
|
||||
closeList()
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return tasks
|
||||
return tasks.filter((task) => {
|
||||
const haystack = [
|
||||
task.title,
|
||||
task.description ?? '',
|
||||
task.list_name ?? '',
|
||||
...(task.tags ?? []).map((tag) => tag.name),
|
||||
].join(' ')
|
||||
return haystack.toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||
const children = new Map<string, T[]>()
|
||||
for (const task of tasks) {
|
||||
if (!task.parent_id) continue
|
||||
children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task])
|
||||
}
|
||||
return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] }))
|
||||
}
|
||||
|
||||
export function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.valueOf())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`
|
||||
}
|
||||
|
||||
export function fromDateTimeLocal(value: string) {
|
||||
if (!value) return null
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
Reference in New Issue
Block a user