Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ✨ add group availability tab content #66

Merged
merged 10 commits into from
Mar 31, 2024
223 changes: 223 additions & 0 deletions src/lib/components/availability/GroupAvailability.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<script lang="ts">
import GroupAvailabilityBlock from "$lib/components/availability/GroupAvailabilityBlock.svelte";
import GroupResponses from "$lib/components/availability/GroupResponses.svelte";
import {
availabilityDates,
availabilityTimeBlocks,
groupMembers,
} from "$lib/stores/availabilityStores";
import { ZotDate } from "$lib/utils/ZotDate";
import { cn } from "$lib/utils/utils";

export let columns: number;

const itemsPerPage: number = columns;
const lastPage: number = Math.floor(($availabilityDates.length - 1) / itemsPerPage);
const numPaddingDates: number =
$availabilityDates.length % itemsPerPage === 0
? 0
: itemsPerPage - ($availabilityDates.length % itemsPerPage);

let currentPage = 0;
let currentPageAvailability: (ZotDate | null)[];
let isMobileDrawerOpen: boolean = false;
let selectedZotDateIndex: number | null = null;
let selectedBlockIndex: number | null = null;
let availableMembersOfSelection: string[] = [];
let notAvailableMembersOfSelection: string[] = [];
let selectionIsLocked: boolean = false;

// Triggers on every pagination change and selection confirmation
$: {
const datesToOffset = currentPage * itemsPerPage;
currentPageAvailability = $availabilityDates.slice(datesToOffset, datesToOffset + itemsPerPage);

if (currentPage === lastPage) {
currentPageAvailability = currentPageAvailability.concat(
new Array(numPaddingDates).fill(null),
);
}
}

// Triggers on every selection change
$: {
if (selectedZotDateIndex !== null && selectedBlockIndex !== null) {
seancfong marked this conversation as resolved.
Show resolved Hide resolved
const availableMemberIndices: number[] =
$availabilityDates[selectedZotDateIndex].getGroupAvailabilityBlock(selectedBlockIndex) ??
[];

availableMembersOfSelection = availableMemberIndices.map(
(availableMemberIndex) => $groupMembers[availableMemberIndex].name,
);

notAvailableMembersOfSelection = $groupMembers
.filter((_, index) => !availableMemberIndices.includes(index))
.map((notAvailableMember) => notAvailableMember.name);
}
}

const generateDateKey = (
selectedDate: ZotDate | null,
timeBlock: number,
pageDateIndex: number,
): string => {
return selectedDate
? `date-${selectedDate.valueOf()}-${timeBlock}`
: `padding-${pageDateIndex}`;
};

const updateSelection = (zotDateIndex: number, blockIndex: number): void => {
isMobileDrawerOpen = true;
selectedZotDateIndex = zotDateIndex;
selectedBlockIndex = blockIndex;
};

const resetSelection = () => {
isMobileDrawerOpen = false;
selectedZotDateIndex = null;
selectedBlockIndex = null;
};

const handleCellClick = (isSelected: boolean, zotDateIndex: number, blockIndex: number) => {
if (selectionIsLocked && isSelected) {
selectionIsLocked = false;
} else {
selectionIsLocked = true;
updateSelection(zotDateIndex, blockIndex);
}
};

const handleCellHover = (zotDateIndex: number, blockIndex: number) => {
if (!selectionIsLocked) {
updateSelection(zotDateIndex, blockIndex);
}
};
</script>

<div class="flex items-center justify-between overflow-x-auto font-dm-sans lg:w-full lg:pr-10">
<button
on:click={() => {
if (currentPage > 0) {
currentPage = currentPage - 1;
}
}}
class="p-3 disabled:opacity-0 md:pl-1"
disabled={currentPage === 0}
>
<span class="text-3xl text-gray-500">&lsaquo;</span>
</button>

