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: ✨ create calendar/meeting setup page #10

Merged
merged 17 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c97ceba
feat: ✨ create calendar component
seancfong Dec 13, 2023
dde5ad7
feat: ✨ add dynamically populating days for calendar
seancfong Dec 13, 2023
4cb2840
feat: ✨ add back and forth selection between consecutive calendar months
seancfong Dec 13, 2023
04749c6
chore: 🔧 move calendar utils to utils directory
seancfong Dec 14, 2023
c3207e1
feat: ✨ add mobile drag multiselect on calendar
seancfong Dec 14, 2023
28db07d
feat: ✨ added calendar day selection on desktop and mobile
seancfong Dec 14, 2023
d0caef4
fix: 🐛 prevent unwanted cancel events from scrolling
seancfong Dec 15, 2023
3fdbdbb
chore: 🔧 refactored functions from calendar utils as static methods o…
seancfong Dec 19, 2023
c887c21
refactor: ♻️ moved date range selection function to selection store file
seancfong Dec 19, 2023
e8482b2
refactor: ♻️ improve readability of reverse selection state variables
seancfong Dec 19, 2023
82f5b0a
refactor: ♻️ added docstrings to event handlers
seancfong Dec 20, 2023
ce2af34
refactor: ♻️ improve code readability of calendar generator function
seancfong Dec 20, 2023
2b498f7
fix: 🐛 removed unintended behavior of over unselecting a date range
seancfong Dec 20, 2023
617a7c7
feat: ✨ extract chronological types to type file
MinhxNguyen7 Dec 22, 2023
7456edd
fix: 🐛 extracted date comparison logic to a method to enable re-rende…
seancfong Jan 2, 2024
5426721
refactor: ♻️ rename CalendarDay class to Day
seancfong Jan 2, 2024
5575126
refactor: ♻️ rename calendar helper method and variables for readability
seancfong Jan 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/lib/components/Calendar/Calendar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script lang="ts">
import CalendarBody from "$lib/components/Calendar/CalendarBody.svelte";
import { Day } from "$lib/components/Calendar/CalendarDay";
import { selectedDays } from "$lib/stores/calendarStores";
import { WEEKDAYS, MONTHS } from "$lib/types/chrono";

let today: Date = new Date();
let currentMonth: number = today.getMonth();
let currentYear: number = today.getFullYear();
let calendarDays: Day[][] = Day.generateCalendarDays(currentMonth, currentYear, $selectedDays);

$: monthName = MONTHS[currentMonth];

const decrementMonth = (): void => {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}

updateCalendar();
};

const incrementMonth = (): void => {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}

updateCalendar();
};

const updateCalendar = (): void => {
calendarDays = Day.generateCalendarDays(currentMonth, currentYear, $selectedDays);
};
</script>

