80 |
81 |
82 |
89 |
90 | Install the PlanMyTimetable Capture browser extension.
91 |
92 |
114 |
115 |
116 | *If you have a chromium
117 | based browser for example:
118 |
119 |
120 | Edge,
121 |
122 |
123 |
124 |
125 | Arc,
126 |
127 |
128 |
129 |
130 | Brave,
131 |
132 | or
133 |
134 |
135 | Opera,
136 |
137 | you can also use the Chrome web store to install the extension.
138 |
139 |
140 |
141 | );
142 | };
143 |
144 | const BookmarkTab = () => {
145 | return (
146 |
147 |
148 | -
149 | Bookmark the following code ( drag this into the bookmarks bar or
150 | right click and bookmark link ) :
151 |
152 |
173 |
174 | If you have concerns regarding privacy and security,
175 |
179 | checkout the source code
180 |
181 | . All data is stored locally and never leaves your computer.
182 |
183 | - Navigate to your Allocate+ system and login.
184 | - Click and run the bookmarklet on this page.
185 | - Select the semester you wish to plan for.
186 | - You will then be redirected to a new page with your data.
187 |
188 |
189 | );
190 | };
191 |
--------------------------------------------------------------------------------
/src/components/Calendar/generated/SortablePopover.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SortableContext,
3 | useSortable,
4 | verticalListSortingStrategy,
5 | sortableKeyboardCoordinates,
6 | arrayMove,
7 | } from "@dnd-kit/sortable";
8 | import { useEffect, useRef, useState } from "react";
9 | import type { Dispatch, SetStateAction } from "react";
10 | import Button from "../../Button/Button";
11 | import { HiMiniSparkles } from "react-icons/hi2";
12 | import {
13 | Dialog,
14 | DialogTrigger,
15 | DialogPortal,
16 | DialogOverlay,
17 | DialogContent,
18 | DialogClose,
19 | DialogTitle,
20 | DialogDescription,
21 | } from "@radix-ui/react-dialog";
22 | import { CSS } from "@dnd-kit/utilities";
23 | import {
24 | DndContext,
25 | closestCenter,
26 | KeyboardSensor,
27 | PointerSensor,
28 | useSensor,
29 | useSensors,
30 | } from "@dnd-kit/core";
31 | import type { DragEndEvent } from "@dnd-kit/core";
32 | import { RxDragHandleDots2 } from "react-icons/rx";
33 | import { HiOutlineX } from "react-icons/hi";
34 | import type { PENALTIES } from "./generate";
35 | import type { Preference } from "~/lib/definitions";
36 | import { usePreview } from "~/contexts/PreviewContext";
37 | import { getAllCampusDescriptions } from "~/lib/functions";
38 | import { ImSpinner8 } from "react-icons/im";
39 | import { useUrlState } from "~/hooks/useUrlState";
40 |
41 | export function SortablePopover({
42 | setGeneratedPreferences,
43 | setIndex,
44 | }: {
45 | setGeneratedPreferences: Dispatch
>;
46 | setIndex: Dispatch>;
47 | }) {
48 | const { courseData, events, setEvents } = usePreview();
49 | const [open, setOpen] = useState(false);
50 | const workerRef = useRef();
51 | const [isLoading, setIsLoading] = useState(false);
52 | const { replaceState, appendState } = useUrlState();
53 |
54 | const [items, setItems] = useState>([
55 | "breaks",
56 | "days",
57 | "campus",
58 | ]);
59 | const [preferredCampus, setPreferredCampus] = useState(
60 | getAllCampusDescriptions(courseData)[0] ?? "",
61 | );
62 | const sensors = useSensors(
63 | useSensor(PointerSensor),
64 | useSensor(KeyboardSensor, {
65 | coordinateGetter: sortableKeyboardCoordinates,
66 | }),
67 | );
68 |
69 | useEffect(() => {
70 | workerRef.current = new Worker(
71 | new URL("./generate.worker.ts", import.meta.url),
72 | );
73 | workerRef.current.onmessage = (event: MessageEvent) => {
74 | // console.log(event.data);
75 | setGeneratedPreferences(event.data);
76 | const newPreferences = event.data[0];
77 | if (newPreferences) {
78 | if (events.length === 0) {
79 | appendState(newPreferences, "pref");
80 | } else {
81 | replaceState(newPreferences, "pref");
82 | }
83 | setEvents(newPreferences);
84 | setIndex(0);
85 | }
86 | setIsLoading(false);
87 | };
88 | return () => {
89 | workerRef.current?.terminate();
90 | };
91 | }, [
92 | setGeneratedPreferences,
93 | appendState,
94 | events.length,
95 | replaceState,
96 | setEvents,
97 | setIndex,
98 | ]);
99 |
100 | const handleDragEnd = (event: DragEndEvent) => {
101 | const { active, over } = event;
102 | if (!over) {
103 | return;
104 | }
105 | if (active.id !== over?.id) {
106 | setItems((items) => {
107 | const oldIndex = items.indexOf(active.id as keyof typeof PENALTIES);
108 | const newIndex = items.indexOf(over.id as keyof typeof PENALTIES);
109 |
110 | return arrayMove(items, oldIndex, newIndex);
111 | });
112 | }
113 | };
114 |
115 | const generatePreferences = () => {
116 | setIsLoading(true);
117 | const options = {
118 | amount: 10,
119 | rankings: items,
120 | campus: preferredCampus,
121 | };
122 | // setGeneratedPreferences(generate(courseData, options));
123 | workerRef.current?.postMessage({ courses: courseData, options });
124 | };
125 |
126 | return (
127 |
198 | );
199 | }
200 |
201 | function SortableItem({ id, index }: { id: string; index: number }) {
202 | const { attributes, listeners, setNodeRef, transform, transition } =
203 | useSortable({ id: id });
204 | const style = {
205 | transform: CSS.Transform.toString(transform),
206 | transition,
207 | };
208 | return (
209 |
210 |
{index}:
211 |
218 | {id}
219 |
220 |
221 |
222 | );
223 | }
224 |
--------------------------------------------------------------------------------
/src/app/classes/add/blocked/BlockedForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { zodResolver } from "@hookform/resolvers/zod";
3 | import * as z from "zod";
4 | import { useForm } from "react-hook-form";
5 | import { Button } from "~/components";
6 | import type { BlockedEvent } from "~/lib/definitions";
7 | import { usePreview } from "~/contexts/PreviewContext";
8 | import { useUrlState } from "~/hooks/useUrlState";
9 | import toast from "react-hot-toast";
10 | import { nanoid } from "nanoid";
11 |
12 | const schema = z.object({
13 | id: z.string().optional(),
14 | day: z.enum(["Mon", "Tue", "Wed", "Thu", "Fri"]),
15 | start_time: z
16 | .string()
17 | .trim()
18 | .regex(
19 | /^([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/,
20 | "Invalid time format",
21 | )
22 | .regex(
23 | /^(0[5-9]|1[0-9]|2[0-3]):[0-5][0-9]$/,
24 | "Time must start on or after 5 am",
25 | ),
26 | duration: z.coerce
27 | .number({ invalid_type_error: "Duration must be a number" })
28 | .gte(30, { message: "Duration must be at least 30 minutes." })
29 | .lte(600, { message: "Duration must be less than than 600 minutes." }),
30 | name: z
31 | .string()
32 | .trim()
33 | .min(1, { message: "Name is required" })
34 | .max(120, { message: "Name must be less than 120 characters" }),
35 | });
36 |
37 | export default function BlockedForm({
38 | defaultValues,
39 | }: {
40 | defaultValues?: z.infer;
41 | }) {
42 | const { blockedEvents } = usePreview();
43 | const { appendState, replaceState } = useUrlState();
44 |
45 | const {
46 | register,
47 | handleSubmit,
48 | formState: { errors },
49 | } = useForm>({
50 | resolver: zodResolver(schema),
51 | defaultValues: defaultValues,
52 | });
53 |
54 | const onSubmit = (values: z.infer) => {
55 | const blockedEvent: BlockedEvent = {
56 | id: defaultValues?.id ?? nanoid(9),
57 | name: values.name,
58 | day: values.day,
59 | duration: values.duration,
60 | start: values.start_time,
61 | };
62 | if (
63 | defaultValues?.day === blockedEvent.day &&
64 | defaultValues?.name === blockedEvent.name &&
65 | defaultValues?.duration === blockedEvent.duration &&
66 | defaultValues?.start_time === blockedEvent.start
67 | )
68 | return;
69 | if (defaultValues) {
70 | const newBlocked = blockedEvents.map((item) => {
71 | if (item.id === defaultValues.id) {
72 | return blockedEvent;
73 | }
74 | return item;
75 | });
76 | replaceState(newBlocked, "blocked");
77 | toast.success(`${blockedEvent.name} updated`);
78 | return;
79 | }
80 | appendState(blockedEvent, "blocked", "/");
81 | toast.success(`${blockedEvent.name} created`);
82 | };
83 |
84 | return (
85 |
199 | );
200 | }
201 |
202 | function ErrorMessage({ children }: { children: React.ReactNode }) {
203 | return {children}
;
204 | }
205 |
--------------------------------------------------------------------------------
/src/app/(Navbar)/bmc.tsx:
--------------------------------------------------------------------------------
1 | export const BMCLogo = () => (
2 |
68 | );
69 |
--------------------------------------------------------------------------------
/src/components/Calendar/generated/generate.ts:
--------------------------------------------------------------------------------
1 | import { Days } from "~/lib/definitions";
2 | import type { Course, Preference, Time } from "~/lib/definitions";
3 | import { convertCourseToPreference } from "~/lib/functions";
4 |
5 | /*
6 | * This code is adapted from [Andogq Timetable project](https://github.com/andogq/timetable) to work with my types.
7 | * Copyright (C) 2022 Tom Anderson
8 | * Copyright (C) 2025 Maximus Dionyssopoulos
9 | *
10 | * This program is free software: you can redistribute it and/or modify
11 | * it under the terms of the GNU Affero General Public License as published by
12 | * the Free Software Foundation, either version 3 of the License, or
13 | * (at your option) any later version.
14 | *
15 | * This program is distributed in the hope that it will be useful,
16 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 | * GNU Affero General Public License for more details.
19 | *
20 | * You should have received a copy of the GNU Affero General Public License
21 | * along with this program. If not, see .
22 | */
23 | interface GenerateOptions {
24 | amount?: number;
25 | log?: (message: string) => void;
26 | rankings: Array;
27 | campus: string;
28 | }
29 |
30 | export interface GenerateEvent {
31 | courses: Course[];
32 | options: GenerateOptions;
33 | }
34 |
35 | // Utility functions remain unchanged
36 | function timeToMinutes(time: string): number {
37 | const [hours, minutes] = time.split(":").map(Number);
38 | return hours! * 60 + minutes!;
39 | }
40 |
41 | function dayToNumber(day: Time["day"]): number {
42 | const dayMap: Record