Skip to content

Commit

Permalink
availability: Tidy up code
Browse files Browse the repository at this point in the history
  • Loading branch information
domdomegg committed Apr 27, 2024
1 parent 127ab4e commit ba0582e
Show file tree
Hide file tree
Showing 9 changed files with 356 additions and 372 deletions.
42 changes: 42 additions & 0 deletions apps/availability/src/components/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import clsx from 'clsx';
import {
useController, UseControllerProps, FieldPath, FieldValues, PathValue,
} from 'react-hook-form';
import Select from 'react-select';

export type ComboBoxProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = {
className?: string,
options: { value: PathValue<TFieldValues, TName>, label: string }[],
} & UseControllerProps<TFieldValues, TName> & Required<Pick<UseControllerProps<TFieldValues, TName>, 'control'>>;

export const ComboBox = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ options, ...props }: ComboBoxProps<TFieldValues, TName>) => {
const { field } = useController(props);

return (
<Select<{ value: PathValue<TFieldValues, TName>, label: string }>
options={options}
className="w-full"
value={{ value: field.value, label: field.value }}
onBlur={() => field.onBlur()}
onChange={(val) => {
field.onChange(val?.value);
}}
theme={(theme) => ({
...theme,
borderRadius: 2,
colors: {
...theme.colors,
primary: '#0037FF',
},
})}
classNames={{
control: (state) => clsx('!border-2 !rounded-sm !min-h-0 !shadow-none', state.isFocused ? '!border-bluedot-normal' : '!border-stone-200'),
valueContainer: () => '!py-0',
dropdownIndicator: () => '!py-0',
}}
/>
);
};
219 changes: 219 additions & 0 deletions apps/availability/src/components/TimeAvailabilityInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import {
useEffect, useRef, useState, useMemo,
} from 'react';
import {
FieldPath, FieldValues, UseControllerProps,
useController,
} from 'react-hook-form';
import { Button } from '@bluedot/ui';
import clsx from 'clsx';
import * as wa from 'weekly-availabilities';
import { snapToRect } from '../lib/util';

type Coord = { day: number; minute: number };
const serializeCoord = ({ day, minute }: Coord): wa.WeeklyTime => day * 24 * 60 + minute as wa.WeeklyTime;

// consts
export const MINUTES_IN_UNIT = 30;
const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];

// utils
const normalizeBlock = ({ anchor, cursor }: { anchor: Coord; cursor: Coord; }): { min: Coord, max: Coord } => {
return {
min: { day: Math.min(anchor.day, cursor.day), minute: Math.min(anchor.minute, cursor.minute) },
max: { day: Math.max(anchor.day, cursor.day), minute: Math.max(anchor.minute, cursor.minute) },
};
};

const isWithin = (
{ min, max }: { min: Coord; max: Coord },
{ day, minute }: Coord,
) => {
return (
min.minute <= minute && max.minute >= minute && min.day <= day && max.day >= day
);
};

type TimeAvailabilityMap = { [weeklyTime: wa.WeeklyTime]: boolean };

