From fd790ffa755b08c7fd96bb9944c43dc8341f26fc Mon Sep 17 00:00:00 2001 From: Nick Braica Date: Thu, 23 Jul 2026 22:51:32 -0400 Subject: [PATCH 1/5] basics of adding dates adn categories --- src/app/layout.tsx | 17 +-- src/components/category-input/index.tsx | 48 ++++++++ .../category-picker.module.css | 40 +++++++ src/components/category-picker/index.tsx | 97 ++++++++++++++++ src/components/task-form/index.tsx | 54 +++++++++ src/components/task-form/task-form.module.css | 18 +++ src/components/task-item/index.tsx | 104 ++++++++++++++++- src/components/task-item/task-item.module.css | 16 ++- src/contexts/CategoryContext.tsx | 106 ++++++++++++++++++ src/contexts/TaskContext.tsx | 2 + src/types.ts | 7 ++ 11 files changed, 497 insertions(+), 12 deletions(-) create mode 100644 src/components/category-input/index.tsx create mode 100644 src/components/category-picker/category-picker.module.css create mode 100644 src/components/category-picker/index.tsx create mode 100644 src/contexts/CategoryContext.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cf8a9dc..df51029 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { AuthProvider } from '@/contexts/AuthContext'; import { GlobalProvider } from '@/contexts/GlobalContext'; import { ClientProvider } from '@/contexts/ClientContext'; import { TaskProvider } from '@/contexts/TaskContext'; +import { CategoryProvider } from '@/contexts/CategoryContext'; import { InvoiceProvider } from '@/contexts/InvoiceContext'; import { ProjectProvider } from '@/contexts/ProjectContext'; import IconLogo from '@/icons/logo'; @@ -55,13 +56,15 @@ export default function RootLayout({ - - - - {children} - - - + + + + + {children} + + + + diff --git a/src/components/category-input/index.tsx b/src/components/category-input/index.tsx new file mode 100644 index 0000000..fbcd0fb --- /dev/null +++ b/src/components/category-input/index.tsx @@ -0,0 +1,48 @@ +import { Category } from '@/types'; + +export default function CategoryInput({ + id, + value, + categories, + onChange, + onBlur, + onFocus, + onKeyDown, + disabled = false, + className = 'form-input', +}: { + id: string; + value: string; + categories: Category[]; + onChange: (value: string) => void; + onBlur?: (e: React.FocusEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + disabled?: boolean; + className?: string; +}) { + const listId = `${id}-options`; + + return ( + <> + onChange(e.target.value)} + onBlur={onBlur} + onFocus={onFocus} + onKeyDown={onKeyDown} + /> + + {categories.map((category) => ( + + + ); +} diff --git a/src/components/category-picker/category-picker.module.css b/src/components/category-picker/category-picker.module.css new file mode 100644 index 0000000..6888427 --- /dev/null +++ b/src/components/category-picker/category-picker.module.css @@ -0,0 +1,40 @@ +.picker { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.pills { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.35em; + font-size: var(--step--1); + font-weight: var(--weight-semibold); + padding: 0.2em 0.4em 0.2em 0.75em; + border-radius: 100px; + background: var(--c-beige); + color: var(--c-black); + white-space: nowrap; +} + +.remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2em; + height: 1.2em; + border-radius: 50%; + line-height: 1; + color: inherit; + transition: background-color 0.2s; +} + +.remove:hover { + background-color: rgba(0, 0, 0, 0.1); +} diff --git a/src/components/category-picker/index.tsx b/src/components/category-picker/index.tsx new file mode 100644 index 0000000..a0e95e6 --- /dev/null +++ b/src/components/category-picker/index.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import CategoryInput from '@/components/category-input'; +import { Category } from '@/types'; +import styles from './category-picker.module.css'; + +export default function CategoryPicker({ + id, + selectedIds, + categories, + onAdd, + onRemove, + onFocus, + disabled = false, + inputClassName = 'form-input', +}: { + id: string; + selectedIds: string[]; + categories: Category[]; + onAdd: (name: string) => void; + onRemove: (categoryId: string) => void; + onFocus?: (e: React.FocusEvent) => void; + disabled?: boolean; + inputClassName?: string; +}) { + const [inputValue, setInputValue] = useState(''); + + const selected = selectedIds + .map((categoryId) => categories.find((category) => category.id === categoryId)) + .filter((category): category is Category => Boolean(category)); + + const options = categories.filter( + (category) => !selectedIds.includes(category.id), + ); + + const commitValue = (rawValue: string) => { + const trimmed = rawValue.trim(); + if (!trimmed) { + return; + } + + onAdd(trimmed); + setInputValue(''); + }; + + const handleChange = (newValue: string) => { + setInputValue(newValue); + + // Choosing a suggestion from the native dropdown only fires a + // plain change event (there's no dedicated "option selected" event), so + // an exact match against an existing option is treated as a selection + // and committed immediately instead of waiting for blur/Enter. + const isExactMatch = options.some((category) => category.name === newValue); + if (isExactMatch) { + commitValue(newValue); + } + }; + + return ( +
+ commitValue(inputValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitValue(inputValue); + } + }} + /> + + {selected.length > 0 && ( +
+ {selected.map((category) => ( + + {category.name} + + + ))} +
+ )} +
+ ); +} diff --git a/src/components/task-form/index.tsx b/src/components/task-form/index.tsx index df0fd4a..660f081 100644 --- a/src/components/task-form/index.tsx +++ b/src/components/task-form/index.tsx @@ -1,7 +1,9 @@ import { useState } from 'react'; import { useTasks } from '@/contexts/TaskContext'; +import { useCategories } from '@/contexts/CategoryContext'; import SlideUpModalForm from '@/components/slide-up-modal-form'; import Toggle from '@/components/toggle'; +import CategoryPicker from '@/components/category-picker'; import { Client, Task } from '@/types'; import styles from './task-form.module.css'; @@ -15,9 +17,12 @@ export default function TaskForm({ client: Client; }) { const { addTask } = useTasks(); + const { categories, getOrCreateCategory } = useCategories(); const [description, setDescription] = useState(''); const [value, setValue] = useState(''); const [isHourly, setIsHourly] = useState(true); + const [date, setDate] = useState(''); + const [categoryIds, setCategoryIds] = useState([]); const [error, setError] = useState(''); const [prevVisible, setPrevVisible] = useState(visible); @@ -27,10 +32,23 @@ export default function TaskForm({ setDescription(''); setValue(''); setIsHourly(true); + setDate(''); + setCategoryIds([]); setError(''); } } + const handleAddCategory = async (name: string) => { + const category = await getOrCreateCategory(name); + if (category && !categoryIds.includes(category.id)) { + setCategoryIds([...categoryIds, category.id]); + } + }; + + const handleRemoveCategory = (categoryId: string) => { + setCategoryIds(categoryIds.filter((id) => id !== categoryId)); + }; + const handleSubmit = async () => { setError(''); @@ -54,6 +72,14 @@ export default function TaskForm({ task.price = Number(value); } + if (date) { + task.date = date; + } + + if (categoryIds.length) { + task.category = categoryIds; + } + await addTask(task); } catch (err) { console.error(err); @@ -111,6 +137,34 @@ export default function TaskForm({ + +
+
+ + setDate(e.target.value)} + /> +
+ +
+ + +
+
); diff --git a/src/components/task-form/task-form.module.css b/src/components/task-form/task-form.module.css index 34bbf75..1da3673 100644 --- a/src/components/task-form/task-form.module.css +++ b/src/components/task-form/task-form.module.css @@ -25,3 +25,21 @@ .fixedValueInput input { padding-left: calc(var(--space-s) * 1.8); } + +.metaRow { + flex-direction: column; +} + +.metaField { + width: 100%; +} + +@media screen and (min-width: 580px) { + .metaRow { + flex-direction: row; + } + + .metaField { + flex: 1; + } +} diff --git a/src/components/task-item/index.tsx b/src/components/task-item/index.tsx index fb46235..41d80c6 100644 --- a/src/components/task-item/index.tsx +++ b/src/components/task-item/index.tsx @@ -2,8 +2,10 @@ import { useCallback, useState, useRef } from 'react'; import sanitizeHtml from 'sanitize-html'; import { useTasks } from '@/contexts/TaskContext'; import { useNewInvoice } from '@/contexts/NewInvoiceContext'; +import { useCategories } from '@/contexts/CategoryContext'; import Button from '@/components/button'; import Toggle from '@/components/toggle'; +import CategoryPicker from '@/components/category-picker'; import IconTrash from '@/icons/trash'; import IconCheckmark from '@/icons/checkmark'; import { moneyFormatter, taskCost } from '@/utils'; @@ -13,6 +15,7 @@ import styles from './task-item.module.css'; export default function TaskItem({ task, rate }: { task: Task; rate: number }) { const { isInvoicing, addTask, removeTask } = useNewInvoice(); const { updateTask, deleteTask } = useTasks(); + const { categories, getOrCreateCategory } = useCategories(); const [isSaving, setIsSaving] = useState(false); const [isConfirmingDeletion, setConfirmDelettion] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -20,8 +23,12 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { const [statusMessage, setStatusMessage] = useState(''); const [description, setDescription] = useState(task.description); const [hours, setHours] = useState(task.hours); - const [price, setPrice] = useState(() => task.isHourly ? 0 : taskCost(task, rate)); + const [price, setPrice] = useState(() => + task.isHourly ? 0 : taskCost(task, rate), + ); const [isHourly, setIsHourly] = useState(task.isHourly); + const [date, setDate] = useState(task.date || ''); + const [categoryIds, setCategoryIds] = useState(task.category || []); const hoursInputRef = useRef(null); const costInputRef = useRef(null); const [prevIsInvoicing, setPrevIsInvoicing] = useState(isInvoicing); @@ -123,6 +130,64 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { } }; + const onDateBlur = useCallback( + async (e: React.FocusEvent) => { + const oldDate = date; + const newDate = e.currentTarget.value; + + if (oldDate === newDate) { + setStatusMessage(''); + return; + } + + setDate(newDate); + try { + await triggerTaskSave({ ...task, date: newDate || null }); + // eslint-disable-next-line + } catch (error) { + setDate(oldDate); + } + }, + [date, task, triggerTaskSave], + ); + + const handleAddCategory = useCallback( + async (name: string) => { + const oldCategoryIds = categoryIds; + const category = await getOrCreateCategory(name); + + if (!category || oldCategoryIds.includes(category.id)) { + return; + } + + const newCategoryIds = [...oldCategoryIds, category.id]; + setCategoryIds(newCategoryIds); + try { + await triggerTaskSave({ ...task, category: newCategoryIds }); + // eslint-disable-next-line + } catch (error) { + setCategoryIds(oldCategoryIds); + } + }, + [categoryIds, task, triggerTaskSave, getOrCreateCategory], + ); + + const handleRemoveCategory = useCallback( + async (categoryId: string) => { + const oldCategoryIds = categoryIds; + const newCategoryIds = oldCategoryIds.filter((id) => id !== categoryId); + + setCategoryIds(newCategoryIds); + try { + await triggerTaskSave({ ...task, category: newCategoryIds }); + // eslint-disable-next-line + } catch (error) { + setCategoryIds(oldCategoryIds); + } + }, + [categoryIds, task, triggerTaskSave], + ); + const handleUnitToggle = async () => { const newIsHourly = !isHourly; setIsHourly(newIsHourly); @@ -181,7 +246,9 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { className={`weight-extrabold ${styles.taskCost} ${!isHourly ? styles.taskCostHoverable : ''}`} ref={costInputRef} > -
{moneyFormatter.format(displayedPrice || 0)}
+
+ {moneyFormatter.format(displayedPrice || 0)} +
{!isHourly && ( +
+

+ + date: + + setStatusMessage('Editing...')} + onChange={(e) => setDate(e.target.value)} + onBlur={onDateBlur} + /> +

+ +
+ + categories: + + setStatusMessage('Editing...')} + /> +
+
+ {isConfirmingDeletion && (

Do you really want to delete this task?

diff --git a/src/components/task-item/task-item.module.css b/src/components/task-item/task-item.module.css index af3d022..876c16e 100644 --- a/src/components/task-item/task-item.module.css +++ b/src/components/task-item/task-item.module.css @@ -23,7 +23,9 @@ .costInput, .description, .costDisplay, -.hours { +.hours, +.dateInput, +.categoryInput { transition: border-color 0.2s; padding: var(--space-xs); border: 1px dashed transparent; @@ -33,13 +35,17 @@ .task:not(.invoicingActive):hover .description, .task:not(.invoicingActive):hover .taskCostHoverable .costDisplay, .task:not(.invoicingActive):hover .hours, -.task:not(.invoicingActive):hover .costInput { +.task:not(.invoicingActive):hover .costInput, +.task:not(.invoicingActive):hover .dateInput, +.task:not(.invoicingActive):hover .categoryInput { border-color: var(--c-beige); } .task:not(.invoicingActive) .description:focus, .task:not(.invoicingActive) .hours:focus, -.task:not(.invoicingActive) .costInput:focus { +.task:not(.invoicingActive) .costInput:focus, +.task:not(.invoicingActive) .dateInput:focus, +.task:not(.invoicingActive) .categoryInput:focus { border-color: var(--c-midnight-green); border-style: solid; outline: none; @@ -119,3 +125,7 @@ display: flex; gap: var(--space-m); } + +.dateWrapper { + padding-left: var(--space-xs); +} diff --git a/src/contexts/CategoryContext.tsx b/src/contexts/CategoryContext.tsx new file mode 100644 index 0000000..2e50d46 --- /dev/null +++ b/src/contexts/CategoryContext.tsx @@ -0,0 +1,106 @@ +// contexts/CategoryContext.tsx +'use client'; + +import { RecordModel } from 'pocketbase'; +import pb from '@/lib/pocketbase'; +import { + ReactNode, + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { Category } from '@/types'; + +interface CategoryContextType { + areCategoriesLoaded: boolean; + categories: Category[]; + getOrCreateCategory: (name: string) => Promise; +} + +const CategoryContext = createContext( + undefined, +); + +const recordToCategory = (record: RecordModel): Category => ({ + id: record.id, + name: record.name, +}); + +export const CategoryProvider = ({ children }: { children: ReactNode }) => { + const [areCategoriesLoaded, setCategoriesLoaded] = useState(false); + const [categories, setCategories] = useState([]); + const { user } = useAuth(); + const hasFetchedRef = useRef(false); + const [prevUser, setPrevUser] = useState(user); + + if (prevUser !== user) { + setPrevUser(user); + if (!user) { + setCategories([]); + setCategoriesLoaded(false); + } + } + + useEffect(() => { + if (!user) { + hasFetchedRef.current = false; + return; + } + if (hasFetchedRef.current) return; + hasFetchedRef.current = true; + + async function fetchCategories() { + try { + const records = await pb.collection('categories').getFullList(); + setCategoriesLoaded(true); + setCategories(records.map(recordToCategory)); + } catch (error) { + console.error('Error fetching categories:', error); + } + } + + fetchCategories(); + }, [user]); + + const getOrCreateCategory = async ( + name: string, + ): Promise => { + const trimmed = name.trim(); + if (!trimmed) { + return null; + } + + const existing = categories.find( + (category) => category.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (existing) { + return existing; + } + + const record = await pb + .collection('categories') + .create({ name: trimmed, user: user?.id }); + const category = recordToCategory(record); + setCategories((oldCategories) => [...oldCategories, category]); + return category; + }; + + return ( + + {children} + + ); +}; + +export const useCategories = () => { + const context = useContext(CategoryContext); + if (!context) { + throw new Error('useCategories must be used within a CategoryProvider'); + } + return context; +}; diff --git a/src/contexts/TaskContext.tsx b/src/contexts/TaskContext.tsx index 7b3de71..f6a7c45 100644 --- a/src/contexts/TaskContext.tsx +++ b/src/contexts/TaskContext.tsx @@ -36,6 +36,8 @@ const recordToTask = (record: RecordModel): Task => ({ isHourly: record.isHourly, hours: record.hours, price: record.price, + date: record.date, + category: record.category, }); export const TaskProvider = ({ children }: { children: ReactNode }) => { diff --git a/src/types.ts b/src/types.ts index df12543..e60c8db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,13 @@ export interface Task { isHourly: boolean; hours?: number | null; price?: number | null; + date?: string | null; + category?: string[] | null; +} + +export interface Category { + id: string; + name: string; } export interface InvoicedTask { From e9270c0e182b984128e6e0499e83e3fe31008fb6 Mon Sep 17 00:00:00 2001 From: Nick Braica Date: Thu, 23 Jul 2026 23:22:11 -0400 Subject: [PATCH 2/5] fix category combobox and introduce downshift --- package-lock.json | 35 +++- package.json | 1 + src/app/globals.css | 6 +- src/components/category-input/index.tsx | 48 ----- .../category-picker.module.css | 41 +++- src/components/category-picker/index.tsx | 190 +++++++++++++----- src/components/task-form/index.tsx | 2 +- src/components/task-item/index.tsx | 6 +- src/components/task-item/task-item.module.css | 1 - src/contexts/TaskContext.tsx | 2 +- src/types.ts | 2 +- 11 files changed, 223 insertions(+), 111 deletions(-) delete mode 100644 src/components/category-input/index.tsx diff --git a/package-lock.json b/package-lock.json index 051f7e0..00bab7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@react-pdf/renderer": "^4.5.1", + "downshift": "^9.4.0", "focus-trap-react": "^12.0.0", "next": "16.2.6", "pocketbase": "^0.26.8", @@ -223,9 +224,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2668,6 +2669,12 @@ "node": ">=12.20" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2946,6 +2953,28 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/downshift": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.4.0.tgz", + "integrity": "sha512-yIwyzRYzycKwMZhvOahsn4BmRr4Ffi0JymdWOtTDhR+LGDEXCZHTcwgUbNBC/oZLT6YrR46iwJe/BrQUS5ClQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "compute-scroll-into-view": "^3.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.2.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": ">=16.12.0" + } + }, + "node_modules/downshift/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 1f0e01b..b9c8690 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@react-pdf/renderer": "^4.5.1", + "downshift": "^9.4.0", "focus-trap-react": "^12.0.0", "next": "16.2.6", "pocketbase": "^0.26.8", diff --git a/src/app/globals.css b/src/app/globals.css index d34c2ab..84f1f75 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -213,12 +213,12 @@ textarea { font: inherit; } .form-row + .form-row { - margin-top: var(--space-s); + margin-top: var(--space-xs); } .form-row.flex-fields { display: flex; - gap: var(--space-m); + gap: var(--space-s); } .form-label { @@ -232,7 +232,7 @@ textarea { border-radius: var(--border-radius-small); border: 1px solid var(--c-gray-light); background-color: var(--c-beige-transparent); - padding: 1em; + padding: 0.6em; width: 100%; } .form-input.outlined { diff --git a/src/components/category-input/index.tsx b/src/components/category-input/index.tsx deleted file mode 100644 index fbcd0fb..0000000 --- a/src/components/category-input/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Category } from '@/types'; - -export default function CategoryInput({ - id, - value, - categories, - onChange, - onBlur, - onFocus, - onKeyDown, - disabled = false, - className = 'form-input', -}: { - id: string; - value: string; - categories: Category[]; - onChange: (value: string) => void; - onBlur?: (e: React.FocusEvent) => void; - onFocus?: (e: React.FocusEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - disabled?: boolean; - className?: string; -}) { - const listId = `${id}-options`; - - return ( - <> - onChange(e.target.value)} - onBlur={onBlur} - onFocus={onFocus} - onKeyDown={onKeyDown} - /> - - {categories.map((category) => ( - - - ); -} diff --git a/src/components/category-picker/category-picker.module.css b/src/components/category-picker/category-picker.module.css index 6888427..ef18e7c 100644 --- a/src/components/category-picker/category-picker.module.css +++ b/src/components/category-picker/category-picker.module.css @@ -4,7 +4,41 @@ gap: var(--space-xs); } -.pills { +.comboboxWrapper { + position: relative; +} + +.menu { + position: absolute; + z-index: 1; + top: calc(100% + var(--space-xs)); + left: 0; + right: 0; + max-height: 6rem; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; + + background-color: var(--c-white); + border-radius: var(--border-radius-small); +} + +.menuOpen { + border: 1px solid var(--bg-accent-border); +} + +.menuItem { + padding: var(--space-xs) var(--space-s); + font-size: var(--step--1); + cursor: pointer; +} + +.menuItemHighlighted { + background-color: var(--c-off-white); +} + +.tagGroup { display: flex; flex-wrap: wrap; gap: var(--space-xs); @@ -23,6 +57,11 @@ white-space: nowrap; } +.pillActive { + outline: 2px solid var(--c-midnight-green); + outline-offset: 1px; +} + .remove { display: inline-flex; align-items: center; diff --git a/src/components/category-picker/index.tsx b/src/components/category-picker/index.tsx index a0e95e6..d184968 100644 --- a/src/components/category-picker/index.tsx +++ b/src/components/category-picker/index.tsx @@ -1,8 +1,12 @@ +'use client'; + import { useState } from 'react'; -import CategoryInput from '@/components/category-input'; +import { useTagGroup, useCombobox } from 'downshift'; import { Category } from '@/types'; import styles from './category-picker.module.css'; +const CREATE_ID = '__create__'; + export default function CategoryPicker({ id, selectedIds, @@ -24,66 +28,154 @@ export default function CategoryPicker({ }) { const [inputValue, setInputValue] = useState(''); - const selected = selectedIds - .map((categoryId) => categories.find((category) => category.id === categoryId)) + const selectedCategories = selectedIds + .map((categoryId) => + categories.find((category) => category.id === categoryId), + ) .filter((category): category is Category => Boolean(category)); - const options = categories.filter( + // `items` is passed as a controlled prop so the selection this renders + // always matches selectedIds (owned by the parent), rather than letting + // useTagGroup keep its own copy. addItem/onItemsChange are still what + // drive additions and removals -- their results are just translated into + // the parent's onAdd/onRemove instead of being treated as the source of + // truth themselves. + const { + addItem, + getTagProps, + getTagRemoveProps, + getTagGroupProps, + activeIndex, + } = useTagGroup({ + items: selectedCategories, + getTagId: (index) => `${id}-tag-${index}`, + onItemsChange: ({ items: newItems }) => { + if (!newItems) { + return; + } + + if (newItems.length > selectedCategories.length) { + const added = newItems.find( + (item) => + !selectedCategories.some((category) => category.id === item.id), + ); + if (added) { + onAdd(added.name); + } + } else { + const removed = selectedCategories.find( + (category) => !newItems.some((item) => item.id === category.id), + ); + if (removed) { + onRemove(removed.id); + } + } + }, + }); + + const availableCategories = categories.filter( (category) => !selectedIds.includes(category.id), ); - const commitValue = (rawValue: string) => { - const trimmed = rawValue.trim(); - if (!trimmed) { - return; - } - - onAdd(trimmed); - setInputValue(''); - }; - - const handleChange = (newValue: string) => { - setInputValue(newValue); - - // Choosing a suggestion from the native dropdown only fires a - // plain change event (there's no dedicated "option selected" event), so - // an exact match against an existing option is treated as a selection - // and committed immediately instead of waiting for blur/Enter. - const isExactMatch = options.some((category) => category.name === newValue); - if (isExactMatch) { - commitValue(newValue); - } - }; + const trimmedInput = inputValue.trim(); + const filteredCategories = trimmedInput + ? availableCategories.filter((category) => + category.name.toLowerCase().includes(trimmedInput.toLowerCase()), + ) + : availableCategories; + + const hasExactMatch = availableCategories.some( + (category) => category.name.toLowerCase() === trimmedInput.toLowerCase(), + ); + + const itemsToAdd: Category[] = + trimmedInput && !hasExactMatch + ? [...filteredCategories, { id: CREATE_ID, name: trimmedInput }] + : filteredCategories; + + const { + isOpen, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + openMenu, + } = useCombobox({ + items: itemsToAdd, + inputValue, + itemToKey: (item) => item?.id ?? '', + itemToString: (item) => item?.name ?? '', + onInputValueChange: ({ inputValue: newValue }) => { + setInputValue(newValue ?? ''); + }, + onSelectedItemChange({ selectedItem }) { + if (selectedItem) { + addItem(selectedItem); + } + }, + stateReducer(_state, actionAndChanges) { + const { changes, type } = actionAndChanges; + + if ( + changes.selectedItem && + type !== useCombobox.stateChangeTypes.InputBlur + ) { + return { + ...changes, + inputValue: '', + highlightedIndex: 0, + isOpen: true, + }; + } + + return changes; + }, + }); return (
- commitValue(inputValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - commitValue(inputValue); - } - }} - /> - - {selected.length > 0 && ( -
- {selected.map((category) => ( - +
+ { + openMenu(); + onFocus?.(e); + }, + })} + className={inputClassName} + /> + +
    + {isOpen && + itemsToAdd.map((item, index) => ( +
  • + {item.id === CREATE_ID ? `Create "${item.name}"` : item.name} +
  • + ))} +
+
+ + {selectedCategories.length > 0 && ( +
+ {selectedCategories.map((category, index) => ( + {category.name} + )} +
    0 ? styles.menuOpen : '', + ].join(' ')} > {isOpen && itemsToAdd.map((item, index) => ( diff --git a/src/components/task-item/index.tsx b/src/components/task-item/index.tsx index 86e3cb1..99d7a8b 100644 --- a/src/components/task-item/index.tsx +++ b/src/components/task-item/index.tsx @@ -8,6 +8,7 @@ import Toggle from '@/components/toggle'; import CategoryPicker from '@/components/category-picker'; import IconTrash from '@/icons/trash'; import IconCheckmark from '@/icons/checkmark'; +import IconPlus from '@/icons/plus'; import { moneyFormatter, taskCost } from '@/utils'; import { Task } from '@/types'; import styles from './task-item.module.css'; @@ -28,7 +29,10 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { ); const [isHourly, setIsHourly] = useState(task.isHourly); const [date, setDate] = useState(task.date || ''); - const [categoryIds, setCategoryIds] = useState(task.categories || []); + const [categoryIds, setCategoryIds] = useState( + task.categories || [], + ); + const [isCategoryPickerOpen, setIsCategoryPickerOpen] = useState(false); const hoursInputRef = useRef(null); const costInputRef = useRef(null); const [prevIsInvoicing, setPrevIsInvoicing] = useState(isInvoicing); @@ -336,8 +340,8 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) {
-
-

+

+

date: @@ -352,20 +356,64 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { />

-
- - categories: - - setStatusMessage('Editing...')} - /> +
+ {isCategoryPickerOpen ? ( + <> + + Categories: + + setIsCategoryPickerOpen(false)} + /> + + ) : categoryIds.length > 0 ? ( +
+ {categoryIds.map((categoryId) => { + const category = categories.find((c) => c.id === categoryId); + if (!category) { + return null; + } + return ( + + {category.name} + + + ); + })} + +
+ ) : ( + + )}
diff --git a/src/components/task-item/task-item.module.css b/src/components/task-item/task-item.module.css index c33f03e..40d86e4 100644 --- a/src/components/task-item/task-item.module.css +++ b/src/components/task-item/task-item.module.css @@ -23,8 +23,7 @@ .description, .costDisplay, .hours, -.dateInput, -.categoryInput { +.dateInput { transition: border-color 0.2s; padding: var(--space-xs); border: 1px dashed transparent; @@ -35,21 +34,30 @@ .task:not(.invoicingActive):hover .taskCostHoverable .costDisplay, .task:not(.invoicingActive):hover .hours, .task:not(.invoicingActive):hover .costInput, -.task:not(.invoicingActive):hover .dateInput, -.task:not(.invoicingActive):hover .categoryInput { +.task:not(.invoicingActive):hover .dateInput { border-color: var(--c-beige); } .task:not(.invoicingActive) .description:focus, .task:not(.invoicingActive) .hours:focus, .task:not(.invoicingActive) .costInput:focus, -.task:not(.invoicingActive) .dateInput:focus, -.task:not(.invoicingActive) .categoryInput:focus { +.task:not(.invoicingActive) .dateInput:focus { border-color: var(--c-midnight-green); border-style: solid; outline: none; } +.categoryInput { + padding: var(--space-xs); + border: 1px solid var(--bg-accent-border); + border-radius: var(--border-radius-small); + outline: none; +} + +.categoryInput:focus { + border-color: var(--c-midnight-green); +} + .costInput { position: absolute; top: 0; @@ -125,6 +133,91 @@ gap: var(--space-m); } -.dateWrapper { +.taskMeta { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + align-items: center; + grid-column: 1 / -1; +} + +.metaWrapper { padding-left: var(--space-xs); } + +.categoriesDisplay { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.35em; + font-size: var(--step--1); + font-weight: var(--weight-semibold); + padding: 0.2em 0.4em 0.2em 0.75em; + border-radius: 100px; + background: var(--c-beige); + color: var(--c-black); + white-space: nowrap; +} + +.remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2em; + height: 1.2em; + border-radius: 50%; + line-height: 1; + color: inherit; + transition: background-color 0.2s; +} + +.remove:hover { + background-color: rgba(0, 0, 0, 0.1); +} + +.categoryToggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + color: var(--c-gray-dark); + transition: + background-color 0.2s, + color 0.2s; +} + +.categoryToggle svg { + width: 0.9em; + height: 0.9em; +} + +.categoryToggle:hover { + background-color: var(--c-beige-transparent); + color: var(--c-black); +} + +.addCategoriesButton { + display: inline-flex; + align-items: center; + gap: 0.35em; + font-size: var(--step--1); + color: var(--c-gray-dark); + transition: color 0.2s; +} + +.addCategoriesButton:hover { + color: var(--c-black); +} + +.addCategoriesButton svg { + width: 0.9em; + height: 0.9em; +} From 66aca611cdea8af75bd8613c2ca1c3f8eb4cf4ba Mon Sep 17 00:00:00 2001 From: Nick Braica Date: Thu, 23 Jul 2026 23:48:35 -0400 Subject: [PATCH 4/5] fix some hourly/fixed cost stuff --- src/components/invoice-task-item/index.tsx | 6 +++--- src/components/task-form/index.tsx | 6 +++--- src/components/task-item/index.tsx | 10 ++++++---- src/components/task-item/task-item.module.css | 3 ++- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/invoice-task-item/index.tsx b/src/components/invoice-task-item/index.tsx index a324715..dff5aef 100644 --- a/src/components/invoice-task-item/index.tsx +++ b/src/components/invoice-task-item/index.tsx @@ -60,10 +60,10 @@ export default function EditInvoiceTask({
setIsHourly(!isHourly)} - onLabel="Hourly" - offLabel="Fixed" + onLabel="Fixed" + offLabel="Hourly" size="small" />
diff --git a/src/components/task-form/index.tsx b/src/components/task-form/index.tsx index 14ffb67..b117c0e 100644 --- a/src/components/task-form/index.tsx +++ b/src/components/task-form/index.tsx @@ -115,10 +115,10 @@ export default function TaskForm({
setIsHourly(!isHourly)} - onLabel="Hourly" - offLabel="Fixed" + onLabel="Fixed" + offLabel="Hourly" />
diff --git a/src/components/task-item/index.tsx b/src/components/task-item/index.tsx index 99d7a8b..fbe8c56 100644 --- a/src/components/task-item/index.tsx +++ b/src/components/task-item/index.tsx @@ -250,7 +250,9 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { className={`weight-extrabold ${styles.taskCost} ${!isHourly ? styles.taskCostHoverable : ''}`} ref={costInputRef} > -
+
{moneyFormatter.format(displayedPrice || 0)}
@@ -276,10 +278,10 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) {
diff --git a/src/components/task-item/task-item.module.css b/src/components/task-item/task-item.module.css index 40d86e4..7371388 100644 --- a/src/components/task-item/task-item.module.css +++ b/src/components/task-item/task-item.module.css @@ -31,7 +31,7 @@ } .task:not(.invoicingActive):hover .description, -.task:not(.invoicingActive):hover .taskCostHoverable .costDisplay, +.task:not(.invoicingActive):hover .costDisplay, .task:not(.invoicingActive):hover .hours, .task:not(.invoicingActive):hover .costInput, .task:not(.invoicingActive):hover .dateInput { @@ -40,6 +40,7 @@ .task:not(.invoicingActive) .description:focus, .task:not(.invoicingActive) .hours:focus, +.task:not(.invoicingActive) .costDisplay:focus, .task:not(.invoicingActive) .costInput:focus, .task:not(.invoicingActive) .dateInput:focus { border-color: var(--c-midnight-green); From fa169fc46c005c74b57864d4bd84fe538a209591 Mon Sep 17 00:00:00 2001 From: Nick Braica Date: Fri, 24 Jul 2026 00:04:30 -0400 Subject: [PATCH 5/5] better UI for dates --- src/components/task-item/index.tsx | 63 ++++++++++++------- src/components/task-item/task-item.module.css | 12 ++-- src/contexts/TaskContext.tsx | 6 +- 3 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/components/task-item/index.tsx b/src/components/task-item/index.tsx index fbe8c56..bb88283 100644 --- a/src/components/task-item/index.tsx +++ b/src/components/task-item/index.tsx @@ -29,6 +29,7 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { ); const [isHourly, setIsHourly] = useState(task.isHourly); const [date, setDate] = useState(task.date || ''); + const [isAddingDate, setIsAddingDate] = useState(false); const [categoryIds, setCategoryIds] = useState( task.categories || [], ); @@ -136,23 +137,33 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { const onDateBlur = useCallback( async (e: React.FocusEvent) => { - const oldDate = date; + // Compare against task.date (the last-persisted value) rather than + // the local `date` state -- onChange already updated `date` to match + // the newly picked value before blur fires, so comparing against it + // here would always see them as equal and skip the save. + const previousDate = task.date || ''; const newDate = e.currentTarget.value; - if (oldDate === newDate) { + if (previousDate === newDate) { setStatusMessage(''); + if (!newDate) { + setIsAddingDate(false); + } return; } setDate(newDate); try { await triggerTaskSave({ ...task, date: newDate || null }); + if (!newDate) { + setIsAddingDate(false); + } // eslint-disable-next-line } catch (error) { - setDate(oldDate); + setDate(previousDate); } }, - [date, task, triggerTaskSave], + [task, triggerTaskSave], ); const handleAddCategory = useCallback( @@ -250,9 +261,7 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { className={`weight-extrabold ${styles.taskCost} ${!isHourly ? styles.taskCostHoverable : ''}`} ref={costInputRef} > -
+
{moneyFormatter.format(displayedPrice || 0)}
@@ -343,22 +352,32 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) {
-

- - date: - - setStatusMessage('Editing...')} - onChange={(e) => setDate(e.target.value)} - onBlur={onDateBlur} - /> +

+ Date + {date || isAddingDate ? ( + setStatusMessage('Editing...')} + onChange={(e) => setDate(e.target.value)} + onBlur={onDateBlur} + /> + ) : ( + + )}

-
+
{isCategoryPickerOpen ? ( <> @@ -409,7 +428,7 @@ export default function TaskItem({ task, rate }: { task: Task; rate: number }) { ) : (