diff --git a/client/src/components/time_indicator.tsx b/client/src/components/time_indicator.tsx index f053a70..ff73636 100644 --- a/client/src/components/time_indicator.tsx +++ b/client/src/components/time_indicator.tsx @@ -3,6 +3,10 @@ import { getVisibleTimetableRect, } from "@/components/timetable"; +/* +Resizes the time indicator to the correct width and positions it at the +y-position corresponding to the current time. +*/ export function resizeAndPositionTimeIndicator() { const time_indicator = document.getElementById("time-indicator"); if (time_indicator == null) return; @@ -10,43 +14,57 @@ export function resizeAndPositionTimeIndicator() { const now_label = document.getElementById("time-indicator-label"); if (now_label == null) return; + const separator_rect = document + .getElementById("timetable-separator") + ?.getBoundingClientRect(); + if (separator_rect == undefined) return; + + const content_rect = document + .getElementById("timetable-content") + ?.getBoundingClientRect(); + if (content_rect == undefined) return; + const visible = getVisibleTimetableRect(); if (visible == undefined) return; - time_indicator.style.width = visible.width + "px"; - time_indicator.style.left = visible.left + "px"; - const now = new Date(Date.now()); const hour = now.getHours(); const mins = now.getMinutes(); - const top = getTimeTopPosition(hour, mins); - if (top == undefined) return; - time_indicator.style.top = top + "px"; + const time_top = getTimeTopPosition(hour, mins); + if (time_top == undefined) return; + + time_indicator.style.left = + visible.left - separator_rect.width - content_rect.left + "px"; + time_indicator.style.top = time_top - content_rect.top + "px"; + time_indicator.style.width = visible.width + separator_rect.width + "px"; const now_label_rect = now_label.getBoundingClientRect(); if (now_label_rect == undefined) return; - const now_label_left = visible.left - now_label_rect.width; - const now_label_top = top - now_label_rect.height / 2; - now_label.style.left = now_label_left + "px"; - now_label.style.top = now_label_top + "px"; - - if (top < visible.top || top > visible.bottom) { - time_indicator.style.display = "none"; - now_label.style.display = "none"; - } else { - time_indicator.style.display = "inline"; - now_label.style.display = "inline"; - } + const now_label_left = + visible.left - now_label_rect.width - separator_rect.width; + const now_label_top = time_top - now_label_rect.height / 2; + + const parent_rect = time_indicator.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + now_label.style.left = now_label_left - parent_rect.left + "px"; + now_label.style.top = now_label_top - parent_rect.top + "px"; } +/* +Component consisting of a rounded label containing the text "Now" and a 2px +thick line stretching across the visible area of the timetable. + +There should only be one TimeIndicator per page. +*/ function TimeIndicator() { return ( <>

Now

diff --git a/client/src/components/time_tag.tsx b/client/src/components/time_tag.tsx index 6e02758..f010aa1 100644 --- a/client/src/components/time_tag.tsx +++ b/client/src/components/time_tag.tsx @@ -1,11 +1,22 @@ import { getDurationMinutes } from "@/components/timetable"; +/* +@prop start_time: string of form HH:MM:SS containing the start time to display +@prop end_time: string of form HH:MM:SS containing the end time to display +@prop display: (optional) string describing what to display in the tag, +defaults to displaying the start and end times, "duration" to display duration, +or "both" to display start and end times with duration in brackets. +*/ interface TimeTagProps { start_time: string; end_time: string; display?: string; } +/* +A small round tag containing a clock icon and either the start and end times, +the duration, or both, specified by the display prop. +*/ function TimeTag({ start_time, end_time, display }: TimeTagProps) { const time_string = start_time.substring(0, 5) + "-" + end_time.substring(0, 5); diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index a4c9207..27240c4 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -1,28 +1,52 @@ -import { ReactElement, useEffect } from "react"; +import { useEffect } from "react"; import TimeIndicator, { resizeAndPositionTimeIndicator, } from "@/components/time_indicator"; -import { - TimetableDayColumn, - TimetableTimeColumn, +import TimetableColumn, { + TimetableTimeLabelsColumn, } from "@/components/timetable_column"; -import { resizeAndPositionTimetableTasks } from "@/components/timetable_task"; - +import { TimetableHeader } from "@/components/timetable_slot"; +import TimetableTask, { + resizeAndPositionTimetableTasks, + TimetableTaskProps, +} from "@/components/timetable_task"; + +/* +Returns a rect {left, right, top, bottom, width, height} of the visible area of +the timetable where timetable tasks are to be rendered. + +Visible area is the area: +- Within timetable-content's bounding client rect +- Right of timetable-separator (right of time labels) +- Below Time-header(s)) + +Returns nothing if any of the required elements are not found (that is, elements +with id: Time-header, timetable-content, timetable-separator, and +timetable-content). +*/ export function getVisibleTimetableRect() { - const time_header = document.getElementById("time-header"); + const time_header = document.getElementById("Time-header"); if (time_header == null) return; const time_header_rect = time_header.getBoundingClientRect(); - const timetable_barrier = document.getElementById("timetable-barrier"); - if (timetable_barrier == null) return; - const timetable_barrier_rect = timetable_barrier.getBoundingClientRect(); + const time_labels = document.getElementById("timetable-time-labels"); + if (time_labels == null) return; + const time_labels_rect = time_labels.getBoundingClientRect(); + + const timetable_content = document.getElementById("timetable-content"); + if (timetable_content == null) return; + const timetable_content_rect = timetable_content.getBoundingClientRect(); + + const timetable_separator = document.getElementById("timetable-separator"); + if (timetable_separator == null) return; + const timetable_separator_rect = timetable_separator.getBoundingClientRect(); const rect = { - left: time_header_rect.right, - right: timetable_barrier_rect.left + timetable_barrier.clientWidth, + left: time_labels_rect.right + timetable_separator_rect.width, + right: timetable_content_rect.left + timetable_content.clientWidth, top: time_header_rect.bottom, - bottom: timetable_barrier_rect.top + timetable_barrier.clientHeight, + bottom: timetable_content_rect.top + timetable_content.clientHeight, width: 0, height: 0, }; @@ -32,6 +56,9 @@ export function getVisibleTimetableRect() { return rect; } +/* +Returns the number of minutes between two strings of format "HH:MM:SS". +*/ export function getDurationMinutes(start_time: string, end_time: string) { const start_hour: number = +start_time.substring(0, 2); const start_minute: number = +start_time.substring(3, 5); @@ -40,12 +67,17 @@ export function getDurationMinutes(start_time: string, end_time: string) { return 60 * (end_hour - start_hour) + (end_minute - start_minute); } +/* +Returns the y-position (top) of any time on the timetable. + +Returns nothing if the hour is not in range [0-23] as time-label with matching +id will not exist. +*/ export function getTimeTopPosition(hour: number, mins: number) { - const hour_label = document.getElementById(hour + ":00:00"); + const hour_id = (hour + ":00:00").padStart(8, "0"); + const hour_label = document.getElementById(hour_id); if (hour_label == null) return; - const hour_rect = hour_label.getBoundingClientRect(); - if (hour_rect == undefined) return; const offset = (mins / 60) * hour_rect.height; const top = hour_rect.top + offset; @@ -53,37 +85,188 @@ export function getTimeTopPosition(hour: number, mins: number) { return top; } +/* +Returns the x-position (left) of any day (string) on the timetable. + +Returns early if an element with the id corresponding to the day is not found. +*/ export function getDayLeftPosition(day: string) { const day_label = document.getElementById(day); if (day_label == null) return; return day_label.getBoundingClientRect().left; } -export function resizeTimetableElements() { +/* +Sync the timetable-foreground element's position and size with the +timetable-content element. Ensures foreground elements inhabit the same area +as the background elements. +*/ +function resizeAndPositionTimetableForeground() { + const foreground = document.getElementById("timetable-foreground"); + if (foreground == null) return; + + const content = document.getElementById("timetable-content"); + if (content == null) return; + const content_rect = content.getBoundingClientRect(); + + foreground.style.top = content_rect.top + "px"; + foreground.style.left = content_rect.left + "px"; + foreground.style.width = content.clientWidth + "px"; + foreground.style.height = content.clientHeight + "px"; +} + +/* +Sync the left position of the TimetableHeaders in the foreground with their +respective columns, set the top position to 0px, ensures Time-header remains +in the top left corner. + +Also resizes the timetable-headers container to the width of timetable-content +element and height of Time-header to prevent underlying elements from showing +through the gaps between headers. +*/ +function resizeAndPositionTimetableHeaders() { + const timetable_headers = document.getElementById("timetable-headers"); + if (timetable_headers == null) return; + const headers = timetable_headers.children; + + const time_header = document.getElementById("Time-header"); + if (time_header == null) return; + + const content_rect = document + .getElementById("timetable-content") + ?.getBoundingClientRect(); + if (content_rect == undefined) return; + + timetable_headers.style.width = content_rect.width + "px"; + timetable_headers.style.height = + time_header.getBoundingClientRect().height + "px"; + + for (let i = 0; i < headers.length; i++) { + const header_id = headers[i].id; + const header = document.getElementById(header_id); + if (header == null) continue; + + const col_id = header.id.slice(0, -"-Header".length); + const col = document.getElementById(col_id); + if (col == null) continue; + const col_rect = col.getBoundingClientRect(); + + const parent_rect = header.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) continue; + + header.style.top = "0px"; + header.style.left = col_rect.left - parent_rect.left + "px"; + header.style.width = col_rect.width + "px"; + } + + time_header.style.left = "0px"; + time_header.style.top = "0px"; + time_header.style.zIndex = "1000"; +} + +/* +Sync the TimeLabels column top position with the underlying Time column and set +the left position to 0px. +*/ +function resizeAndPositionTimetableTimeLabels() { + const time_labels = document.getElementById("timetable-time-labels"); + if (time_labels == null) return; + + const time_col = document.getElementById("Time"); + if (time_col == null) return; + const time_col_rect = time_col.getBoundingClientRect(); + + const parent_rect = time_labels.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + time_labels.style.top = time_col_rect.top - parent_rect.top + "px"; + time_labels.style.left = "0px"; + + time_labels.style.width = time_col_rect.width + "px"; +} + +/* +Sets the size and position of the Timetable Separator to directly right of the +time label column and taking up the entire visible vertical space of the +timetable. + +Returns nothing if elements with id: timetable-separator, Time do not exist. +*/ +function resizeAndPositionTimetableSeparator() { + const separator = document.getElementById("timetable-separator"); + if (separator == null) return; + + const time_col = document.getElementById("timetable-time-labels"); + if (time_col == null) return; + const col_rect = time_col.getBoundingClientRect(); + + const time_header = document.getElementById("Time-header"); + if (time_header == null) return; + const header_rect = time_header.getBoundingClientRect(); + + const parent_rect = separator.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + separator.style.top = header_rect.top - parent_rect.top + "px"; + separator.style.left = col_rect.right - parent_rect.left + "px"; + separator.style.height = col_rect.height + "px"; +} + +/* +Sync the timetable-tasks element's size and position with the visible area of +the timetable (area returned by getVisibleTimetableRect() ). Allows overflowing +TimetableTasks to be hidden. +*/ +function resizeAndPositionTimetableTaskArea() { + const task_container = document.getElementById("timetable-tasks"); + if (task_container == null) return; + + const parent_rect = task_container.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + const visible = getVisibleTimetableRect(); + if (visible == undefined) return; + + task_container.style.top = visible.top - parent_rect.top + "px"; + task_container.style.left = visible.left - parent_rect.left + "px"; + task_container.style.width = visible.width + "px"; + task_container.style.height = visible.height + "px"; +} + +/* +Calls all of the resizeAndPosition functions. Syncs the foreground elements of +the timetable with the background elements. +*/ +export function resizeAndPositionTimetableElements() { + resizeAndPositionTimetableForeground(); + resizeAndPositionTimetableHeaders(); + resizeAndPositionTimetableTimeLabels(); + resizeAndPositionTimetableSeparator(); + resizeAndPositionTimetableTaskArea(); resizeAndPositionTimetableTasks(); resizeAndPositionTimeIndicator(); } +/* +Sets the vertical scroll of the timetable-content to make the current time +visible. + +@param align: An optional string argument indicating where the now position +should be aligned to in the visible area of the timetable. By default, +sets the now position to the center of the visible area, can additionally be +specified "top" or "bottom". +*/ export function scrollToCurrentTime(align?: string) { - const timetable_barrier = document.getElementById("timetable-barrier"); - if (timetable_barrier == null) return; + const timetable_content = document.getElementById("timetable-content"); + if (timetable_content == null) return; + const content_rect = timetable_content.getBoundingClientRect(); const visible = getVisibleTimetableRect(); if (visible == undefined) return; - const timetable = document.getElementById("timetable"); + const timetable = document.getElementById("timetable-background"); if (timetable == null) return; - const time_header = document.getElementById("time-header"); - if (time_header == undefined) return; - const time_header_height = time_header.getBoundingClientRect().height; - if (time_header_height == undefined) return; - - const scroll_height = timetable.scrollHeight - time_header_height; - const scroll_max = scroll_height - visible.height; // Top row is sticky - - timetable_barrier.scrollTop = 0; // Ensures consistent position - const now = new Date(Date.now()); const now_top = getTimeTopPosition(now.getHours(), now.getMinutes()); if (now_top == undefined) return; @@ -91,36 +274,37 @@ export function scrollToCurrentTime(align?: string) { let target_top; if (align === "top") target_top = visible.top; else if (align === "bottom") target_top = visible.bottom; - else target_top = visible.top + visible.height / 2; - - let scroll_amount = now_top - target_top; - if (scroll_amount < 0) scroll_amount = 0; - if (scroll_amount > scroll_max) scroll_amount = scroll_max; + else target_top = content_rect.top + content_rect.height / 2; - timetable_barrier.scrollTop = scroll_amount; + const scroll_amount = now_top - target_top; + timetable_content.scrollTop += scroll_amount; } +/* +Sets the horizontal scroll position of the timetable-content to display the +column of the current day positioned in the center/left/right specified by +align argument. + +@param align: An optional String argument that controls whether the scroll +position should be set to align the current day to the left, right or center +of the visible timetable area. By default center, otherwise can be specified +"left" or "right". +*/ export function scrollToCurrentDay(align?: string) { - const timetable_barrier = document.getElementById("timetable-barrier"); - if (timetable_barrier == null) return; + const timetable_content = document.getElementById("timetable-content"); + if (timetable_content == null) return; const visible = getVisibleTimetableRect(); if (visible == undefined) return; - const timetable = document.getElementById("timetable"); + const timetable = document.getElementById("timetable-background"); if (timetable == null) return; - const time_header = document.getElementById("time-header"); - if (time_header == undefined) return; + const time_header = document.getElementById("Time-header"); + if (time_header == null) return; const time_header_width = time_header.getBoundingClientRect().width; - if (time_header_width == undefined) return; const row_width = time_header_width; // easier to read later - const scroll_width = timetable.scrollWidth - time_header_width; // sticky - const scroll_max = scroll_width - visible.width; - - timetable_barrier.scrollLeft = 0; - enum DateDay { Monday = 1, Tuesday, @@ -141,13 +325,16 @@ export function scrollToCurrentDay(align?: string) { else if (align == "right") target_left = visible.right - row_width; else target_left = visible.left + visible.width / 2 - row_width / 2; - let scroll_amount = today_left - target_left; - if (scroll_amount < 0) scroll_amount = 0; - if (scroll_amount > scroll_max) scroll_amount = scroll_max; - - timetable_barrier.scrollLeft = scroll_amount; + const scroll_amount = today_left - target_left; + timetable_content.scrollLeft += scroll_amount; } +/* +Scrolls the vertical and horizonal scroll of timetable-content to position +the current time and current day within the visible area of the timetable. + +Calls scrollToCurrentTime and scrollToCurrentDay. +*/ export function scrollToCurrentTimeAndDay( time_align?: string, day_align?: string, @@ -156,13 +343,27 @@ export function scrollToCurrentTimeAndDay( scrollToCurrentDay(day_align); } +/* +@prop timetable_tasks_props: TimetableTaskProps to be rendered as TimetableTasks +within the timetable. +*/ interface TimetableProps { - children: ReactElement[]; + timetable_tasks_props: TimetableTaskProps[]; } -function Timetable({ children }: TimetableProps) { +/* +A Timetable composed of 8 columns (header + each day) and 25 rows +(header + each hour). The header row and leftmost column are sticky to allow +user to scroll without positional information (day and time) disappearing. + +Takes 1 prop containing the props for TimetableTasks to be rendered within the +timetable. + +There can only be one timetable per page as the logic uses ids. +*/ +function Timetable({ timetable_tasks_props }: TimetableProps) { useEffect(() => { - resizeTimetableElements(); + resizeAndPositionTimetableElements(); scrollToCurrentTimeAndDay(); }); @@ -172,29 +373,58 @@ function Timetable({ children }: TimetableProps) { className="h-full w-full rounded-lg bg-slate-900 p-3" >
+ + + + + + + + +
+
- - - - - - - - - +
+ {[ + "Time", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ].map((header) => ( + + ))} +
+ +
- {children} + {timetable_tasks_props.map((props) => ( + + ))}
+
diff --git a/client/src/components/timetable_column.tsx b/client/src/components/timetable_column.tsx index 60de809..0ff3767 100644 --- a/client/src/components/timetable_column.tsx +++ b/client/src/components/timetable_column.tsx @@ -1,73 +1,83 @@ -import React, { ReactElement } from "react"; - import TimetableSlot from "./timetable_slot"; +/* +@prop children: ReactElements to render within the column +@prop sticky: Boolean describing if the column should be sticky (used for the +leftmost column containing the labels). Also gives left-0 positioning if true. +@prop day: String to display within the header, also sets id to this value. +*/ interface TimetableColumnProps { - children?: ReactElement[]; - sticky?: boolean; - label: string; day: string; } -export default function TimetableColumn({ - children, - day, - sticky, -}: TimetableColumnProps) { - let class_name = - "timetable-column w-full min-w-48 h-full \ - flex flex-col bg-slate-500"; - - if (sticky === true) { - class_name += " sticky left-0 z-10"; - } else { - class_name += " z-0"; +/* +A column of the timetable containing 25 TimetableSlots (header + one each hour). +*/ +export default function TimetableColumn({ day }: TimetableColumnProps) { + const now = new Date(Date.now()); + enum DateDay { + Monday = 1, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, } + const current_day = DateDay[now.getDay()]; + const is_current_day = day === current_day; return ( -
- {children} +
+ + + + + + + + + + + + + + + + + + + + + + + + +
); } -export function TimetableDayColumn({ label, day }: TimetableColumnProps) { +/* +A TimetableColumn for the time labels. Header has text "Time", all other slots +have text "HH:MM" for time time they represent. Each slot has its id set to its +time prop value. +*/ +export function TimetableTimeLabelsColumn() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export function TimetableTimeColumn() { - return ( - +
- +
); } diff --git a/client/src/components/timetable_slot.tsx b/client/src/components/timetable_slot.tsx index 8e44e32..027799b 100644 --- a/client/src/components/timetable_slot.tsx +++ b/client/src/components/timetable_slot.tsx @@ -1,34 +1,53 @@ +/* +@prop label: Optional string to display within slot. + +@prop header: Optional boolean determining if slot is a header or not. If true, +sets positioning to sticky + top-0, darkens background colour, and +sets id to the label + the suffix "-header". + +@prop time_label: Optional boolean determining if slot is a time label or not, +sets id to the time prop value. + +@prop time: The time the slot represents, currently unused but intended to be +used by drag and drop functionality. + +@prop highlight: Optional boolean determining if the slot should be brighter +(used to highlight slots on the current day). +*/ interface TimetableSlotProps { label?: string; header?: boolean; time_label?: boolean; time: string; + highlight?: boolean; } +/* +A single slot of the timetable (representing 1 hour of 1 day). Must have a time +in HH:MM:DD format, all other props are optional. +*/ function TimetableSlot({ label, header, time_label, time, + highlight, }: TimetableSlotProps) { let class_name = - "timetable-slot w-full h-full min-h-16 \ + "timetable-slot w-full min-h-16 \ flex justify-center items-center border-solid "; if (header === true) { - class_name = - class_name + - " bg-slate-800 sticky top-0 z-10 \ - border-b-2 border-b-slate-900"; + class_name += + " timetable-header bg-slate-800 border-b-2 border-b-slate-900 "; } else { - class_name = - class_name + - " bg-slate-600 \ - border-t border-b border-t-slate-400 border-b-slate-400"; + const bg_color = highlight === true ? " bg-slate-500" : " bg-slate-600"; + class_name += + bg_color + " border-t border-b border-t-slate-400 border-b-slate-400"; } if (header !== true && time_label !== true) { - class_name = class_name + " hover:bg-slate-500"; + class_name += " hover:bg-slate-500"; } // There's probably a better way to do this @@ -47,3 +66,19 @@ function TimetableSlot({ } export default TimetableSlot; + +/* +A slightly modified TimetableSlot designed to be displayed in the foreground +with the styling of a TimetableSlot with header=true. +Has id set to its label + "-header" suffix. +*/ +export function TimetableHeader({ label }: TimetableSlotProps) { + return ( +
+

{label}

+
+ ); +} diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx index 14a26e3..2dcac54 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -1,8 +1,28 @@ import TimeTag from "@/components/time_tag"; -import { getVisibleTimetableRect } from "@/components/timetable"; +import { + getTimeTopPosition, + getVisibleTimetableRect, +} from "@/components/timetable"; import TopicTag from "@/components/topic_tag"; -function getTimetableTaskDataProperties(task: HTMLElement) { +/* +An interface for the data properties of a TimetableTask with numerical times +in hours (rather than minutes as they are stored). +*/ +interface timetableTaskDataProperties { + day: string; + hour: string; + duration_hours: number; + start_offset_hours: number; +} + +/* +Returns the data properties of a timetable task element in an object format +with minute data converted to hours. +*/ +function getTimetableTaskDataProperties( + task: HTMLElement, +): undefined | timetableTaskDataProperties { const day = task.dataset.day; if (day == undefined) return; @@ -26,123 +46,97 @@ function getTimetableTaskDataProperties(task: HTMLElement) { } /* -NOTE: there is a small issue with this code which causes the TimetableTask to -expand to the size of the column + border while the next column is offscreen -before returning to just the size of the column (no border) once the next -column becomes visible. - -Pretty sure the issue is because the client rect.right includes the border -which I don't want it to. +Sets the size and position of the timetable task provided in the task parameter. */ export function resizeAndPositionTimetableTask(task: HTMLElement) { - // Get task if (task == null) return; - // Get task data + const task_data = getTimetableTaskDataProperties(task); if (task_data == null) return; const { day, hour, duration_hours, start_offset_hours } = task_data; - // Get visible area - const visible = getVisibleTimetableRect(); - if (visible == null) return; - // Get column - const col = document.getElementById(day); - if (col == null) return; - // Get row - const row = document.getElementById(hour); - if (row == null) return; - // Get dimensions of column and row - const col_rect = col.getBoundingClientRect(); - const row_rect = row.getBoundingClientRect(); - - // Calculate dimensions - let left, top, width, height; - let left_width_reduction = 0; - if (col_rect.left > visible.left) { - left = col_rect.left; - } else { - left = visible.left; - left_width_reduction = visible.left - col_rect.left; - } + const col_rect = document.getElementById(day)?.getBoundingClientRect(); + if (col_rect == undefined) return; - width = - col_rect.right < visible.right ? row_rect.width : visible.right - left; - width -= left_width_reduction; + const row_rect = document.getElementById(hour)?.getBoundingClientRect(); + if (row_rect == undefined) return; const one_hour_height = row_rect.height; const duration_height = one_hour_height * duration_hours; - const offset_top = row_rect.top + one_hour_height * start_offset_hours; - let top_height_reduction = 0; - if (offset_top > visible.top) { - top = offset_top; - } else { - top = visible.top; - top_height_reduction = visible.top - offset_top; - } + const hours = Number(hour.substring(0, 2)); + const time_top = getTimeTopPosition(hours, start_offset_hours * 60); + if (time_top == undefined) return; - height = - top + duration_height < visible.bottom - ? duration_height - : visible.bottom - top; - height -= top_height_reduction; - - // Set dimensions - task.style.left = left + "px"; - task.style.top = top + "px"; - task.style.width = width + "px"; - task.style.height = height + "px"; - - // Hide elements not in the visible area - const right = left + width; - const bottom = top + height; - const is_visible = !( - right <= visible.left || - left >= visible.right || - bottom <= visible.top || - top >= visible.bottom - ); - if (is_visible) { - task.style.display = "inline"; - } else { - task.style.display = "none"; - } + const parent_rect = task.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + task.style.left = col_rect.left - parent_rect.left + "px"; + task.style.top = time_top - parent_rect.top + "px"; + task.style.width = row_rect.width + "px"; + task.style.height = duration_height + "px"; } +/* +Sets the size and position of the timetable task tooltip provided in the +parameter. Will position the tooltip to the side (left/right) with the most +space in the visible timetable area. +*/ function resizeAndPositionTimetableTaskTooltip(tooltip: HTMLElement) { const task_id = tooltip.id.slice(0, -"-tooltip".length); const task = document.getElementById(task_id); if (task == null) return; const task_rect = task.getBoundingClientRect(); - if (task_rect == undefined) return; tooltip.style.display = "flex"; const tooltip_rect = tooltip.getBoundingClientRect(); - if (tooltip_rect == undefined) return; const visible = getVisibleTimetableRect(); if (visible == undefined) return; - if (task_rect.left - visible.left > visible.right - task_rect.right) { - tooltip.style.left = task_rect.left - tooltip_rect.width + "px"; + const left_space = task_rect.left - visible.left; + const right_space = visible.right - task_rect.right; + + let left, top, width, height; + + if (left_space > right_space) { + left = task_rect.left - tooltip_rect.width; + // Tooltip has a max width so won't get too big + width = left_space; } else { - tooltip.style.left = task_rect.right + "px"; + left = task_rect.right; + // Tooltip has a max width so won't get too big + width = right_space; + } + + if (tooltip_rect.height > visible.height) { + height = visible.height; } const target_pos = task_rect.top + task_rect.height / 2 - tooltip_rect.height / 2; - let tooltip_top = target_pos; + top = target_pos; + if (target_pos < visible.top) { - tooltip_top = visible.top; - } else if (target_pos > visible.bottom) { - tooltip_top = visible.bottom - tooltip_rect.height; + top = visible.top; + } else if (target_pos > visible.bottom - tooltip_rect.height) { + top = visible.bottom - tooltip_rect.height; } - tooltip.style.top = tooltip_top + "px"; + const parent_rect = tooltip.parentElement?.getBoundingClientRect(); + if (parent_rect == undefined) return; + + tooltip.style.left = left - parent_rect.left + "px"; + tooltip.style.top = top - parent_rect.top + "px"; + tooltip.style.width = width + "px"; + tooltip.style.height = height + "px"; } +/* +Calls resizeAndPositionTimetableTask on each TimetableTask Element. +*/ export function resizeAndPositionTimetableTasks() { const tasks = document.getElementsByClassName("timetable-task"); for (let i = 0; i < tasks.length; i++) { @@ -151,6 +145,9 @@ export function resizeAndPositionTimetableTasks() { } } +/* +Database representation of Topic. +*/ export interface Topic { id: number; name: string; @@ -158,6 +155,17 @@ export interface Topic { user: number; } +/* +Props for a TimetableTaskContent element. + +@prop name: The string to display as the title. +@prop start_time: The string containing the starting time in HH:MM:DD format. +@prop end_time: The string containing the ending time in HH:MM:DD format. +@prop topics: Topic data to display as TopicTags. +@prop description: String containing the description of the task. +@prop time_display: Optional string, controls display mode of the TimeTag, see +TimeTag for more information. +*/ export interface TimetableTaskContentProps { name: string; start_time: string; @@ -167,6 +175,10 @@ export interface TimetableTaskContentProps { time_display?: string; } +/* +The contents of a Timetable Task inlcuding title, time tag, topic tags, and +description. Separated into its own component to be reusable for tooltips. +*/ function TimetableTaskContent({ name, start_time, @@ -199,6 +211,25 @@ function TimetableTaskContent({ ); } +/* +The props for a TimetableTask. + +@prop id: The id to give the TimetableTask, typically task_id:time_id. +@prop name: The title of the Task. +@topics: The topics assigned to the Task. +@description: The description of the Task. +@completed: The completion status of the Task. +@day: The day (string) the time is on. +@start_time: A string representing the start time in HH:MM:SS format, (used +to calculate position). +@end_time: A string representing the end time in HH:MM:SS format. +@duration_minutes: The duration of the time assigned to the task in minutes +(used to calculate display height). +@clash: A boolean indicating whether the timetable task represents a clash +(time with two overlapping tasks). +@tooltip_props: The props of the TimetableTaskContents to display in the tooltip. + +*/ export interface TimetableTaskProps { id: string; name: string; @@ -213,6 +244,15 @@ export interface TimetableTaskProps { tooltip_props: TimetableTaskContentProps[]; } +/* +A rounded rectangle representing a Time on the timetable which has been assigned +to a Task on a specific day. + +Contains the information of the task as well as a tooltip that displays while +mouse is over the TimetableTask or tooltip that also displays the task +information (allows user to view full content of tasks if task does not have +enough space, as well as times that have multiple tasks assigned to them). +*/ function TimetableTask({ id, name, @@ -255,7 +295,7 @@ function TimetableTask({
- {tooltip_props.map((props) => ( -
- -
- ))} +
+ {tooltip_props.map((props) => ( +
+ +
+ ))} +
); diff --git a/client/src/components/topic_tag.tsx b/client/src/components/topic_tag.tsx index 82901d4..17446b1 100644 --- a/client/src/components/topic_tag.tsx +++ b/client/src/components/topic_tag.tsx @@ -1,8 +1,17 @@ +/* +@prop name: The String to display within the topic tag. +@prop color_hex: The integer representing the hex code of the color to display +within the circle in the tag. +*/ interface TopicTagProps { name: string; color_hex: number; } +/* +A small rounded label containing the topic's name and a circle of the colour +stored in color_hex. +*/ function TopicTag({ name, color_hex }: TopicTagProps) { const bg_color = "#" + color_hex.toString(16).padStart(6, "0"); diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx index 99704b6..8eec7a2 100644 --- a/client/src/pages/[id]/schedule.tsx +++ b/client/src/pages/[id]/schedule.tsx @@ -2,10 +2,13 @@ import { useEffect, useState } from "react"; import Timetable, { getDurationMinutes, - resizeTimetableElements, + resizeAndPositionTimetableElements, } from "@/components/timetable"; -import TimetableTask, { TimetableTaskProps } from "@/components/timetable_task"; +import { TimetableTaskProps } from "@/components/timetable_task"; +/* +Database representation of Time. +*/ interface Time { id: number; day: number; @@ -15,6 +18,9 @@ interface Time { task: number; } +/* +Database representation of Topic. +*/ interface Topic { id: number; name: string; @@ -22,6 +28,9 @@ interface Topic { user: number; } +/* +Database representation of Task. +*/ interface Task { id: number; name: string; @@ -32,6 +41,9 @@ interface Task { times: Time[]; } +/* +Used to convert database representation of a day to string used by front end. +*/ enum Day { Monday = 0, Tuesday, @@ -42,22 +54,33 @@ enum Day { Sunday, } +/* +Page containing a Timetable to visually represent the times that tasks have been +assigned. +*/ function Schedule() { - const [timetableTaskProps, setTimetableTaskProps] = useState< + const [timetableTasksProps, setTimetableTasksProps] = useState< TimetableTaskProps[] >([]); - /* This effect runs in an infinte loop if timetableTaskProps is defined as a - dependency */ + /* + This effect runs in an infinte loop IF timetableTasksProps is defined as a + dependency. To prevent this an empty array has been provided to the + dependencies argument. + */ useEffect(() => { function addEventListeners() { - window.addEventListener("resize", resizeTimetableElements); + window.addEventListener("resize", resizeAndPositionTimetableElements); } function removeEventListeners() { - window.removeEventListener("resize", resizeTimetableElements); + window.removeEventListener("resize", resizeAndPositionTimetableElements); } + /* + Get the task data from the API and format the tasks into the TimetableTasks + to be displayed. Sets the timetableTasksProps State variable once finished. + */ async function fetchTasks() { const API_URL = "http://localhost:8000/api/planner/task/"; try { @@ -70,12 +93,18 @@ function Schedule() { await formatTaskDataToTimetableTaskProps(data); timetable_task_props = await formatTimetableClashes(timetable_task_props); - setTimetableTaskProps(timetable_task_props); + setTimetableTasksProps(timetable_task_props); } catch (error) { console.error(error); } } + /* + Format the task data from the API into TimetableTaskProps. Creates a + TimetableTaskProps object for each Task x Time where Time is assigned to + Task (i.e. if a Task has 4 times assigned to it 4 sets of TimetableTaskProps + are created). + */ async function formatTaskDataToTimetableTaskProps(data: Task[]) { const timetable_task_props: TimetableTaskProps[] = []; for (let i = 0; i < data.length; i++) { @@ -111,6 +140,10 @@ function Schedule() { return timetable_task_props; } + /* + Convert a time string in format HH:MM:TT to a minute representation where + 0 represents 00:00:00, 60 represents 01:00:00 etc. + */ function timeToMins(time: string) { const hours = Number(time.substring(0, 2)); const mins = Number(time.substring(3, 5)); @@ -199,6 +232,11 @@ function Schedule() { } } + /* + Checks for timetable clashes between TimetableTasks, creates new + TimetableTasks for each clash found and fixes the information of the + clashing tasks to remove the clashing section. + */ async function formatTimetableClashes( timetable_task_props: TimetableTaskProps[], ) { @@ -232,17 +270,13 @@ function Schedule() { removeEventListeners(); }; }, []); - /* This should be dependent on timetableTaskProps however doing so causes it + /* This should be dependent on timetableTasksProps however doing so causes it to run in an infinite loop. */ return ( -
-
- - {timetableTaskProps.map((props) => ( - - ))} - +
+
+
);