Skip to content

Instantly share code, notes, and snippets.

@kukiron
Last active October 20, 2023 03:14
Show Gist options
  • Save kukiron/24e3f94f78229a4c4e38713f45bdc200 to your computer and use it in GitHub Desktop.
Save kukiron/24e3f94f78229a4c4e38713f45bdc200 to your computer and use it in GitHub Desktop.
Vessel centric view utils
import differenceBy from 'lodash/differenceBy';
import flatten from 'lodash/flatten';
import partition from 'lodash/partition';
import moment from 'moment';
import { getTimelineMonths } from '../helpers';
import {
CrewSchedule,
FormattedSchedules,
RowSegment,
TimelineMonth,
VesselCrewScheduleResponse,
} from '../types';
// module containing utils for vessel-centric view
export class CrewScheduleModule {
scheduleResponse: VesselCrewScheduleResponse;
constructor(scheduleResponse: VesselCrewScheduleResponse) {
this.scheduleResponse = scheduleResponse;
}
get timelineMonths(): TimelineMonth[] {
const sortedCrewSchedules = flatten(
Object.values(this.scheduleResponse)
).sort((a, b) => a.signOnDate.localeCompare(b.signOnDate));
// earliest crew `signOnDate` in the screw schedule response
const earliest = sortedCrewSchedules[0].signOnDate;
// LATEST crew `signOnDate` in the screw schedule response
const latest =
sortedCrewSchedules[sortedCrewSchedules.length - 1].signOnDate;
return getTimelineMonths(earliest, latest);
}
// convert ISO date string to `YYYY-MM-DD` format
private trimDate(dateStr: string) {
return dateStr.split('T')[0];
}
private findDatesDifference(date1: string, date2: string) {
return moment(date1).diff(moment(date2), 'days');
}
// check if a scpecific schedule is historical
private isHistorical(schedule: CrewSchedule) {
const { signOnDateType, signOffDateType } = schedule;
return signOnDateType === 'RECORD' && signOffDateType === 'RECORD';
}
// checks if 2 crew schedules are within 2 days
private areEventsWithin2Days(event1: CrewSchedule, event2: CrewSchedule) {
const difference1 = this.findDatesDifference(
event2.signOnDate,
event1.signOffDate
);
const difference2 = this.findDatesDifference(
event1.signOnDate,
event2.signOffDate
);
return Math.abs(difference1) <= 2 || Math.abs(difference2) <= 2;
}
private getCustomKey(details: CrewSchedule, past: boolean) {
return past
? `${this.trimDate(details.signOffDate)}--${details.externalCrewId}`
: `${this.trimDate(details.signOnDate)}--${details.externalCrewId}`;
}
private removeId(schedules: (CrewSchedule & { id?: number })[]) {
return schedules.map(({ id, ...rest }) => ({ ...rest }));
}
// takes schedule items array & convert to key-value pair
// values have grouped schedules that match sign-off & sign-on date
private formatToScheduleGroups(
schedules: CrewSchedule[],
past: boolean = false
) {
if (schedules.length === 1) {
return { [this.getCustomKey(schedules[0], past)]: schedules };
}
const formattedSchedules = schedules.map((item, index) => ({
...item,
id: index,
}));
return formattedSchedules.reduce<{
[date: string]: CrewSchedule[];
}>((acc, item) => {
const includedSchedules = flatten(Object.values(acc));
const remainingSchedules = differenceBy(
formattedSchedules,
includedSchedules,
'id'
);
if (!remainingSchedules.length) {
return acc;
}
const replacementCrew = remainingSchedules.find(
(crew) =>
crew.externalCrewId === item.signOffEvent?.replacementCrewId ||
this.areEventsWithin2Days(item, crew)
);
if (replacementCrew) {
const pairedCrew = [item, replacementCrew].sort((a, b) =>
a.signOnDate.localeCompare(b.signOnDate)
);
const customKey = this.getCustomKey(
past ? pairedCrew[pairedCrew.length - 1] : pairedCrew[0],
past
);
return { ...acc, [customKey]: pairedCrew };
}
return { ...acc, [this.getCustomKey(item, past)]: [item] };
}, {});
}
// finds schedule obj key that has matching date with input date str
// used to find matching past schedules for a group of future schedules
private findMatchingDate(stringArr: string[], dateStr: string) {
const [pastDate, pastCrewId] = dateStr.split('--');
return stringArr.reduce<string>((prev, curr) => {
const [currDate, crewId] = curr.split('--');
const [prevDate] = prev.split('--');
if (pastCrewId === crewId) return prev;
const diffWithCurrDate = this.findDatesDifference(currDate, pastDate);
if (Math.abs(diffWithCurrDate) <= 7) return curr;
const diffWithPrevDate = this.findDatesDifference(prevDate, pastDate);
return diffWithCurrDate <= diffWithPrevDate ? curr : prev;
}, '');
}
// remove a group of shcedules that include any historical item inside
// this is to provide a trimmed schedule groups in each iteration
// to insert the accurate past schedules to a group of future schedules
private removeHistoricalScheduleGroup(scheduleGroup: {
[key: string]: CrewSchedule[];
}) {
return Object.keys(scheduleGroup).reduce<{
[key: string]: CrewSchedule[];
}>((acc, key) => {
const schedules = scheduleGroup[key];
const hasHistorical = schedules.some(this.isHistorical);
return hasHistorical ? acc : { ...acc, [key]: schedules };
}, {});
}
// formats all the schedules for a specific rank
// generates groups of schedules arrays which are then represented in a timeline array
private formatRankSchedules(rankSchedules: CrewSchedule[], rank?: string) {
const [pastSchedules, futureSchedules] = partition(
rankSchedules,
this.isHistorical
);
const futureScheduleGroups = this.formatToScheduleGroups(futureSchedules);
if (!pastSchedules.length) {
return Object.values(futureScheduleGroups);
}
const pastScheduleGroups = this.formatToScheduleGroups(pastSchedules, true);
if (!futureSchedules.length) {
return Object.values(pastScheduleGroups);
}
const result = Object.keys(pastScheduleGroups).reduce<{
[date: string]: CrewSchedule[];
}>((acc, dateStr) => {
const matchedKey = this.findMatchingDate(
Object.keys(this.removeHistoricalScheduleGroup(acc)),
dateStr
);
const updatedItem = [
...(pastScheduleGroups[dateStr] || []),
...(futureScheduleGroups[matchedKey] || []),
];
return {
...acc,
...(updatedItem.length ? { [matchedKey]: updatedItem } : {}),
};
}, futureScheduleGroups);
return Object.values(result)
.map(this.removeId) // remove inserted `id` property (in previous step)
.sort((a, b) => a[0].signOnDate.localeCompare(b[0].signOnDate));
}
public getFormattedCrewSchedules(): FormattedSchedules {
return Object.keys(this.scheduleResponse).reduce(
(acc, rank) => ({
...acc,
[rank]: this.formatRankSchedules(this.scheduleResponse[rank], rank),
}),
{}
);
}
// generates segments/sections of a timeline row with details
// all of which total to 100%
public getTimelineRowDetails(rowSchedules: CrewSchedule[]): RowSegment[] {
// starting point in the timeline row
const { date: startDate } = this.timelineMonths[0];
// start date of ending month
const { date: endMonthStartDate } =
this.timelineMonths[this.timelineMonths.length - 1];
// finishing point of timeline roe
const endDate = moment(endMonthStartDate).add(1, 'month');
// total number of days in the timeline row
const totalDays = moment(endDate).diff(moment(startDate), 'days');
// sign-on date of first crew in the order
const firstScheduleDate = rowSchedules[0].signOnDate;
// sign-off date of last crew in the order - could be beyond the timeline `endDate`
// if the `signOffDateType` is `PLACEHOLDER`
const lastScheduleDate = rowSchedules[rowSchedules.length - 1].signOffDate;
// first segment of the timeline row
// section info available if the `startDate` is before `firstScheduleDate` (most cases)
// otherwise, empty array
let initialSection: RowSegment[] = [];
// last segment of the timeline row
// section info available if the `endDate` is after `lastScheduleDate`
// otherwise, empty array - in which case `lastScheduleDate` goes beynd timeline `endDate`
let finalSection: RowSegment[] = [];
// find difference between timeline `startDate` & `firstScheduleDate`
const initialDifference = moment(firstScheduleDate).diff(
moment(startDate),
'days'
);
// if there's a positive difference, `initialSection` is NOT an empty array
if (initialDifference > 0) {
initialSection = [
{
width: Math.ceil((initialDifference / totalDays) * 100),
empty: true,
},
];
}
// find difference between timeline `endDate` & `lastScheduleDate`
const finalDifference = moment(endDate).diff(
moment(lastScheduleDate),
'days'
);
// if there's a positive difference, `finalSection` is NOT an empty array
if (finalDifference > 0) {
finalSection = [
{ width: (finalDifference / totalDays) * 100, empty: true },
];
}
// now generate the sections/segments for all crew schedules for a timeline row
const crewSegments = rowSchedules.reduce<RowSegment[]>(
(acc, item, index) => {
const { name, signOnDate, signOffDate, signOffDateType } = item;
let difference = 0;
if (index + 1 < rowSchedules.length) {
const nextItem = rowSchedules[index + 1];
difference = moment(nextItem.signOnDate).diff(
moment(signOnDate),
'days'
);
} else {
difference = moment(
finalDifference > 0 ? item.signOffDate : endDate
).diff(moment(signOnDate), 'days');
}
return [
...acc,
{
width: (difference / totalDays) * 100,
past: this.isHistorical(item),
name: name,
signOnDate,
...(signOffDateType !== 'PLACEHOLDER' ? { signOffDate } : {}),
},
];
},
[]
);
// padded with empty segments at the start & end
return [...initialSection, ...crewSegments, ...finalSection];
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment