diff --git a/README.md b/README.md index b123c95..55178b5 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ dewy |---------|-------------| | `add ` | Add a task via quick add | | `done ` | Complete a task by its number | -| `project ` | View tasks in a project | | `filter ` | View tasks matching a Todoist filter | | `today` | View today's tasks | | `home` | View the home filter | diff --git a/source/app.tsx b/source/app.tsx index e116aed..f91c434 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -2,8 +2,10 @@ import React, {useState, useEffect, useCallback} from 'react'; import {Box, Text, useApp} from 'ink'; import TextInput from 'ink-text-input'; import {TodoistApi, type Task} from '@doist/todoist-api-typescript'; -import {commands} from './commands.js'; +import {type View, commands} from './commands.js'; +import {priorityColor} from './utils.js'; import {loadConfig, configPath} from './config.js'; +import EditView from './edit-view.js'; const config = loadConfig(); const apiToken = config?.apiToken ?? process.env['TODOIST_API_TOKEN']; @@ -23,9 +25,7 @@ export default function App() { const [tasks, setTasks] = useState([]); const [projects, setProjects] = useState>(new Map()); - const [view, setView] = useState< - {type: 'filter'; query: string} | {type: 'project'; projectId: string} - >({ + const [view, setView] = useState({ type: 'filter', query: homeFilter, }); @@ -35,12 +35,14 @@ export default function App() { const [message, setMessage] = useState(''); const refresh = useCallback(async () => { + if (view.type === 'edit') { + return; + } + setLoading(true); const [taskResponse, projectResponse] = await Promise.all([ - view.type === 'filter' - ? api.getTasksByFilter({query: view.query}) - : api.getTasks({projectId: view.projectId}), + api.getTasksByFilter({query: view.query}), api.getProjects(), ]); @@ -88,10 +90,28 @@ export default function App() { } const viewLabel = - view.type === 'project' - ? `dewy ∙ #${projects.get(view.projectId) ?? view.projectId}` + view.type === 'edit' + ? `dewy ∙ edit: ${view.task.content}` : `dewy ∙ ${view.query}`; + if (view.type === 'edit') { + return ( + + + {viewLabel} + + { + setView({type: 'filter', query: homeFilter}); + refresh(); + }} + /> + + ); + } + return ( @@ -117,13 +137,21 @@ export default function App() { '' )} {task.due?.date ? ( - + {' '} {task.due.date} ) : ( '' )} + {task.priority > 1 ? ( + + {' '} + p{5 - task.priority} + + ) : ( + '' + )} ))} {message && {message}} diff --git a/source/commands.ts b/source/commands.ts index 8759098..02c8278 100644 --- a/source/commands.ts +++ b/source/commands.ts @@ -1,5 +1,7 @@ import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; +export type View = {type: 'filter'; query: string} | {type: 'edit'; task: Task}; + export type CommandContext = { api: TodoistApi; tasks: Task[]; @@ -8,12 +10,7 @@ export type CommandContext = { setMessage: (msg: string) => void; refresh: () => Promise; - setView: ( - view: - | {type: 'filter'; query: string} - | {type: 'project'; projectId: string}, - ) => void; - + setView: (view: View) => void; exit: () => void; }; type Command = { @@ -38,6 +35,21 @@ export const commands: Command[] = [ } }, }, + + { + prefix: 'edit ', + hint: 'edit ', + run: async (args, {tasks, setView, setMessage}) => { + const num = Number.parseInt(args, 10); + const task = tasks[num - 1]; + if (task) { + setView({type: 'edit', task}); + } else { + setMessage(`No task #${num}`); + } + }, + }, + { prefix: 'add ', hint: 'add ', @@ -63,21 +75,6 @@ export const commands: Command[] = [ setMessage('↻ refreshed'); }, }, - { - prefix: 'project ', - hint: 'project ', - run: async (args, {projects, setMessage, setView}) => { - const projectId = [...projects.entries()].find( - ([, n]) => n.toLowerCase() === args.toLowerCase(), - )?.[0]; - if (projectId) { - setView({type: 'project', projectId}); - setMessage(`→ #${args}`); - } else { - setMessage(`Project not found: ${args}`); - } - }, - }, { prefix: 'today', diff --git a/source/edit-view.tsx b/source/edit-view.tsx new file mode 100644 index 0000000..d7fd280 --- /dev/null +++ b/source/edit-view.tsx @@ -0,0 +1,142 @@ +import React, {useState} from 'react'; +import {Box, Text, useInput} from 'ink'; +import TextInput from 'ink-text-input'; +import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; +import {priorityColor} from './utils.js'; + +type EditViewProps = { + task: Task; + api: TodoistApi; + onBack: () => void; +}; + +const fields = [ + {key: 'content', label: 'Title', color: () => undefined}, + {key: 'description', label: 'Description', color: () => 'gray'}, + {key: 'due', label: 'Due', color: () => 'magenta'}, + { + key: 'priority', + label: 'Priority', + color: (v: string) => priorityColor(Number.parseInt(v, 10)), + }, + {key: 'labels', label: 'Labels', color: () => 'yellow'}, +]; + +function getFieldValue(task: Task, key: string): string { + switch (key) { + case 'content': + return task.content; + case 'description': + return task.description; + case 'due': + return task.due?.string ?? ''; + case 'priority': + return String(5 - task.priority); + case 'labels': + return task.labels.join(', '); + default: + return ''; + } +} + +function buildUpdateArgs(key: string, value: string) { + switch (key) { + case 'content': + return {content: value}; + case 'description': + return {description: value}; + case 'due': + return value ? {dueString: value} : {dueString: 'no date'}; + case 'priority': + return { + priority: Math.max( + 1, + Math.min(4, 5 - (Number.parseInt(value, 10) || 4)), + ), + }; + case 'labels': + return { + labels: value + .split(',') + .map(l => l.trim()) + .filter(Boolean), + }; + default: + return {}; + } +} + +export default function EditView({task, api, onBack}: EditViewProps) { + const [cursor, setCursor] = useState(0); + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(''); + const [fieldValues, setFieldValues] = useState(() => + Object.fromEntries(fields.map(f => [f.key, getFieldValue(task, f.key)])), + ); + + const handleSave = async (value: string) => { + const field = fields[cursor]!; + const args = buildUpdateArgs(fields[cursor]!.key, value); + await api.updateTask(task.id, args); + setFieldValues(prev => ({...prev, [field.key]: value})); + setEditing(false); + }; + + useInput( + (input, key) => { + if (key.upArrow || input === 'k') { + setCursor(c => Math.max(0, c - 1)); + } + + if (key.downArrow || input === 'j') { + setCursor(c => Math.min(fields.length - 1, c + 1)); + } + + if (key.escape) { + onBack(); + } + + if (key.return && !editing) { + setEditing(true); + setEditValue(getFieldValue(task, fields[cursor]!.key)); + } + }, + {isActive: !editing}, + ); + + useInput( + (_input, key) => { + if (key.escape) { + setEditing(false); + } + }, + {isActive: editing}, + ); + + return ( + + {fields.map((f, i) => ( + + {cursor === i ? '> ' : ' '} + {f.label.padEnd(14)} + {cursor === i && editing ? ( + + ) : ( + + {fieldValues[f.key]} + + )} + + ))} + + {editing + ? ' Enter to save ∙ Escape to cancel' + : ' Enter to edit ∙ Escape to go back'} + + + ); +} diff --git a/source/utils.ts b/source/utils.ts new file mode 100644 index 0000000..afdd9cc --- /dev/null +++ b/source/utils.ts @@ -0,0 +1,12 @@ +export function priorityColor(displayPriority: number): string | undefined { + switch (displayPriority) { + case 1: + return 'red'; + case 2: + return 'yellow'; + case 3: + return 'blue'; + default: + return undefined; + } +}