diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e2deb1d..865e92f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { NavBar } from './views/Navigation/NavBar' -import { Outlet } from 'react-router-dom' +import { Outlet, useLocation } from 'react-router-dom' import { isTokenValid } from './utils/api' import React from 'react' import { WithNavigate } from './utils/navigation' @@ -17,6 +17,7 @@ import { FIVE_MINUTES_MS } from '@/constants/time' type AppProps = { refreshStaleData: boolean + pathname: string fetchLabels: () => Promise fetchUser: () => Promise @@ -26,12 +27,41 @@ type AppProps = { } & WithNavigate class AppImpl extends React.Component { + private initializedAuthenticated = false + private initializingAuthenticated = false + private onVisibilityChange = () => { if (!document.hidden) { this.refreshStaleData() } } + private initializeAuthenticated = async () => { + if (this.initializedAuthenticated || this.initializingAuthenticated) { + return + } + + if (!isTokenValid()) { + return + } + + this.initializingAuthenticated = true + try { + preloadSounds() + WebSocketManager.getInstance() + + await this.props.fetchUser() + await this.props.fetchLabels() + await this.props.fetchTasks() + await this.props.fetchTokens() + await this.props.initGroups() + + this.initializedAuthenticated = true + } finally { + this.initializingAuthenticated = false + } + } + private refreshStaleData = async () => { if (!this.props.refreshStaleData) { return @@ -70,18 +100,27 @@ class AppImpl extends React.Component { } async componentDidMount(): Promise { - if (isTokenValid()) { - preloadSounds(); - WebSocketManager.getInstance(); + await this.initializeAuthenticated() - await this.props.fetchUser() - await this.props.fetchLabels() - await this.props.fetchTasks() - await this.props.fetchTokens() - await this.props.initGroups() + document.addEventListener('visibilitychange', this.onVisibilityChange) + } + + async componentDidUpdate(prevProps: AppProps): Promise { + // If we just navigated away from auth routes (e.g. successful login), + // the token becomes valid after App has already mounted. Ensure we + // initialize data once without requiring a full page refresh. + if (prevProps.pathname !== this.props.pathname) { + if (!isTokenValid()) { + this.initializedAuthenticated = false + return + } + await this.initializeAuthenticated() + return } - document.addEventListener('visibilitychange', this.onVisibilityChange) + // Also handle the case where token becomes valid without a pathname change + // (defensive; should be rare). + await this.initializeAuthenticated() } componentWillUnmount(): void { @@ -90,6 +129,7 @@ class AppImpl extends React.Component { render() { const { navigate } = this.props + const { pathname } = this.props return (
@@ -100,7 +140,10 @@ class AppImpl extends React.Component { defaultMode='system' colorSchemeNode={document.body} > - + @@ -121,7 +164,17 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({ fetchTokens: () => dispatch(fetchTokens()), }) -export const App = connect( +const ConnectedApp = connect( mapStateToProps, mapDispatchToProps, )(AppImpl) + +export const App = (props: WithNavigate) => { + const location = useLocation() + return ( + + ) +} diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index 691dfb83..62f89dba 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -45,6 +45,12 @@ export const SkipTask = async (id: number): Promise => ws: (ws) => ws.request('skip_task', id), }) +export const UncompleteTask = async (id: number): Promise => + await transport({ + http: () => Request(`/tasks/${id}/undo`, 'POST'), + ws: (ws) => ws.request('uncomplete_task', id), + }) + export const CreateTask = async (task: Omit) => await transport({ http: () => Request(`/tasks/`, 'POST', MarshallLabels(task)), diff --git a/frontend/src/models/task.ts b/frontend/src/models/task.ts index f3a5531e..c181b455 100644 --- a/frontend/src/models/task.ts +++ b/frontend/src/models/task.ts @@ -89,7 +89,7 @@ export const newTask = (): Task => ({ labels: [], }) -export type TASK_UPDATE_EVENT = 'updated' | 'completed' | 'rescheduled' | 'skipped' | 'deleted' +export type TASK_UPDATE_EVENT = 'updated' | 'completed' | 'uncompleted' | 'rescheduled' | 'skipped' | 'deleted' export const getDueDateChipText = (nextDueDate: Date | null): string => { if (nextDueDate === null) { diff --git a/frontend/src/store/tasksSlice.ts b/frontend/src/store/tasksSlice.ts index 34eec9af..d7d88343 100644 --- a/frontend/src/store/tasksSlice.ts +++ b/frontend/src/store/tasksSlice.ts @@ -3,6 +3,7 @@ import { GetTasks, GetCompletedTasks, MarkTaskComplete, + UncompleteTask, DeleteTask, SkipTask, CreateTask, @@ -98,6 +99,14 @@ export const skipTask = createAsyncThunk( }, ) +export const uncompleteTask = createAsyncThunk( + 'tasks/uncompleteTask', + async (taskId: number) => { + const response = await UncompleteTask(taskId) + return response.task + }, +) + export const deleteTask = createAsyncThunk( 'tasks/deleteTask', async (taskId: number) => await DeleteTask(taskId), @@ -229,8 +238,31 @@ const tasksSlice = createSlice({ const taskId = action.payload state.items = state.items.filter(t => t.id !== taskId) state.filteredItems = state.filteredItems.filter(t => t.id !== taskId) + + // Keep completed list consistent when a completed task is deleted. + state.completedItems = state.completedItems.filter(t => t.id !== taskId) + + deleteTaskFromGroups(taskId, state.groupedItems) + }, + taskRemovedFromActive: (state, action: PayloadAction) => { + const taskId = action.payload + state.items = state.items.filter(t => t.id !== taskId) + state.filteredItems = state.filteredItems.filter(t => t.id !== taskId) deleteTaskFromGroups(taskId, state.groupedItems) }, + completedTaskRemoved: (state, action: PayloadAction) => { + const taskId = action.payload + state.completedItems = state.completedItems.filter(t => t.id !== taskId) + }, + completedTaskUpserted: (state, action: PayloadAction) => { + const task = action.payload + const index = state.completedItems.findIndex(t => t.id === task.id) + if (index >= 0) { + state.completedItems[index] = task + } else { + state.completedItems.unshift(task) + } + }, }, extraReducers: builder => { builder @@ -356,10 +388,15 @@ const tasksSlice = createSlice({ type: 'tasks/taskUpserted', }) } else { - tasksSlice.caseReducers.taskDeleted(state, { - payload: newTask.id, - type: 'tasks/taskDeleted', - }) + // Task is now completed/inactive; remove from active lists and add to completed list. + tasksSlice.caseReducers.taskRemovedFromActive(state, { + payload: newTask.id, + type: 'tasks/taskRemovedFromActive', + }) + tasksSlice.caseReducers.completedTaskUpserted(state, { + payload: newTask, + type: 'tasks/completedTaskUpserted', + }) } state.status = 'succeeded' @@ -426,10 +463,36 @@ const tasksSlice = createSlice({ state.status = 'failed' state.error = action.error.message ?? null }) + + // Uncomplete tasks + .addCase(uncompleteTask.pending, state => { + state.status = 'loading' + state.error = null + }) + .addCase(uncompleteTask.fulfilled, (state, action) => { + const updatedTask = action.payload + + // Task is active again; remove from completed list and upsert into active lists. + tasksSlice.caseReducers.completedTaskRemoved(state, { + payload: updatedTask.id, + type: 'tasks/completedTaskRemoved', + }) + tasksSlice.caseReducers.taskUpserted(state, { + payload: updatedTask, + type: 'tasks/taskUpserted', + }) + + state.status = 'succeeded' + state.error = null + }) + .addCase(uncompleteTask.rejected, (state, action) => { + state.status = 'failed' + state.error = action.error.message ?? null + }) }, }) -export const { setDraft, filterTasks, toggleShowCompleted, toggleGroup } = tasksSlice.actions +export const { setDraft, filterTasks, toggleShowCompleted, toggleGroup, completedTaskRemoved } = tasksSlice.actions export const tasksReducer = tasksSlice.reducer @@ -451,11 +514,13 @@ const onTaskCompleted = (data: WSEventPayloads['task_completed']) => { if (data.next_due_date) { store.dispatch(taskUpserted(data)) } else { - store.dispatch(taskDeleted(data.id)) + store.dispatch(tasksSlice.actions.taskRemovedFromActive(data.id)) + store.dispatch(tasksSlice.actions.completedTaskUpserted(data)) } } const onTaskUncompleted = (data: WSEventPayloads['task_uncompleted']) => { + store.dispatch(completedTaskRemoved(data.id)) store.dispatch(taskUpserted(data)) } diff --git a/frontend/src/utils/websocket.ts b/frontend/src/utils/websocket.ts index 8aa86fdd..0f447eb1 100644 --- a/frontend/src/utils/websocket.ts +++ b/frontend/src/utils/websocket.ts @@ -280,7 +280,6 @@ export class WebSocketManager { private newRequestId(): string { try { // Modern browsers - // eslint-disable-next-line @typescript-eslint/no-explicit-any const c: any = crypto if (c && typeof c.randomUUID === 'function') { return c.randomUUID() diff --git a/frontend/src/views/Navigation/NavBar.tsx b/frontend/src/views/Navigation/NavBar.tsx index f748c416..3d3cd0f2 100644 --- a/frontend/src/views/Navigation/NavBar.tsx +++ b/frontend/src/views/Navigation/NavBar.tsx @@ -20,11 +20,13 @@ import React from 'react' import { ThemeToggleButton } from '../Settings/ThemeToggleButton' import { SyncStatus } from './SyncStatus' import { NavBarLink } from './NavBarLink' -import { getPathName, NavigationPaths, WithNavigate } from '@/utils/navigation' +import { NavigationPaths, WithNavigate } from '@/utils/navigation' import { isMobile } from '@/utils/dom' import { Logo } from '@/Logo' -type NavBarProps = WithNavigate +type NavBarProps = WithNavigate & { + pathname: string +} interface NavBarState { drawerOpen: boolean @@ -56,7 +58,7 @@ export class NavBar extends React.Component { } render(): React.ReactNode { - if (['/signup', '/login', '/forgot-password'].includes(getPathName())) { + if (['/signup', '/login', '/forgot-password'].includes(this.props.pathname)) { return null } diff --git a/frontend/src/views/Tasks/MyTasks.tsx b/frontend/src/views/Tasks/MyTasks.tsx index d90cb4ab..4257c596 100644 --- a/frontend/src/views/Tasks/MyTasks.tsx +++ b/frontend/src/views/Tasks/MyTasks.tsx @@ -1,4 +1,4 @@ -import { skipTask, deleteTask, updateDueDate, setGroupBy, toggleGroup, toggleShowCompleted, fetchCompletedTasks, completeTask } from '@/store/tasksSlice' +import { skipTask, deleteTask, updateDueDate, setGroupBy, toggleGroup, toggleShowCompleted, fetchCompletedTasks, completeTask, uncompleteTask } from '@/store/tasksSlice' import { Task, TASK_UPDATE_EVENT } from '@/models/task' import { ExpandCircleDown, @@ -13,6 +13,7 @@ import { Visibility, VisibilityOff, PublishedWithChanges, + Cancel, } from '@mui/icons-material' import { Container, @@ -56,6 +57,7 @@ type MyTasksProps = { fetchCompletedTasks: () => Promise completeTask: (taskId: number, endRecurrence: boolean) => Promise + uncompleteTask: (taskId: number) => Promise deleteTask: (taskId: number) => Promise skipTask: (taskId: number) => Promise updateDueDate: (taskId: number, dueDate: string) => Promise @@ -65,6 +67,7 @@ type MyTasksProps = { interface MyTasksState { isMoreMenuOpen: boolean contextMenuTask: TaskUI | null + contextMenuIsCompleted: boolean } class MyTasksImpl extends React.Component { @@ -85,6 +88,7 @@ class MyTasksImpl extends React.Component { this.state = { isMoreMenuOpen: false, contextMenuTask: null, + contextMenuIsCompleted: false, } } @@ -93,7 +97,10 @@ class MyTasksImpl extends React.Component { switch (event) { case 'completed': message = 'Task completed' - break + break + case 'uncompleted': + message = 'Task uncompleted' + break case 'rescheduled': message = 'Task rescheduled' break @@ -118,6 +125,11 @@ class MyTasksImpl extends React.Component { componentDidMount(): void { setTitle('My Tasks') + const { showCompleted, completedTasks } = this.props + if (showCompleted && completedTasks.length === 0) { + void this.props.fetchCompletedTasks() + } + document.addEventListener('click', this.dismissMoreMenu) } @@ -149,7 +161,7 @@ class MyTasksImpl extends React.Component { this.menuAnchorRef.style.top = `${y}px` } - private showContextMenu = (event: React.MouseEvent, task: TaskUI) => { + private showContextMenu = (event: React.MouseEvent, task: TaskUI, isCompleted: boolean) => { const { pageX, pageY } = event this.setMenuAnchorPos(pageX, pageY); @@ -159,6 +171,7 @@ class MyTasksImpl extends React.Component { this.setState({ isMoreMenuOpen: true, contextMenuTask: task, + contextMenuIsCompleted: isCompleted, }) } @@ -166,6 +179,7 @@ class MyTasksImpl extends React.Component { this.setState({ isMoreMenuOpen: false, contextMenuTask: null, + contextMenuIsCompleted: false, }) } @@ -213,6 +227,18 @@ class MyTasksImpl extends React.Component { this.onEvent('skipped') } + private onUncompleteClicked = async () => { + const { contextMenuTask: task } = this.state + if (task === null) { + throw new Error('Attempted to uncomplete a task without a reference') + } + + await this.props.uncompleteTask(task.id) + + this.dismissMoreMenu() + this.onEvent('uncompleted') + } + private onChangeDueDateClicked = () => { const { contextMenuTask: task } = this.state if (task === null) { @@ -256,7 +282,7 @@ class MyTasksImpl extends React.Component { render(): React.ReactNode { const { groupBy, groups, expandedGroups, completedTasks, showCompleted } = this.props - const { isMoreMenuOpen, contextMenuTask } = this.state + const { isMoreMenuOpen, contextMenuTask, contextMenuIsCompleted } = this.state const { navigate } = this.props const hasTasks = this.hasTasks() @@ -397,26 +423,35 @@ class MyTasksImpl extends React.Component { open={isMoreMenuOpen} ref={this.menuRef} > - {contextMenuTask && contextMenuTask.frequency.type !== 'once' && ( + {contextMenuTask && contextMenuIsCompleted ? ( + + + Uncomplete + + ) : ( <> - - - Complete and end recurrence + {contextMenuTask && contextMenuTask.frequency.type !== 'once' && ( + <> + + + Complete and end recurrence + + + + Skip to next due date + + + )} + + + Change due date - - - Skip to next due date + + + Edit )} - - - Change due date - - - - Edit - @@ -451,8 +486,9 @@ class MyTasksImpl extends React.Component { ))} @@ -532,6 +568,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => ({ fetchCompletedTasks: () => dispatch(fetchCompletedTasks()), completeTask: (taskId: number, endRecurrence: boolean) => dispatch(completeTask({ taskId, endRecurrence })), + uncompleteTask: (taskId: number) => dispatch(uncompleteTask(taskId)), deleteTask: (taskId: number) => dispatch(deleteTask(taskId)), skipTask: (taskId: number) => dispatch(skipTask(taskId)), updateDueDate: (taskId: number, dueDate: string) => dispatch(updateDueDate({ taskId, dueDate })), diff --git a/frontend/src/views/Tasks/TaskCard.tsx b/frontend/src/views/Tasks/TaskCard.tsx index 25a093cc..ca39923b 100644 --- a/frontend/src/views/Tasks/TaskCard.tsx +++ b/frontend/src/views/Tasks/TaskCard.tsx @@ -10,6 +10,7 @@ import { TimesOneMobiledata, Repeat, Check, + Cancel, NotificationsActive, } from '@mui/icons-material' import { @@ -23,16 +24,18 @@ import { import React from 'react' import { NavigationPaths, WithNavigate } from '@/utils/navigation' import { connect } from 'react-redux' -import { completeTask } from '@/store/tasksSlice' +import { completeTask, uncompleteTask } from '@/store/tasksSlice' import { AppDispatch } from '@/store/store' import { TaskUI } from '@/utils/marshalling' type TaskCardProps = WithNavigate & { task: TaskUI + isCompleted?: boolean completeTask: (taskId: number, endRecurrence: boolean) => Promise + uncompleteTask: (taskId: number) => Promise onTaskUpdate: (event: TASK_UPDATE_EVENT) => void - onContextMenu: (event: React.MouseEvent, task: TaskUI) => void + onContextMenu: (event: React.MouseEvent, task: TaskUI, isCompleted: boolean) => void } class TaskCardImpl extends React.Component { @@ -54,6 +57,12 @@ class TaskCardImpl extends React.Component { onTaskUpdate('completed') } + private handleTaskUncomplete = async () => { + const { task, onTaskUpdate } = this.props + await this.props.uncompleteTask(task.id) + onTaskUpdate('uncompleted') + } + private hasAnyNotificationsActive = () => { const { task } = this.props if (!task.notification.enabled) { @@ -67,7 +76,7 @@ class TaskCardImpl extends React.Component { } render(): React.ReactNode { - const { task, navigate } = this.props + const { task, navigate, isCompleted } = this.props const notificationsActive = this.hasAnyNotificationsActive() @@ -161,23 +170,22 @@ class TaskCardImpl extends React.Component { > -
- -
+ {isCompleted ? : }
@@ -186,7 +194,7 @@ class TaskCardImpl extends React.Component { cursor: 'pointer', flex: 1, }} - onContextMenu={(e) => this.props.onContextMenu(e, task)} + onContextMenu={(e) => this.props.onContextMenu(e, task, !!isCompleted)} onClick={() => navigate(NavigationPaths.TaskEdit(task.id))} > { const mapDispatchToProps = (dispatch: AppDispatch) => ({ completeTask: (taskId: number, endRecurrence: boolean) => dispatch(completeTask({ taskId, endRecurrence })), + uncompleteTask: (taskId: number) => dispatch(uncompleteTask(taskId)), }) export const TaskCard = connect( diff --git a/frontend/src/views/Tasks/TasksOverview.tsx b/frontend/src/views/Tasks/TasksOverview.tsx index d15bf77e..f93964af 100644 --- a/frontend/src/views/Tasks/TasksOverview.tsx +++ b/frontend/src/views/Tasks/TasksOverview.tsx @@ -58,6 +58,11 @@ class TasksOverviewImpl extends React.Component { componentDidMount(): void { setTitle('Tasks Overview') + const { showCompleted, completedTasks } = this.props + if (showCompleted && completedTasks.length === 0) { + void this.props.fetchCompletedTasks() + } + this.registerKeyboardShortcuts() }