diff --git a/src/components/Tasks/TaskEdit.tsx b/src/components/Tasks/TaskEdit.tsx index 3314322..4a1064c 100644 --- a/src/components/Tasks/TaskEdit.tsx +++ b/src/components/Tasks/TaskEdit.tsx @@ -86,7 +86,6 @@ class TaskEdit extends React.PureComponent { status: TaskStatusType.NeedsAction, priority: TaskPriorityType.Undefined, includeTime: false, - // rrule: , location: '', description: '', tags: [], @@ -196,7 +195,7 @@ class TaskEdit extends React.PureComponent { e.preventDefault(); if (this.state.rrule && !(this.state.start || this.state.due)) { - this.setState({ error: 'A recurring task must have a hide until or due date set!' }); + this.setState({ error: 'A recurring task must have either Hide Until or Due Date set!' }); return; } @@ -262,8 +261,19 @@ class TaskEdit extends React.PureComponent { task.component.updatePropertyWithValue('last-modified', ICAL.Time.now()); this.props.onSave(task, this.state.journalUid, this.props.item) + .then(() => { + const nextTask = task.finished && task.getNextOccurence(); + if (nextTask) { + return this.props.onSave(nextTask, this.state.journalUid); + } else { + return Promise.resolve(); + } + }) .then(() => { this.props.history.goBack(); + }) + .catch(() => { + this.setState({ error: 'Could not save task' }); }); } diff --git a/src/components/Tasks/TaskList.tsx b/src/components/Tasks/TaskList.tsx index 4690a6f..c3e1d9d 100644 --- a/src/components/Tasks/TaskList.tsx +++ b/src/components/Tasks/TaskList.tsx @@ -6,8 +6,9 @@ import * as React from 'react'; import * as EteSync from 'etesync'; import { List } from '../../widgets/List'; +import Toast from '../../widgets/Toast'; -import { TaskType, PimType } from '../../pim-types'; +import { TaskType, PimType, TaskStatusType } from '../../pim-types'; import Divider from '@material-ui/core/Divider'; import Grid from '@material-ui/core/Grid'; import { useTheme, makeStyles } from '@material-ui/core/styles'; @@ -22,6 +23,7 @@ import Toolbar from './Toolbar'; import QuickAdd from './QuickAdd'; import { StoreState } from '../../store'; +import { formatDate } from '../../helpers'; function sortCompleted(a: TaskType, b: TaskType) { return (!!a.finished === !!b.finished) ? 0 : (a.finished) ? 1 : -1; @@ -108,11 +110,26 @@ export default function TaskList(props: PropsType) { const [showCompleted, setShowCompleted] = React.useState(false); const [showHidden, setShowHidden] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); + const [info, setInfo] = React.useState(''); const settings = useSelector((state: StoreState) => state.settings.taskSettings); const { filterBy, sortBy } = settings; const theme = useTheme(); const classes = useStyles(); + const handleToggleComplete = async (task: TaskType, completed: boolean) => { + const clonedTask = task.clone(); + clonedTask.status = completed ? TaskStatusType.Completed : TaskStatusType.NeedsAction; + + const nextTask = completed ? task.getNextOccurence() : null; + + await props.onItemSave(clonedTask, (task as any).journalUid, task); + + if (nextTask) { + await props.onItemSave(nextTask, (task as any).journalUid); + setInfo(`${nextTask.title} rescheduled for ${formatDate(nextTask.startDate ?? nextTask.dueDate)}`); + } + }; + const potentialEntries = React.useMemo( () => { if (searchTerm) { @@ -156,7 +173,7 @@ export default function TaskList(props: PropsType) { key={uid} entry={entry} onClick={props.onItemClick} - onSave={props.onItemSave} + onToggleComplete={(completed: boolean) => handleToggleComplete(entry, completed)} /> ); }); @@ -194,6 +211,10 @@ export default function TaskList(props: PropsType) { {itemList} + + setInfo('')} autoHideDuration={3000}> + {info} + ); } diff --git a/src/components/Tasks/TaskListItem.tsx b/src/components/Tasks/TaskListItem.tsx index 26b4407..50178e3 100644 --- a/src/components/Tasks/TaskListItem.tsx +++ b/src/components/Tasks/TaskListItem.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; -import { TaskType, TaskStatusType, PimType, TaskPriorityType } from '../../pim-types'; +import { TaskType, TaskPriorityType } from '../../pim-types'; import { ListItem } from '../../widgets/List'; import Checkbox from '@material-ui/core/Checkbox'; @@ -35,33 +35,31 @@ const TagsList = React.memo((props: { tags: string[] }) => ( interface PropsType { entry: TaskType; onClick: (task: TaskType) => void; - onSave: (item: PimType, journalUid: string, originalItem?: PimType) => Promise; + onToggleComplete: (completed: boolean) => void; } export default React.memo(function TaskListItem(props: PropsType) { const { entry: task, onClick, - onSave: save, + onToggleComplete, } = props; const title = task.title; - function toggleComplete(_e: React.ChangeEvent, checked: boolean) { - const clonedTask = task.clone(); - clonedTask.status = checked ? TaskStatusType.Completed : TaskStatusType.NeedsAction; - save(clonedTask, (task as any).journalUid, task); - } + const dueDateText = task.dueDate ? `Due ${formatDate(task.dueDate)}` : ''; + const freqText = task.rrule ? `(repeats ${task.rrule.freq.toLowerCase()})` : ''; + const secondaryText = `${dueDateText} ${freqText}`; return ( onClick(task)} leftIcon={ e.stopPropagation()} - onChange={toggleComplete} + onChange={(_e, checked) => onToggleComplete(checked)} checked={task.finished} icon={} /> diff --git a/src/pim-types.ts b/src/pim-types.ts index 8398ac1..2f5ee69 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -4,6 +4,7 @@ import * as ICAL from 'ical.js'; import * as zones from './data/zones.json'; import moment from 'moment'; +import * as uuid from 'uuid'; export const PRODID = '-//iCal.js EteSync iOS'; @@ -263,6 +264,57 @@ export class TaskType extends EventType { ret.color = this.color; return ret; } + + public getNextOccurence(): TaskType | null { + if (!this.isRecurring()) { + return null; + } + + const rrule = this.rrule.clone(); + + if (rrule.count && rrule.count <= 1) { + return null; // end of reccurence + } + + rrule.count = null; // clear count so we can iterate as many times as needed + const recur = rrule.iterator(this.startDate ?? this.dueDate); + let nextRecurrence = recur.next(); + while ((nextRecurrence = recur.next())) { + if (nextRecurrence.compare(ICAL.Time.now()) > 0) { + break; + } + } + + if (!nextRecurrence) { + return null; // end of reccurence + } + + const nextStartDate = this.startDate ? nextRecurrence : undefined; + const nextDueDate = this.dueDate ? nextRecurrence : undefined; + if (nextStartDate && nextDueDate) { + const offset = this.dueDate!.subtractDateTz(this.startDate); + nextDueDate.addDuration(offset); + } + + const nextTask = this.clone(); + nextTask.uid = uuid.v4(); + if (nextStartDate) { + nextTask.startDate = nextStartDate; + } + if (nextDueDate) { + nextTask.dueDate = nextDueDate; + } + + if (this.rrule.count) { + rrule.count = this.rrule.count - 1; + nextTask.rrule = rrule; + } + + nextTask.status = TaskStatusType.NeedsAction; + nextTask.lastModified = ICAL.Time.now(); + + return nextTask; + } } export class ContactType implements PimType { diff --git a/src/types/ical.js.d.ts b/src/types/ical.js.d.ts index d1673a3..16fee46 100644 --- a/src/types/ical.js.d.ts +++ b/src/types/ical.js.d.ts @@ -176,10 +176,19 @@ declare module 'ical.js' { public bysetpos?: number[] | number; } + export class RecurIterator { + public next(): Time; + } + export class Recur { constructor(data?: RecurData); public until: Time | null; + public freq: FrequencyValues; + public count: number | null; + public clone(): Recur; public toJSON(): Omit & { until?: string }; + public iterator(startTime?: Time): RecurIterator; + public isByCount(): boolean; } } diff --git a/src/widgets/Toast.tsx b/src/widgets/Toast.tsx index 61221e1..9b65060 100644 --- a/src/widgets/Toast.tsx +++ b/src/widgets/Toast.tsx @@ -10,14 +10,16 @@ interface PropsType { open: boolean; children: React.ReactNode; onClose?: (event?: React.SyntheticEvent, reason?: string) => void; + severity?: 'error' | 'info' | 'success' | 'warning'; + autoHideDuration?: number; } export default function Toast(props: PropsType) { - const { open, children, onClose } = props; + const { open, children, onClose, severity, autoHideDuration } = props; return ( - - + + {children}