initial commit
This commit is contained in:
45
shared/lessons.d.ts
vendored
Normal file
45
shared/lessons.d.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
interface LessonLike {
|
||||
day: number;
|
||||
date: string | null;
|
||||
start: string;
|
||||
end: string | null;
|
||||
subject: string;
|
||||
teacher: string;
|
||||
room: string;
|
||||
type: string;
|
||||
condition: string;
|
||||
variant: "all" | "upper" | "lower";
|
||||
groups: string[];
|
||||
subgroups: { group: string; label: string }[];
|
||||
}
|
||||
export interface FieldChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | number;
|
||||
to: string | number;
|
||||
}
|
||||
export interface ScheduleDiff<L> {
|
||||
added: L[];
|
||||
removed: L[];
|
||||
changed: { before: L; after: L; fields: FieldChange[] }[];
|
||||
}
|
||||
export interface PersonalPrefs {
|
||||
subgroup?: string | null;
|
||||
hidden?: string[];
|
||||
}
|
||||
export function lessonKey(lesson: LessonLike, group: string): string;
|
||||
export function diffSchedules<L extends LessonLike>(
|
||||
before: { lessons: L[] } | null | undefined,
|
||||
after: { lessons: L[] } | null | undefined,
|
||||
group: string,
|
||||
): ScheduleDiff<L>;
|
||||
export function changeCount(diff: ScheduleDiff<unknown> | null | undefined): number;
|
||||
export function personalFilter(
|
||||
lesson: LessonLike,
|
||||
group: string,
|
||||
prefs: PersonalPrefs | null | undefined,
|
||||
): "shown" | "hidden" | "other-subgroup";
|
||||
export function subgroupsOf(
|
||||
schedule: { lessons: LessonLike[] } | null | undefined,
|
||||
group: string,
|
||||
): string[];
|
||||
105
shared/lessons.js
Normal file
105
shared/lessons.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Lesson identity and schedule comparison, shared by the browser and the tests.
|
||||
// Lesson ids depend on the table row, so they change whenever the university
|
||||
// republishes a file; these keys describe what a student would call "the same lesson".
|
||||
|
||||
const norm = (s) => (s || "").toLowerCase().replace(/\s+/g, " ").trim();
|
||||
const subgroupOf = (lesson, group) =>
|
||||
lesson.subgroups?.find((s) => s.group === group)?.label || "";
|
||||
|
||||
/** Same day, time, subject, row colour and subgroup: survives republishing. */
|
||||
export function lessonKey(lesson, group) {
|
||||
return [
|
||||
lesson.date || lesson.day,
|
||||
lesson.start,
|
||||
norm(lesson.subject),
|
||||
lesson.variant,
|
||||
subgroupOf(lesson, group),
|
||||
norm(lesson.condition),
|
||||
].join("|");
|
||||
}
|
||||
// Looser identity for pairing a lesson whose time or week mark changed.
|
||||
const looseKey = (lesson, group) =>
|
||||
[norm(lesson.subject), lesson.variant, subgroupOf(lesson, group), norm(lesson.type)].join("|");
|
||||
|
||||
const FIELDS = [
|
||||
["start", "Начало"],
|
||||
["end", "Окончание"],
|
||||
["day", "День"],
|
||||
["date", "Дата"],
|
||||
["room", "Аудитория"],
|
||||
["teacher", "Преподаватель"],
|
||||
["condition", "Недели"],
|
||||
["type", "Вид занятия"],
|
||||
];
|
||||
|
||||
/**
|
||||
* What changed for one group between two versions of the same document.
|
||||
* Returns { added, removed, changed } where changed items list field differences.
|
||||
*/
|
||||
export function diffSchedules(before, after, group) {
|
||||
const pick = (s) => (s?.lessons || []).filter((l) => l.groups.includes(group));
|
||||
const removed = [],
|
||||
added = [],
|
||||
changed = [];
|
||||
const old = new Map();
|
||||
for (const lesson of pick(before)) {
|
||||
const key = lessonKey(lesson, group);
|
||||
old.set(key, [...(old.get(key) || []), lesson]);
|
||||
}
|
||||
const unmatched = [];
|
||||
for (const lesson of pick(after)) {
|
||||
const key = lessonKey(lesson, group),
|
||||
list = old.get(key);
|
||||
if (list?.length) {
|
||||
const was = list.shift();
|
||||
const fields = differences(was, lesson);
|
||||
if (fields.length) changed.push({ before: was, after: lesson, fields });
|
||||
} else unmatched.push(lesson);
|
||||
}
|
||||
const left = [...old.values()].flat();
|
||||
// Pair the rest by subject on the same day first, then by subject alone (a lesson moved to another day).
|
||||
for (const sameDay of [true, false])
|
||||
for (const lesson of [...unmatched]) {
|
||||
const index = left.findIndex(
|
||||
(l) =>
|
||||
looseKey(l, group) === looseKey(lesson, group) &&
|
||||
(!sameDay || (l.date || l.day) === (lesson.date || lesson.day)),
|
||||
);
|
||||
if (index < 0) continue;
|
||||
const [was] = left.splice(index, 1);
|
||||
unmatched.splice(unmatched.indexOf(lesson), 1);
|
||||
changed.push({ before: was, after: lesson, fields: differences(was, lesson) });
|
||||
}
|
||||
added.push(...unmatched);
|
||||
removed.push(...left);
|
||||
const order = (l) => `${l.date || ""}${l.day}${l.start}`;
|
||||
added.sort((a, b) => order(a).localeCompare(order(b)));
|
||||
removed.sort((a, b) => order(a).localeCompare(order(b)));
|
||||
changed.sort((a, b) => order(a.after).localeCompare(order(b.after)));
|
||||
return { added, removed, changed };
|
||||
}
|
||||
function differences(before, after) {
|
||||
return FIELDS.filter(([field]) => (before[field] ?? "") !== (after[field] ?? "")).map(
|
||||
([field, label]) => ({ field, label, from: before[field] ?? "", to: after[field] ?? "" }),
|
||||
);
|
||||
}
|
||||
export const changeCount = (diff) =>
|
||||
diff ? diff.added.length + diff.removed.length + diff.changed.length : 0;
|
||||
|
||||
/** Applies the student's own choices: their subgroup and the lessons they hid. */
|
||||
export function personalFilter(lesson, group, prefs) {
|
||||
const own = subgroupOf(lesson, group);
|
||||
if (prefs?.subgroup && own && own !== prefs.subgroup) return "other-subgroup";
|
||||
if (prefs?.hidden?.includes(lessonKey(lesson, group))) return "hidden";
|
||||
return "shown";
|
||||
}
|
||||
/** Subgroup labels present for a group, in column order. */
|
||||
export function subgroupsOf(schedule, group) {
|
||||
const labels = new Set();
|
||||
for (const lesson of schedule?.lessons || [])
|
||||
if (lesson.groups.includes(group)) {
|
||||
const label = subgroupOf(lesson, group);
|
||||
if (label) labels.add(label);
|
||||
}
|
||||
return [...labels].sort((a, b) => a.localeCompare(b, "ru", { numeric: true }));
|
||||
}
|
||||
22
shared/updates.d.ts
vendored
Normal file
22
shared/updates.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
interface CatalogFileLike {
|
||||
id: string;
|
||||
name: string;
|
||||
faculty: string;
|
||||
modified: string;
|
||||
}
|
||||
interface SavedLike {
|
||||
schemaVersion?: number;
|
||||
source: {
|
||||
fileId?: string;
|
||||
modified?: string;
|
||||
importedAt: string;
|
||||
faculty?: string;
|
||||
};
|
||||
}
|
||||
export function namesGroup(fileName: string, group: string): boolean;
|
||||
export function scheduleUpdate<F extends CatalogFileLike>(
|
||||
saved: SavedLike | null | undefined,
|
||||
catalog: { files: F[]; stale?: boolean } | null | undefined,
|
||||
group: string,
|
||||
options?: { dismissed?: string[]; schemaVersion?: number },
|
||||
): { changed: F | null; newer: F | null; missing: boolean } | null;
|
||||
47
shared/updates.js
Normal file
47
shared/updates.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Decides what a saved schedule needs after the catalog was read again.
|
||||
// Runs in the browser; kept free of DOM code so the rules are testable in Node.
|
||||
|
||||
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const time = (value) => Date.parse(value || "") || 0;
|
||||
|
||||
/** "23-БА" matches "23-ЛГ_23-БА.docx" but not "23-БАз" or "123-БА". */
|
||||
export function namesGroup(fileName, group) {
|
||||
if (!group) return false;
|
||||
const base = fileName.replace(/\.docx?$/i, "");
|
||||
return new RegExp(
|
||||
`(^|[^0-9A-Za-zА-ЯЁа-яё])${escape(group)}(?![0-9A-Za-zА-ЯЁа-яё])`,
|
||||
"iu",
|
||||
).test(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* changed: the same document was republished (or parsed by an older version) — reload it quietly.
|
||||
* newer: another document for this group appeared later, e.g. the next semester — offer it.
|
||||
* missing: the document left the catalog, e.g. a new academic year began.
|
||||
*/
|
||||
export function scheduleUpdate(saved, catalog, group, options = {}) {
|
||||
const { dismissed = [], schemaVersion = 0 } = options;
|
||||
const fileId = saved?.source?.fileId;
|
||||
if (!fileId || !catalog?.files || catalog.stale) return null;
|
||||
const current = catalog.files.find((f) => f.id === fileId) || null;
|
||||
const savedTime = time(saved.source.modified) || time(saved.source.importedAt);
|
||||
const newer =
|
||||
catalog.files
|
||||
.filter(
|
||||
(f) =>
|
||||
f.id !== fileId &&
|
||||
!dismissed.includes(f.id) &&
|
||||
namesGroup(f.name, group) &&
|
||||
time(f.modified) > savedTime &&
|
||||
(!saved.source.faculty || f.faculty === saved.source.faculty),
|
||||
)
|
||||
.sort((a, b) => time(b.modified) - time(a.modified))[0] || null;
|
||||
const changed =
|
||||
current &&
|
||||
(current.modified !== saved.source.modified ||
|
||||
(saved.schemaVersion || 0) < schemaVersion)
|
||||
? current
|
||||
: null;
|
||||
if (!changed && !newer && current) return null;
|
||||
return { changed, newer, missing: !current };
|
||||
}
|
||||
84
shared/weeks.d.ts
vendored
Normal file
84
shared/weeks.d.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
export type WeekRange = [number, number | null];
|
||||
export interface CalendarWeek {
|
||||
n: number;
|
||||
start: string;
|
||||
end: string;
|
||||
color: "white" | "green";
|
||||
semester?: string;
|
||||
}
|
||||
export interface Semester {
|
||||
id: string;
|
||||
kind: "autumn" | "spring";
|
||||
year: number;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
export interface Bell {
|
||||
pair: number;
|
||||
start: string;
|
||||
end: string;
|
||||
halves: [string, string][];
|
||||
}
|
||||
export interface AcademicCalendar {
|
||||
weeks: CalendarWeek[];
|
||||
semesters?: Semester[];
|
||||
bells: Bell[];
|
||||
source: {
|
||||
weeks?: string | null;
|
||||
bells?: string | null;
|
||||
weeksFiles?: string[];
|
||||
bellsFile?: string | null;
|
||||
};
|
||||
updatedAt: string | null;
|
||||
stale?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
/** A calendar narrowed to the semester a schedule belongs to. */
|
||||
export interface SemesterCalendar extends AcademicCalendar {
|
||||
semester: Semester;
|
||||
}
|
||||
interface ScheduleLike {
|
||||
lessons: { date: string | null }[];
|
||||
period?: string;
|
||||
academicYear?: string | number | null;
|
||||
}
|
||||
interface WeekLesson {
|
||||
day: number;
|
||||
date: string | null;
|
||||
dates?: string[];
|
||||
condition: string;
|
||||
variant: "all" | "upper" | "lower";
|
||||
weekRanges?: WeekRange[] | null;
|
||||
}
|
||||
export function parseWeekRanges(condition: string): WeekRange[] | null;
|
||||
export function parseTextDates(raw: string, year: string | number): string[];
|
||||
export function isoDate(time: number): string;
|
||||
export function addDays(iso: string, days: number): string;
|
||||
export function weekday(iso: string): number;
|
||||
export function todayIso(now?: Date): string;
|
||||
export function weekIndexOf(
|
||||
calendar: AcademicCalendar | null | undefined,
|
||||
iso: string,
|
||||
): number;
|
||||
export function dateInWeek(week: CalendarWeek, day: number): string | null;
|
||||
export function lessonWeeks(lesson: WeekLesson, lastWeek: number): number[] | null;
|
||||
export function lessonDates(
|
||||
lesson: WeekLesson,
|
||||
calendar: AcademicCalendar | null | undefined,
|
||||
): string[] | null;
|
||||
export function academicStartYear(iso: string): number;
|
||||
export function assignSemesters(weeks: CalendarWeek[]): {
|
||||
weeks: CalendarWeek[];
|
||||
semesters: Semester[];
|
||||
};
|
||||
export function semesterFor(
|
||||
calendar: AcademicCalendar | null | undefined,
|
||||
schedule: ScheduleLike | null | undefined,
|
||||
today: string,
|
||||
): Semester | null;
|
||||
export function semesterView(
|
||||
calendar: AcademicCalendar | null | undefined,
|
||||
schedule: ScheduleLike | null | undefined,
|
||||
today: string,
|
||||
): SemesterCalendar | null;
|
||||
223
shared/weeks.js
Normal file
223
shared/weeks.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// Week rules shared by the Word parser (server) and the PWA (browser).
|
||||
// The university publishes "Расписание белых и зеленых недель": week 1 starts on
|
||||
// 1 September, odd weeks are white and even weeks are green. A week mark inside a
|
||||
// white or green table row only applies to weeks of that row's colour.
|
||||
|
||||
const MONTHS = [
|
||||
"январ",
|
||||
"феврал",
|
||||
"март",
|
||||
"апрел",
|
||||
"ма[яй]",
|
||||
"июн",
|
||||
"июл",
|
||||
"август",
|
||||
"сентябр",
|
||||
"октябр",
|
||||
"ноябр",
|
||||
"декабр",
|
||||
];
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
/**
|
||||
* "6-13н", "1-4,6-11н.", "с 3н.", "со 2 нед.", "1-8,10-17" → [[6, 13]], [[1, 4], [6, 11]], [[3, null]]…
|
||||
* Returns null when the text is empty or not a pure week list.
|
||||
*/
|
||||
export function parseWeekRanges(condition) {
|
||||
let text = (condition || "").trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
const from = text.match(/^(?:с|со)\s*(\d{1,2})\s*(?:н(?:ед)?)?[.\s]*$/);
|
||||
if (from) return [[Number(from[1]), null]];
|
||||
// "с 4-16н" reads as an ordinary range.
|
||||
text = text
|
||||
.replace(/^(?:с|со)\s+/, "")
|
||||
.replace(/\s*н(?:ед)?[.\s]*$/, "")
|
||||
.trim();
|
||||
if (!/^\d{1,2}(?:\s*[-–,]\s*\d{1,2})*$/.test(text)) return null;
|
||||
const ranges = [];
|
||||
for (const part of text.split(/\s*,\s*/)) {
|
||||
const bounds = part.split(/\s*[-–]\s*/).map(Number);
|
||||
// "6-12-16" is a typo in the source; leave it for the student to check.
|
||||
if (bounds.length > 2) return null;
|
||||
const [a, b = a] = bounds;
|
||||
if (!a || !b || a > b || b > 60) return null;
|
||||
ranges.push([a, b]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** "10 сентября, 15 октября" → ISO dates inside the academic year that starts in `year`. */
|
||||
export function parseTextDates(raw, year) {
|
||||
if (!raw || !year) return [];
|
||||
const pattern = new RegExp(`(\\d{1,2})\\s+(${MONTHS.join("|")})[а-я]*`, "giu");
|
||||
const dates = [];
|
||||
for (const m of raw.matchAll(pattern)) {
|
||||
const month =
|
||||
MONTHS.findIndex((stem) => new RegExp(`^${stem}`, "iu").test(m[2])) + 1;
|
||||
const day = Number(m[1]);
|
||||
if (!month || day < 1 || day > 31) continue;
|
||||
const y = month >= 8 ? Number(year) : Number(year) + 1;
|
||||
dates.push(
|
||||
`${y}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
return [...new Set(dates)].sort();
|
||||
}
|
||||
|
||||
const toTime = (iso) => Date.parse(`${iso}T00:00:00Z`);
|
||||
export const isoDate = (time) => new Date(time).toISOString().slice(0, 10);
|
||||
export const addDays = (iso, days) => isoDate(toTime(iso) + days * DAY_MS);
|
||||
/** 1 = Monday … 7 = Sunday */
|
||||
export const weekday = (iso) => ((new Date(toTime(iso)).getUTCDay() + 6) % 7) + 1;
|
||||
|
||||
/** Today in Europe/Minsk as YYYY-MM-DD. */
|
||||
export function todayIso(now = new Date()) {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Minsk",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
/** Index of the calendar week that contains the date, or -1. */
|
||||
export function weekIndexOf(calendar, iso) {
|
||||
return calendar?.weeks.findIndex((w) => w.start <= iso && iso <= w.end) ?? -1;
|
||||
}
|
||||
|
||||
/** Date of a weekday inside a calendar week; null when it falls outside the week (week 1 starts on Tuesday). */
|
||||
export function dateInWeek(week, day) {
|
||||
const monday = addDays(week.start, 1 - weekday(week.start));
|
||||
const date = addDays(monday, day - 1);
|
||||
return date < week.start || date > week.end ? null : date;
|
||||
}
|
||||
|
||||
/** Week numbers in which a weekly lesson takes place, limited by the calendar length. */
|
||||
export function lessonWeeks(lesson, lastWeek) {
|
||||
const ranges =
|
||||
lesson.weekRanges !== undefined
|
||||
? lesson.weekRanges
|
||||
: parseWeekRanges(lesson.condition);
|
||||
const parity =
|
||||
lesson.variant === "upper" ? 1 : lesson.variant === "lower" ? 0 : null;
|
||||
if (lesson.condition && !ranges) return null;
|
||||
const all = [];
|
||||
for (const [a, b] of ranges || [[1, lastWeek]])
|
||||
for (let n = a; n <= Math.min(b ?? lastWeek, lastWeek); n++) all.push(n);
|
||||
const unique = [...new Set(all)].sort((x, y) => x - y);
|
||||
const matching =
|
||||
parity === null ? unique : unique.filter((n) => n % 2 === parity);
|
||||
// A mark that contradicts its row colour is kept literally rather than dropped.
|
||||
return matching.length ? matching : unique;
|
||||
}
|
||||
|
||||
/** Concrete dates of a lesson; null when its weeks cannot be read from the condition. */
|
||||
export function lessonDates(lesson, calendar) {
|
||||
if (!calendar?.weeks.length) return null;
|
||||
if (lesson.date) return [lesson.date];
|
||||
if (lesson.dates?.length) return lesson.dates;
|
||||
const numbers = lessonWeeks(lesson, calendar.weeks.at(-1).n);
|
||||
if (!numbers) return null;
|
||||
return numbers
|
||||
.map((n) => calendar.weeks.find((w) => w.n === n))
|
||||
.filter(Boolean)
|
||||
.map((w) => dateInWeek(w, lesson.day))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The academic year starts on 1 September: 2026-08-31 → 2025, 2026-09-01 → 2026. */
|
||||
export function academicStartYear(iso) {
|
||||
const [year, month] = iso.split("-").map(Number);
|
||||
return month >= 9 ? year : year - 1;
|
||||
}
|
||||
|
||||
const daysBetween = (a, b) => Math.round((toTime(b) - toTime(a)) / DAY_MS);
|
||||
|
||||
/**
|
||||
* Splits a week table into semesters. A new semester starts when numbering
|
||||
* restarts or when more than three weeks separate two rows.
|
||||
*/
|
||||
export function assignSemesters(weeks) {
|
||||
const sorted = [...weeks].sort((a, b) => a.start.localeCompare(b.start));
|
||||
const groups = [];
|
||||
for (const week of sorted) {
|
||||
const group = groups.at(-1),
|
||||
previous = group?.at(-1);
|
||||
if (!previous || week.n <= previous.n || daysBetween(previous.end, week.start) > 21)
|
||||
groups.push([week]);
|
||||
else group.push(week);
|
||||
}
|
||||
const semesters = [];
|
||||
const result = [];
|
||||
for (const group of groups) {
|
||||
const first = group[0],
|
||||
month = Number(first.start.slice(5, 7)),
|
||||
kind = month >= 7 ? "autumn" : "spring",
|
||||
year = Number(first.start.slice(0, 4)) - (kind === "spring" ? 1 : 0);
|
||||
let id = `${year}-${kind}`;
|
||||
if (semesters.some((s) => s.id === id)) id += `-${semesters.length + 1}`;
|
||||
semesters.push({
|
||||
id,
|
||||
kind,
|
||||
year,
|
||||
label: `${kind === "autumn" ? "Осенний" : "Весенний"} семестр ${year}/${String(year + 1).slice(2)}`,
|
||||
start: first.start,
|
||||
end: group.at(-1).end,
|
||||
});
|
||||
for (const week of group) result.push({ ...week, semester: id });
|
||||
}
|
||||
return { weeks: result, semesters };
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of the week table that a schedule refers to. Week marks restart in
|
||||
* every semester, so mixing them would give confidently wrong dates.
|
||||
* Returns null when the semester cannot be determined unambiguously.
|
||||
*/
|
||||
export function semesterFor(calendar, schedule, today) {
|
||||
if (!calendar?.weeks?.length) return null;
|
||||
const { semesters } = calendar.semesters
|
||||
? calendar
|
||||
: assignSemesters(calendar.weeks);
|
||||
const lessons = schedule?.lessons || [];
|
||||
const dated = lessons.length > 0 && lessons.every((l) => l.date);
|
||||
if (dated) {
|
||||
const dates = lessons.map((l) => l.date).sort();
|
||||
const target = dates.find((d) => d >= today) || dates[0];
|
||||
return semesters.find((s) => s.start <= target && target <= s.end) || null;
|
||||
}
|
||||
const text = `${schedule?.period || ""}`;
|
||||
const kind = /весенн/i.test(text) ? "spring" : /осенн/i.test(text) ? "autumn" : null;
|
||||
const year = Number(schedule?.academicYear) || null;
|
||||
if (kind) {
|
||||
const matching = semesters.filter(
|
||||
(s) => s.kind === kind && (!year || s.year === year),
|
||||
);
|
||||
if (matching.length === 1) return matching[0];
|
||||
if (!year && matching.length > 1)
|
||||
return (
|
||||
matching.find((s) => s.start <= today && today <= s.end) ||
|
||||
[...matching].sort(
|
||||
(a, b) =>
|
||||
Math.abs(daysBetween(today, a.start)) -
|
||||
Math.abs(daysBetween(today, b.start)),
|
||||
)[0]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return semesters.find((s) => s.start <= today && today <= s.end) || null;
|
||||
}
|
||||
|
||||
/** Calendar narrowed to one semester; null when the schedule's semester is not in the table. */
|
||||
export function semesterView(calendar, schedule, today) {
|
||||
const semester = semesterFor(calendar, schedule, today);
|
||||
if (!semester) return null;
|
||||
const weeks = calendar.semesters
|
||||
? calendar.weeks
|
||||
: assignSemesters(calendar.weeks).weeks;
|
||||
return {
|
||||
...calendar,
|
||||
weeks: weeks.filter((w) => w.semester === semester.id),
|
||||
semester,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user