Task: add recurrence features to list view

master
Andrew P Maney 5 years ago committed by Tom Hacohen
parent aada3e6d36
commit de94a02025

@ -86,7 +86,6 @@ class TaskEdit extends React.PureComponent<PropsType> {
status: TaskStatusType.NeedsAction, status: TaskStatusType.NeedsAction,
priority: TaskPriorityType.Undefined, priority: TaskPriorityType.Undefined,
includeTime: false, includeTime: false,
// rrule: ,
location: '', location: '',
description: '', description: '',
tags: [], tags: [],
@ -196,7 +195,7 @@ class TaskEdit extends React.PureComponent<PropsType> {
e.preventDefault(); e.preventDefault();
if (this.state.rrule && !(this.state.start || this.state.due)) { 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; return;
} }
@ -262,8 +261,19 @@ class TaskEdit extends React.PureComponent<PropsType> {
task.component.updatePropertyWithValue('last-modified', ICAL.Time.now()); task.component.updatePropertyWithValue('last-modified', ICAL.Time.now());
this.props.onSave(task, this.state.journalUid, this.props.item) 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(() => { .then(() => {
this.props.history.goBack(); 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 * as EteSync from 'etesync';
import { List } from '../../widgets/List'; 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 Divider from '@material-ui/core/Divider';
import Grid from '@material-ui/core/Grid'; import Grid from '@material-ui/core/Grid';
import { useTheme, makeStyles } from '@material-ui/core/styles'; import { useTheme, makeStyles } from '@material-ui/core/styles';
@ -22,6 +23,7 @@ import Toolbar from './Toolbar';
import QuickAdd from './QuickAdd'; import QuickAdd from './QuickAdd';
import { StoreState } from '../../store'; import { StoreState } from '../../store';
import { formatDate } from '../../helpers';
function sortCompleted(a: TaskType, b: TaskType) { function sortCompleted(a: TaskType, b: TaskType) {
return (!!a.finished === !!b.finished) ? 0 : (a.finished) ? 1 : -1; 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 [showCompleted, setShowCompleted] = React.useState(false);
const [showHidden, setShowHidden] = React.useState(false); const [showHidden, setShowHidden] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState(''); const [searchTerm, setSearchTerm] = React.useState('');
const [info, setInfo] = React.useState('');
const settings = useSelector((state: StoreState) => state.settings.taskSettings); const settings = useSelector((state: StoreState) => state.settings.taskSettings);
const { filterBy, sortBy } = settings; const { filterBy, sortBy } = settings;
const theme = useTheme(); const theme = useTheme();
const classes = useStyles(); 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( const potentialEntries = React.useMemo(
() => { () => {
if (searchTerm) { if (searchTerm) {
@ -156,7 +173,7 @@ export default function TaskList(props: PropsType) {
key={uid} key={uid}
entry={entry} entry={entry}
onClick={props.onItemClick} onClick={props.onItemClick}
onSave={props.onItemSave} onToggleComplete={(completed: boolean) => handleToggleComplete(entry, completed)}
/> />
); );
}); });
@ -194,6 +211,10 @@ export default function TaskList(props: PropsType) {
{itemList} {itemList}
</List> </List>
</Grid> </Grid>
<Toast open={!!info} severity="info" onClose={() => setInfo('')} autoHideDuration={3000}>
{info}
</Toast>
</Grid> </Grid>
); );
} }

@ -3,7 +3,7 @@
import * as React from 'react'; 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 { ListItem } from '../../widgets/List';
import Checkbox from '@material-ui/core/Checkbox'; import Checkbox from '@material-ui/core/Checkbox';
@ -35,33 +35,31 @@ const TagsList = React.memo((props: { tags: string[] }) => (
interface PropsType { interface PropsType {
entry: TaskType; entry: TaskType;
onClick: (task: TaskType) => void; 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) { export default React.memo(function TaskListItem(props: PropsType) {
const { const {
entry: task, entry: task,
onClick, onClick,
onSave: save, onToggleComplete,
} = props; } = props;
const title = task.title; const title = task.title;
function toggleComplete(_e: React.ChangeEvent<HTMLInputElement>, checked: boolean) { const dueDateText = task.dueDate ? `Due ${formatDate(task.dueDate)}` : '';
const clonedTask = task.clone(); const freqText = task.rrule ? `(repeats ${task.rrule.freq.toLowerCase()})` : '';
clonedTask.status = checked ? TaskStatusType.Completed : TaskStatusType.NeedsAction; const secondaryText = `${dueDateText} ${freqText}`;
save(clonedTask, (task as any).journalUid, task);
}
return ( return (
<ListItem <ListItem
primaryText={title} primaryText={title}
secondaryText={task.dueDate && `Due ${formatDate(task.dueDate)}`} secondaryText={secondaryText}
secondaryTextColor={task.overdue ? 'error' : 'textSecondary'} secondaryTextColor={task.overdue ? 'error' : 'textSecondary'}
onClick={() => onClick(task)} onClick={() => onClick(task)}
leftIcon={ leftIcon={
<Checkbox <Checkbox
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={toggleComplete} onChange={(_e, checked) => onToggleComplete(checked)}
checked={task.finished} checked={task.finished}
icon={<CheckBoxOutlineBlankIcon style={{ color: checkboxColor[mapPriority(task.priority)] }} />} icon={<CheckBoxOutlineBlankIcon style={{ color: checkboxColor[mapPriority(task.priority)] }} />}
/> />

@ -4,6 +4,7 @@
import * as ICAL from 'ical.js'; import * as ICAL from 'ical.js';
import * as zones from './data/zones.json'; import * as zones from './data/zones.json';
import moment from 'moment'; import moment from 'moment';
import * as uuid from 'uuid';
export const PRODID = '-//iCal.js EteSync iOS'; export const PRODID = '-//iCal.js EteSync iOS';
@ -263,6 +264,57 @@ export class TaskType extends EventType {
ret.color = this.color; ret.color = this.color;
return ret; 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 { export class ContactType implements PimType {

@ -176,10 +176,19 @@ declare module 'ical.js' {
public bysetpos?: number[] | number; public bysetpos?: number[] | number;
} }
export class RecurIterator {
public next(): Time;
}
export class Recur { export class Recur {
constructor(data?: RecurData); constructor(data?: RecurData);
public until: Time | null; public until: Time | null;
public freq: FrequencyValues;
public count: number | null;
public clone(): Recur;
public toJSON(): Omit<RecurData, 'until'> & { until?: string }; public toJSON(): Omit<RecurData, 'until'> & { until?: string };
public iterator(startTime?: Time): RecurIterator;
public isByCount(): boolean;
} }
} }

@ -10,14 +10,16 @@ interface PropsType {
open: boolean; open: boolean;
children: React.ReactNode; children: React.ReactNode;
onClose?: (event?: React.SyntheticEvent, reason?: string) => void; onClose?: (event?: React.SyntheticEvent, reason?: string) => void;
severity?: 'error' | 'info' | 'success' | 'warning';
autoHideDuration?: number;
} }
export default function Toast(props: PropsType) { export default function Toast(props: PropsType) {
const { open, children, onClose } = props; const { open, children, onClose, severity, autoHideDuration } = props;
return ( return (
<Snackbar open={open} onClose={onClose}> <Snackbar open={open} onClose={onClose} autoHideDuration={autoHideDuration}>
<Alert severity="error" variant="filled" elevation={6} onClose={onClose}> <Alert severity={severity ?? 'error'} variant="filled" elevation={6} onClose={onClose}>
{children} {children}
</Alert> </Alert>
</Snackbar> </Snackbar>

Loading…
Cancel
Save