Task: add recurrence features to list view
parent
aada3e6d36
commit
de94a02025
|
@ -86,7 +86,6 @@ class TaskEdit extends React.PureComponent<PropsType> {
|
|||
status: TaskStatusType.NeedsAction,
|
||||
priority: TaskPriorityType.Undefined,
|
||||
includeTime: false,
|
||||
// rrule: ,
|
||||
location: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
|
@ -196,7 +195,7 @@ class TaskEdit extends React.PureComponent<PropsType> {
|
|||
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<PropsType> {
|
|||
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' });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
</List>
|
||||
</Grid>
|
||||
|
||||
<Toast open={!!info} severity="info" onClose={() => setInfo('')} autoHideDuration={3000}>
|
||||
{info}
|
||||
</Toast>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<void>;
|
||||
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<HTMLInputElement>, 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 (
|
||||
<ListItem
|
||||
primaryText={title}
|
||||
secondaryText={task.dueDate && `Due ${formatDate(task.dueDate)}`}
|
||||
secondaryText={secondaryText}
|
||||
secondaryTextColor={task.overdue ? 'error' : 'textSecondary'}
|
||||
onClick={() => onClick(task)}
|
||||
leftIcon={
|
||||
<Checkbox
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={toggleComplete}
|
||||
onChange={(_e, checked) => onToggleComplete(checked)}
|
||||
checked={task.finished}
|
||||
icon={<CheckBoxOutlineBlankIcon style={{ color: checkboxColor[mapPriority(task.priority)] }} />}
|
||||
/>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<RecurData, 'until'> & { until?: string };
|
||||
public iterator(startTime?: Time): RecurIterator;
|
||||
public isByCount(): boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<Snackbar open={open} onClose={onClose}>
|
||||
<Alert severity="error" variant="filled" elevation={6} onClose={onClose}>
|
||||
<Snackbar open={open} onClose={onClose} autoHideDuration={autoHideDuration}>
|
||||
<Alert severity={severity ?? 'error'} variant="filled" elevation={6} onClose={onClose}>
|
||||
{children}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
|
Loading…
Reference in New Issue