const TimeAvailabilityGrid: React.FC<{ show24: boolean, value: TimeAvailabilityMap, onChange: (v: TimeAvailabilityMap) => void }> = ({ show24, value, onChange }) => {
const startUnit = show24 ? 0 : (8 * 60) / MINUTES_IN_UNIT;
const endUnit = show24
? (24 * 60) / MINUTES_IN_UNIT
: (23 * 60) / MINUTES_IN_UNIT;

const cellCoords: Coord[] = [];
const cellRefs: ({ ref: HTMLDivElement | null; coord: Coord } | null)[] = useMemo(() => [], []);
const times = [];
for (let i = startUnit; i <= endUnit; i++) {
times.push(i);
if (i !== endUnit) {
for (let d = 0; d < days.length; d++) {
cellCoords.push({ day: d, minute: i * MINUTES_IN_UNIT });
cellRefs.push(null);
}
}
}

const timeToLabel = (time: number) => {
const minutes = time * MINUTES_IN_UNIT;
if (minutes < 0 || minutes > 1440) throw new Error(`Invalid time: ${time} (${minutes} mins)`);
const hours = Math.floor(minutes / 60);
const minutesRemaining = minutes - hours * 60;
return `${hours.toString().padStart(2, '0')}:${minutesRemaining.toString().padStart(2, '0')}`;
};

const [dragState, setDragState] = useState<{
dragging: false | 'neg' | 'pos';
anchor?: Coord;
cursor?: Coord;
}>({ dragging: false });

const dragStart = (cell: Coord) => {
setDragState({
dragging: value[serializeCoord(cell)] ? 'neg' : 'pos',
anchor: cell,
cursor: cell,
});
};

const mainGrid = useRef<HTMLDivElement>(null);

useEffect(() => {
const mouseMoveListener = (e: MouseEvent) => {
if (!dragState.dragging || !mainGrid.current) return;

const mousepos = { x: e.clientX, y: e.clientY };
const { x, y } = snapToRect(mainGrid.current.getBoundingClientRect(), mousepos);

const cell = cellRefs.find((c) => {
if (!c?.ref) return false;
const {
top, bottom, left, right,
} = c.ref.getBoundingClientRect();

return (x >= left && x <= right && y >= top && y <= bottom);
});

if (cell) {
setDragState((prev) => ({ ...prev, cursor: cell.coord }));
}
};
document.addEventListener('mousemove', mouseMoveListener);

const mouseUpListener = () => {
if (!dragState.dragging || !dragState.anchor || !dragState.cursor) return;
const { min, max } = normalizeBlock({
anchor: dragState.anchor,
cursor: dragState.cursor,
});

const targetVal = dragState.dragging === 'pos';
const valueCopy = { ...value };

for (let { day } = min; day <= max.day; day++) {
for (let { minute } = min; minute <= max.minute; minute += MINUTES_IN_UNIT) {
valueCopy[serializeCoord({ day, minute })] = targetVal;
}
}
onChange(valueCopy);
setDragState({ dragging: false });
};
document.addEventListener('mouseup', mouseUpListener);

return () => {
document.removeEventListener('mousemove', mouseMoveListener);
document.removeEventListener('mouseup', mouseUpListener);
};
}, [value, cellRefs, dragState, mainGrid]);

return (
<div className="w-full touch-none text-xs text-stone-500">
<div className="flex">
<div className="w-12" />
<div className="grid grid-cols-7 w-full text-center">
{/* eslint-disable-next-line react/no-array-index-key */}
{days.map((day, index) => <div key={index}>{day.slice(0, 1)}</div>)}
</div>
</div>
<div className="flex">
<div className="w-12">
{times.map((time, i) => i % 2 === 0 && (
<div key={time} className="h-8 flex justify-end px-1 py-px">
<div className="-translate-y-2">{timeToLabel(time)}</div>
</div>
))}
</div>
<div className="w-full">
<div
ref={mainGrid}
className="grid grid-cols-7 bg-white border-t border-l border-gray-800 w-full"
>
{cellCoords.map((coord, i) => {
const isBlocked = value[serializeCoord(coord)];

const borderStyle = Math.floor(i / 7) % 2 === 0
? '[border-bottom-style:dotted]'
: 'border-solid';

const isInDraggedOverArea = dragState.dragging
&& dragState.anchor
&& dragState.cursor
&& isWithin(
normalizeBlock({
anchor: dragState.anchor,
cursor: dragState.cursor,
}),
coord,
);

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
// eslint-disable-next-line react/no-array-index-key
key={i}
ref={(ref) => { cellRefs[i] = { ref, coord }; }}
className={clsx(`relative h-4 border-gray-800 border-r border-b ${borderStyle}`, isBlocked && 'bg-green-400')}
onMouseDown={(e) => {
e.preventDefault();
dragStart(coord);
}}
onTouchStart={(e) => {
e.preventDefault();
dragStart(coord);
}}
>
<div className="w-full h-full" draggable={false} />
{isInDraggedOverArea && <div className={clsx('w-full h-full opacity-75 absolute inset-0 pointer-events-none', dragState.dragging === 'pos' ? 'bg-green-400' : 'bg-purple-400')} />}
</div>
);
})}
</div>
</div>
</div>
</div>
);
};