<div class="max-w-xl p-5 mx-auto bg-surface-50">
<p class="text-center h3">{monthName} {currentYear}</p>
<div class="flex items-center justify-between pt-5 overflow-x-auto">
<button on:click={decrementMonth} class="p-3 pl-1">
<span class="text-3xl text-gray-500">&lsaquo;</span>
</button>
<table class="w-full">
<thead>
<tr>
{#each WEEKDAYS as dayOfWeek}
<th>
<div class="flex justify-center w-full">
<p class="text-base font-medium text-center text-gray-800 dark:text-gray-100">
{dayOfWeek}
</p>
</div>
</th>
{/each}
</tr>
</thead>
<CalendarBody {calendarDays} {updateCalendar} />
</table>
<button on:click={incrementMonth} class="p-3 pr-1">
<span class="text-3xl text-gray-500">&rsaquo;</span>
</button>
</div>
</div>
106 changes: 106 additions & 0 deletions src/lib/components/Calendar/CalendarBody.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script lang="ts">
import CalendarBodyDay from "$lib/components/Calendar/CalendarBodyDay.svelte";
import { Day } from "$lib/components/Calendar/CalendarDay";
import { updateSelectedRange } from "$lib/stores/calendarStores";

export let calendarDays: Day[][];
export let updateCalendar: () => void = () => {};

let startDaySelection: Day | null = null;
let endDaySelection: Day | null = null;

/**
* Updates the current highlight selection whenever a mobile user drags on the calendar
* @param e a TouchEvent object from a mobile user
*/
const handleTouchMove = (e: TouchEvent): void => {
const touchingElement: Element | null = document.elementFromPoint(
e.touches[0].clientX,
e.touches[0].clientY,
);

if (!touchingElement) return;

const touchingDay = touchingElement.getAttribute("data-day");

if (startDaySelection && touchingDay) {
endDaySelection = Day.extractDayFromElement(touchingElement);
}
};

/**
* Creates the selectionupdateSelectedRange */
const handleEndSelection = (): void => {
if (startDaySelection && endDaySelection) {
try {
updateSelectedRange(startDaySelection, endDaySelection);
} catch (err) {
console.error(err);
}
}

updateCalendar();

startDaySelection = null;
endDaySelection = null;
};
</script>

<tbody>
{#each calendarDays as calendarWeek}
<tr>
{#each calendarWeek as calendarDay}
<td
class="py-3"
on:mouseup={() => {
if (startDaySelection) {
endDaySelection = calendarDay;
handleEndSelection();
}
}}
>
{#if calendarDay.day > 0}
{@const isHighlighted =
startDaySelection &&
endDaySelection &&
calendarDay.determineDayWithinBounds(startDaySelection, endDaySelection)}
<button
on:touchstart={(e) => {
if (e.cancelable) {
e.preventDefault();
}
startDaySelection = calendarDay;
}}
on:mousedown={() => {
startDaySelection = calendarDay;
}}
on:touchmove={handleTouchMove}
on:mousemove={() => {
if (startDaySelection) {
endDaySelection = calendarDay;
}
}}
on:touchend={(e) => {
if (e.cancelable) {
e.preventDefault();
}
if (!endDaySelection) {
endDaySelection = calendarDay;
}
handleEndSelection();
}}
tabindex="0"
class="relative flex justify-center w-full cursor-pointer select-none"
>
<CalendarBodyDay {isHighlighted} {calendarDay} />
</button>
{:else}
<div class="select-none">
<p class="p-2">&nbsp;</p>
</div>
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
25 changes: 25 additions & 0 deletions src/lib/components/Calendar/CalendarBodyDay.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Day } from "$lib/components/Calendar/CalendarDay";

export let isHighlighted: boolean | null;
export let calendarDay: Day;
</script>

{#if isHighlighted}
<div
class="absolute w-10 h-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-200 top-1/2 left-1/2"
></div>
{:else if calendarDay.isSelected}
<div
class="absolute w-10 h-10 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-400 top-1/2 left-1/2"
></div>
{/if}
<p
class="relative p-2 text-base font-medium text-gray-500"
data-day={calendarDay.day}
data-month={calendarDay.month}
data-year={calendarDay.year}
data-selected={calendarDay.isSelected}
>
{calendarDay.day}
</p>
119 changes: 119 additions & 0 deletions src/lib/components/Calendar/CalendarDay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
enum CalendarConstants {
MAX_WEEKS_PER_MONTH = 6,
MAX_DAYS_PER_WEEK = 7,
}

export class Day {
day: number;
month: number;
year: number;
isSelected: boolean;

constructor(day: number, month: number, year: number, isSelected: boolean = false) {
this.day = day;
this.month = month;
this.year = year;
this.isSelected = isSelected;
}

equals(otherDay: Day): boolean {
return (
this.day === otherDay.day && this.month === otherDay.month && this.year === otherDay.year
);
}

toString() {
return `[${this.year}/${this.month}/${this.day}] [${
this.isSelected ? "SELECTED" : "NOT SELECTED"
}]`;
}

/**
* Used for comparing dates with < and >
*/
valueOf() {
return this.day + this.month * 31 + this.year * 366;
}

/**
* Given two dates, determines whether the date falls within range of those dates
* @param date1 a date representing a boundary of the date range
* @param date2 a date representing a boundary of the date range
* @returns a boolean of whether the date is selected within the start and end dates
*/
determineDayWithinBounds = (date1: Day, date2: Day): boolean => {
if (date1 > date2) {
return date2 <= this && this <= date1;
} else {
return date1 <= this && this <= date2;
}
};

/**
* Given a zero-indexed month and year, returns formatted days per week with appropriate padding
* @param month zero-indexed month of the year
* @param year number representing the year
* @param [selectedDays] an array of selected days to render in the calendar
* @returns a nested array of formatted days per week
*/
static generateCalendarDays = (month: number, year: number, selectedDays?: Day[]): Day[][] => {
const dayOfWeekOfFirst: number = new Date(year, month).getDay();
const daysInMonth: number = getDaysInMonth(month, year);

const generatedCalendarDays: Day[][] = [];

let day = 1;
MinhxNguyen7 marked this conversation as resolved.
Show resolved Hide resolved

for (let weekIndex = 0; weekIndex < CalendarConstants.MAX_WEEKS_PER_MONTH; weekIndex++) {
const generatedWeek: Day[] = [];

for (let dayIndex = 0; dayIndex < CalendarConstants.MAX_DAYS_PER_WEEK; dayIndex++) {
if (day > daysInMonth || (weekIndex === 0 && dayIndex < dayOfWeekOfFirst)) {
// Add a padding day if before the first day of month or after the last day of month
generatedWeek.push(new Day(-1, month, year, false));
} else {
const newCalendarDay = new Day(day, month, year, false);

// Check if day is selected
if (selectedDays && selectedDays.find((d) => d.equals(newCalendarDay))) {
newCalendarDay.isSelected = true;
}

generatedWeek.push(newCalendarDay);
day++;
}
}
generatedCalendarDays.push(generatedWeek);
}

return generatedCalendarDays;
};

/**
* Extracts data attributes from a DOM element in the calendar that represents a day
* @param element a DOM element in the calendar that represents a day
* @returns a CalendarDay object that is represented by the DOM element
*/
static extractDayFromElement = (element: Element): Day | null => {
const day = parseInt(element.getAttribute("data-day") ?? "");
const month = parseInt(element.getAttribute("data-month") ?? "");
const year = parseInt(element.getAttribute("data-year") ?? "");
const isSelected = element.getAttribute("data-selected") === "true" ? true : false;

if ([day, month, year, isSelected].every((attr) => !Number.isNaN(attr) && attr !== null)) {
return new Day(day, month, year, isSelected);
}

return null;
};
}

/**
* Given a zero-indexed month and year, returns the number of days in the month and year
* @param month zero-indexed month of the year
* @param year number representing the year
* @returns the amount of days in the given month and year
*/
const getDaysInMonth = (month: number, year: number): number => {
return new Date(year, month + 1, 0).getDate();
};
1 change: 1 addition & 0 deletions src/lib/components/Calendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Calendar } from "./Calendar.svelte";
48 changes: 48 additions & 0 deletions src/lib/stores/calendarStores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { writable } from "svelte/store";

import { Day } from "$lib/components/Calendar/CalendarDay";

export const selectedDays = writable<Day[]>([]);

/**
* Updates a range of dates based on a user selection
* @param startDate the day that the user first initiated the date multiselect range
* @param endDate the day that the user ended the date multiselect range
*/
export const updateSelectedRange = (startDate: Day, endDate: Day): void => {
if (startDate.month !== endDate.month || startDate.year != endDate.year) {
throw "The selected range must be in the same month.";
}

let lowerBound = startDate;
let upperBound = endDate;

// If the user selects backwards, swap the selections such that the date of lowerBound is before upperBound
if (startDate > endDate) {
lowerBound = endDate;
upperBound = startDate;
}

selectedDays.update((alreadySelectedDays: Day[]) => {
let modifiedSelectedDays = [...alreadySelectedDays];

const month = lowerBound.month;
const year = lowerBound.year;

for (let day = lowerBound.day; day <= upperBound.day; day++) {
const foundSelectedDay = alreadySelectedDays.find(
(d) => d.isSelected && d.equals(new Day(day, month, year)),
);

if (startDate.isSelected && foundSelectedDay) {
// Remove any selected days if the multiselect initiated from an already selected day
modifiedSelectedDays = modifiedSelectedDays.filter((d) => !d.equals(foundSelectedDay));
} else if (!startDate.isSelected && !foundSelectedDay) {
// Add day to selected days if the multiselect did not initiate from an already selected day
modifiedSelectedDays.push(new Day(day, month, year, true));
}
}

return modifiedSelectedDays;
});
};
28 changes: 28 additions & 0 deletions src/lib/types/chrono.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export enum Weekday {
Sunday = "Su",
Monday = "Mo",
Tuesday = "Tu",
Wednesday = "We",
Thursday = "Th",
Friday = "Fr",
Saturday = "Sa",
}

export const WEEKDAYS = Object.values(Weekday);

export enum Months {
January = "January",
February = "February",
March = "March",
April = "April",
May = "May",
June = "June",
July = "July",
August = "August",
September = "September",
October = "October",
November = "November",
December = "December",
}

export const MONTHS = Object.values(Months);
Loading