diff --git a/public/locales/en/chores.json b/public/locales/en/chores.json
index 50b151f..d4d0001 100644
--- a/public/locales/en/chores.json
+++ b/public/locales/en/chores.json
@@ -63,6 +63,8 @@
"skip": "Skip",
"cancel": "Cancel",
"noPriority": "No Priority",
+ "more": "More",
+ "changeDueDate": "Change due date",
"subtasks": "Subtasks",
"noDescription": "No description available",
"timer": {
diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx
index bbc3a76..25472d6 100644
--- a/src/components/common/FilterBar.jsx
+++ b/src/components/common/FilterBar.jsx
@@ -147,8 +147,19 @@ const FilterBar = ({
onClearAll,
resultCount,
totalCount,
+ // When the host renders its own trigger (e.g. an icon button in a toolbar
+ // row), it drives the sheet through `open`/`onOpenChange` and hides ours.
+ open,
+ onOpenChange,
+ showTrigger = true,
}) => {
- const [isOpen, setIsOpen] = useState(false)
+ const [internalOpen, setInternalOpen] = useState(false)
+ const isControlled = open !== undefined
+ const isOpen = isControlled ? open : internalOpen
+ const setIsOpen = next => {
+ if (!isControlled) setInternalOpen(next)
+ onOpenChange?.(next)
+ }
// ── Active count ───────────────────────────────────────────────────────────
@@ -293,65 +304,75 @@ const FilterBar = ({
// ── Render ─────────────────────────────────────────────────────────────────
+ const activeChips = filterDefs
+ .map(def => ({ def, label: getActiveChipLabel(def) }))
+ .filter(({ label }) => !!label)
+ .map(({ def, label }) => ({
+ key: def.id,
+ label,
+ onClear: () => onSetFilter(def.id, null),
+ }))
+
+ // With the trigger hoisted into a toolbar, the inline row has nothing to show
+ // until a filter is on — rendering it anyway would leave a phantom gap.
+ const showInlineBar = showTrigger || activeChips.length > 0
+
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
-
-
- }
- onClick={() => setIsOpen(true)}
- sx={{
- borderRadius: 'xl',
- py: 0.5,
- px: 1,
- gap: 0.5,
- alignItems: 'center',
- '& .MuiButton-startDecorator': {
- display: 'flex',
- alignItems: 'center',
- mr: 0.5,
- },
- }}
- >
- Filters
-
-
+ {showTrigger && (
+
+ }
+ onClick={() => setIsOpen(true)}
+ sx={{
+ borderRadius: 'xl',
+ py: 0.5,
+ px: 1,
+ gap: 0.5,
+ alignItems: 'center',
+ '& .MuiButton-startDecorator': {
+ display: 'flex',
+ alignItems: 'center',
+ mr: 0.5,
+ },
+ }}
+ >
+ Filters
+
+
+ )}
- ({ def, label: getActiveChipLabel(def) }))
- .filter(({ label }) => !!label)
- .map(({ def, label }) => ({
- key: def.id,
- label,
- onClear: () => onSetFilter(def.id, null),
- }))}
- onOpen={() => setIsOpen(true)}
- onClearAll={hasActive ? onClearAll : undefined}
- resultCount={hasActive ? resultCount : undefined}
- totalCount={hasActive ? totalCount : undefined}
- maxVisible={2}
- chipSize='md'
- />
-
+ setIsOpen(true)}
+ onClearAll={hasActive ? onClearAll : undefined}
+ resultCount={hasActive ? resultCount : undefined}
+ totalCount={hasActive ? totalCount : undefined}
+ maxVisible={2}
+ chipSize='md'
+ />
+
+ )}
{/* ── Bottom sheet ────────────────────────────────────── */}
{
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
+const getNFCUrl = choreId =>
+ Capacitor.getPlatform() === 'android' || Capacitor.getPlatform() === 'ios'
+ ? `donetick://chores/${choreId}`
+ : `${window.location.origin}/chores/${choreId}`
+
const ChoreView = () => {
const { t } = useTranslation('chores')
const { fmt } = useLocalization()
@@ -124,7 +138,7 @@ const ChoreView = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
- const [chorePriority, setChorePriority] = useState(null)
+ const [activeModal, setActiveModal] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
@@ -165,7 +179,6 @@ const ChoreView = () => {
return
}
setChore(choreData.res)
- setChorePriority(Priorities.find(p => p.value === choreData.res.priority))
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
@@ -236,7 +249,7 @@ const ChoreView = () => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(() => {
- setChorePriority(priority)
+ setChore(prev => ({ ...prev, priority: priority.value }))
queryClient.invalidateQueries(['chores'])
})
}
@@ -583,6 +596,168 @@ const ChoreView = () => {
}
}
+ const confirmSkipTask = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: t('choreView.skipTask'),
+ message: t('choreView.skipTaskConfirmation'),
+ confirmText: t('choreView.skip'),
+ cancelText: t('choreView.cancel'),
+ onClose: confirmed => {
+ if (confirmed) {
+ handleSkippingTask()
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+
+ const handleArchiveChore = async () => {
+ try {
+ const response = await ArchiveChore(choreId)
+ if (response.ok) {
+ await offlineDB.saveChores([{ ...chore, isActive: false }])
+ setChore({ ...chore, isActive: false })
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to archive',
+ message: error?.message || 'Unable to archive task',
+ })
+ }
+ }
+
+ const confirmDeleteChore = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete task',
+ message: 'Are you sure you want to delete this task?',
+ confirmText: 'Delete',
+ cancelText: t('choreView.cancel'),
+ onClose: async confirmed => {
+ setConfirmModelConfig({})
+ if (!confirmed) return
+ try {
+ const response = await DeleteChore(choreId)
+ if (response.ok) {
+ queryClient.invalidateQueries(['chores'])
+ showSuccess({
+ title: 'Task Deleted',
+ message: 'The task has been deleted successfully.',
+ })
+ navigate('/chores')
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to delete',
+ message: error?.message || 'Unable to delete task',
+ })
+ }
+ },
+ })
+ }
+
+ const handleDueDateChange = async newDate => {
+ try {
+ const response = await UpdateDueDate(choreId, newDate)
+ if (response.ok) {
+ setChore(prev => ({ ...prev, nextDueDate: newDate }))
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to reschedule',
+ message: error?.message || 'Unable to change the due date',
+ })
+ }
+ }
+
+ const handleMoveToProject = async project => {
+ const projectId = project?.id ?? null
+ try {
+ const response = await SaveChore({ ...chore, projectId })
+ if (response.ok) {
+ setChore(prev => ({ ...prev, projectId }))
+ queryClient.invalidateQueries(['chores'])
+ showSuccess({
+ title: 'Task Moved',
+ message: `Task moved to ${project?.name || 'Default Project'}.`,
+ })
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to move task',
+ message: error?.message || 'Unable to move task to project',
+ })
+ }
+ }
+
+ const handleNudge = async ({ message, notifyAllAssignees }) => {
+ try {
+ const response = await NudgeChore(choreId, {
+ message,
+ notifyAllAssignees,
+ })
+ if (!response.ok) {
+ throw new Error('Failed to send nudge')
+ }
+ const data = await response.json()
+ showSuccess({
+ title: 'Nudge Sent!',
+ message: data.message || 'Nudge sent successfully',
+ })
+ } catch (error) {
+ showError({
+ title: 'Failed to Send Nudge',
+ message: error?.message || 'Unable to send nudge at this time',
+ })
+ }
+ }
+
+ const handleAssigneeChange = async assigneeId => {
+ try {
+ const response = await UpdateChoreAssignee(choreId, assigneeId)
+ if (response.ok) {
+ const data = await response.json()
+ setChore(data.res)
+ queryClient.invalidateQueries(['chores'])
+ }
+ } catch (error) {
+ showError({
+ title: 'Failed to delegate',
+ message: error?.message || 'Unable to change the assignee',
+ })
+ }
+ }
+
+ // Actions the menu raises that ChoreView owns; the rest of its items either
+ // navigate on their own or come in through the dedicated callbacks.
+ const handleMenuAction = (type, _chore, extraData) => {
+ switch (type) {
+ case 'skip':
+ confirmSkipTask()
+ break
+ case 'archive':
+ handleArchiveChore()
+ break
+ case 'unarchive':
+ handleUnarchiveChore()
+ break
+ case 'delete':
+ confirmDeleteChore()
+ break
+ case 'changeDueDate':
+ handleDueDateChange(extraData?.date?.toISOString() ?? null)
+ break
+ case 'moveToProject':
+ handleMoveToProject(extraData?.project)
+ break
+ default:
+ break
+ }
+ }
+
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
@@ -794,64 +969,6 @@ const ChoreView = () => {
mb: 1,
}}
>
-
-
- {chorePriority ? chorePriority.icon : }
- {chorePriority ? chorePriority.name : t('choreView.noPriority')}
-
-
-
-
+ setActiveModal('nudge')}
+ onWriteNFC={() => setActiveModal('writeNFC')}
+ onCompleteWithNote={() => setNote('')}
+ onCompleteWithPastDate={() =>
+ setCompletedDate(moment(new Date()).format('YYYY-MM-DDTHH:00:00'))
+ }
+ onChangeAssignee={() => setActiveModal('changeAssignee')}
+ onChangeDueDate={() => setActiveModal('changeDueDate')}
+ onChangePriority={handleUpdatePriority}
+ onDelete={confirmDeleteChore}
+ trigger={
+
+ }
+ />
{chore.description && (
@@ -1261,21 +1410,7 @@ const ChoreView = () => {