export type TimeAvailabilityInputProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = {
className?: string,
} & UseControllerProps<TFieldValues, TName> & Required<Pick<UseControllerProps<TFieldValues, TName>, 'control'>>;

export const TimeAvailabilityInput = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ className, ...props }: TimeAvailabilityInputProps<TFieldValues, TName>) => {
const { field } = useController(props);
const [show24, setShow24] = useState(false);

return (
<div className="sm:flex gap-4">
<TimeAvailabilityGrid value={field.value} onChange={(v) => field.onChange(v)} show24={show24} />
<div className="sm:w-40 sm:mt-4 flex sm:flex-col gap-2">
<Button className="w-full text-sm" onPress={() => setShow24(!show24)}>
Show {show24 ? 'less' : 'more'}
</Button>
</div>
</div>
);
};
26 changes: 26 additions & 0 deletions apps/availability/src/components/TimeOffsetSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
FieldPath, FieldValues, PathValue, UseControllerProps, useController,
} from 'react-hook-form';
import { offsets } from '../lib/offset';
import { ComboBox } from './ComboBox';

const browserTimezoneName = new Intl.DateTimeFormat().resolvedOptions().timeZone;

export type TimeOffsetSelectorProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = {
className?: string,
} & UseControllerProps<TFieldValues, TName> & Required<Pick<UseControllerProps<TFieldValues, TName>, 'control'>>;

export const TimeOffsetSelector = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ className, ...props }: TimeOffsetSelectorProps<TFieldValues, TName>) => {
const { field, fieldState } = useController(props);
const options = offsets.map((s) => ({ value: s as PathValue<TFieldValues, TName>, label: s }));

return (
<div className={className}>
<label className="text-xs text-stone-500 block">Time offset {!fieldState.isDirty ? `(Automatically set to ${browserTimezoneName})` : ''}</label>
<ComboBox options={options} control={props.control} name={field.name} />
</div>
);
};
Empty file.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { offsets, parseOffsetFromStringToMinutes } from './date';
import { formatOffsetFromMinutesToString, offsets, parseOffsetFromStringToMinutes } from './offset';

describe('parseOffsetFromStringToMinutes', () => {
test.each([
Expand All @@ -12,8 +12,8 @@ describe('parseOffsetFromStringToMinutes', () => {
])('%s', (timezone, offset) => {
expect(parseOffsetFromStringToMinutes(timezone)).toBe(offset);
});
});

test('all offsets can be parsed', () => {
offsets.forEach((offset) => expect(() => parseOffsetFromStringToMinutes(offset)).not.toThrow());
});
test('all offsets can be parsed and stringified to themselves', () => {
offsets.forEach((offset) => expect(formatOffsetFromMinutesToString(parseOffsetFromStringToMinutes(offset))).toBe(offset));
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,9 @@ export function parseOffsetFromStringToMinutes(offset: string): number {
const minutes = (parseInt(offset[4]!) * 10 + parseInt(offset[5]!)) * 60 + (parseInt(offset[7]!) * 10 + parseInt(offset[8]!));
return sign * minutes;
}

export function formatOffsetFromMinutesToString(minutes: number): string {
// eslint-disable-next-line no-nested-ternary
const signSymbol = minutes === 0 ? '' : (minutes < 0 ? '+' : '-');
return `UTC${signSymbol}${(Math.floor(Math.abs(minutes) / 60)).toString().padStart(2, '0')}:${(Math.floor(Math.abs(minutes) % 60)).toString().padStart(2, '0')}`;
}
5 changes: 0 additions & 5 deletions apps/availability/src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,3 @@ export function snapToRect(
y: y > bottom ? bottom - 5 : y < top ? top + 5 : y,
};
}

// pad number with zeros so that it has 2 digits
export function pad(num: number) {
return num < 10 ? `0${num}` : num;
}
Loading

0 comments on commit ba0582e

Please sign in to comment.