show hidden and empty items in calendar

This commit is contained in:
gabrielburnworth 2018-01-03 22:01:12 -08:00
parent 7a88a1f900
commit 2ddc5b16e8
7 changed files with 274 additions and 139 deletions

View file

@ -9,6 +9,8 @@ import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import * as moment from "moment";
import { countBy } from "lodash";
import { TimeUnit } from "../../interfaces";
describe("mapStateToProps()", () => {
function testState(time: number) {
@ -108,15 +110,24 @@ describe("mapStateToProps()", () => {
});
describe("mapResourcesToCalendar(): sequence farm events", () => {
function fakeSeqFEResources() {
interface EventData {
start_time: string;
end_time: string;
repeat?: number;
time_unit?: TimeUnit;
}
function fakeSeqFEResources(props: EventData) {
const sequence = fakeSequence();
sequence.body.id = 1;
sequence.body.body = [{ kind: "take_photo", args: {} }];
const sequenceFarmEvent = fakeFarmEvent("Sequence", 1);
sequenceFarmEvent.body.id = 1;
sequenceFarmEvent.body.start_time = "2017-12-20T01:02:00.000Z";
sequenceFarmEvent.body.end_time = "2017-12-20T01:05:00.000Z";
sequenceFarmEvent.body.start_time = props.start_time;
sequenceFarmEvent.body.end_time = props.end_time;
sequenceFarmEvent.body.repeat = props.repeat || 1;
sequenceFarmEvent.body.time_unit = props.time_unit || "never";
return buildResourceIndex([sequence, sequenceFarmEvent]);
}
@ -136,12 +147,47 @@ describe("mapResourcesToCalendar(): sequence farm events", () => {
year: 17
}];
it("returns calendar rows", () => {
it("returns calendar rows: single event", () => {
const eventData: EventData = {
start_time: "2017-12-20T01:02:00.000Z",
end_time: "2017-12-20T01:05:00.000Z"
};
const testTime = moment("2017-12-15T01:00:00.000Z");
const calendar = mapResourcesToCalendar(
fakeSeqFEResources().index, testTime);
fakeSeqFEResources(eventData).index, testTime);
expect(calendar.getAll()).toEqual(fakeSequenceFE);
});
it("returns calendar rows: hidden items", () => {
const eventData: EventData = {
start_time: "2017-12-20T01:02:00.000Z",
end_time: "2017-12-20T04:05:00.000Z",
repeat: 1,
time_unit: "minutely"
};
const testTime = moment("2017-12-15T01:00:00.000Z");
const calendar = mapResourcesToCalendar(
fakeSeqFEResources(eventData).index, testTime);
const dayOneItems = calendar.getAll()[0].items;
expect(countBy(dayOneItems, "heading")).toEqual({
"fake": 59,
"+ 123 more: fake": 1
});
});
it("returns calendar rows: empty", () => {
const eventData: EventData = {
start_time: "2017-12-20T01:02:00.000Z",
end_time: "2019-12-20T01:05:00.000Z",
repeat: 100,
time_unit: "yearly"
};
const testTime = moment("2017-12-30T01:00:00.000Z");
const calendar = mapResourcesToCalendar(
fakeSeqFEResources(eventData).index, testTime);
const dayOneItems = calendar.getAll()[0].items;
expect(countBy(dayOneItems, "heading")).toEqual({ "*Empty*": 1 });
});
});
describe("mapResourcesToCalendar(): regimen farm events", () => {

View file

@ -15,4 +15,17 @@ describe("occurrence", () => {
expect(t.heading).toBe(fe.executable.name);
expect(t.id).toBe(fe.id);
});
it("builds entry with modified heading: hidden items", () => {
const fe = fakeFarmEventWithExecutable();
fe.executable.name = "Fake Sequence";
const t = occurrence(TIME.MONDAY, fe, 0, { numHidden: 10 });
expect(t.heading).toBe("+ 10 more: Fake Sequence");
});
it("builds entry with modified heading: no items", () => {
const fe = fakeFarmEventWithExecutable();
const t = occurrence(TIME.MONDAY, fe, 0, { empty: true });
expect(t.heading).toBe("*Empty*");
});
});

View file

@ -30,9 +30,9 @@ describe("scheduler", () => {
startTime: tuesday,
endTime: thursday
});
expect(result1[0].format("dd")).toEqual("Tu");
expect(result1[0].hour()).toEqual(4);
expect(result1.length).toEqual(16);
expect(result1.items[0].format("dd")).toEqual("Tu");
expect(result1.items[0].hour()).toEqual(4);
expect(result1.items.length).toEqual(16);
const EXPECTED = [
"04:00am Tu",
"08:00am Tu",
@ -51,156 +51,197 @@ describe("scheduler", () => {
"12:00pm Th",
"04:00pm Th"
];
const REALITY = result1.map(x => x.format("hh:mma dd"));
const REALITY = result1.items.map(x => x.format("hh:mma dd"));
EXPECTED.map(x => expect(REALITY).toContain(x));
});
});
function testSchedule(
description: string,
fakeEvent: TimeLine,
timeNow: Moment,
expected: Moment[]) {
describe("scheduleForFarmEvent", () => {
interface TestScheduleProps {
description: string;
fakeEvent: TimeLine;
timeNow: Moment;
expected: Moment[];
shortenedBy: number;
}
function testSchedule(props: TestScheduleProps) {
const { description, fakeEvent, timeNow, expected, shortenedBy } = props;
it(description, () => {
const result = scheduleForFarmEvent(fakeEvent, timeNow);
expect(result.length).toEqual(expected.length);
expect(result.items.length).toEqual(expected.length);
expected.map((expectation, index) => {
expect(result[index]).toBeSameTimeAs(expectation);
expect(result.items[index]).toBeSameTimeAs(expectation);
});
expect(result.shortenedBy).toEqual(shortenedBy);
});
}
const singleFarmEvent: TimeLine = {
"start_time": "2017-08-01T17:00:00.000Z",
"end_time": "2017-08-01T18:00:00.000Z",
"repeat": 1,
"time_unit": "never"
start_time: "2017-08-01T17:00:00.000Z",
end_time: "2017-08-01T18:00:00.000Z",
repeat: 1,
time_unit: "never"
};
testSchedule("schedules a FarmEvent",
const scheduleTestData: TestScheduleProps[] = [
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-07T05:00:00.000Z",
"repeat": 2,
"time_unit": "daily"
description: "schedules a FarmEvent",
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-07T05:00:00.000Z",
repeat: 2,
time_unit: "daily"
},
timeNow: moment("2017-08-01T16:30:00.000Z"),
expected: [
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-03T17:30:00.000Z"),
moment("2017-08-05T17:30:00.000Z")
],
shortenedBy: 0
},
moment("2017-08-01T16:30:00.000Z"),
[
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-03T17:30:00.000Z"),
moment("2017-08-05T17:30:00.000Z")
]);
testSchedule("handles 0 as a repeat value",
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-07T05:00:00.000Z",
"repeat": 0,
"time_unit": "daily"
description: "handles 0 as a repeat value",
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-07T05:00:00.000Z",
repeat: 0,
time_unit: "daily"
},
timeNow: moment("2017-08-01T16:30:00.000Z"),
expected: [moment("2017-08-01T17:30:00.000Z")],
shortenedBy: 0
},
moment("2017-08-01T16:30:00.000Z"),
[moment("2017-08-01T17:30:00.000Z")]);
testSchedule("handles start_time in the past",
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-09T05:00:00.000Z",
"repeat": 2,
"time_unit": "daily"
description: "handles start_time in the past",
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-09T05:00:00.000Z",
repeat: 2,
time_unit: "daily"
},
timeNow: moment("2017-08-03T18:30:00.000Z"),
expected: [
moment("2017-08-05T17:30:00.000Z"),
moment("2017-08-07T17:30:00.000Z")
],
shortenedBy: 0
},
moment("2017-08-03T18:30:00.000Z"),
[
moment("2017-08-05T17:30:00.000Z"),
moment("2017-08-07T17:30:00.000Z")
]);
testSchedule("handles start_time in the past: no repeat",
singleFarmEvent,
moment("2017-08-01T17:30:00.000Z"),
[moment("2017-08-01T17:00:00.000Z")]);
testSchedule(`uses grace period (${gracePeriodSeconds}s)`,
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-02T05:00:00.000Z",
"repeat": 4,
"time_unit": "hourly"
description: "handles start_time in the past: no repeat",
fakeEvent: singleFarmEvent,
timeNow: moment("2017-08-01T17:30:00.000Z"),
expected: [moment("2017-08-01T17:00:00.000Z")],
shortenedBy: 0
},
moment("2017-08-01T17:30:00.000Z").add(gracePeriodSeconds / 2, "seconds"),
[
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z"),
moment("2017-08-02T01:30:00.000Z")
]);
testSchedule(`uses grace period (${gracePeriodSeconds}s): no repeat`,
singleFarmEvent,
moment("2017-08-01T17:00:00.000Z").add(gracePeriodSeconds / 2, "seconds"),
[moment("2017-08-01T17:00:00.000Z")]);
testSchedule("farm event over",
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-02T05:00:00.000Z",
"repeat": 4,
"time_unit": "hourly"
description: `uses grace period (${gracePeriodSeconds}s)`,
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-02T05:00:00.000Z",
repeat: 4,
time_unit: "hourly"
},
timeNow: moment("2017-08-01T17:30:00.000Z")
.add(gracePeriodSeconds / 2, "seconds"),
expected: [
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z"),
moment("2017-08-02T01:30:00.000Z")
],
shortenedBy: 0
},
moment("2017-08-03T17:30:30.000Z"),
[]);
testSchedule("farm event over: no repeat",
singleFarmEvent,
moment("2017-08-01T19:00:00.000Z"),
[]);
testSchedule(`first ${maxDisplayItems} items`,
{
"start_time": "2017-08-02T17:00:00.000Z",
"end_time": "2017-08-02T19:00:00.000Z",
"repeat": 1,
"time_unit": "minutely"
description: `uses grace period (${gracePeriodSeconds}s): no repeat`,
fakeEvent: singleFarmEvent,
timeNow: moment("2017-08-01T17:00:00.000Z")
.add(gracePeriodSeconds / 2, "seconds"),
expected: [moment("2017-08-01T17:00:00.000Z")],
shortenedBy: 0
},
moment("2017-08-01T15:30:00.000Z"),
range(0, maxDisplayItems)
.map((x: number) =>
moment(`2017-08-02T17:${padStart("" + x, 2, "0")}:00.000Z`)));
testSchedule(`only ${maxDisplayItems} items`,
{
"start_time": "2017-08-02T16:00:00.000Z",
"end_time": "2017-08-02T21:00:00.000Z",
"repeat": 1,
"time_unit": "minutely"
description: "farm event over",
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-02T05:00:00.000Z",
repeat: 4,
time_unit: "hourly"
},
timeNow: moment("2017-08-03T17:30:30.000Z"),
expected: [],
shortenedBy: 0
},
moment("2017-08-02T17:00:00.000Z").add(gracePeriodSeconds, "seconds"),
range(0, maxDisplayItems)
.map((x: number) =>
moment(`2017-08-02T17:${padStart("" + x, 2, "0")}:00.000Z`)));
testSchedule("item at end time is not rendered",
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-02T01:30:00.000Z",
"repeat": 4,
"time_unit": "hourly"
description: "farm event over: no repeat",
fakeEvent: singleFarmEvent,
timeNow: moment("2017-08-01T19:00:00.000Z"),
expected: [],
shortenedBy: 0
},
moment("2017-08-01T16:30:00.000Z"),
[
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z")
]);
testSchedule(`renders item at grace period (${gracePeriodSeconds}s) cutoff`,
{
"start_time": "2017-08-01T17:30:00.000Z",
"end_time": "2017-08-02T01:30:00.000Z",
"repeat": 4,
"time_unit": "hourly"
description: `first ${maxDisplayItems} items`,
fakeEvent: {
start_time: "2017-08-02T17:00:00.000Z",
end_time: "2017-08-02T19:00:00.000Z",
repeat: 1,
time_unit: "minutely"
},
timeNow: moment("2017-08-01T15:30:00.000Z"),
expected: range(0, maxDisplayItems)
.map((x: number) =>
moment(`2017-08-02T17:${padStart("" + x, 2, "0")}:00.000Z`)),
shortenedBy: 120 - maxDisplayItems
},
moment("2017-08-01T17:30:00.000Z").add(gracePeriodSeconds, "seconds"),
[
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z")
]);
{
description: `only ${maxDisplayItems} items`,
fakeEvent: {
start_time: "2017-08-02T16:00:00.000Z",
end_time: "2017-08-02T21:00:00.000Z",
repeat: 1,
time_unit: "minutely"
},
timeNow: moment("2017-08-02T17:00:00.000Z")
.add(gracePeriodSeconds, "seconds"),
expected: range(0, maxDisplayItems)
.map((x: number) =>
moment(`2017-08-02T17:${padStart("" + x, 2, "0")}:00.000Z`)),
shortenedBy: 240 - maxDisplayItems
},
{
description: "item at end time is not rendered",
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-02T01:30:00.000Z",
repeat: 4,
time_unit: "hourly"
},
timeNow: moment("2017-08-01T16:30:00.000Z"),
expected: [
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z")
],
shortenedBy: 0
},
{
description: `shows item at grace period (${gracePeriodSeconds}s) cutoff`,
fakeEvent: {
start_time: "2017-08-01T17:30:00.000Z",
end_time: "2017-08-02T01:30:00.000Z",
repeat: 4,
time_unit: "hourly"
},
timeNow: moment("2017-08-01T17:30:00.000Z")
.add(gracePeriodSeconds, "seconds"),
expected: [
moment("2017-08-01T17:30:00.000Z"),
moment("2017-08-01T21:30:00.000Z")
],
shortenedBy: 0
},
];
scheduleTestData.map(testCaseData => testSchedule(testCaseData));
});
describe("farmEventIntervalSeconds", () => {

View file

@ -6,15 +6,27 @@ import { Calendar } from "./index";
/** An occurrence is a single event on the calendar, usually rendered as a
* little white square on the farm event UI. This is the data representation for
* single calendar entries. */
export function occurrence(m: moment.Moment,
export function occurrence(
m: moment.Moment,
fe: FarmEventWithExecutable,
utcOffset: number):
utcOffset: number,
modifiers?: { numHidden?: number, empty?: boolean }):
CalendarOccurrence {
const normalHeading = fe.executable.name || fe.executable_type;
const heading = () => {
if (modifiers && modifiers.empty) {
return "*Empty*";
}
if (modifiers && modifiers.numHidden) {
return `+ ${modifiers.numHidden} more: ` + normalHeading;
}
return normalHeading;
};
return {
mmddyy: m.format(Calendar.DATE_FORMAT),
sortKey: m.unix(),
timeStr: m.clone().utcOffset(utcOffset).format("hh:mma"),
heading: fe.executable.name || fe.executable_type,
heading: heading(),
executableId: fe.executable_id || 0,
id: fe.id || 0,
};

View file

@ -24,7 +24,7 @@ export function scheduler({
currentTime,
endTime,
intervalSeconds
}: SchedulerProps): Moment[] {
}: SchedulerProps): { items: Moment[], shortenedBy: number } {
// Convert from Moment to seconds.
const eventStartTime = startTime.unix();
const eventEndTime = endTime.unix();
@ -44,10 +44,16 @@ export function scheduler({
? itemEndTime
: eventEndTime;
// Calculate the number of future items hidden from the calendar.
const shortenedBy = Math.ceil(
Math.abs(eventEndTime - lastItemTime) / intervalSeconds);
/** Generate the list of Farm Event items to display
* and convert from seconds back to Moment. */
return range(nextItemTime, lastItemTime, intervalSeconds)
const items = range(nextItemTime, lastItemTime, intervalSeconds)
.map(x => moment.unix(x));
return { items, shortenedBy };
}
/** Translate farmbot interval names to momentjs interval names */
@ -88,12 +94,14 @@ export interface TimeLine {
/** Takes a subset of FarmEvent<Sequence> data and generates a list of dates. */
export function scheduleForFarmEvent(
{ start_time, end_time, repeat, time_unit }: TimeLine, timeNow = moment()
): Moment[] {
): { items: Moment[], shortenedBy: number } {
const interval = repeat && farmEventIntervalSeconds(repeat, time_unit);
const gracePeriod = timeNow.clone().subtract(gracePeriodSeconds, "seconds");
// Farm event is over.
if (moment(end_time).isBefore(gracePeriod)) { return []; }
if (moment(end_time).isBefore(gracePeriod)) {
return { items: [], shortenedBy: 0 };
}
if (interval && (time_unit !== NEVER)) {
// Repeating event.
@ -103,9 +111,9 @@ export function scheduleForFarmEvent(
endTime: end_time ? moment(end_time) : nextYear(),
intervalSeconds: interval
});
return schedule;
return { items: schedule.items, shortenedBy: schedule.shortenedBy };
} else {
// Non-repeating event.
return [moment(start_time)];
return { items: [moment(start_time)], shortenedBy: 0 };
}
}

View file

@ -195,7 +195,7 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
this.setState({ specialStatusLocal: SpecialStatus.SAVED });
history.push("/app/designer/farm_events");
const frmEvnt = this.props.farmEvent;
const nextRun = _.first(scheduleForFarmEvent(frmEvnt.body));
const nextRun = _.first(scheduleForFarmEvent(frmEvnt.body).items);
if (nextRun) {
// TODO: Internationalizing this will be a challenge.
success(`This Farm Event will run ${nextRun.fromNow()}, but

View file

@ -76,6 +76,21 @@ export let regimenCalendarAdder = (index: ResourceIndex) =>
export let addSequenceToCalendar =
(f: FarmEventWithSequence, c: Calendar, now = moment(), offset: number) => {
scheduleForFarmEvent(f, now)
.map(m => c.insert(occurrence(m, f, offset)));
const schedule = scheduleForFarmEvent(f, now);
// Display empty calendars in UI so that they can be edited or deleted.
if (f.end_time && schedule.items.length === 0) {
c.insert(occurrence(moment(f.end_time), f, offset, { empty: true }));
}
// Separate the last item from the calendar.
const lastItem = schedule.items.pop();
// Add all other items.
schedule.items.map(m => c.insert(occurrence(m, f, offset)));
if (schedule.shortenedBy > 0) {
// Indicate that not all items are displayed in the final item.
lastItem && c.insert(occurrence(
lastItem, f, offset, { numHidden: schedule.shortenedBy }));
} else {
// Add the final item. All items are displayed.
lastItem && c.insert(occurrence(lastItem, f, offset));
}
};