<table class="w-full table-fixed">
<thead>
<tr>
<th class="w-10 md:w-16"><span class="sr-only">Time</span></th>
{#each currentPageAvailability as dateHeader}
<th class="pb-2 text-sm font-normal">
{#if dateHeader}
<div class="flex flex-col">
<span class="text-[10px] font-bold uppercase text-gray-500 md:text-xs">
{dateHeader.day.toLocaleDateString("en-US", {
weekday: "short",
})}
</span>

<span class="text-center text-[12px] uppercase text-gray-medium md:text-base">
{dateHeader.day.toLocaleDateString("en-US", {
month: "numeric",
day: "numeric",
})}
</span>
</div>
{/if}
</th>
{/each}
</tr>
</thead>

<tbody>
{#each $availabilityTimeBlocks as timeBlock, blockIndex (`block-${timeBlock}`)}
{@const isTopOfHour = timeBlock % 60 === 0}
{@const isHalfHour = timeBlock % 60 === 30}
{@const isLastRow = blockIndex === $availabilityTimeBlocks.length - 1}
<tr>
<td class="w-2 border-r-[1px] border-r-gray-medium py-0 pr-3 align-top">
{#if isTopOfHour}
<span
class="float-right hidden whitespace-nowrap text-[10px] font-bold text-gray-medium md:flex md:text-xs"
>
{ZotDate.toTimeBlockString(timeBlock, false)}
</span>
<span
class="float-right flex whitespace-nowrap text-[10px] font-bold text-gray-medium md:hidden md:text-xs"
>
{ZotDate.toTimeBlockString(timeBlock, true)}
</span>
{/if}
</td>

{#each currentPageAvailability as selectedDate, pageDateIndex (generateDateKey(selectedDate, timeBlock, pageDateIndex))}
{#if selectedDate}
{@const zotDateIndex = pageDateIndex + currentPage * itemsPerPage}
{@const availableMemberIndices = selectedDate.getGroupAvailabilityBlock(blockIndex)}
{@const isSelected =
selectedZotDateIndex === zotDateIndex && selectedBlockIndex === blockIndex}
{@const tableCellStyles = cn(
isTopOfHour && "border-t-[1px] border-t-gray-medium",
isHalfHour && "border-t-[1px] border-t-gray-base",
isLastRow && "border-b-[1px]",
isSelected && "outline-dashed outline-2 outline-slate-500",
)}

<td class="px-0 py-0">
<GroupAvailabilityBlock
class="hidden lg:block"
onClick={() => {
handleCellClick(isSelected, zotDateIndex, blockIndex);
}}
onHover={() => {
// Enable cell hover behavior on large screens to prevent drawer triggers
handleCellHover(zotDateIndex, blockIndex);
}}
{availableMemberIndices}
{tableCellStyles}
/>
<GroupAvailabilityBlock
class="block lg:hidden"
onClick={() => {
handleCellClick(isSelected, zotDateIndex, blockIndex);
}}
{availableMemberIndices}
{tableCellStyles}
/>
</td>
{:else}
<td></td>
{/if}
{/each}
</tr>
{/each}
</tbody>
</table>

<button
on:click={() => {
if (currentPage < lastPage) {
currentPage = currentPage + 1;
}
}}
class="p-3 disabled:opacity-0 md:pr-1"
disabled={currentPage === lastPage}
>
<span class="text-3xl text-gray-500">&rsaquo;</span>
</button>
</div>

<GroupResponses
{isMobileDrawerOpen}
{selectedZotDateIndex}
{selectedBlockIndex}
{availableMembersOfSelection}
{notAvailableMembersOfSelection}
closeMobileDrawer={resetSelection}
/>
<div class:h-96={isMobileDrawerOpen} class="lg:hidden" />
29 changes: 29 additions & 0 deletions src/lib/components/availability/GroupAvailabilityBlock.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import { groupMembers } from "$lib/stores/availabilityStores";
import { cn } from "$lib/utils/utils";

export let availableMemberIndices: number[] | null;
export let tableCellStyles: string;
export let onHover = () => {};
export let onClick = () => {};

const calculateGroupBlockColor = (availableMemberIndices: number[] | null): string => {
if (availableMemberIndices) {
const opacity = availableMemberIndices.length / $groupMembers.length;
return `rgba(55, 124, 251, ${opacity})`;
}
return "transparent";
};
</script>

<button
tabindex="0"
class={cn("h-full w-full border-r-[1px] border-gray-medium", tableCellStyles, $$props.class)}
on:click={onClick}
on:mouseenter={onHover}
>
<div
class="block h-full w-full py-2"
style:background-color={calculateGroupBlockColor(availableMemberIndices)}
></div>
</button>
104 changes: 104 additions & 0 deletions src/lib/components/availability/GroupResponses.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<script lang="ts">
import { availabilityDates } from "$lib/stores/availabilityStores";
import { ZotDate } from "$lib/utils/ZotDate";
import { cn } from "$lib/utils/utils";
import MdiClose from "~icons/mdi/close";

export let isMobileDrawerOpen: boolean;
export let selectedZotDateIndex: number | null;
export let selectedBlockIndex: number | null;
export let availableMembersOfSelection: string[];
export let notAvailableMembersOfSelection: string[];
export let closeMobileDrawer: () => void;

let blockInfoString: string;

$: {
if (selectedZotDateIndex !== null && selectedBlockIndex !== null) {
const formattedDate = $availabilityDates[selectedZotDateIndex].day.toLocaleDateString(
"en-US",
{
month: "short",
day: "numeric",
},
);

const earliestTime = $availabilityDates[selectedZotDateIndex].earliestTime;
const blockLength = $availabilityDates[selectedZotDateIndex].blockLength;
const startTime = ZotDate.toTimeBlockString(
earliestTime + selectedBlockIndex * blockLength,
false,
);
const endTime = ZotDate.toTimeBlockString(
earliestTime + selectedBlockIndex * blockLength + blockLength,
false,
);

blockInfoString = `${formattedDate}, ${startTime} - ${endTime}`;
} else {
blockInfoString = "Select a cell to view";
}
}
</script>

<div>
<div class="hidden pb-1 pl-8 lg:block">
<h3 class="font-montserrat text-xl font-medium">Responders</h3>
<p class="font-dm-sans text-xs font-bold uppercase tracking-wide text-slate-400">
{blockInfoString}
</p>
</div>
<div
class={cn(
"fixed bottom-0 left-0 h-96 w-full translate-y-full overflow-auto rounded-t-xl border-[1px] border-gray-400 bg-gray-100 bg-opacity-90 transition-transform duration-500 ease-in-out sm:left-auto sm:right-0 sm:w-96 lg:relative lg:right-0 lg:top-0 lg:h-auto lg:w-72 lg:translate-y-0 lg:self-stretch lg:rounded-l-xl lg:rounded-r-none lg:bg-opacity-50",
isMobileDrawerOpen && "translate-y-0",
)}
>
<div class="flex items-center justify-between px-8 py-4 lg:hidden">
<div>
<h3 class="font-montserrat font-medium">Responders</h3>
<p class="font-dm-sans text-xs font-bold uppercase tracking-wide text-slate-400">
{blockInfoString}
</p>
</div>
<button
class="rounded-lg border-[1px] border-slate-400 p-0.5 lg:hidden"
on:click={closeMobileDrawer}
>
<MdiClose class="text-lg text-slate-400" />
</button>
</div>
<div class="grid grid-cols-2 lg:flex lg:flex-col lg:gap-10 lg:py-4">
<div>
<div class="border-b-[1px] border-gray-300 px-8">
<div class="mr-1 inline-block h-2 w-2 rounded-full bg-success" />
<span class="font-dm-sans text-xs font-bold uppercase tracking-wide text-slate-400">
AVAILABLE
</span>
</div>
<ul class="h-64 space-y-2 overflow-auto py-2 pl-8">
{#each availableMembersOfSelection as availableName}
<li class="text-lg text-gray-800">{availableName}</li>
{:else}
<li class="text-gray-400 italic text-sm">N/A</li>
{/each}
</ul>
</div>
<div>
<div class="border-b-[1px] border-gray-300 px-8">
<div class="mr-1 inline-block h-2 w-2 rounded-full bg-gray-400" />
<span class="font-dm-sans text-xs font-bold uppercase tracking-wide text-slate-400">
NOT AVAILABLE
</span>
</div>
<ul class="h-64 space-y-2 overflow-auto py-2 pl-8">
{#each notAvailableMembersOfSelection as notAvailableName}
<li class="text-lg text-gray-400">{notAvailableName}</li>
{:else}
<li class="text-gray-400 italic text-sm">N/A</li>
{/each}
</ul>
</div>
</div>
</div>
</div>
1 change: 1 addition & 0 deletions src/lib/components/availability/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as PersonalAvailability } from "./PersonalAvailability.svelte";
export { default as GroupAvailability } from "./GroupAvailability.svelte";
Loading
Loading