diff --git a/client/src/components/time_indicator.tsx b/client/src/components/time_indicator.tsx new file mode 100644 index 0000000..f053a70 --- /dev/null +++ b/client/src/components/time_indicator.tsx @@ -0,0 +1,61 @@ +import { + getTimeTopPosition, + getVisibleTimetableRect, +} from "@/components/timetable"; + +export function resizeAndPositionTimeIndicator() { + const time_indicator = document.getElementById("time-indicator"); + if (time_indicator == null) return; + + const now_label = document.getElementById("time-indicator-label"); + if (now_label == null) 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 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"; + } +} + +function TimeIndicator() { + return ( + <> +
+

Now

+
+
+ + ); +} + +export default TimeIndicator; diff --git a/client/src/components/time_tag.tsx b/client/src/components/time_tag.tsx new file mode 100644 index 0000000..6e02758 --- /dev/null +++ b/client/src/components/time_tag.tsx @@ -0,0 +1,37 @@ +import { getDurationMinutes } from "@/components/timetable"; + +interface TimeTagProps { + start_time: string; + end_time: string; + display?: string; +} + +function TimeTag({ start_time, end_time, display }: TimeTagProps) { + const time_string = + start_time.substring(0, 5) + "-" + end_time.substring(0, 5); + + const duration_minutes = getDurationMinutes(start_time, end_time); + const hours = Math.floor(duration_minutes / 60); + const minutes = duration_minutes % 60; + const duration_string = hours + "h " + minutes + "m"; + + let display_string = time_string; // Default to time string + if (display != undefined) { + display = display.toLowerCase(); + if (display == "duration") display_string = duration_string; + else if (display == "both") display_string += " (" + duration_string + ")"; + } + + return ( +
+
+

{display_string}

+
+ ); +} + +export default TimeTag; diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx new file mode 100644 index 0000000..a4c9207 --- /dev/null +++ b/client/src/components/timetable.tsx @@ -0,0 +1,204 @@ +import { ReactElement, useEffect } from "react"; + +import TimeIndicator, { + resizeAndPositionTimeIndicator, +} from "@/components/time_indicator"; +import { + TimetableDayColumn, + TimetableTimeColumn, +} from "@/components/timetable_column"; +import { resizeAndPositionTimetableTasks } from "@/components/timetable_task"; + +export function getVisibleTimetableRect() { + 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 rect = { + left: time_header_rect.right, + right: timetable_barrier_rect.left + timetable_barrier.clientWidth, + top: time_header_rect.bottom, + bottom: timetable_barrier_rect.top + timetable_barrier.clientHeight, + width: 0, + height: 0, + }; + rect.width = rect.right - rect.left; + rect.height = rect.bottom - rect.top; + + return rect; +} + +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); + const end_hour: number = +end_time.substring(0, 2); + const end_minute: number = +end_time.substring(3, 5); + return 60 * (end_hour - start_hour) + (end_minute - start_minute); +} + +export function getTimeTopPosition(hour: number, mins: number) { + const hour_label = document.getElementById(hour + ":00:00"); + 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; + + return top; +} + +export function getDayLeftPosition(day: string) { + const day_label = document.getElementById(day); + if (day_label == null) return; + return day_label.getBoundingClientRect().left; +} + +export function resizeTimetableElements() { + resizeAndPositionTimetableTasks(); + resizeAndPositionTimeIndicator(); +} + +export function scrollToCurrentTime(align?: string) { + const timetable_barrier = document.getElementById("timetable-barrier"); + if (timetable_barrier == null) return; + + const visible = getVisibleTimetableRect(); + if (visible == undefined) return; + + const timetable = document.getElementById("timetable"); + 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; + + 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; + + timetable_barrier.scrollTop = scroll_amount; +} + +export function scrollToCurrentDay(align?: string) { + const timetable_barrier = document.getElementById("timetable-barrier"); + if (timetable_barrier == null) return; + + const visible = getVisibleTimetableRect(); + if (visible == undefined) return; + + const timetable = document.getElementById("timetable"); + if (timetable == null) return; + + const time_header = document.getElementById("time-header"); + if (time_header == undefined) 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, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, + } + + const now = new Date(Date.now()); + const today = DateDay[now.getDay()]; + const today_left = getDayLeftPosition(today); + if (today_left == undefined) return; + + let target_left; + if (align == "left") target_left = visible.left; + 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; +} + +export function scrollToCurrentTimeAndDay( + time_align?: string, + day_align?: string, +) { + scrollToCurrentTime(time_align); + scrollToCurrentDay(day_align); +} + +interface TimetableProps { + children: ReactElement[]; +} + +function Timetable({ children }: TimetableProps) { + useEffect(() => { + resizeTimetableElements(); + scrollToCurrentTimeAndDay(); + }); + + return ( +
+
+
+ + + + + + + + + +
+ {children} +
+
+
+
+ ); +} + +export default Timetable; diff --git a/client/src/components/timetable_column.tsx b/client/src/components/timetable_column.tsx new file mode 100644 index 0000000..60de809 --- /dev/null +++ b/client/src/components/timetable_column.tsx @@ -0,0 +1,101 @@ +import React, { ReactElement } from "react"; + +import TimetableSlot from "./timetable_slot"; + +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"; + } + + return ( +
+ {children} +
+ ); +} + +export function TimetableDayColumn({ label, day }: TimetableColumnProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function TimetableTimeColumn() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/client/src/components/timetable_slot.tsx b/client/src/components/timetable_slot.tsx new file mode 100644 index 0000000..8e44e32 --- /dev/null +++ b/client/src/components/timetable_slot.tsx @@ -0,0 +1,49 @@ +interface TimetableSlotProps { + label?: string; + header?: boolean; + time_label?: boolean; + time: string; +} + +function TimetableSlot({ + label, + header, + time_label, + time, +}: TimetableSlotProps) { + let class_name = + "timetable-slot w-full h-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"; + } else { + class_name = + class_name + + " bg-slate-600 \ + 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"; + } + + // There's probably a better way to do this + if (time_label === true) + return ( +
+

{label}

+
+ ); + else + return ( +
+

{label}

+
+ ); +} + +export default TimetableSlot; diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx new file mode 100644 index 0000000..14a26e3 --- /dev/null +++ b/client/src/components/timetable_task.tsx @@ -0,0 +1,300 @@ +import TimeTag from "@/components/time_tag"; +import { getVisibleTimetableRect } from "@/components/timetable"; +import TopicTag from "@/components/topic_tag"; + +function getTimetableTaskDataProperties(task: HTMLElement) { + const day = task.dataset.day; + if (day == undefined) return; + + const hour = task.dataset.start_hour; + if (hour == undefined) return; + + const duration_minutes_data = task.dataset.duration_minutes; + if (duration_minutes_data == undefined) return; + const duration_hours = Number(duration_minutes_data) / 60; + + const start_offset_data = task.dataset.start_offset; + if (start_offset_data == undefined) return; + const start_offset_hours = Number(start_offset_data) / 60; + + return { + day: day, + hour: hour, + duration_hours: duration_hours, + start_offset_hours: start_offset_hours, + }; +} + +/* +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. +*/ +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; + } + + width = + col_rect.right < visible.right ? row_rect.width : visible.right - left; + width -= left_width_reduction; + + 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; + } + + 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"; + } +} + +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"; + } else { + tooltip.style.left = task_rect.right + "px"; + } + + const target_pos = + task_rect.top + task_rect.height / 2 - tooltip_rect.height / 2; + + let tooltip_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; + } + + tooltip.style.top = tooltip_top + "px"; +} + +export function resizeAndPositionTimetableTasks() { + const tasks = document.getElementsByClassName("timetable-task"); + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + resizeAndPositionTimetableTask(task); + } +} + +export interface Topic { + id: number; + name: string; + color_hex: number; + user: number; +} + +export interface TimetableTaskContentProps { + name: string; + start_time: string; + end_time: string; + topics?: Topic[]; + description?: string; + time_display?: string; +} + +function TimetableTaskContent({ + name, + start_time, + end_time, + topics, + description, + time_display, +}: TimetableTaskContentProps) { + return ( +
+

{name}

+ +
+ {topics?.map((topic) => ( + + ))} +
+
+

{description}

+
+
+ ); +} + +export interface TimetableTaskProps { + id: string; + name: string; + topics?: Topic[]; + description?: string; + completed: boolean; + day: string; + start_time: string; + end_time: string; + duration_minutes: number; + clash?: boolean; + tooltip_props: TimetableTaskContentProps[]; +} + +function TimetableTask({ + id, + name, + topics, + description, + completed, + day, + start_time, + end_time, + duration_minutes, + clash, + tooltip_props, +}: TimetableTaskProps) { + let additional_style = " "; + + if (completed === true) { + additional_style += "opacity-75 "; + } + + if (clash === true) { + additional_style += "bg-slate-700 hover:bg-slate-500 "; + } else { + additional_style += "bg-slate-400 hover:bg-slate-300 "; + } + + const mouseOverHandler = () => { + const tooltip = document.getElementById(id + "-tooltip"); + if (tooltip == null) return; + resizeAndPositionTimetableTaskTooltip(tooltip); // Also sets display + }; + + const mouseOutHandler = () => { + const tooltip = document.getElementById(id + "-tooltip"); + if (tooltip == null) return; + tooltip.style.display = "none"; + }; + + return ( + <> +
+ +
+
+ {tooltip_props.map((props) => ( +
+ +
+ ))} +
+ + ); +} + +export default TimetableTask; diff --git a/client/src/components/topic_tag.tsx b/client/src/components/topic_tag.tsx new file mode 100644 index 0000000..82901d4 --- /dev/null +++ b/client/src/components/topic_tag.tsx @@ -0,0 +1,22 @@ +interface TopicTagProps { + name: string; + color_hex: number; +} + +function TopicTag({ name, color_hex }: TopicTagProps) { + const bg_color = "#" + color_hex.toString(16).padStart(6, "0"); + + return ( +
+
+ {" "} +
+

{name}

+
+ ); +} + +export default TopicTag; diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx new file mode 100644 index 0000000..99704b6 --- /dev/null +++ b/client/src/pages/[id]/schedule.tsx @@ -0,0 +1,251 @@ +import { useEffect, useState } from "react"; + +import Timetable, { + getDurationMinutes, + resizeTimetableElements, +} from "@/components/timetable"; +import TimetableTask, { TimetableTaskProps } from "@/components/timetable_task"; + +interface Time { + id: number; + day: number; + start_time: string; + end_time: string; + repeating: boolean; + task: number; +} + +interface Topic { + id: number; + name: string; + color_hex: number; + user: number; +} + +interface Task { + id: number; + name: string; + description: string; + completed: boolean; + user: number; + topics: Topic[]; + times: Time[]; +} + +enum Day { + Monday = 0, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, + Sunday, +} + +function Schedule() { + const [timetableTaskProps, setTimetableTaskProps] = useState< + TimetableTaskProps[] + >([]); + + /* This effect runs in an infinte loop if timetableTaskProps is defined as a + dependency */ + useEffect(() => { + function addEventListeners() { + window.addEventListener("resize", resizeTimetableElements); + } + + function removeEventListeners() { + window.removeEventListener("resize", resizeTimetableElements); + } + + async function fetchTasks() { + const API_URL = "http://localhost:8000/api/planner/task/"; + try { + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } + const data = await response.json(); + let timetable_task_props = + await formatTaskDataToTimetableTaskProps(data); + timetable_task_props = + await formatTimetableClashes(timetable_task_props); + setTimetableTaskProps(timetable_task_props); + } catch (error) { + console.error(error); + } + } + + async function formatTaskDataToTimetableTaskProps(data: Task[]) { + const timetable_task_props: TimetableTaskProps[] = []; + for (let i = 0; i < data.length; i++) { + const task = data[i]; + for (let j = 0; j < task.times.length; j++) { + const time = task.times[j]; + const props = { + id: task.id + ":" + time.id, + name: task.name, + topics: task.topics, + description: task.description, + completed: task.completed, + day: Day[time.day], + start_time: time.start_time, + end_time: time.end_time, + duration_minutes: getDurationMinutes( + time.start_time, + time.end_time, + ), + tooltip_props: [ + { + name: task.name, + start_time: time.start_time, + end_time: time.end_time, + topics: task.topics, + description: task.description, + }, + ], + }; + timetable_task_props.push(props); + } + } + return timetable_task_props; + } + + function timeToMins(time: string) { + const hours = Number(time.substring(0, 2)); + const mins = Number(time.substring(3, 5)); + return hours * 60 + mins; + } + + /* + Returns null if no clash exists, otherwise returns clash_props as a + TimetableTaskProps and alters the parameters to not clash anymore. + + NOTE: If there is a clash between two tasks that have the same start_time + or the same end_time one or both of them will be given a duration of 0. + */ + function checkForTimetableClash( + task_a: TimetableTaskProps, + task_b: TimetableTaskProps, + ): null | TimetableTaskProps { + if (task_a.day !== task_b.day) return null; + if (task_a.duration_minutes === 0 || task_b.duration_minutes === 0) + return null; + + const start_a = timeToMins(task_a.start_time); + const end_a = timeToMins(task_a.end_time); + const start_b = timeToMins(task_b.start_time); + const end_b = timeToMins(task_b.end_time); + + let first_task, second_task; + // First start is commented out to avoid a linting error (unused variable) + let /*first_start,*/ first_end, second_start, second_end; + if (start_a <= start_b) { + first_task = task_a; + /*first_start = start_a;*/ first_end = end_a; + second_task = task_b; + second_start = start_b; + second_end = end_b; + } else { + first_task = task_b; + /*first_start = start_b;*/ first_end = end_b; + second_task = task_a; + second_start = start_a; + second_end = end_a; + } + + if (second_start < first_end) { + const clash_props = { + id: first_task.id + "/" + second_task.id, + name: "Multiple Tasks", + topics: [], + description: "Hover for more details...", + completed: false, + day: first_task.day, + start_time: second_task.start_time, + end_time: first_task.end_time, + duration_minutes: 0, // Set just below + clash: true, + tooltip_props: first_task.tooltip_props.concat( + second_task.tooltip_props, + ), + }; + clash_props.duration_minutes = getDurationMinutes( + clash_props.start_time, + clash_props.end_time, + ); + + /* Second task is contained within first task so is replaced completely + by the clash, the first task in then split into 2 using a deep copy */ + if (second_end < first_end) { + second_task = JSON.parse(JSON.stringify(first_task)); // Deep copy + second_task.id += ".2"; // Make the ID unique again + } + + first_task.end_time = clash_props.start_time; + first_task.duration_minutes = getDurationMinutes( + first_task.start_time, + first_task.end_time, + ); + second_task.start_time = clash_props.end_time; + second_task.duration_minutes = getDurationMinutes( + second_task.start_time, + second_task.end_time, + ); + + return clash_props; + } else { + return null; + } + } + + async function formatTimetableClashes( + timetable_task_props: TimetableTaskProps[], + ) { + for (let i = 0; i < timetable_task_props.length; i++) { + const current_prop = timetable_task_props[i]; + + for (let j = i + 1; j < timetable_task_props.length; j++) { + const compare_prop = timetable_task_props[j]; + + const clash_props = checkForTimetableClash( + current_prop, + compare_prop, + ); + if (clash_props === null) continue; + + timetable_task_props.push(clash_props); + } + } + + // Remove tasks that are contained within a clash + timetable_task_props = timetable_task_props.filter( + (props) => props.duration_minutes > 0, + ); + return timetable_task_props; + } + + addEventListeners(); + fetchTasks(); + + return () => { + removeEventListeners(); + }; + }, []); + /* This should be dependent on timetableTaskProps however doing so causes it + to run in an infinite loop. */ + + return ( +
+
+ + {timetableTaskProps.map((props) => ( + + ))} + +
+
+ ); +} + +export default Schedule;