From 6dfa86c047d67e677aefc26c5a3a978d0151b6c2 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 16:33:10 -0800 Subject: [PATCH 01/15] Basic layout --- backend/api/src/create-category.ts | 18 ++ backend/api/src/create-task.ts | 18 ++ backend/api/src/get-categories.ts | 18 ++ backend/api/src/routes.ts | 10 + backend/api/src/update-category.ts | 38 +++ backend/api/src/update-task.ts | 36 +++ backend/supabase/categories.sql | 21 ++ backend/supabase/tasks.sql | 27 ++ common/src/api/schema.ts | 65 +++++ common/src/todo.ts | 17 ++ web/pages/todo.tsx | 403 +++++++++++++++++++++++++++++ 11 files changed, 671 insertions(+) create mode 100644 backend/api/src/create-category.ts create mode 100644 backend/api/src/create-task.ts create mode 100644 backend/api/src/get-categories.ts create mode 100644 backend/api/src/update-category.ts create mode 100644 backend/api/src/update-task.ts create mode 100644 backend/supabase/categories.sql create mode 100644 backend/supabase/tasks.sql create mode 100644 common/src/todo.ts create mode 100644 web/pages/todo.tsx diff --git a/backend/api/src/create-category.ts b/backend/api/src/create-category.ts new file mode 100644 index 0000000000..1a9008d00d --- /dev/null +++ b/backend/api/src/create-category.ts @@ -0,0 +1,18 @@ +import { APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const createCategory: APIHandler<'create-category'> = async (props, auth) => { + const { name, color } = props + const pg = createSupabaseDirectClient() + + console.log('Creating category', { userId: auth.uid, name, color }) + + const result = await pg.one( + `insert into categories (user_id, name, color) + values ($1, $2, $3) + returning id`, + [auth.uid, name, color] + ) + + return { id: result.id } +} diff --git a/backend/api/src/create-task.ts b/backend/api/src/create-task.ts new file mode 100644 index 0000000000..cfc308e2f3 --- /dev/null +++ b/backend/api/src/create-task.ts @@ -0,0 +1,18 @@ +import { APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const createTask: APIHandler<'create-task'> = async (props, auth) => { + const { text, categoryId, priority } = props + const pg = createSupabaseDirectClient() + + console.log('Creating task', { userId: auth.uid, text, categoryId, priority }) + + const result = await pg.one( + `insert into tasks (creator_id, assignee_id, text, category_id, priority) + values ($1, $2, $3, $4, $5) + returning id`, + [auth.uid, auth.uid, text, categoryId, priority] + ) + + return { id: result.id } +} diff --git a/backend/api/src/get-categories.ts b/backend/api/src/get-categories.ts new file mode 100644 index 0000000000..c275c49204 --- /dev/null +++ b/backend/api/src/get-categories.ts @@ -0,0 +1,18 @@ +import { APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getCategories: APIHandler<'get-categories'> = async (_, auth) => { + const pg = createSupabaseDirectClient() + + console.log('Getting categories for user', auth.uid) + + const categories = await pg.manyOrNone( + `select * + from categories + where user_id = $1 + order by display_order, created_time`, + [auth.uid] + ) + + return { categories } +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index bf5b08e244..d173bc0ba3 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -139,6 +139,11 @@ import { generateAIAnswers } from './generate-ai-answers' import { getmonthlybets2024 } from './get-monthly-bets-2024' import { getmaxminprofit2024 } from './get-max-min-profit-2024' import { getNextLoanAmount } from './get-next-loan-amount' +import { createTask } from './create-task' +import { updateTask } from './update-task' +import { createCategory } from './create-category' +import { getCategories } from './get-categories' +import { updateCategory } from './update-category' // we define the handlers in this object in order to typecheck that every API has a handler export const handlers: { [k in APIPath]: APIHandler } = { @@ -297,4 +302,9 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'get-monthly-bets-2024': getmonthlybets2024, 'get-max-min-profit-2024': getmaxminprofit2024, 'get-next-loan-amount': getNextLoanAmount, + 'create-task': createTask, + 'update-task': updateTask, + 'create-category': createCategory, + 'get-categories': getCategories, + 'update-category': updateCategory, } diff --git a/backend/api/src/update-category.ts b/backend/api/src/update-category.ts new file mode 100644 index 0000000000..e964991e60 --- /dev/null +++ b/backend/api/src/update-category.ts @@ -0,0 +1,38 @@ +import { APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const updateCategory: APIHandler<'update-category'> = async ( + props, + auth +) => { + const { categoryId, name, color, displayOrder, archived } = props + const pg = createSupabaseDirectClient() + + console.log('Updating category', { + categoryId, + name, + color, + displayOrder, + archived, + userId: auth.uid, + }) + + const updates: { [key: string]: any } = {} + if (name !== undefined) updates.name = name + if (color !== undefined) updates.color = color + if (displayOrder !== undefined) updates.display_order = displayOrder + if (archived !== undefined) updates.archived = archived + + const setClauses = Object.entries(updates) + .map(([key], i) => `${key} = $${i + 3}`) + .join(', ') + + await pg.none( + `update categories + set ${setClauses} + where id = $1 and user_id = $2`, + [categoryId, auth.uid, ...Object.values(updates)] + ) + + return { success: true } +} diff --git a/backend/api/src/update-task.ts b/backend/api/src/update-task.ts new file mode 100644 index 0000000000..fe1d016364 --- /dev/null +++ b/backend/api/src/update-task.ts @@ -0,0 +1,36 @@ +import { log } from 'shared/utils' +import { APIError, APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const updateTask: APIHandler<'update-task'> = async (props, auth) => { + const { id, text, completed, priority, categoryId, archived } = props + const pg = createSupabaseDirectClient() + + log('Updating task', props) + + // Build update fields dynamically + const updates: { [key: string]: any } = {} + if (completed !== undefined) updates.completed = completed + if (priority !== undefined) updates.priority = priority + if (categoryId !== undefined) updates.category_id = categoryId + if (text !== undefined) updates.text = text + if (archived !== undefined) updates.archived = archived + const setClauses = Object.entries(updates) + .map(([key], i) => `${key} = $${i + 3}`) + .join(', ') + + const result = await pg.oneOrNone( + `update tasks + set ${setClauses} + where id = $1 and (creator_id = $2 or assignee_id = $2) + returning id, priority, category_id, text, completed + `, + [id, auth.uid, ...Object.values(updates)] + ) + + if (!result) { + throw new APIError(404, 'Task not found or unauthorized') + } + + return { success: true } +} diff --git a/backend/supabase/categories.sql b/backend/supabase/categories.sql new file mode 100644 index 0000000000..4c7b317d5a --- /dev/null +++ b/backend/supabase/categories.sql @@ -0,0 +1,21 @@ +create table if not exists + categories ( + id bigint primary key generated always as identity not null, + user_id text not null, + name text not null, + color text, + display_order integer default 0 not null, + archived boolean default false not null, + created_time timestamp with time zone default now() not null + ); + +-- Row Level Security +alter table categories enable row level security; + +-- Policies +create policy "public read" on categories for +select + using (true); + +-- Indexes +create index categories_user_id_idx on categories (user_id); diff --git a/backend/supabase/tasks.sql b/backend/supabase/tasks.sql new file mode 100644 index 0000000000..f347ad924f --- /dev/null +++ b/backend/supabase/tasks.sql @@ -0,0 +1,27 @@ +create table if not exists + tasks ( + id bigint primary key generated always as identity not null, + creator_id text not null references users (id), + assignee_id text not null references users (id), + category_id bigint not null, + text text not null, + completed boolean default false not null, + priority integer default 0 not null, + archived boolean default false not null, + created_time timestamp with time zone default now() not null + ); + +-- Row Level Security +alter table tasks enable row level security; + +-- Policies +create policy "public read" on tasks for +select + using (true); + +-- Indexes +create index tasks_creator_id_idx on tasks (creator_id); + +create index tasks_assignee_id_idx on tasks (assignee_id); + +create index tasks_category_id_idx on tasks (category_id); diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 7a59cbb602..42d7beb696 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -67,6 +67,7 @@ import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' import { JSONContent } from '@tiptap/core' +import { TaskCategory } from 'common/todo' // mqp: very unscientific, just balancing our willingness to accept load // with user willingness to put up with stale data export const DEFAULT_CACHE_STRATEGY = @@ -1913,6 +1914,70 @@ export const API = (_apiTypeCheck = { userId: z.string(), }), }, + 'create-task': { + method: 'POST', + visibility: 'public', + authed: true, + returns: {} as { id: number }, + props: z + .object({ + text: z.string(), + categoryId: z.number().optional(), + priority: z.number().default(0), + }) + .strict(), + }, + 'update-task': { + method: 'POST', + visibility: 'public', + authed: true, + returns: {} as { success: boolean }, + props: z + .object({ + id: z.number(), + text: z.string().optional(), + completed: z.boolean().optional(), + priority: z.number().optional(), + categoryId: z.number().optional(), + archived: z.boolean().optional(), + }) + .strict(), + }, + 'create-category': { + method: 'POST', + visibility: 'public', + authed: true, + returns: {} as { id: number }, + props: z + .object({ + name: z.string(), + color: z.string().optional(), + displayOrder: z.number().optional(), + }) + .strict(), + }, + 'get-categories': { + method: 'GET', + visibility: 'public', + authed: true, + returns: {} as { categories: TaskCategory[] }, + props: z.object({}).strict(), + }, + 'update-category': { + method: 'POST', + visibility: 'public', + authed: true, + returns: {} as { success: boolean }, + props: z + .object({ + categoryId: z.number(), + name: z.string().optional(), + color: z.string().optional(), + displayOrder: z.number().optional(), + archived: z.boolean().optional(), + }) + .strict(), + }, } as const) export type APIPath = keyof typeof API diff --git a/common/src/todo.ts b/common/src/todo.ts new file mode 100644 index 0000000000..366dae2743 --- /dev/null +++ b/common/src/todo.ts @@ -0,0 +1,17 @@ +export type TaskCategory = { + id: number + name: string + color?: string + displayOrder: number + archived?: boolean +} + +export type Task = { + id: number + text: string + completed: boolean + categoryId: number // -1 for inbox + createdAt: number + priority: number + archived: boolean +} diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx new file mode 100644 index 0000000000..56316251f6 --- /dev/null +++ b/web/pages/todo.tsx @@ -0,0 +1,403 @@ +import { useState } from 'react' +import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' +import { Modal } from 'web/components/layout/modal' +import { Page } from 'web/components/layout/page' +import { Input } from 'web/components/widgets/input' +import { Checkbox } from 'web/components/widgets/checkbox' +import { Task } from 'common/src/todo' +import { ArchiveIcon, PlusIcon } from '@heroicons/react/solid' +import clsx from 'clsx' +import { Col } from 'web/components/layout/col' +import { Row } from 'web/components/layout/row' +import { api } from 'web/lib/api/api' +import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state' +import { Button } from 'web/components/buttons/button' +import { CirclePicker } from 'react-color' +import { useAPIGetter } from 'web/hooks/use-api-getter' +import { EditInPlaceInput } from 'web/components/widgets/edit-in-place' +import DropdownMenu from 'web/components/widgets/dropdown-menu' +import DotsVerticalIcon from '@heroicons/react/outline/DotsVerticalIcon' +import { ValidatedAPIParams } from 'common/api/schema' + +export default function TodoPage() { + const [isModalOpen, setIsModalOpen] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [newCategoryColor, setNewCategoryColor] = useState('') + // Persist todos and categories in local storage + const [tasks, setTasks] = usePersistentLocalState([], 'todos-4') + const { data: categoriesData, refresh: refreshCategories } = useAPIGetter( + 'get-categories', + {} + ) + const categories = categoriesData?.categories ?? [] + const [newTodoText, setNewTodoText] = useState('') + const [selectedCategoryId, setSelectedCategoryId] = useState(-1) + const [isSidebarOpen, setIsSidebarOpen] = useState(false) + + const createTodo = async (text: string) => { + try { + // First create remotely + const { id } = await api('create-task', { + text, + categoryId: selectedCategoryId ?? -1, + priority: 0, + }) + + // Then update locally + const newTodo: Task = { + id, + text, + completed: false, + categoryId: selectedCategoryId, + createdAt: Date.now(), + priority: 0, + archived: false, + } + setTasks([...tasks, newTodo]) + setNewTodoText('') + } catch (error) { + console.error('Failed to create todo:', error) + } + } + + const updateTask = async (params: ValidatedAPIParams<'update-task'>) => { + const { id, ...updates } = params + try { + // First update locally + const newTasks = tasks.map((task) => + task.id === id ? { ...task, ...updates } : task + ) + setTasks(newTasks) + + // Then update remotely + await api('update-task', params) + } catch (error) { + console.error('Failed to update todo:', error) + // Revert on error + setTasks(tasks) + } + } + + const addTodo = () => { + if (!newTodoText.trim()) return + createTodo(newTodoText) + } + + const toggleTodo = (taskId: number) => { + const task = tasks.find((t) => t.id === taskId) + if (task) { + updateTask({ id: taskId, completed: !task.completed }) + } + } + + const filteredTasks = ( + selectedCategoryId + ? tasks.filter((task) => task.categoryId === selectedCategoryId) + : tasks.filter((task) => task.categoryId === -1) + ) + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + .filter((task) => !task.archived) + + const getSelectedCategoryTitle = () => { + if (selectedCategoryId === -1) return 'Inbox' + const category = categories.find((c) => c.id === selectedCategoryId) + return category?.name ?? 'Inbox' + } + + const filteredCategories = categories.filter((category) => !category.archived) + + return ( + + { + console.log('Drag ended:', result) + if (!result.destination) { + console.log('No destination') + return + } + + console.log( + 'Destination droppableId:', + result.destination.droppableId + ) + const todoId = parseInt(result.draggableId) + let categoryId: number + + // Handle drops on different targets + if (result.destination.droppableId === 'inbox') { + console.log('Dropping in inbox') + categoryId = -1 + } else if (result.destination.droppableId.startsWith('category-')) { + console.log('Dropping in category') + categoryId = parseInt( + result.destination.droppableId.replace('category-', '') + ) + } else { + console.log('Dropping in current list') + categoryId = selectedCategoryId + } + + try { + await updateTask({ + id: todoId, + categoryId, + }) + } catch (error) { + console.error('Failed to update todo category:', error) + } + }} + > +
+ {/* Main content */} + +

{getSelectedCategoryTitle()}

+ + + setNewTodoText(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && addTodo()} + placeholder="Add a new todo..." + className="flex-1" + /> + + + + + {(provided, snapshot) => ( + + {filteredTasks.map((task, index) => ( + + {(provided, snapshot) => ( +
+ + + toggleTodo(task.id)} + /> + + updateTask({ id: task.id, text }) + } + > + {(value) => {value}} + + + + } + items={[ + { + icon: , + name: 'Archive', + onClick: async () => { + updateTask({ id: task.id, archived: true }) + }, + }, + ]} + /> + +
+ )} +
+ ))} + {provided.placeholder} + + )} +
+ + + {/* Sidebar */} +
+ +

Categories

+ +
+
+ + {(provided, snapshot) => ( +
+ +
+ {provided.placeholder} +
+
+ )} +
+ +
+ {filteredCategories.map((category) => ( + + {(provided, snapshot) => ( +
+ +
+ {provided.placeholder} +
+
+ )} +
+ ))} +
+
+
+ + {/* Mobile sidebar toggle */} + +
+ + {/* Add category modal */} + + +

Add Category

+ setNewCategoryName(e.target.value)} + placeholder="Category name" + /> +
+ + setNewCategoryColor(color.hex)} + /> +
+ + + + + +
+
+
+ ) +} From fed360afdb2d32a102e4274fd923ecf48e90f13b Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 16:41:23 -0800 Subject: [PATCH 02/15] Tweaks --- web/pages/todo.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 56316251f6..b4f8da83e2 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -158,7 +158,7 @@ export default function TodoPage() { value={newTodoText} onChange={(e) => setNewTodoText(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addTodo()} - placeholder="Add a new todo..." + placeholder="Add a new task..." className="flex-1" /> From 4cd077c8dcde6b5d1bb2e8e6a3ae70fad1ee123d Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 17:57:56 -0800 Subject: [PATCH 06/15] Assign tasks to users --- backend/api/src/create-task.ts | 21 ++++-- backend/api/src/get-categories.ts | 10 ++- backend/api/src/get-tasks.ts | 18 +++++ backend/api/src/routes.ts | 2 + backend/api/src/update-task.ts | 9 ++- common/src/api/schema.ts | 17 ++++- common/src/todo.ts | 6 +- web/components/tasks/assign-user-modal.tsx | 70 ++++++++++++++++++ web/components/widgets/user-link.tsx | 24 +++++- web/pages/todo.tsx | 86 ++++++++++++++++------ 10 files changed, 222 insertions(+), 41 deletions(-) create mode 100644 backend/api/src/get-tasks.ts create mode 100644 web/components/tasks/assign-user-modal.tsx diff --git a/backend/api/src/create-task.ts b/backend/api/src/create-task.ts index cfc308e2f3..2e26464c9c 100644 --- a/backend/api/src/create-task.ts +++ b/backend/api/src/create-task.ts @@ -2,17 +2,28 @@ import { APIHandler } from './helpers/endpoint' import { createSupabaseDirectClient } from 'shared/supabase/init' export const createTask: APIHandler<'create-task'> = async (props, auth) => { - const { text, categoryId, priority } = props + const { + text, + category_id: categoryId, + priority, + assignee_id: assigneeId, + } = props const pg = createSupabaseDirectClient() - console.log('Creating task', { userId: auth.uid, text, categoryId, priority }) + console.log('Creating task', { + userId: auth.uid, + text, + categoryId, + priority, + assigneeId, + }) const result = await pg.one( `insert into tasks (creator_id, assignee_id, text, category_id, priority) values ($1, $2, $3, $4, $5) - returning id`, - [auth.uid, auth.uid, text, categoryId, priority] + returning *`, + [auth.uid, assigneeId || auth.uid, text, categoryId, priority] ) - return { id: result.id } + return result } diff --git a/backend/api/src/get-categories.ts b/backend/api/src/get-categories.ts index c275c49204..a7ae136591 100644 --- a/backend/api/src/get-categories.ts +++ b/backend/api/src/get-categories.ts @@ -7,10 +7,12 @@ export const getCategories: APIHandler<'get-categories'> = async (_, auth) => { console.log('Getting categories for user', auth.uid) const categories = await pg.manyOrNone( - `select * - from categories - where user_id = $1 - order by display_order, created_time`, + `select c.* + from categories c + left join tasks t on c.id = t.category_id + where c.user_id = $1 + or t.assignee_id = $1 + order by c.display_order, c.created_time`, [auth.uid] ) diff --git a/backend/api/src/get-tasks.ts b/backend/api/src/get-tasks.ts new file mode 100644 index 0000000000..ebacc2c975 --- /dev/null +++ b/backend/api/src/get-tasks.ts @@ -0,0 +1,18 @@ +import { APIHandler } from './helpers/endpoint' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getTasks: APIHandler<'get-tasks'> = async (_, auth) => { + const pg = createSupabaseDirectClient() + + console.log('Getting tasks for user', auth.uid) + + const tasks = await pg.manyOrNone( + `select * + from tasks + where creator_id = $1 or assignee_id = $1 + order by priority, created_time desc`, + [auth.uid] + ) + + return { tasks } +} diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index d173bc0ba3..663421784a 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -144,6 +144,7 @@ import { updateTask } from './update-task' import { createCategory } from './create-category' import { getCategories } from './get-categories' import { updateCategory } from './update-category' +import { getTasks } from './get-tasks' // we define the handlers in this object in order to typecheck that every API has a handler export const handlers: { [k in APIPath]: APIHandler } = { @@ -307,4 +308,5 @@ export const handlers: { [k in APIPath]: APIHandler } = { 'create-category': createCategory, 'get-categories': getCategories, 'update-category': updateCategory, + 'get-tasks': getTasks, } diff --git a/backend/api/src/update-task.ts b/backend/api/src/update-task.ts index fe1d016364..c2e65e5823 100644 --- a/backend/api/src/update-task.ts +++ b/backend/api/src/update-task.ts @@ -3,18 +3,19 @@ import { APIError, APIHandler } from './helpers/endpoint' import { createSupabaseDirectClient } from 'shared/supabase/init' export const updateTask: APIHandler<'update-task'> = async (props, auth) => { - const { id, text, completed, priority, categoryId, archived } = props + const { id, text, completed, priority, category_id, archived, assignee_id } = + props const pg = createSupabaseDirectClient() - log('Updating task', props) // Build update fields dynamically const updates: { [key: string]: any } = {} if (completed !== undefined) updates.completed = completed if (priority !== undefined) updates.priority = priority - if (categoryId !== undefined) updates.category_id = categoryId + if (category_id !== undefined) updates.category_id = category_id if (text !== undefined) updates.text = text if (archived !== undefined) updates.archived = archived + if (assignee_id !== undefined) updates.assignee_id = assignee_id const setClauses = Object.entries(updates) .map(([key], i) => `${key} = $${i + 3}`) .join(', ') @@ -23,7 +24,7 @@ export const updateTask: APIHandler<'update-task'> = async (props, auth) => { `update tasks set ${setClauses} where id = $1 and (creator_id = $2 or assignee_id = $2) - returning id, priority, category_id, text, completed + returning id, priority, category_id, text, completed, assignee_id `, [id, auth.uid, ...Object.values(updates)] ) diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 42d7beb696..1a29a4c65c 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -67,7 +67,7 @@ import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' import { JSONContent } from '@tiptap/core' -import { TaskCategory } from 'common/todo' +import { Task, TaskCategory } from 'common/todo' // mqp: very unscientific, just balancing our willingness to accept load // with user willingness to put up with stale data export const DEFAULT_CACHE_STRATEGY = @@ -1918,12 +1918,13 @@ export const API = (_apiTypeCheck = { method: 'POST', visibility: 'public', authed: true, - returns: {} as { id: number }, + returns: {} as Task, props: z .object({ text: z.string(), - categoryId: z.number().optional(), + category_id: z.number().optional(), priority: z.number().default(0), + assignee_id: z.string().optional(), }) .strict(), }, @@ -1938,8 +1939,9 @@ export const API = (_apiTypeCheck = { text: z.string().optional(), completed: z.boolean().optional(), priority: z.number().optional(), - categoryId: z.number().optional(), + category_id: z.number().optional(), archived: z.boolean().optional(), + assignee_id: z.string().optional(), }) .strict(), }, @@ -1978,6 +1980,13 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'get-tasks': { + method: 'GET', + visibility: 'public', + authed: true, + returns: {} as { tasks: Task[] }, + props: z.object({}).strict(), + }, } as const) export type APIPath = keyof typeof API diff --git a/common/src/todo.ts b/common/src/todo.ts index 366dae2743..9b2aeb274b 100644 --- a/common/src/todo.ts +++ b/common/src/todo.ts @@ -8,10 +8,12 @@ export type TaskCategory = { export type Task = { id: number + creator_id: string + assignee_id: string text: string completed: boolean - categoryId: number // -1 for inbox - createdAt: number + category_id: number // -1 for inbox + created_time: number priority: number archived: boolean } diff --git a/web/components/tasks/assign-user-modal.tsx b/web/components/tasks/assign-user-modal.tsx new file mode 100644 index 0000000000..b19875a460 --- /dev/null +++ b/web/components/tasks/assign-user-modal.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { User } from 'common/user' +import { Avatar } from '../widgets/avatar' +import { Input } from '../widgets/input' +import { Modal, MODAL_CLASS } from '../layout/modal' +import { useEvent } from 'web/hooks/use-event' +import { searchUsers } from 'web/lib/supabase/users' + +export function AssignUserModal(props: { + open: boolean + onClose: () => void + onAssign: (userId: string) => void +}) { + const { open, onClose, onAssign } = props + const [query, setQuery] = useState('') + const [users, setUsers] = useState([]) + + const debouncedSearch = async (query: string) => { + if (!query) { + setUsers([]) + return + } + const result = await searchUsers(query, 10) + setUsers(result) + } + + const onChange = useEvent((e: React.ChangeEvent) => { + const value = e.target.value + setQuery(value) + debouncedSearch(value) + }) + + const onSelectUser = useEvent((userId: string) => { + onAssign(userId) + onClose() + }) + + return ( + + Assign task to user + +
+ +
+ +
+ {users.map((user) => ( + + ))} +
+
+ ) +} diff --git a/web/components/widgets/user-link.tsx b/web/components/widgets/user-link.tsx index 0d30b9deeb..112ab7b2f5 100644 --- a/web/components/widgets/user-link.tsx +++ b/web/components/widgets/user-link.tsx @@ -14,7 +14,7 @@ import { SparklesIcon } from '@heroicons/react/solid' import { Tooltip } from './tooltip' import { BadgeCheckIcon, ShieldCheckIcon } from '@heroicons/react/outline' import { Row } from '../layout/row' -import { Avatar } from './avatar' +import { Avatar, AvatarSizeType } from './avatar' import { DAY_MS } from 'common/util/time' import ScalesIcon from 'web/lib/icons/scales-icon.svg' import { linkClass } from './site-link' @@ -66,6 +66,28 @@ export function UserAvatarAndBadge(props: { ) } +export function UserAvatar(props: { + user: { id: string; name?: string; username?: string; avatarUrl?: string } + noLink?: boolean + className?: string + size?: AvatarSizeType +}) { + const { noLink, className, size } = props + const user = useDisplayUserById(props.user.id) ?? props.user + const { username, avatarUrl } = user + return ( + + + + + + ) +} export function UserLink(props: { user?: { id: string; name?: string; username?: string } | undefined | null diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 01221d047f..751103a670 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' import { Modal } from 'web/components/layout/modal' import { Page } from 'web/components/layout/page' @@ -10,7 +10,10 @@ import { MenuIcon, PlusIcon, ScaleIcon, + UserIcon, } from '@heroicons/react/solid' +import { uniqBy } from 'lodash' + import clsx from 'clsx' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -22,50 +25,58 @@ import { useAPIGetter } from 'web/hooks/use-api-getter' import { EditInPlaceInput } from 'web/components/widgets/edit-in-place' import DropdownMenu from 'web/components/widgets/dropdown-menu' import DotsVerticalIcon from '@heroicons/react/outline/DotsVerticalIcon' -import { ValidatedAPIParams } from 'common/api/schema' import { DAY_MS } from 'common/util/time' import { useRouter } from 'next/router' import toast from 'react-hot-toast' - -// Create audio element for the chaching sound +import { AssignUserModal } from 'web/components/tasks/assign-user-modal' +import { UserAvatar } from 'web/components/widgets/user-link' +import { useUser } from 'web/hooks/use-user' +import { ValidatedAPIParams } from 'common/api/schema' const chachingSound = typeof window !== 'undefined' ? new Audio('/sounds/droplet3.m4a') : null export default function TodoPage() { const [isModalOpen, setIsModalOpen] = useState(false) + const [isAssignModalOpen, setIsAssignModalOpen] = useState(false) + const [selectedTaskId, setSelectedTaskId] = useState(null) const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryColor, setNewCategoryColor] = useState('') // Persist todos and categories in local storage - const [tasks, setTasks] = usePersistentLocalState([], 'todos-4') + const [tasks, setTasks] = usePersistentLocalState([], 'todos-5') const { data: categoriesData, refresh: refreshCategories } = useAPIGetter( 'get-categories', {} ) + const user = useUser() const categories = categoriesData?.categories ?? [] const [newTodoText, setNewTodoText] = useState('') const [selectedCategoryId, setSelectedCategoryId] = useState(-1) const [isSidebarOpen, setIsSidebarOpen] = useState(false) const router = useRouter() + + // Only fetch tasks once on initial load + useEffect(() => { + const loadTasks = async () => { + try { + const result = await api('get-tasks', {}) + setTasks(uniqBy([...tasks, ...result.tasks], 'id')) + } catch (error) { + console.error('Failed to load tasks:', error) + } + } + loadTasks() + }, []) + const createTodo = async (text: string) => { try { // First create remotely - const { id } = await api('create-task', { + const newTask = await api('create-task', { text, - categoryId: selectedCategoryId ?? -1, + category_id: selectedCategoryId ?? -1, priority: 0, }) - // Then update locally - const newTodo: Task = { - id, - text, - completed: false, - categoryId: selectedCategoryId, - createdAt: Date.now(), - priority: 0, - archived: false, - } - setTasks([...tasks, newTodo]) + setTasks([...tasks, newTask]) setNewTodoText('') } catch (error) { console.error('Failed to create todo:', error) @@ -116,8 +127,8 @@ export default function TodoPage() { const filteredTasks = ( selectedCategoryId - ? tasks.filter((task) => task.categoryId === selectedCategoryId) - : tasks.filter((task) => task.categoryId === -1) + ? tasks.filter((task) => task.category_id === selectedCategoryId) + : tasks.filter((task) => task.category_id === -1) ) .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) .filter((task) => !task.archived) @@ -134,6 +145,14 @@ export default function TodoPage() { dateStyle: 'short', timeStyle: 'short', }) + + const handleAssign = async (userId: string) => { + if (selectedTaskId) { + await updateTask({ id: selectedTaskId, assignee_id: userId }) + } + } + console.log(filteredTasks) + return ( {value}} + {task.assignee_id !== user?.id && ( + + )} } items={[ + { + icon: , + name: 'Assign to...', + onClick: () => { + setSelectedTaskId(task.id) + setIsAssignModalOpen(true) + }, + }, { icon: , name: 'Archive', @@ -477,6 +511,16 @@ export default function TodoPage() { + + {/* Assign user modal */} + { + setIsAssignModalOpen(false) + setSelectedTaskId(null) + }} + onAssign={handleAssign} + /> ) From fc5de1ad421fd008d88a32eb1491e45f0c6f5ad9 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 18:11:17 -0800 Subject: [PATCH 07/15] Show/hide completed tasks --- web/pages/todo.tsx | 115 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 751103a670..5e3bc4e32c 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -39,6 +39,8 @@ export default function TodoPage() { const [isModalOpen, setIsModalOpen] = useState(false) const [isAssignModalOpen, setIsAssignModalOpen] = useState(false) const [selectedTaskId, setSelectedTaskId] = useState(null) + const [showArchivedTasks, setShowArchivedTasks] = useState(false) + const [showCompletedTasks, setShowCompletedTasks] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [newCategoryColor, setNewCategoryColor] = useState('') // Persist todos and categories in local storage @@ -111,7 +113,6 @@ export default function TodoPage() { if (task) { const newCompleted = !task.completed try { - await updateTask({ id: taskId, completed: newCompleted }) if (newCompleted) { // Play sound and show toast only on completion chachingSound?.play() @@ -119,19 +120,26 @@ export default function TodoPage() { duration: 2000, }) } + await updateTask({ id: taskId, completed: newCompleted }) } catch (error) { - console.error('Failed to toggle todo:', error) + toast.error('Failed to toggle todo: ' + error) } } } - const filteredTasks = ( - selectedCategoryId - ? tasks.filter((task) => task.category_id === selectedCategoryId) - : tasks.filter((task) => task.category_id === -1) - ) + const categoryTasks = selectedCategoryId + ? tasks.filter((task) => task.category_id === selectedCategoryId) + : tasks.filter((task) => task.category_id === -1) + + const filteredTasks = categoryTasks + .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + .filter((task) => !task.archived && !task.completed) + + const archivedTasks = categoryTasks .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) - .filter((task) => !task.archived) + .filter((task) => task.archived) + + const completedTasks = categoryTasks.filter((task) => task.completed) const getSelectedCategoryTitle = () => { if (selectedCategoryId === -1) return 'Inbox' @@ -197,7 +205,24 @@ export default function TodoPage() {
{/* Main content */} -

{getSelectedCategoryTitle()}

+

+ + {getSelectedCategoryTitle()} + } + items={[ + { + name: `${showCompletedTasks ? 'Hide' : 'Show'} Completed`, + onClick: () => setShowCompletedTasks(!showCompletedTasks), + }, + { + name: `${showArchivedTasks ? 'Hide' : 'Show'} Archived`, + onClick: () => setShowArchivedTasks(!showArchivedTasks), + }, + ]} + /> + +

))} {provided.placeholder} + {showArchivedTasks && archivedTasks.length > 0 && ( +
+

+ Archived Tasks +

+ {archivedTasks.map((task, index) => ( + + + toggleTodo(task.id)} + /> + {task.text} + + {task.assignee_id !== user?.id && ( + + )} + + } + items={[ + { + icon: , + name: 'Unarchive', + onClick: async () => { + updateTask({ id: task.id, archived: false }) + }, + }, + ]} + /> + + ))} +
+ )} + {showCompletedTasks && completedTasks.length > 0 && ( +
+

+ Completed Tasks +

+ {completedTasks.map((task, index) => ( + + + toggleTodo(task.id)} + /> + {task.text} + + {task.assignee_id !== user?.id && ( + + )} + + ))} +
+ )} )} From 30135c75aff18a10c09cbdb4d2a119d6979af40e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 18:16:29 -0800 Subject: [PATCH 08/15] Tweak --- web/pages/todo.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 5e3bc4e32c..f1fd617886 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -209,7 +209,9 @@ export default function TodoPage() { {getSelectedCategoryTitle()} } + buttonContent={ + + } items={[ { name: `${showCompletedTasks ? 'Hide' : 'Show'} Completed`, @@ -300,7 +302,7 @@ export default function TodoPage() { )} + } items={[ { @@ -398,7 +400,7 @@ export default function TodoPage() { )} + } items={[ { @@ -527,7 +529,7 @@ export default function TodoPage() { {category.name} + } items={[ { From 39821ea8d4bc8aa5e777a2d3d6ce5bc2547b88f5 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 18:26:14 -0800 Subject: [PATCH 09/15] Fix categories and tap outside --- backend/api/src/get-categories.ts | 12 +++++------ web/pages/todo.tsx | 36 ++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/backend/api/src/get-categories.ts b/backend/api/src/get-categories.ts index a7ae136591..26eae9d1ef 100644 --- a/backend/api/src/get-categories.ts +++ b/backend/api/src/get-categories.ts @@ -7,12 +7,12 @@ export const getCategories: APIHandler<'get-categories'> = async (_, auth) => { console.log('Getting categories for user', auth.uid) const categories = await pg.manyOrNone( - `select c.* - from categories c - left join tasks t on c.id = t.category_id - where c.user_id = $1 - or t.assignee_id = $1 - order by c.display_order, c.created_time`, + `SELECT DISTINCT ON (c.id) c.* + FROM categories c + LEFT JOIN tasks t ON c.id = t.category_id + WHERE c.user_id = $1 + OR t.assignee_id = $1 + ORDER BY c.id, c.display_order, c.created_time`, [auth.uid] ) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index f1fd617886..59f6dd1b9d 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd' import { Modal } from 'web/components/layout/modal' import { Page } from 'web/components/layout/page' @@ -55,6 +55,18 @@ export default function TodoPage() { const [selectedCategoryId, setSelectedCategoryId] = useState(-1) const [isSidebarOpen, setIsSidebarOpen] = useState(false) const router = useRouter() + const sidebarRef = useRef(null) + const [isMobile, setIsMobile] = useState(false) + + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768) + } + + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) // Only fetch tasks once on initial load useEffect(() => { @@ -159,7 +171,24 @@ export default function TodoPage() { await updateTask({ id: selectedTaskId, assignee_id: userId }) } } - console.log(filteredTasks) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + isMobile && + isSidebarOpen && + sidebarRef.current && + !sidebarRef.current.contains(event.target as Node) + ) { + setIsSidebarOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isSidebarOpen, isMobile]) return ( @@ -206,7 +235,7 @@ export default function TodoPage() { {/* Main content */}

- + {getSelectedCategoryTitle()} Date: Wed, 11 Dec 2024 18:28:35 -0800 Subject: [PATCH 10/15] Use mobile hook --- web/pages/todo.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 59f6dd1b9d..83124b2f69 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -32,6 +32,7 @@ import { AssignUserModal } from 'web/components/tasks/assign-user-modal' import { UserAvatar } from 'web/components/widgets/user-link' import { useUser } from 'web/hooks/use-user' import { ValidatedAPIParams } from 'common/api/schema' +import { useIsMobile } from 'web/hooks/use-is-mobile' const chachingSound = typeof window !== 'undefined' ? new Audio('/sounds/droplet3.m4a') : null @@ -56,17 +57,7 @@ export default function TodoPage() { const [isSidebarOpen, setIsSidebarOpen] = useState(false) const router = useRouter() const sidebarRef = useRef(null) - const [isMobile, setIsMobile] = useState(false) - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768) - } - - checkMobile() - window.addEventListener('resize', checkMobile) - return () => window.removeEventListener('resize', checkMobile) - }, []) + const isMobile = useIsMobile() // Only fetch tasks once on initial load useEffect(() => { From 77d0de0afc120439f72c6da1a6af49b92eacc8bd Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 18:31:04 -0800 Subject: [PATCH 11/15] Remove public read policies --- backend/supabase/categories.sql | 5 ----- backend/supabase/tasks.sql | 5 ----- 2 files changed, 10 deletions(-) diff --git a/backend/supabase/categories.sql b/backend/supabase/categories.sql index 4c7b317d5a..89c637fa54 100644 --- a/backend/supabase/categories.sql +++ b/backend/supabase/categories.sql @@ -12,10 +12,5 @@ create table if not exists -- Row Level Security alter table categories enable row level security; --- Policies -create policy "public read" on categories for -select - using (true); - -- Indexes create index categories_user_id_idx on categories (user_id); diff --git a/backend/supabase/tasks.sql b/backend/supabase/tasks.sql index f347ad924f..535e74287e 100644 --- a/backend/supabase/tasks.sql +++ b/backend/supabase/tasks.sql @@ -14,11 +14,6 @@ create table if not exists -- Row Level Security alter table tasks enable row level security; --- Policies -create policy "public read" on tasks for -select - using (true); - -- Indexes create index tasks_creator_id_idx on tasks (creator_id); From 884f4b01b35998d5d8ebd34f151d9816ef9d51ab Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 19:14:06 -0800 Subject: [PATCH 12/15] Add todo to sitemap --- web/pages/sitemap.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/pages/sitemap.tsx b/web/pages/sitemap.tsx index aed05a34b6..af5b0ca5cc 100644 --- a/web/pages/sitemap.tsx +++ b/web/pages/sitemap.tsx @@ -134,6 +134,7 @@ export default function AboutPage() { + {/* */}

From d5abda83bc22bf438fa3cd185cae5570b0858569 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 19:17:02 -0800 Subject: [PATCH 13/15] Only admins can assign tasks --- web/pages/todo.tsx | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 83124b2f69..5ca9da3b41 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -33,6 +33,8 @@ import { UserAvatar } from 'web/components/widgets/user-link' import { useUser } from 'web/hooks/use-user' import { ValidatedAPIParams } from 'common/api/schema' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { isAdminId } from 'common/envs/constants' +import { buildArray } from 'common/util/array' const chachingSound = typeof window !== 'undefined' ? new Audio('/sounds/droplet3.m4a') : null @@ -51,6 +53,7 @@ export default function TodoPage() { {} ) const user = useUser() + const isAdmin = isAdminId(user?.id ?? '') const categories = categoriesData?.categories ?? [] const [newTodoText, setNewTodoText] = useState('') const [selectedCategoryId, setSelectedCategoryId] = useState(-1) @@ -324,8 +327,8 @@ export default function TodoPage() { buttonContent={ } - items={[ - { + items={buildArray( + isAdmin && { icon: , name: 'Assign to...', onClick: () => { @@ -384,8 +387,8 @@ export default function TodoPage() { )}` router.push(url) }, - }, - ]} + } + )} /> @@ -633,14 +636,16 @@ export default function TodoPage() { {/* Assign user modal */} - { - setIsAssignModalOpen(false) - setSelectedTaskId(null) - }} - onAssign={handleAssign} - /> + {isAssignModalOpen && ( + { + setIsAssignModalOpen(false) + setSelectedTaskId(null) + }} + onAssign={handleAssign} + /> + )} ) From 27be10e2ae20470c301a96eb31f0111c1396cb57 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 19:37:24 -0800 Subject: [PATCH 14/15] Set priorities --- web/pages/todo.tsx | 95 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 16 deletions(-) diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index 5ca9da3b41..cb34c5fbaf 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -38,6 +38,21 @@ import { buildArray } from 'common/util/array' const chachingSound = typeof window !== 'undefined' ? new Audio('/sounds/droplet3.m4a') : null +export const TASK_PRIORITIES = { + URGENT: 3, + HIGH: 2, + MEDIUM: 1, + LOW: 0, + NONE: -1, +} as const + +export const PRIORITY_COLORS: Record = { + [TASK_PRIORITIES.URGENT]: 'text-red-500', + [TASK_PRIORITIES.HIGH]: 'text-orange-500', + [TASK_PRIORITIES.MEDIUM]: 'text-yellow-500', + [TASK_PRIORITIES.LOW]: 'text-blue-500', +} as const + export default function TodoPage() { const [isModalOpen, setIsModalOpen] = useState(false) const [isAssignModalOpen, setIsAssignModalOpen] = useState(false) @@ -81,7 +96,7 @@ export default function TodoPage() { const newTask = await api('create-task', { text, category_id: selectedCategoryId ?? -1, - priority: 0, + priority: TASK_PRIORITIES.NONE, }) setTasks([...tasks, newTask]) @@ -138,11 +153,11 @@ export default function TodoPage() { : tasks.filter((task) => task.category_id === -1) const filteredTasks = categoryTasks - .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .filter((task) => !task.archived && !task.completed) const archivedTasks = categoryTasks - .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) .filter((task) => task.archived) const completedTasks = categoryTasks.filter((task) => task.completed) @@ -313,7 +328,15 @@ export default function TodoPage() { updateTask({ id: task.id, text }) } > - {(value) => {value}} + {(value) => ( + + {value} + + )} {task.assignee_id !== user?.id && ( @@ -328,20 +351,45 @@ export default function TodoPage() { } items={buildArray( - isAdmin && { - icon: , - name: 'Assign to...', - onClick: () => { - setSelectedTaskId(task.id) - setIsAssignModalOpen(true) - }, + { + name: 'πŸ”΄ Set urgent', + onClick: () => + updateTask({ + id: task.id, + priority: TASK_PRIORITIES.URGENT, + }), }, { - icon: , - name: 'Archive', - onClick: async () => { - updateTask({ id: task.id, archived: true }) - }, + name: '🟠 Set high', + onClick: () => + updateTask({ + id: task.id, + priority: TASK_PRIORITIES.HIGH, + }), + }, + { + name: '🟑 Set medium', + onClick: () => + updateTask({ + id: task.id, + priority: TASK_PRIORITIES.MEDIUM, + }), + }, + { + name: 'πŸ”΅ Set low', + onClick: () => + updateTask({ + id: task.id, + priority: TASK_PRIORITIES.LOW, + }), + }, + { + name: '⚫️ Set None', + onClick: () => + updateTask({ + id: task.id, + priority: TASK_PRIORITIES.NONE, + }), }, { name: 'Convert to Market', @@ -387,6 +435,21 @@ export default function TodoPage() { )}` router.push(url) }, + }, + isAdmin && { + icon: , + name: 'Assign to...', + onClick: () => { + setSelectedTaskId(task.id) + setIsAssignModalOpen(true) + }, + }, + { + icon: , + name: 'Archive', + onClick: async () => { + updateTask({ id: task.id, archived: true }) + }, } )} /> From 0daea0f53c051773b78f0501927b86a4c76a5ace Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 11 Dec 2024 19:48:45 -0800 Subject: [PATCH 15/15] Priority styles --- web/components/widgets/checkbox.tsx | 16 +++++++++-- web/pages/todo.tsx | 44 +++++++++++++---------------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/web/components/widgets/checkbox.tsx b/web/components/widgets/checkbox.tsx index 28014f4f6d..c47665ce00 100644 --- a/web/components/widgets/checkbox.tsx +++ b/web/components/widgets/checkbox.tsx @@ -9,8 +9,17 @@ export function Checkbox(props: { className?: string disabled?: boolean icon?: ReactNode + checkboxClassName?: string }) { - const { label, checked, toggle, className, disabled, icon } = props + const { + label, + checked, + toggle, + className, + disabled, + icon, + checkboxClassName, + } = props return (
@@ -19,7 +28,10 @@ export function Checkbox(props: { toggle(e.target.checked)} disabled={disabled} diff --git a/web/pages/todo.tsx b/web/pages/todo.tsx index cb34c5fbaf..6b215fbf92 100644 --- a/web/pages/todo.tsx +++ b/web/pages/todo.tsx @@ -38,22 +38,21 @@ import { buildArray } from 'common/util/array' const chachingSound = typeof window !== 'undefined' ? new Audio('/sounds/droplet3.m4a') : null -export const TASK_PRIORITIES = { - URGENT: 3, - HIGH: 2, - MEDIUM: 1, - LOW: 0, - NONE: -1, -} as const - -export const PRIORITY_COLORS: Record = { - [TASK_PRIORITIES.URGENT]: 'text-red-500', - [TASK_PRIORITIES.HIGH]: 'text-orange-500', - [TASK_PRIORITIES.MEDIUM]: 'text-yellow-500', - [TASK_PRIORITIES.LOW]: 'text-blue-500', -} as const - export default function TodoPage() { + const TASK_PRIORITIES = { + URGENT: 3, + HIGH: 2, + MEDIUM: 1, + LOW: 0, + NONE: -1, + } as const + + const PRIORITY_COLORS: Record = { + [TASK_PRIORITIES.URGENT]: 'bg-red-700', + [TASK_PRIORITIES.HIGH]: 'bg-orange-500', + [TASK_PRIORITIES.MEDIUM]: 'bg-yellow-600', + [TASK_PRIORITIES.LOW]: 'bg-blue-600', + } as const const [isModalOpen, setIsModalOpen] = useState(false) const [isAssignModalOpen, setIsAssignModalOpen] = useState(false) const [selectedTaskId, setSelectedTaskId] = useState(null) @@ -320,6 +319,9 @@ export default function TodoPage() { label="" checked={task.completed} toggle={() => toggleTodo(task.id)} + checkboxClassName={ + PRIORITY_COLORS[task.priority ?? 0] + } /> - {(value) => ( - - {value} - - )} + {(value) => {value}} {task.assignee_id !== user?.id && ( @@ -474,6 +468,7 @@ export default function TodoPage() { label="" checked={task.completed} toggle={() => toggleTodo(task.id)} + className={PRIORITY_COLORS[task.priority ?? 0]} /> {task.text} @@ -517,6 +512,7 @@ export default function TodoPage() { label="" checked={task.completed} toggle={() => toggleTodo(task.id)} + className={PRIORITY_COLORS[task.priority ?? 0]} /> {task.text}