From 0788cacc166362019e94699d3e21b557fd2a8940 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Tue, 16 Dec 2025 09:58:03 +0000 Subject: [PATCH 01/10] created timetable component for schedule page --- client/src/components/timetable.tsx | 146 ++++++++++++++++++++++++++++ client/src/pages/schedule.tsx | 19 ++++ 2 files changed, 165 insertions(+) create mode 100644 client/src/components/timetable.tsx create mode 100644 client/src/pages/schedule.tsx diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx new file mode 100644 index 0000000..5b06445 --- /dev/null +++ b/client/src/components/timetable.tsx @@ -0,0 +1,146 @@ +import { ReactElement } from "react"; + +interface TimetableSlotProps { + label?: string; + header?: boolean; + time_label?: boolean; + time: string; +} + +interface TimetableColumnProps { + children?: ReactElement[]; + sticky?: boolean; + label: string; + day: 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"; + + if (header === true) { + class_name = class_name + " bg-slate-800 sticky top-0 z-10"; + } else { + class_name = + class_name + + " bg-slate-600 \ + border-solid 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"; + } + + return ( +
+

{label}

+
+ ); +} + +function TimetableColumn({ children, day, sticky }: TimetableColumnProps) { + let class_name = + "timetable-column w-full min-w-48 h-full \ + flex flex-col bg-slate-500 border-r-4 border-slate-900"; + + if (sticky === true) { + class_name = class_name + " sticky left-0 z-10"; + } else { + class_name = class_name + " z-0"; + } + + return ( +
+ {children} +
+ ); +} + +function TimetableDayColumn({ label, day }: TimetableColumnProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TimetableTimeColumn() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function Timetable() { + return ( +
+
+
+ + + + + + + + +
+
+
+ ); +} diff --git a/client/src/pages/schedule.tsx b/client/src/pages/schedule.tsx new file mode 100644 index 0000000..d0f08b9 --- /dev/null +++ b/client/src/pages/schedule.tsx @@ -0,0 +1,19 @@ +import Timetable from "@/components/timetable"; + +function Schedule() { + return ( +
+
+ +
+
+
+

Tasks

+
+
+
+
+ ); +} + +export default Schedule; From 11ab32aff87c21f4776dbf7f4e3bbe218cc07404 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Thu, 18 Dec 2025 14:25:35 +0000 Subject: [PATCH 02/10] created TimetableTask component and implemented resizeAndPositionTimetableTask --- client/src/components/timetable.tsx | 66 ++++++++--- client/src/components/timetable_task.tsx | 140 +++++++++++++++++++++++ client/src/pages/schedule.tsx | 7 +- 3 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 client/src/components/timetable_task.tsx diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index 5b06445..e586bb3 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -1,4 +1,6 @@ -import { ReactElement } from "react"; +import React, { ReactElement } from "react"; + +import TimetableTask, { resizeAndPositionTimetableTask } from "@/components/timetable_task"; interface TimetableSlotProps { label?: string; @@ -22,26 +24,37 @@ function TimetableSlot({ }: TimetableSlotProps) { let class_name = "timetable-slot w-full h-full min-h-16 \ - flex justify-center items-center"; + flex justify-center items-center border-solid "; if (header === true) { - class_name = class_name + " bg-slate-800 sticky top-0 z-10"; + 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-solid border-t border-b border-t-slate-400 border-b-slate-400"; + 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"; } - return ( -
-

{label}

-
- ); + // There's probably a better way to do this + if (time_label === true) + return ( +
+

{label}

+
+ ); + else + return ( +
+

{label}

+
+ ); } function TimetableColumn({ children, day, sticky }: TimetableColumnProps) { @@ -56,7 +69,7 @@ function TimetableColumn({ children, day, sticky }: TimetableColumnProps) { } return ( -
+
{children}
); @@ -65,7 +78,7 @@ function TimetableColumn({ children, day, sticky }: TimetableColumnProps) { function TimetableDayColumn({ label, day }: TimetableColumnProps) { return ( - + @@ -96,8 +109,13 @@ function TimetableDayColumn({ label, day }: TimetableColumnProps) { function TimetableTimeColumn() { return ( - - + + @@ -128,9 +146,16 @@ function TimetableTimeColumn() { export default function Timetable() { return ( -
-
-
+
+
+
@@ -139,6 +164,15 @@ export default function Timetable() { +
+ +
diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx new file mode 100644 index 0000000..31411f3 --- /dev/null +++ b/client/src/components/timetable_task.tsx @@ -0,0 +1,140 @@ +import { useEffect } from "react"; + +interface TimetableTaskProps { + title: string; + day: string; + start_time: string; + end_time: string; + duration_minutes: number; + description?: string; +} + +function getVisibleTimetableRect() { + const time_header = document.getElementById("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(); + + return { + top: time_header_rect.bottom, + bottom: timetable_barrier_rect.bottom, + left: time_header_rect.right, + right: timetable_barrier_rect.right, + }; +} + +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, + }; +} + +export function resizeAndPositionTimetableTask() { + // Get task + const task = document.getElementById("test"); + 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(); + // Check if cell is visible + const is_visible = !( + col_rect.right <= visible.left || + col_rect.left >= visible.right || + row_rect.bottom <= visible.top || + row_rect.top >= visible.bottom + ); + if (is_visible) { + task.style.display = "inline"; + } else { + task.style.display = "none"; + return; + } + // Calculate dimensions + const left = col_rect.left > visible.left ? col_rect.left : visible.left; + const width = + col_rect.right < visible.right ? row_rect.width : visible.right - left; + const one_hour_height = row_rect.height; + const offset_top = row_rect.top + one_hour_height * start_offset_hours; + const top = offset_top > visible.top ? offset_top : visible.top; + const duration_height = one_hour_height * duration_hours; + const height = + top + duration_height < visible.bottom + ? duration_height + : visible.bottom - top; + // Set dimensions + task.style.left = left + "px"; + task.style.top = top + "px"; + task.style.width = width + "px"; + task.style.height = height + "px"; +} + +function TimetableTask({ + title, + day, + start_time, + end_time, + duration_minutes, + description, +}: TimetableTaskProps) { + // remove this useEffect + useEffect(() => { + window.addEventListener("resize", resizeAndPositionTimetableTask); + + return () => { + window.removeEventListener("resize", resizeAndPositionTimetableTask); + }; + }); + + return ( +
+

{title}

+

{start_time.substring(0, 5) + "-" + end_time.substring(0, 5)}

+

{description}

+
+ ); +} + +export default TimetableTask; diff --git a/client/src/pages/schedule.tsx b/client/src/pages/schedule.tsx index d0f08b9..4cb0825 100644 --- a/client/src/pages/schedule.tsx +++ b/client/src/pages/schedule.tsx @@ -2,12 +2,11 @@ import Timetable from "@/components/timetable"; function Schedule() { return ( -
-
+
+
-
-
+

Tasks

From c5083d1bb44c6bbff03814c3203129c5819cd106 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Fri, 19 Dec 2025 10:15:24 +0000 Subject: [PATCH 03/10] improved resizeAndPosition function to account for scroll bars and adjust width and height properly, added resizeAndPositionTimetableTasks, added onScroll and resize listeners for this function --- client/src/components/timetable.tsx | 28 ++++-- client/src/components/timetable_task.tsx | 119 ++++++++++++++--------- client/src/pages/schedule.tsx | 11 +++ 3 files changed, 106 insertions(+), 52 deletions(-) diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index e586bb3..040ab4e 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -1,6 +1,8 @@ import React, { ReactElement } from "react"; -import TimetableTask, { resizeAndPositionTimetableTask } from "@/components/timetable_task"; +import TimetableTask, { + resizeAndPositionTimetableTasks, +} from "@/components/timetable_task"; interface TimetableSlotProps { label?: string; @@ -150,12 +152,12 @@ export default function Timetable() { id="timetable-border" className="h-full w-full rounded-lg bg-slate-900 p-3" > -
-
+
+
@@ -164,7 +166,17 @@ export default function Timetable() { -
+
+ = visible.right || - row_rect.bottom <= visible.top || - row_rect.top >= visible.bottom - ); - if (is_visible) { - task.style.display = "inline"; + + // Calculate dimensions + let left, top, width, height; + + let left_width_reduction = 0; + if (col_rect.left > visible.left) { + left = col_rect.left; } else { - task.style.display = "none"; - return; + left = visible.left; + left_width_reduction = visible.left - col_rect.left; } - // Calculate dimensions - const left = col_rect.left > visible.left ? col_rect.left : visible.left; - const width = + + width = col_rect.right < visible.right ? row_rect.width : visible.right - left; + width -= left_width_reduction; + const one_hour_height = row_rect.height; - const offset_top = row_rect.top + one_hour_height * start_offset_hours; - const top = offset_top > visible.top ? offset_top : visible.top; const duration_height = one_hour_height * duration_hours; - const height = + 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.visibility = "visible"; + } else { + task.style.visibility = "hidden"; + } +} + +export function resizeAndPositionTimetableTasks() { + const task_container = document.getElementById("timetable-tasks"); + if (task_container == null) return; + + const tasks = task_container.children; + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + resizeAndPositionTimetableTask(task); + } +} + +interface TimetableTaskProps { + title: string; + day: string; + start_time: string; + end_time: string; + duration_minutes: number; + description?: string; } function TimetableTask({ @@ -109,20 +150,10 @@ function TimetableTask({ duration_minutes, description, }: TimetableTaskProps) { - // remove this useEffect - useEffect(() => { - window.addEventListener("resize", resizeAndPositionTimetableTask); - - return () => { - window.removeEventListener("resize", resizeAndPositionTimetableTask); - }; - }); - return (
{ + window.addEventListener("resize", resizeAndPositionTimetableTasks); + + return () => { + window.removeEventListener("resize", resizeAndPositionTimetableTasks); + }; + }); + return (
From 64923364e4d5893b6ceedc169bfd4b3b1ac19dd8 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Sat, 27 Dec 2025 03:11:00 +0000 Subject: [PATCH 04/10] added task creation based on api response, added task information to timetable tasks, added a current time indicator --- client/src/components/time_indicator.tsx | 63 +++++++ client/src/components/time_tag.tsx | 15 ++ client/src/components/timetable.tsx | 193 ++++----------------- client/src/components/timetable_column.tsx | 101 +++++++++++ client/src/components/timetable_slot.tsx | 49 ++++++ client/src/components/timetable_task.tsx | 67 ++++--- client/src/components/topic_tag.tsx | 27 +++ client/src/pages/[id]/schedule.tsx | 135 ++++++++++++++ client/src/pages/schedule.tsx | 29 ---- 9 files changed, 467 insertions(+), 212 deletions(-) create mode 100644 client/src/components/time_indicator.tsx create mode 100644 client/src/components/time_tag.tsx create mode 100644 client/src/components/timetable_column.tsx create mode 100644 client/src/components/timetable_slot.tsx create mode 100644 client/src/components/topic_tag.tsx create mode 100644 client/src/pages/[id]/schedule.tsx delete mode 100644 client/src/pages/schedule.tsx diff --git a/client/src/components/time_indicator.tsx b/client/src/components/time_indicator.tsx new file mode 100644 index 0000000..369884f --- /dev/null +++ b/client/src/components/time_indicator.tsx @@ -0,0 +1,63 @@ +import { 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 min = now.getMinutes(); + + 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 = (min / 60) * hour_rect.height; + const top = hour_rect.top + offset; + 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..7b515c3 --- /dev/null +++ b/client/src/components/time_tag.tsx @@ -0,0 +1,15 @@ +interface TimeTagProps { + start_time: string; + end_time: string; +} + +function TimeTag({ start_time, end_time }: TimeTagProps) { + return ( +
+
+

{start_time.substring(0, 5) + "-" + end_time.substring(0, 5)}

+
+ ); +} + +export default TimeTag; diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index 040ab4e..0da85d6 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -1,152 +1,42 @@ -import React, { ReactElement } from "react"; +import { ReactElement } from "react"; -import TimetableTask, { - resizeAndPositionTimetableTasks, -} from "@/components/timetable_task"; +import TimeIndicator, { + resizeAndPositionTimeIndicator, +} from "@/components/time_indicator"; +import { + TimetableDayColumn, + TimetableTimeColumn, +} from "@/components/timetable_column"; +import { resizeAndPositionTimetableTasks } from "@/components/timetable_task"; -interface TimetableSlotProps { - label?: string; - header?: boolean; - time_label?: boolean; - time: string; -} - -interface TimetableColumnProps { - children?: ReactElement[]; - sticky?: boolean; - label: string; - day: 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 function getVisibleTimetableRect() { + const time_header = document.getElementById("Header"); + if (time_header == null) return; + const time_header_rect = time_header.getBoundingClientRect(); -function TimetableColumn({ children, day, sticky }: TimetableColumnProps) { - let class_name = - "timetable-column w-full min-w-48 h-full \ - flex flex-col bg-slate-500 border-r-4 border-slate-900"; + const timetable_barrier = document.getElementById("timetable-barrier"); + if (timetable_barrier == null) return; + const timetable_barrier_rect = timetable_barrier.getBoundingClientRect(); - if (sticky === true) { - class_name = class_name + " sticky left-0 z-10"; - } else { - class_name = class_name + " z-0"; - } + 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 ( -
- {children} -
- ); -} - -function TimetableDayColumn({ label, day }: TimetableColumnProps) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); + return rect; } -function TimetableTimeColumn() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); +interface TimetableProps { + children: ReactElement[]; } -export default function Timetable() { +function Timetable({ children }: TimetableProps) { return (
{ + resizeAndPositionTimetableTasks(); + resizeAndPositionTimeIndicator(); + }} >
@@ -166,27 +59,17 @@ export default function Timetable() { +
- - + {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..e6c706d --- /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 border-r-4 border-slate-900"; + + if (sticky === true) { + class_name = class_name + " sticky left-0 z-10"; + } else { + class_name = 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 index 1858dff..085ce69 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -1,19 +1,6 @@ -function getVisibleTimetableRect() { - const time_header = document.getElementById("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(); - - return { - 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, - }; -} +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; @@ -116,9 +103,9 @@ export function resizeAndPositionTimetableTask(task: HTMLElement) { top >= visible.bottom ); if (is_visible) { - task.style.visibility = "visible"; + task.style.display = "inline"; } else { - task.style.visibility = "hidden"; + task.style.display = "none"; } } @@ -133,27 +120,46 @@ export function resizeAndPositionTimetableTasks() { } } -interface TimetableTaskProps { - title: string; +export interface Topic { + id: number; + name: string; + color_hex: number; + user: number; +} + +export interface TimetableTaskProps { + id: string; + name: string; + topics?: Topic[]; + description?: string; day: string; start_time: string; end_time: string; duration_minutes: number; - description?: string; } function TimetableTask({ - title, + id, + name, + topics, + description, day, start_time, end_time, duration_minutes, - description, }: TimetableTaskProps) { + const topic_tags = topics?.map((topic) => ( + + )); + return (
-

{title}

-

{start_time.substring(0, 5) + "-" + end_time.substring(0, 5)}

-

{description}

+

{name}

+ +
+ {topic_tags} +
+
+

{description}

+
); } diff --git a/client/src/components/topic_tag.tsx b/client/src/components/topic_tag.tsx new file mode 100644 index 0000000..e017f6d --- /dev/null +++ b/client/src/components/topic_tag.tsx @@ -0,0 +1,27 @@ +interface TopicTagProps { + name: string; + color_hex: number; +} + +function numberToHexCode(color_hex: number) { + let hex = color_hex.toString(16); + for (let i = 0; i < 5; i++) { + if (hex.length == 6) break; + hex = "0" + hex; + } + return "#" + hex; +} + +function TopicTag({ name, color_hex }: TopicTagProps) { + const bg_color = " bg-[" + numberToHexCode(color_hex) + "]"; + 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..c58bb86 --- /dev/null +++ b/client/src/pages/[id]/schedule.tsx @@ -0,0 +1,135 @@ +import { useEffect,useState } from "react"; + +import { resizeAndPositionTimeIndicator } from "@/components/time_indicator"; +import Timetable from "@/components/timetable"; +import TimetableTask, { + resizeAndPositionTimetableTasks, + 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 getDuration(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); +} + +function Schedule() { + const [timetableTaskProps, setTimetableTaskProps] = useState< + TimetableTaskProps[] + >([]); + + useEffect(() => { + function addEventListeners() { + window.addEventListener("resize", resizeAndPositionTimetableTasks); + window.addEventListener("resize", resizeAndPositionTimeIndicator); + } + + function removeEventListeners() { + window.removeEventListener("resize", resizeAndPositionTimetableTasks); + window.removeEventListener("resize", resizeAndPositionTimetableTasks); + } + + 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, + day: Day[time.day], + start_time: time.start_time, + end_time: time.end_time, + duration_minutes: getDuration(time.start_time, time.end_time), + }; + timetable_task_props.push(props); + } + } + return timetable_task_props; + } + + 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(); + const timetable_task_props: TimetableTaskProps[] = + await formatTaskDataToTimetableTaskProps(data); + setTimetableTaskProps(timetable_task_props); + } catch (error) { + console.error(error); + } + } + + addEventListeners(); + + fetchTasks(); + + return () => { + removeEventListeners(); + }; + }); + + const timetableTasks = timetableTaskProps.map((props) => ( + + )); + + return ( +
+
+ {timetableTasks} +
+
+

Tasks

+
+
+
+
+ ); +} + +export default Schedule; diff --git a/client/src/pages/schedule.tsx b/client/src/pages/schedule.tsx deleted file mode 100644 index 041b5f4..0000000 --- a/client/src/pages/schedule.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect } from "react"; - -import Timetable from "@/components/timetable"; -import { resizeAndPositionTimetableTasks } from "@/components/timetable_task"; - -function Schedule() { - useEffect(() => { - window.addEventListener("resize", resizeAndPositionTimetableTasks); - - return () => { - window.removeEventListener("resize", resizeAndPositionTimetableTasks); - }; - }); - - return ( -
-
- -
-
-

Tasks

-
-
-
-
- ); -} - -export default Schedule; From e8dc159480a5498be94c7776181af2aaef4e7658 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Sat, 27 Dec 2025 12:03:35 +0000 Subject: [PATCH 05/10] improved styling of timetable tasks to be more clear to read, addded automatic scroll and resize on load to improve user experience --- client/src/components/time_indicator.tsx | 20 ++-- client/src/components/time_tag.tsx | 12 +- client/src/components/timetable.tsx | 132 +++++++++++++++++++-- client/src/components/timetable_column.tsx | 2 +- client/src/components/timetable_task.tsx | 24 +++- client/src/components/topic_tag.tsx | 21 ++-- client/src/pages/[id]/schedule.tsx | 28 ++--- 7 files changed, 182 insertions(+), 57 deletions(-) diff --git a/client/src/components/time_indicator.tsx b/client/src/components/time_indicator.tsx index 369884f..f053a70 100644 --- a/client/src/components/time_indicator.tsx +++ b/client/src/components/time_indicator.tsx @@ -1,4 +1,7 @@ -import { getVisibleTimetableRect } from "@/components/timetable"; +import { + getTimeTopPosition, + getVisibleTimetableRect, +} from "@/components/timetable"; export function resizeAndPositionTimeIndicator() { const time_indicator = document.getElementById("time-indicator"); @@ -15,15 +18,10 @@ export function resizeAndPositionTimeIndicator() { const now = new Date(Date.now()); const hour = now.getHours(); - const min = now.getMinutes(); + const mins = now.getMinutes(); - 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 = (min / 60) * hour_rect.height; - const top = hour_rect.top + offset; + const top = getTimeTopPosition(hour, mins); + if (top == undefined) return; time_indicator.style.top = top + "px"; const now_label_rect = now_label.getBoundingClientRect(); @@ -48,13 +46,13 @@ function TimeIndicator() { <>

Now

); diff --git a/client/src/components/time_tag.tsx b/client/src/components/time_tag.tsx index 7b515c3..c7f5b73 100644 --- a/client/src/components/time_tag.tsx +++ b/client/src/components/time_tag.tsx @@ -5,9 +5,15 @@ interface TimeTagProps { function TimeTag({ start_time, end_time }: TimeTagProps) { return ( -
-
-

{start_time.substring(0, 5) + "-" + end_time.substring(0, 5)}

+
+
+

+ {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 0da85d6..f757dce 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from "react"; +import { ReactElement, useEffect } from "react"; import TimeIndicator, { resizeAndPositionTimeIndicator, @@ -10,7 +10,7 @@ import { import { resizeAndPositionTimetableTasks } from "@/components/timetable_task"; export function getVisibleTimetableRect() { - const time_header = document.getElementById("Header"); + const time_header = document.getElementById("time-header"); if (time_header == null) return; const time_header_rect = time_header.getBoundingClientRect(); @@ -32,11 +32,132 @@ export function getVisibleTimetableRect() { return rect; } +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; + + 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 (
{ - resizeAndPositionTimetableTasks(); - resizeAndPositionTimeIndicator(); - }} + onScroll={resizeTimetableElements} >
@@ -62,7 +180,7 @@ function Timetable({ children }: TimetableProps) {
{children}
diff --git a/client/src/components/timetable_column.tsx b/client/src/components/timetable_column.tsx index e6c706d..abe824c 100644 --- a/client/src/components/timetable_column.tsx +++ b/client/src/components/timetable_column.tsx @@ -67,7 +67,7 @@ export function TimetableTimeColumn() { return ( )); + let additional_style = " "; + + if (completed === true) { + additional_style += "opacity-75"; + } + return (
-

{name}

- -
+

{name}

+ +
{topic_tags}
-
-

{description}

+
+

{description}

); diff --git a/client/src/components/topic_tag.tsx b/client/src/components/topic_tag.tsx index e017f6d..82901d4 100644 --- a/client/src/components/topic_tag.tsx +++ b/client/src/components/topic_tag.tsx @@ -3,22 +3,17 @@ interface TopicTagProps { color_hex: number; } -function numberToHexCode(color_hex: number) { - let hex = color_hex.toString(16); - for (let i = 0; i < 5; i++) { - if (hex.length == 6) break; - hex = "0" + hex; - } - return "#" + hex; -} - function TopicTag({ name, color_hex }: TopicTagProps) { - const bg_color = " bg-[" + numberToHexCode(color_hex) + "]"; + const bg_color = "#" + color_hex.toString(16).padStart(6, "0"); + return ( -
+
+ className="aspect-1/1 mr-1 min-h-2 min-w-2 rounded-full" + style={{ backgroundColor: bg_color }} + > + {" "} +

{name}

); diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx index c58bb86..bf458dd 100644 --- a/client/src/pages/[id]/schedule.tsx +++ b/client/src/pages/[id]/schedule.tsx @@ -1,11 +1,7 @@ -import { useEffect,useState } from "react"; +import { useEffect, useState } from "react"; -import { resizeAndPositionTimeIndicator } from "@/components/time_indicator"; -import Timetable from "@/components/timetable"; -import TimetableTask, { - resizeAndPositionTimetableTasks, - TimetableTaskProps, -} from "@/components/timetable_task"; +import Timetable, { resizeTimetableElements } from "@/components/timetable"; +import TimetableTask, { TimetableTaskProps } from "@/components/timetable_task"; interface Time { id: number; @@ -56,15 +52,15 @@ function Schedule() { TimetableTaskProps[] >([]); + /* This effect runs in an infinte loop if timetableTaskProps is defined as a + dependency */ useEffect(() => { function addEventListeners() { - window.addEventListener("resize", resizeAndPositionTimetableTasks); - window.addEventListener("resize", resizeAndPositionTimeIndicator); + window.addEventListener("resize", resizeTimetableElements); } function removeEventListeners() { - window.removeEventListener("resize", resizeAndPositionTimetableTasks); - window.removeEventListener("resize", resizeAndPositionTimetableTasks); + window.removeEventListener("resize", resizeTimetableElements); } async function formatTaskDataToTimetableTaskProps(data: Task[]) { @@ -78,6 +74,7 @@ function Schedule() { 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, @@ -97,22 +94,21 @@ function Schedule() { throw new Error(`Response status: ${response.status}`); } const data = await response.json(); - const timetable_task_props: TimetableTaskProps[] = - await formatTaskDataToTimetableTaskProps(data); - setTimetableTaskProps(timetable_task_props); + setTimetableTaskProps(await formatTaskDataToTimetableTaskProps(data)); } catch (error) { console.error(error); } } addEventListeners(); - fetchTasks(); return () => { removeEventListeners(); }; - }); + }, []); + /* This should be dependent on timetableTaskProps however doing so causes it + to run in an infinite loop. */ const timetableTasks = timetableTaskProps.map((props) => ( From 74931f9890c9406ead9eef99156439ef9d540793 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Fri, 2 Jan 2026 10:15:53 +0000 Subject: [PATCH 06/10] added timetableTask clash handling to replace times where tasks would overlap with a multiple tasks timetableTask --- client/src/components/time_tag.tsx | 24 +++- client/src/components/timetable.tsx | 10 +- client/src/components/timetable_task.tsx | 87 +++++++++--- client/src/pages/[id]/schedule.tsx | 164 ++++++++++++++++++++--- 4 files changed, 238 insertions(+), 47 deletions(-) diff --git a/client/src/components/time_tag.tsx b/client/src/components/time_tag.tsx index c7f5b73..6e02758 100644 --- a/client/src/components/time_tag.tsx +++ b/client/src/components/time_tag.tsx @@ -1,9 +1,27 @@ +import { getDurationMinutes } from "@/components/timetable"; + interface TimeTagProps { start_time: string; end_time: string; + display?: string; } -function TimeTag({ start_time, end_time }: TimeTagProps) { +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 (
-

- {start_time.substring(0, 5) + "-" + end_time.substring(0, 5)} -

+

{display_string}

); } diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index f757dce..e339e31 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -32,6 +32,14 @@ export function getVisibleTimetableRect() { 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; @@ -131,7 +139,7 @@ export function scrollToCurrentDay(align?: string) { 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; + 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; diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx index 7974011..3fb6a3e 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -110,10 +110,7 @@ export function resizeAndPositionTimetableTask(task: HTMLElement) { } export function resizeAndPositionTimetableTasks() { - const task_container = document.getElementById("timetable-tasks"); - if (task_container == null) return; - - const tasks = task_container.children; + const tasks = document.getElementsByClassName("timetable-task"); for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; resizeAndPositionTimetableTask(task); @@ -127,6 +124,47 @@ export interface Topic { user: number; } +export interface TimetableTaskContentProps { + name: string; + start_time: string; + end_time: string; + topics?: Topic[]; + description?: string; +} + +function TimetableTaskContent({ + name, + start_time, + end_time, + topics, + description, +}: TimetableTaskContentProps) { + const topic_tags = topics?.map((topic) => ( + + )); + + return ( +
+

{name}

+ +
+ {topic_tags} +
+
+

{description}

+
+
+ ); +} + export interface TimetableTaskProps { id: string; name: string; @@ -137,6 +175,8 @@ export interface TimetableTaskProps { start_time: string; end_time: string; duration_minutes: number; + clash?: boolean; + tooltip_props: TimetableTaskContentProps[]; } function TimetableTask({ @@ -149,26 +189,30 @@ function TimetableTask({ start_time, end_time, duration_minutes, + clash, + tooltip_props, }: TimetableTaskProps) { - const topic_tags = topics?.map((topic) => ( - - )); - let additional_style = " "; if (completed === true) { - additional_style += "opacity-75"; + 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 "; + } + + if (tooltip_props.length > 0) { + // Avoids linting issue remove later } return (
-

{name}

- -
- {topic_tags} -
-
-

{description}

-
+
); } diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx index bf458dd..ddb9999 100644 --- a/client/src/pages/[id]/schedule.tsx +++ b/client/src/pages/[id]/schedule.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; -import Timetable, { resizeTimetableElements } from "@/components/timetable"; +import Timetable, { + getDurationMinutes, + resizeTimetableElements, +} from "@/components/timetable"; import TimetableTask, { TimetableTaskProps } from "@/components/timetable_task"; interface Time { @@ -39,14 +42,6 @@ enum Day { Sunday, } -function getDuration(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); -} - function Schedule() { const [timetableTaskProps, setTimetableTaskProps] = useState< TimetableTaskProps[] @@ -63,6 +58,24 @@ function Schedule() { 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++) { @@ -78,7 +91,19 @@ function Schedule() { day: Day[time.day], start_time: time.start_time, end_time: time.end_time, - duration_minutes: getDuration(time.start_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); } @@ -86,20 +111,119 @@ function Schedule() { return timetable_task_props; } - 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}`); + 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: "Click 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 } - const data = await response.json(); - setTimetableTaskProps(await formatTaskDataToTimetableTaskProps(data)); - } catch (error) { - console.error(error); + + 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(); From 640621168431db6c6eb4c0f01e91fef916d95f84 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Fri, 2 Jan 2026 12:50:31 +0000 Subject: [PATCH 07/10] added tooltips to tasks to display full information when hovered over --- client/src/components/timetable.tsx | 5 +- client/src/components/timetable_column.tsx | 6 +- client/src/components/timetable_task.tsx | 128 +++++++++++++++------ client/src/pages/[id]/schedule.tsx | 13 ++- 4 files changed, 108 insertions(+), 44 deletions(-) diff --git a/client/src/components/timetable.tsx b/client/src/components/timetable.tsx index e339e31..a4c9207 100644 --- a/client/src/components/timetable.tsx +++ b/client/src/components/timetable.tsx @@ -176,7 +176,10 @@ function Timetable({ children }: TimetableProps) { className="h-full w-full overflow-auto" onScroll={resizeTimetableElements} > -
+
diff --git a/client/src/components/timetable_column.tsx b/client/src/components/timetable_column.tsx index abe824c..60de809 100644 --- a/client/src/components/timetable_column.tsx +++ b/client/src/components/timetable_column.tsx @@ -16,12 +16,12 @@ export default function TimetableColumn({ }: TimetableColumnProps) { let class_name = "timetable-column w-full min-w-48 h-full \ - flex flex-col bg-slate-500 border-r-4 border-slate-900"; + flex flex-col bg-slate-500"; if (sticky === true) { - class_name = class_name + " sticky left-0 z-10"; + class_name += " sticky left-0 z-10"; } else { - class_name = class_name + " z-0"; + class_name += " z-0"; } return ( diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx index 3fb6a3e..d04231b 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -109,6 +109,40 @@ export function resizeAndPositionTimetableTask(task: HTMLElement) { } } +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.right = task_rect.left + "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++) { @@ -139,14 +173,6 @@ function TimetableTaskContent({ topics, description, }: TimetableTaskContentProps) { - const topic_tags = topics?.map((topic) => ( - - )); - return (

{name}

@@ -156,7 +182,13 @@ function TimetableTaskContent({ display={"duration"} />
- {topic_tags} + {topics?.map((topic) => ( + + ))}

{description}

@@ -204,33 +236,61 @@ function TimetableTask({ additional_style += "bg-slate-400 hover:bg-slate-300 "; } - if (tooltip_props.length > 0) { - // Avoids linting issue remove later - } + 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) => ( +
+ +
+ ))} +
+ ); } diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx index ddb9999..6fc8e18 100644 --- a/client/src/pages/[id]/schedule.tsx +++ b/client/src/pages/[id]/schedule.tsx @@ -159,7 +159,7 @@ function Schedule() { id: first_task.id + "/" + second_task.id, name: "Multiple Tasks", topics: [], - description: "Click for more details...", + description: "Hover for more details...", completed: false, day: first_task.day, start_time: second_task.start_time, @@ -179,6 +179,7 @@ function Schedule() { 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; @@ -234,14 +235,14 @@ function Schedule() { /* This should be dependent on timetableTaskProps however doing so causes it to run in an infinite loop. */ - const timetableTasks = timetableTaskProps.map((props) => ( - - )); - return (
- {timetableTasks} + + {timetableTaskProps.map((props) => ( + + ))} +

Tasks

From 043289a18c2d5ae79698202505e4997a61d88241 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Fri, 2 Jan 2026 12:53:20 +0000 Subject: [PATCH 08/10] fixed an issue where tooltips would display over task slightly when there is more visible area to the left of the task --- client/src/components/timetable_task.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx index d04231b..c82b8b4 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -125,7 +125,7 @@ function resizeAndPositionTimetableTaskTooltip(tooltip: HTMLElement) { if (visible == undefined) return; if (task_rect.left - visible.left > visible.right - task_rect.right) { - tooltip.style.right = task_rect.left + "px"; + tooltip.style.left = task_rect.left - tooltip_rect.width + "px"; } else { tooltip.style.left = task_rect.right + "px"; } From 992c067b4f81b61ff554dc0b10df84cf9450e01e Mon Sep 17 00:00:00 2001 From: Archer Key Date: Fri, 2 Jan 2026 12:58:44 +0000 Subject: [PATCH 09/10] added an optional time_display prop to TimetableTaskContentProps to allow more customisation of time display mode --- client/src/components/timetable_task.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/components/timetable_task.tsx b/client/src/components/timetable_task.tsx index c82b8b4..14a26e3 100644 --- a/client/src/components/timetable_task.tsx +++ b/client/src/components/timetable_task.tsx @@ -164,6 +164,7 @@ export interface TimetableTaskContentProps { end_time: string; topics?: Topic[]; description?: string; + time_display?: string; } function TimetableTaskContent({ @@ -172,6 +173,7 @@ function TimetableTaskContent({ end_time, topics, description, + time_display, }: TimetableTaskContentProps) { return (
@@ -179,7 +181,7 @@ function TimetableTaskContent({
{topics?.map((topic) => ( @@ -272,6 +274,7 @@ function TimetableTask({ end_time={end_time} topics={topics} description={description} + time_display={"duration"} />
- +
))}
From d7631497b2379dfb90ccb236ab1e4f763f9e4513 Mon Sep 17 00:00:00 2001 From: Archer Key Date: Sat, 3 Jan 2026 03:25:23 +0000 Subject: [PATCH 10/10] removed task barfrom the side of schedule page and made timetable full screen to meet deadline easier --- client/src/pages/[id]/schedule.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/src/pages/[id]/schedule.tsx b/client/src/pages/[id]/schedule.tsx index 6fc8e18..99704b6 100644 --- a/client/src/pages/[id]/schedule.tsx +++ b/client/src/pages/[id]/schedule.tsx @@ -237,18 +237,13 @@ function Schedule() { return (
-
+
{timetableTaskProps.map((props) => ( ))}
-
-

Tasks

-
-
-
); }