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
77 changes: 65 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +17,7 @@ import { FIVE_MINUTES_MS } from '@/constants/time'

type AppProps = {
refreshStaleData: boolean
pathname: string

fetchLabels: () => Promise<any>
fetchUser: () => Promise<any>
Expand All @@ -26,12 +27,41 @@ type AppProps = {
} & WithNavigate

class AppImpl extends React.Component<AppProps> {
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
Expand Down Expand Up @@ -70,18 +100,27 @@ class AppImpl extends React.Component<AppProps> {
}

async componentDidMount(): Promise<void> {
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<void> {
// 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 {
Expand All @@ -90,6 +129,7 @@ class AppImpl extends React.Component<AppProps> {

render() {
const { navigate } = this.props
const { pathname } = this.props

return (
<div style={{ minHeight: '100vh' }}>
Expand All @@ -100,7 +140,10 @@ class AppImpl extends React.Component<AppProps> {
defaultMode='system'
colorSchemeNode={document.body}
>
<NavBar navigate={navigate} />
<NavBar
navigate={navigate}
pathname={pathname}
/>
<Outlet />
<StatusList />
</CssVarsProvider>
Expand All @@ -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 (
<ConnectedApp
{...props}
pathname={location.pathname}
/>
)
}
6 changes: 6 additions & 0 deletions frontend/src/api/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export const SkipTask = async (id: number): Promise<SingleTaskResponse> =>
ws: (ws) => ws.request('skip_task', id),
})

export const UncompleteTask = async (id: number): Promise<SingleTaskResponse> =>
await transport({
http: () => Request<SingleTaskResponse>(`/tasks/${id}/undo`, 'POST'),
ws: (ws) => ws.request('uncomplete_task', id),
})

export const CreateTask = async (task: Omit<Task, 'id'>) =>
await transport({
http: () => Request<TaskIdResponse>(`/tasks/`, 'POST', MarshallLabels(task)),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/models/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
77 changes: 71 additions & 6 deletions frontend/src/store/tasksSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
GetTasks,
GetCompletedTasks,
MarkTaskComplete,
UncompleteTask,
DeleteTask,
SkipTask,
CreateTask,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<number>) => {
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<number>) => {
const taskId = action.payload
state.completedItems = state.completedItems.filter(t => t.id !== taskId)
},
Comment on lines +253 to +256
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: These lines use tabs instead of spaces. The rest of the codebase consistently uses spaces (2 spaces per indentation level). Please replace tabs with spaces to maintain consistency.

Suggested change
completedTaskRemoved: (state, action: PayloadAction<number>) => {
const taskId = action.payload
state.completedItems = state.completedItems.filter(t => t.id !== taskId)
},
completedTaskRemoved: (state, action: PayloadAction<number>) => {
const taskId = action.payload
state.completedItems = state.completedItems.filter(t => t.id !== taskId)
},

Copilot uses AI. Check for mistakes.
completedTaskUpserted: (state, action: PayloadAction<Task>) => {
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
Expand Down Expand Up @@ -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',
})
Comment on lines +391 to +399
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: These lines use tabs instead of spaces. The rest of the codebase consistently uses spaces (2 spaces per indentation level). Please replace tabs with spaces to maintain consistency.

Suggested change
// 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',
})
// 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',
})

Copilot uses AI. Check for mistakes.
}

state.status = 'succeeded'
Expand Down Expand Up @@ -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
Comment on lines +469 to +490
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: The callback functions in these addCase blocks are not indented correctly. They should have an additional 2 spaces of indentation to match the pattern used in other addCase blocks (see lines 451-465 above). The state assignments inside should be indented accordingly.

Suggested change
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
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

Copilot uses AI. Check for mistakes.
})
},
})

export const { setDraft, filterTasks, toggleShowCompleted, toggleGroup } = tasksSlice.actions
export const { setDraft, filterTasks, toggleShowCompleted, toggleGroup, completedTaskRemoved } = tasksSlice.actions

export const tasksReducer = tasksSlice.reducer

Expand All @@ -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))
Comment on lines +517 to +518
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent indentation: These lines use tabs instead of spaces. The rest of the codebase consistently uses spaces (2 spaces per indentation level). Please replace tabs with spaces to maintain consistency.

Suggested change
store.dispatch(tasksSlice.actions.taskRemovedFromActive(data.id))
store.dispatch(tasksSlice.actions.completedTaskUpserted(data))
store.dispatch(tasksSlice.actions.taskRemovedFromActive(data.id))
store.dispatch(tasksSlice.actions.completedTaskUpserted(data))

Copilot uses AI. Check for mistakes.
}
}

const onTaskUncompleted = (data: WSEventPayloads['task_uncompleted']) => {
store.dispatch(completedTaskRemoved(data.id))
store.dispatch(taskUpserted(data))
}

Expand Down
1 change: 0 additions & 1 deletion frontend/src/utils/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/views/Navigation/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,7 +58,7 @@ export class NavBar extends React.Component<NavBarProps, NavBarState> {
}

render(): React.ReactNode {
if (['/signup', '/login', '/forgot-password'].includes(getPathName())) {
if (['/signup', '/login', '/forgot-password'].includes(this.props.pathname)) {
return null
}

Expand Down
Loading
Loading