Add HelpPanel and TodoItemTouch components, extend task commands, and refactor task and view logic

- Introduced `HelpPanel.vue` for displaying keyboard shortcuts and command descriptions.
- Added `TodoItemTouch.vue`, a mobile-friendly task item component with updated bindings and improved actions.
- Extended task commands with support for tagging, due date parsing, and dynamic text formatting.
- Implemented `useActions` utility for parsing and executing command-based task modifications.
- Streamlined task editing and creation in `useTasks` for consistency and API integration.
- Updated `ListScreen` to support collapsible, categorized task lists with visual enhancements.
- Refactored `App.vue` for adaptive input handling on mobile versus desktop views.
- Enhanced API communication in `useApi` with cleaner header generation and error handling.
This commit is contained in:
2026-02-23 16:34:52 +01:00
parent ec76a52fdd
commit 56f89b6669
21 changed files with 1347 additions and 214 deletions

403
src/utils/actions.ts Normal file
View File

@@ -0,0 +1,403 @@
import type { Task } from '../types.ts'
import type { Command } from './parser.ts'
import { TaskStatus } from '../types.ts'
import { parseDueDate, stopWorkLogging } from './helpers.ts'
export function moveCommand(tasks: Task[], ids: Task['id_'][], cmd: Command) {
return tasks.filter(t => ids.includes(t.id_) && t.tag && cmd?.tag).map(t => ({
...t,
tag: cmd?.tag,
} as Task))
}
export function beginCommand(tasks: Task[], ids: Task['id_'][]) {
return tasks.filter(t => ids.includes(t.id_) && t.status !== TaskStatus.WIP).map((t) => {
t.status = TaskStatus.WIP
t.logs = (t.logs || []).concat({
start: Date.now(),
end: 0,
})
return t
})
}
export function checkCommand(tasks: Task[], ids: Task['id_'][]) {
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
t.status
= t.status === TaskStatus.DONE ? TaskStatus.WAIT : TaskStatus.DONE
if (t.status === TaskStatus.DONE) {
t = stopWorkLogging(t)
}
return t
})
}
export function deleteCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
if (ids.length === 0) {
// Delete by tag
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
if (tag) {
return tasks.filter(t => t.tag === tag).map(t => ({
...t,
status: TaskStatus.NONE,
} as Task))
}
// Delete by status
const status = (
cmd?.id?.match(/^(finished|done|flag|ongoing|wip|wait|pending)/)
|| []
).pop()
if (status) {
let taskStatus = null
switch (status) {
case 'finished':
case 'done':
taskStatus = TaskStatus.DONE
break
case 'flag':
case 'flagged':
taskStatus = TaskStatus.FLAG
break
case 'ongoing':
case 'wip':
taskStatus = TaskStatus.WIP
break
case 'wait':
case 'pending':
taskStatus = TaskStatus.WAIT
break
default:
break
}
if (taskStatus) {
return tasks.filter(t => t.status === taskStatus && !t.archived).map(t => ({
...t,
status: TaskStatus.NONE,
} as Task))
}
}
return []
}
else {
// Delete by id
return tasks.filter(t => ids.includes(t.id_)).map(t => ({
...t,
status: TaskStatus.NONE,
} as Task))
}
}
export function flagCommand(tasks: Task[], ids: Task['id_'][]) {
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
t.status
= t.status === TaskStatus.FLAG ? TaskStatus.WAIT : TaskStatus.FLAG
t = stopWorkLogging(t)
return t
})
}
export function stopCommand(tasks: Task[], ids: Task['id_'][]) {
return tasks.filter(t => ids.includes(t.id_) && t.status === TaskStatus.WIP).map((t) => {
t = stopWorkLogging(t)
return {
...t,
status: TaskStatus.WAIT,
} as Task
})
}
export function switchCommand(tasks: Task[], ids: Task['id_'][]) {
if (ids.length === 2) {
const stopId = ids[0]
const startId = ids[1]
return tasks.filter(t => ids.includes(t.id_)).map((t) => {
if (t.id_ === stopId && t.status === TaskStatus.WIP) {
return stopCommand([t], [stopId])[0]
}
if (t.id_ === startId && t.status !== TaskStatus.WIP) {
return beginCommand([t], [startId])[0]
}
return t
})
}
return []
}
export function archiveCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
if (ids.length === 0) {
// Archive by tag
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
if (tag) {
return tasks.filter(t => t.tag === tag).map(t => ({
...t,
archived: true,
} as Task))
}
}
else {
// Archive by Ids
return tasks.filter(t => ids.includes(t.id_)).map(t => ({
...t,
archived: true,
} as Task))
}
return []
}
export function restoreCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
if (ids.length === 0) {
// Archive by tag
const tag = (cmd?.id?.match(/^(@.*)/) || []).pop()
if (tag) {
return tasks.filter(t => t.tag === tag).map(t => ({
...t,
archived: false,
} as Task))
}
}
else {
// Archive by Ids
return tasks.filter(t => ids.includes(t.id)).map(t => ({
...t,
archived: false,
} as Task))
}
return []
}
export function insertTaskCommand(cmd: Command) {
const tag = cmd?.tag || '@uncategorized'
const task = cmd?.text
if (task && task.length) {
return {
tag,
title: task,
dueDate: null,
} as Pick<Task, 'title' | 'dueDate' | 'tag'>
}
return null
}
export function editTaskCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
const id = ids[0]
const task = cmd?.text
if (task && task.length) {
return tasks.filter(t => t.id === id).map(t => ({
...t,
title: task,
} as Task))
}
return []
}
export function dueCommand(ids: Task['id_'][], cmd: Command, tasks: Task[]) {
const id = ids && ids.length ? ids[0] : null
const text = (cmd?.text || '').trim()
if (id) {
return tasks.filter(t => t.id_ === id).map((t: Task) => {
if (/^(?:clear|none|remove)$/i.test(text)) {
t.dueDate = null
}
else if (text && text.length) {
t.dueDate = parseDueDate(text)
}
return t
})
}
return []
}
export function tagRenameCommand(cmd: Command, tasks: Task[]) {
const [from, to] = cmd?.tag?.split(' ') || []
if (!from || !to)
return []
return tasks.filter(t => t.tag.match(from)).map(t => ({
...t,
tag: to,
} as Task))
}
// export function hideCommand(updateCandidate, cmd) {
// updateCandidate = (() => {
// switch (cmd.text) {
// case 'finished':
// case 'done':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// done: false,
// },
// }
// case 'flag':
// case 'flagged':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// flagged: false,
// },
// }
// case 'ongoing':
// case 'wip':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// wip: false,
// },
// }
// case 'pending':
// case 'wait':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// wait: false,
// },
// }
// default:
// return updateCandidate
// }
// })()
// return updateCandidate
// }
//
// export function showCommand(updateCandidate, cmd) {
// updateCandidate = (() => {
// switch (cmd.text) {
// case 'finished':
// case 'done':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// done: true,
// },
// }
// case 'flag':
// case 'flagged':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// flagged: true,
// },
// }
// case 'wip':
// case 'ongoing':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// wip: true,
// },
// }
// case 'pending':
// case 'wait':
// return {
// ...updateCandidate,
// taskVisibility: {
// ...updateCandidate.taskVisibility,
// wait: true,
// },
// }
// default:
// return updateCandidate
// }
// })()
// return updateCandidate
// }
//
// export function searchCommand(updateCandidate: any, cmd) {
// if (cmd.command.match(/search/i)) {
// updateCandidate = {
// ...updateCandidate,
// filterBy: cmd.text,
// }
// }
// return updateCandidate
// }
//
// export function otherCommand(updateCandidate, cmd, tasks: Task[]) {
// updateCandidate = (() => {
// const commandText = cmd.command.toLowerCase()
// if (commandText === 'help') {
// return {
// ...updateCandidate,
// showHelp: true,
// }
// }
// else if (commandText === 'quickhelp') {
// return {
// ...updateCandidate,
// showQuickHelp: true,
// }
// }
// else if (commandText === 'today') {
// return {
// ...updateCandidate,
// showToday: !state.showToday,
// }
// }
// else if (commandText === 'dark') {
// return {
// ...updateCandidate,
// darkMode: true,
// }
// }
// else if (commandText === 'light') {
// return {
// ...updateCandidate,
// darkMode: false,
// }
// }
// else if (commandText === 'setting') {
// return {
// ...updateCandidate,
// showSettings: true,
// }
// }
// else if (commandText === 'customize') {
// return {
// ...updateCandidate,
// showCustomCSS: !updateCandidate.showCustomCSS,
// }
// }
// else if (commandText === 'list-archived') {
// return {
// ...updateCandidate,
// showArchived: !updateCandidate.showArchived,
// }
// }
// else if (commandText === 'login') {
// // OK, Let me explain the weird @demo stuff here:
// // If the user is already has their data on another machine, and
// // they opened this app on a new machine, then login right away,
// // the tasks in the range of 1..12 will be conflict with the demo
// // tasks. So, we will explicitly remove these demo tasks if they're
// // actually a demo, when login.
// return {
// ...updateCandidate,
// tasks: updateCandidate.tasks.filter(t =>
// (t.id - 1) * (t.id - 12) <= 0 ? t.tag !== '@demo' : true,
// ),
// userWantToLogin: true,
// }
// }
// else if (commandText === 'logout') {
// return {
// ...updateCandidate,
// authToken: '',
// userName: '',
// userWantToLogin: true,
// }
// }
// else {
// return updateCandidate
// }
// })()
// return updateCandidate
// }