chuvsu-js 4.1.5 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +165 -205
  2. package/dist/browser.d.ts +1 -1
  3. package/dist/browser.js +1 -1
  4. package/dist/common/cache.js +30 -9
  5. package/dist/common/http.d.ts +6 -0
  6. package/dist/common/http.js +8 -1
  7. package/dist/common/parse.d.ts +1 -1
  8. package/dist/common/parse.js +3 -3
  9. package/dist/common/types.d.ts +8 -2
  10. package/dist/common/types.js +12 -0
  11. package/dist/core.d.ts +10 -0
  12. package/dist/core.js +5 -0
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.js +4 -4
  15. package/dist/lk/client.d.ts +8 -6
  16. package/dist/lk/client.js +51 -23
  17. package/dist/lk/types.d.ts +7 -7
  18. package/dist/node.d.ts +1 -0
  19. package/dist/node.js +1 -0
  20. package/dist/parsers.d.ts +3 -0
  21. package/dist/parsers.js +2 -0
  22. package/dist/tt/client.d.ts +55 -63
  23. package/dist/tt/client.js +477 -226
  24. package/dist/tt/domain/directory.d.ts +14 -0
  25. package/dist/tt/domain/directory.js +116 -0
  26. package/dist/tt/domain/ids.d.ts +7 -0
  27. package/dist/tt/domain/ids.js +20 -0
  28. package/dist/tt/domain/index.d.ts +7 -0
  29. package/dist/tt/domain/index.js +5 -0
  30. package/dist/tt/domain/normalize.d.ts +7 -0
  31. package/dist/tt/domain/normalize.js +65 -0
  32. package/dist/tt/domain/repository.d.ts +51 -0
  33. package/dist/tt/domain/repository.js +805 -0
  34. package/dist/tt/domain/schedule.d.ts +37 -0
  35. package/dist/tt/domain/schedule.js +292 -0
  36. package/dist/tt/domain/types.d.ts +175 -0
  37. package/dist/tt/domain/types.js +1 -0
  38. package/dist/tt/observations.d.ts +11 -0
  39. package/dist/tt/observations.js +131 -0
  40. package/dist/tt/parse/audience.d.ts +3 -3
  41. package/dist/tt/parse/audience.js +58 -34
  42. package/dist/tt/parse/entry-parts.d.ts +8 -0
  43. package/dist/tt/parse/entry-parts.js +52 -0
  44. package/dist/tt/parse/full-schedule.d.ts +9 -4
  45. package/dist/tt/parse/full-schedule.js +49 -51
  46. package/dist/tt/parse/groups.d.ts +1 -1
  47. package/dist/tt/parse/groups.js +1 -1
  48. package/dist/tt/parse/index.d.ts +4 -4
  49. package/dist/tt/parse/index.js +4 -4
  50. package/dist/tt/parse/lists.d.ts +6 -5
  51. package/dist/tt/parse/lists.js +7 -2
  52. package/dist/tt/parse/overlays.d.ts +5 -4
  53. package/dist/tt/parse/overlays.js +13 -14
  54. package/dist/tt/parse/patterns.d.ts +2 -2
  55. package/dist/tt/parse/patterns.js +3 -3
  56. package/dist/tt/parse/teacher.d.ts +2 -3
  57. package/dist/tt/parse/teacher.js +21 -65
  58. package/dist/tt/parse/webinars.js +3 -4
  59. package/dist/tt/types.d.ts +56 -69
  60. package/dist/tt/utils/date.d.ts +4 -0
  61. package/dist/tt/utils/date.js +30 -1
  62. package/dist/tt/utils/index.d.ts +2 -3
  63. package/dist/tt/utils/index.js +2 -3
  64. package/dist/tt/utils/period.d.ts +4 -4
  65. package/dist/tt/utils/period.js +9 -8
  66. package/dist/tt/utils/semester.d.ts +4 -4
  67. package/dist/tt/utils/semester.js +25 -5
  68. package/dist/tt/utils/time-slots.d.ts +3 -5
  69. package/dist/tt/utils/time-slots.js +19 -34
  70. package/dist/tt/webinars.d.ts +7 -0
  71. package/dist/tt/webinars.js +38 -0
  72. package/docs/fixture-review.md +56 -0
  73. package/docs/testing.md +23 -0
  74. package/docs/v5-architecture.md +144 -0
  75. package/docs/v5-migration.md +451 -0
  76. package/package.json +19 -7
  77. package/dist/shared.d.ts +0 -9
  78. package/dist/shared.js +0 -7
  79. package/dist/tt/schedule.d.ts +0 -55
  80. package/dist/tt/schedule.js +0 -233
  81. package/dist/tt/utils/lessons.d.ts +0 -16
  82. package/dist/tt/utils/lessons.js +0 -173
@@ -1,15 +1,13 @@
1
1
  import { parseHtml, parseTime, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
- import { getLessonNumber } from "../utils/index.js";
3
- import { parseSemesterScheduleWith } from "./full-schedule.js";
2
+ import { parseSemesterScheduleWith, parseSessionScheduleWith, } from "./full-schedule.js";
4
3
  import { parseGroupsString } from "./groups.js";
5
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
6
5
  import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
7
- const DISTANCE_RE = /дистанционно|ДОТ/i;
8
- export function parseTeacherFullSchedule(html, educationType) {
6
+ import { containsGroupCode, hasDistanceMarker, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
7
+ export function parseTeacherSchedule(html) {
9
8
  const doc = parseHtml(html);
10
- const edType = educationType ?? 1 /* EducationType.HigherEducation */;
11
9
  if (doc.querySelector('td[id^="trd2"]')) {
12
- return parseTeacherSessionSchedule(doc, edType);
10
+ return parseSessionScheduleWith(doc, parseTeacherSessionEntry);
13
11
  }
14
12
  return parseSemesterScheduleWith(doc, parseTeacherSemesterEntry);
15
13
  }
@@ -56,61 +54,24 @@ function parseTeacherSemesterEntry(el) {
56
54
  return null;
57
55
  const typeMatch = cleanText.match(LESSON_TYPE_RE);
58
56
  const weeksMatch = cleanText.match(WEEKS_RE);
59
- const roomMatch = cleanHtml.match(/(?:<sup>[^<]*<\/sup>)?([А-Яа-яA-Za-z]-\d+)/);
60
- const groupsMatch = cleanHtml.match(/<br\s*\/?>\s*([^<]+?)(?:<br|<\/td|<div|<i|$)/);
57
+ const room = parseEntryRoom(cleanHtml, subject);
58
+ const groupsLine = linesAfterSubject(cleanHtml, subject).find((line) => containsGroupCode(stripDistanceMarker(line))) ?? "";
61
59
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
62
60
  const weekParity = parseWeekParity(cleanHtml);
61
+ const groups = parseGroupsString(stripDistanceMarker(groupsLine));
63
62
  return {
64
- room: roomMatch?.[1] ?? "",
63
+ room: room || undefined,
65
64
  subject,
66
65
  type: typeMatch?.[1] ?? "",
67
66
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
68
- teacher: { name: "" },
69
- groups: parseGroupsString(groupsMatch?.[1]),
67
+ groups: groups.length > 0 ? groups : undefined,
70
68
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
71
69
  weekParity,
72
- isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
70
+ isDistance: hasDistanceMarker(cleanText) || hasDistanceMarker(room ?? ""),
73
71
  substitutions: substitutions.length > 0 ? substitutions : undefined,
74
72
  possibleChanges,
75
73
  };
76
74
  }
77
- function parseTeacherSessionSchedule(doc, educationType) {
78
- const days = [];
79
- for (const dateCell of doc.querySelectorAll('td[id^="trd2"]')) {
80
- const id = dateCell.getAttribute("id") ?? "";
81
- const dateMatch = id.match(/trd(\d{4})(\d{2})(\d{2})/);
82
- if (!dateMatch)
83
- continue;
84
- const year = parseInt(dateMatch[1]);
85
- const month = parseInt(dateMatch[2]) - 1;
86
- const dayNum = parseInt(dateMatch[3]);
87
- const date = new Date(year, month, dayNum);
88
- const cellHtml = dateCell.innerHTML ?? "";
89
- const brMatch = cellHtml.match(/<br\s*\/?>\s*(.+)/i);
90
- const weekday = brMatch ? brMatch[1].trim() : "";
91
- const dataCell = dateCell.nextElementSibling;
92
- if (!dataCell?.matches("td.trdata:not(.trfd)")) {
93
- continue;
94
- }
95
- const slots = [];
96
- for (const entryRow of dataCell.querySelectorAll("table tr")) {
97
- const td = entryRow.querySelector("td") ?? entryRow;
98
- const entry = parseTeacherSessionEntry(td);
99
- if (!entry)
100
- continue;
101
- slots.push({
102
- number: getLessonNumber(entry.timeStart, educationType),
103
- timeStart: entry.timeStart,
104
- timeEnd: entry.timeEnd,
105
- entries: [entry.entry],
106
- });
107
- }
108
- if (slots.length > 0) {
109
- days.push({ weekday, date, slots });
110
- }
111
- }
112
- return days;
113
- }
114
75
  function parseTeacherSessionEntry(td) {
115
76
  const fullHtml = td.innerHTML ?? "";
116
77
  const plainText = text(td);
@@ -121,34 +82,29 @@ function parseTeacherSessionEntry(td) {
121
82
  const subject = subjectEl ? text(subjectEl) : "";
122
83
  if (!subject)
123
84
  return null;
124
- const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
125
- const room = roomMatch ? roomMatch[1].trim() : "";
85
+ const room = parseEntryRoom(fullHtml, subject);
126
86
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
127
87
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
128
88
  const subgroupMatch = plainText.match(SUBGROUP_RE);
129
89
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
130
90
  if (!timeMatch)
131
91
  return null;
132
- const parts = fullHtml
133
- .split(/<br\s*\/?>/i)
134
- .map((part) => part.replace(/<[^>]*>/g, "").trim())
135
- .filter((part) => part.length > 0);
136
- const groupsPart = parts.find((part) => !part.includes(subject) &&
137
- !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part)) ?? "";
92
+ const parts = linesAfterSubject(fullHtml, subject);
93
+ const groupsPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
94
+ !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
95
+ containsGroupCode(stripDistanceMarker(part))) ?? "";
96
+ const groups = parseGroupsString(stripDistanceMarker(groupsPart));
138
97
  return {
139
- entry: {
140
- room,
98
+ lesson: {
99
+ room: room || undefined,
141
100
  subject,
142
101
  type,
143
- weeks: { from: 0, to: 0 },
144
- teacher: { name: "" },
145
- groups: parseGroupsString(groupsPart),
102
+ groups: groups.length > 0 ? groups : undefined,
146
103
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
147
- isDistance: DISTANCE_RE.test(plainText) || DISTANCE_RE.test(room),
104
+ isDistance: hasDistanceMarker(plainText) || hasDistanceMarker(room ?? ""),
148
105
  possibleChanges,
149
106
  },
150
- timeStart: parseTime(timeMatch[1]),
151
- timeEnd: parseTime(timeMatch[2]),
107
+ time: { start: parseTime(timeMatch[1]), end: parseTime(timeMatch[2]) },
152
108
  };
153
109
  }
154
110
  export function parseTeacherInfo(html) {
@@ -6,7 +6,7 @@ function parseDateValue(value) {
6
6
  const match = value?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
7
7
  if (!match)
8
8
  return undefined;
9
- return new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]));
9
+ return `${match[1]}-${match[2]}-${match[3]}`;
10
10
  }
11
11
  function parseTimeRange(raw) {
12
12
  const match = raw.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
@@ -71,10 +71,9 @@ function parseWebinarRows(doc, tableSelector, scheduled, fallbackDate) {
71
71
  id: joinMatch?.[1] ?? "",
72
72
  idType: joinMatch ? parseInt(joinMatch[2]) : 1,
73
73
  scheduled,
74
- date,
74
+ scheduledDate: date,
75
75
  slotNumber,
76
- timeStart: timeRange.start,
77
- timeEnd: timeRange.end,
76
+ time: timeRange,
78
77
  subject: parsed.subject,
79
78
  type: parsed.type,
80
79
  teacher: parseTeacher(parsed.teacherRaw),
@@ -1,5 +1,7 @@
1
1
  import type { BlobAdapter, CacheAdapter } from "../common/cache.js";
2
- import type { Time, WeekRange, Teacher, EducationType } from "../common/types.js";
2
+ import type { TimetableRepositoryAdapter, TimetableRepositorySnapshot } from "./domain/types.js";
3
+ import type { TimetableRepository } from "./domain/repository.js";
4
+ import type { TimeRange, WeekRange, Teacher, EducationLevel, AcademicPeriod, LocalDate } from "../common/types.js";
3
5
  export interface Faculty {
4
6
  id: number;
5
7
  name: string;
@@ -10,11 +12,11 @@ export interface Group {
10
12
  specialty?: string;
11
13
  profile?: string;
12
14
  }
13
- export interface Audience {
15
+ export interface Room {
14
16
  id: number;
15
17
  name: string;
16
18
  }
17
- export interface AudienceInfo {
19
+ export interface RoomInfo {
18
20
  name: string;
19
21
  /** Building letter/name, e.g. "Б". */
20
22
  building?: string;
@@ -39,7 +41,7 @@ export interface AudienceInfo {
39
41
  /** A date-specific substitution (room and/or teacher change). */
40
42
  export interface Substitution {
41
43
  /** The date this substitution applies to. */
42
- date: Date;
44
+ date: LocalDate;
43
45
  /** New room, if changed. */
44
46
  room?: string;
45
47
  /** Whether the substitution moves the lesson online. */
@@ -50,34 +52,37 @@ export interface Substitution {
50
52
  /** Info about a lesson this teacher is substituting for another teacher. */
51
53
  export interface SubstituteForInfo {
52
54
  /** The date this substitute lesson takes place. */
53
- date: Date;
55
+ date: LocalDate;
54
56
  /** The original teacher being replaced. */
55
- originalTeacher: Teacher;
57
+ originalTeacher?: Teacher;
56
58
  }
57
59
  /** Info about a lesson transferred from another date/slot. */
58
60
  export interface TransferInfo {
59
61
  /** Date when this lesson takes place (target). */
60
- targetDate: Date;
62
+ targetDate: LocalDate;
61
63
  /** Original date the lesson was moved from. */
62
- fromDate: Date;
64
+ fromDate: LocalDate;
63
65
  /** Original slot number (пара). */
64
66
  fromSlot: number;
65
67
  /** Subject name (used to match the source entry). */
66
68
  subject: string;
67
69
  }
68
- export interface ScheduleEntry {
69
- room: string;
70
+ export interface ParsedLesson {
71
+ /** Missing means this source projection does not expose room information. */
72
+ room?: string | null;
70
73
  subject: string;
71
74
  type: string;
72
- weeks: WeekRange;
73
- teacher: Teacher;
75
+ /** Recurrence range; absent for concrete session lessons or unknown evidence. */
76
+ weeks?: WeekRange;
77
+ /** Missing means this source projection does not expose teacher information. */
78
+ teacher?: Teacher | null;
74
79
  /**
75
80
  * Group names for this lesson (e.g. `["КТ-42-25 (АихС)", "КТ-41-25"]`).
76
81
  * Parenthesized annotations that are part of the group name are preserved;
77
- * service markers like "(N подгруппа)" are stripped and moved to {@link ScheduleEntry.subgroup}.
82
+ * service markers like "(N подгруппа)" are stripped and moved to {@link ParsedLesson.subgroup}.
78
83
  * Empty array if no groups are listed.
79
84
  */
80
- groups: string[];
85
+ groups?: string[] | null;
81
86
  subgroup?: number;
82
87
  weekParity?: "even" | "odd";
83
88
  /** True when the lesson is explicitly marked as дистанционно / ДОТ. */
@@ -91,54 +96,23 @@ export interface ScheduleEntry {
91
96
  /** Whether this entry is marked as potentially changing (class="want"). */
92
97
  possibleChanges?: boolean;
93
98
  }
94
- export interface FullScheduleSlot {
95
- number: number;
96
- timeStart: Time;
97
- timeEnd: Time;
98
- entries: ScheduleEntry[];
99
+ export interface ParsedScheduleBlock {
100
+ /** Portal ordinal claim. It is independent from the time claim. */
101
+ slotNumber?: number;
102
+ /** Missing when the source does not expose a trustworthy time range. */
103
+ time?: TimeRange;
104
+ lessons: ParsedLesson[];
99
105
  }
100
- export interface FullScheduleDay {
106
+ export interface ParsedScheduleDay {
101
107
  weekday: string;
102
- date?: Date;
103
- slots: FullScheduleSlot[];
104
- }
105
- export interface LessonTimeSlot {
106
- number: number;
107
- start: Time;
108
- end: Time;
109
- }
110
- export interface LessonTime {
111
- date: Date;
112
- hours: number;
113
- minutes: number;
114
- }
115
- export interface Lesson {
116
- number: number;
117
- start: LessonTime;
118
- end: LessonTime;
119
- subject: string;
120
- type: string;
121
- room: string;
122
- teacher: Teacher;
123
- /** Group names for this lesson. See {@link ScheduleEntry.groups}. */
124
- groups: string[];
125
- weeks: WeekRange;
126
- subgroup?: number;
127
- weekParity?: "even" | "odd";
128
- /** True when the lesson is explicitly marked as дистанционно / ДОТ. */
129
- isDistance?: boolean;
130
- /** Matched webinar metadata, if attached by `attachWebinarsToLessons`. */
131
- webinar?: Webinar;
132
- /** If a substitution was applied, the original room. */
133
- originalRoom?: string;
134
- /** If a substitution was applied, the original teacher. */
135
- originalTeacher?: Teacher;
136
- /** Transfer info if this lesson was moved from another date/slot. */
137
- transfer?: TransferInfo;
138
- /** If this lesson is a substitute (замена вместо), the original teacher. */
139
- substituteFor?: SubstituteForInfo;
140
- /** Whether this lesson is marked as potentially changing. */
141
- possibleChanges?: boolean;
108
+ date?: LocalDate;
109
+ /** True when portal marks this weekday as a self-study day. */
110
+ isSelfStudyDay?: boolean;
111
+ blocks: ParsedScheduleBlock[];
112
+ }
113
+ export interface StandardScheduleBlock {
114
+ slotNumber: number;
115
+ time: TimeRange;
142
116
  }
143
117
  /** Teacher info from the schedule page header. */
144
118
  export interface TeacherInfo {
@@ -160,10 +134,9 @@ export interface Webinar {
160
134
  idType: number;
161
135
  /** True for "Вебинары по расписанию"; false for external webinars. */
162
136
  scheduled: boolean;
163
- date?: Date;
137
+ scheduledDate?: LocalDate;
164
138
  slotNumber?: number;
165
- timeStart: Time;
166
- timeEnd: Time;
139
+ time: TimeRange;
167
140
  subject: string;
168
141
  type: string;
169
142
  teacher: Teacher;
@@ -178,18 +151,32 @@ export interface CacheConfig {
178
151
  schedule?: number;
179
152
  faculties?: number;
180
153
  groups?: number;
181
- audiences?: number;
182
- audienceNames?: number;
154
+ rooms?: number;
155
+ roomNames?: number;
183
156
  teachers?: number;
184
157
  teacherInfo?: number;
185
158
  teacherPhotos?: number;
186
- audienceInfo?: number;
187
- audienceImages?: number;
159
+ roomInfo?: number;
160
+ roomImages?: number;
188
161
  webinars?: number;
189
162
  }
190
- export interface TtClientOptions {
191
- educationType?: EducationType;
163
+ export interface TimetableClientOptions {
164
+ educationLevel?: EducationLevel;
192
165
  cache?: number | CacheConfig;
193
166
  cacheAdapter?: CacheAdapter;
194
167
  blobAdapter?: BlobAdapter;
168
+ /** Existing canonical repository, useful for dependency injection/browser handoff. */
169
+ repository?: TimetableRepository;
170
+ /** Persistent canonical identity storage. Independent from the TTL cache. */
171
+ repositoryAdapter?: TimetableRepositoryAdapter;
172
+ }
173
+ export interface GetScheduleOptions {
174
+ periods?: readonly AcademicPeriod[];
175
+ }
176
+ export type EntityResolutionStrategy = "cache-only" | "search";
177
+ export interface DirectoryPreloadOptions {
178
+ teachers?: boolean;
179
+ rooms?: boolean;
180
+ facultyIds?: number[];
195
181
  }
182
+ export type { TimetableRepositorySnapshot };
@@ -1,5 +1,9 @@
1
+ import type { LocalDate } from "../../common/types.js";
1
2
  export declare function getWeekdayName(weekday: number): string;
2
3
  /** Monday of the week containing `date` (at 00:00 local time). */
3
4
  export declare function getMonday(date: Date): Date;
4
5
  /** True if `a` and `b` fall on the same calendar day (local time). */
5
6
  export declare function isSameDay(a: Date, b: Date): boolean;
7
+ export declare function formatLocalDate(date: Date): LocalDate;
8
+ export declare function isLocalDate(value: string): value is LocalDate;
9
+ export declare function parseLocalDate(value: LocalDate): Date;
@@ -8,10 +8,15 @@ const WEEKDAY_NAMES = [
8
8
  "Суббота",
9
9
  ];
10
10
  export function getWeekdayName(weekday) {
11
- return WEEKDAY_NAMES[weekday] ?? "";
11
+ if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6) {
12
+ throw new RangeError("Weekday must be an integer from 0 to 6");
13
+ }
14
+ return WEEKDAY_NAMES[weekday];
12
15
  }
13
16
  /** Monday of the week containing `date` (at 00:00 local time). */
14
17
  export function getMonday(date) {
18
+ if (!Number.isFinite(date.getTime()))
19
+ throw new RangeError("Invalid date");
15
20
  const d = new Date(date);
16
21
  const day = d.getDay();
17
22
  const diff = day === 0 ? -6 : 1 - day;
@@ -25,3 +30,27 @@ export function isSameDay(a, b) {
25
30
  a.getMonth() === b.getMonth() &&
26
31
  a.getDate() === b.getDate());
27
32
  }
33
+ export function formatLocalDate(date) {
34
+ const year = date.getFullYear();
35
+ const month = String(date.getMonth() + 1).padStart(2, "0");
36
+ const day = String(date.getDate()).padStart(2, "0");
37
+ return `${year}-${month}-${day}`;
38
+ }
39
+ export function isLocalDate(value) {
40
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/u);
41
+ if (!match)
42
+ return false;
43
+ const year = Number(match[1]);
44
+ const month = Number(match[2]);
45
+ const day = Number(match[3]);
46
+ const date = new Date(year, month - 1, day);
47
+ return date.getFullYear() === year &&
48
+ date.getMonth() === month - 1 &&
49
+ date.getDate() === day;
50
+ }
51
+ export function parseLocalDate(value) {
52
+ if (!isLocalDate(value))
53
+ throw new RangeError(`Invalid local date: ${value}`);
54
+ const [year, month, day] = value.split("-").map(Number);
55
+ return new Date(year, month - 1, day);
56
+ }
@@ -1,7 +1,6 @@
1
- export { getMonday, getWeekdayName, isSameDay } from "./date.js";
1
+ export { formatLocalDate, getMonday, getWeekdayName, isSameDay, isLocalDate, parseLocalDate, } from "./date.js";
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
- export { getLessonNumber, getTimeSlots } from "./time-slots.js";
5
- export { attachWebinarsToLessons, collectTransfers, filterSlots, matchWebinarToLesson, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
4
+ export { getStandardScheduleBlocks } from "./time-slots.js";
6
5
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
7
6
  export type { Holiday, HolidayTransfer } from "./holidays.js";
@@ -1,6 +1,5 @@
1
- export { getMonday, getWeekdayName, isSameDay } from "./date.js";
1
+ export { formatLocalDate, getMonday, getWeekdayName, isSameDay, isLocalDate, parseLocalDate, } from "./date.js";
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
- export { getLessonNumber, getTimeSlots } from "./time-slots.js";
5
- export { attachWebinarsToLessons, collectTransfers, filterSlots, matchWebinarToLesson, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
4
+ export { getStandardScheduleBlocks } from "./time-slots.js";
6
5
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
@@ -1,6 +1,6 @@
1
- import { Period } from "../../common/types.js";
1
+ import { AcademicPeriod } from "../../common/types.js";
2
2
  export declare function getCurrentPeriod(opts?: {
3
3
  date?: Date;
4
- }): Period;
5
- export declare function isSessionPeriod(period: Period): boolean;
6
- export declare function getAdjacentSemester(session: Period): Period;
4
+ }): AcademicPeriod;
5
+ export declare function isSessionPeriod(period: AcademicPeriod): boolean;
6
+ export declare function getAdjacentSemester(session: AcademicPeriod): AcademicPeriod;
@@ -1,24 +1,25 @@
1
+ import { AcademicPeriod } from "../../common/types.js";
1
2
  export function getCurrentPeriod(opts) {
2
3
  const date = opts?.date ?? new Date();
3
4
  const month = date.getMonth();
4
5
  const day = date.getDate();
5
6
  // Dec 25+ and Jan -> Winter session (зимняя сессия)
6
7
  if (month === 0 || (month === 11 && day >= 25))
7
- return 2 /* Period.WinterSession */;
8
+ return AcademicPeriod.WinterSession;
8
9
  // Feb-May -> Spring semester (весенний семестр)
9
10
  if (month >= 1 && month <= 4)
10
- return 3 /* Period.SpringSemester */;
11
+ return AcademicPeriod.SpringSemester;
11
12
  // Jun-Aug -> Summer session (летняя сессия)
12
13
  if (month >= 5 && month <= 7)
13
- return 4 /* Period.SummerSession */;
14
+ return AcademicPeriod.SummerSession;
14
15
  // Sep - Dec 24 -> Fall semester (осенний семестр)
15
- return 1 /* Period.FallSemester */;
16
+ return AcademicPeriod.FallSemester;
16
17
  }
17
18
  export function isSessionPeriod(period) {
18
- return period === 2 /* Period.WinterSession */ || period === 4 /* Period.SummerSession */;
19
+ return period === AcademicPeriod.WinterSession || period === AcademicPeriod.SummerSession;
19
20
  }
20
21
  export function getAdjacentSemester(session) {
21
- return session === 2 /* Period.WinterSession */
22
- ? 1 /* Period.FallSemester */
23
- : 3 /* Period.SpringSemester */;
22
+ return session === AcademicPeriod.WinterSession
23
+ ? AcademicPeriod.FallSemester
24
+ : AcademicPeriod.SpringSemester;
24
25
  }
@@ -1,4 +1,4 @@
1
- import { Period } from "../../common/types.js";
1
+ import { AcademicPeriod } from "../../common/types.js";
2
2
  import type { SemesterWeek } from "../types.js";
3
3
  /**
4
4
  * Start date of a semester.
@@ -8,7 +8,7 @@ import type { SemesterWeek } from "../types.js";
8
8
  * academic year instead of the calendar year.
9
9
  */
10
10
  export declare function getSemesterStart(opts: {
11
- period: Period;
11
+ period: AcademicPeriod;
12
12
  year?: number;
13
13
  date?: Date;
14
14
  }): Date;
@@ -17,14 +17,14 @@ export declare function getSemesterStart(opts: {
17
17
  * Week 1 is the calendar week containing the semester start date.
18
18
  */
19
19
  export declare function getSemesterWeeks(opts: {
20
- period: Period;
20
+ period: AcademicPeriod;
21
21
  year?: number;
22
22
  date?: Date;
23
23
  weekCount?: number;
24
24
  }): SemesterWeek[];
25
25
  /** Current week number within a semester. */
26
26
  export declare function getWeekNumber(opts: {
27
- period: Period;
27
+ period: AcademicPeriod;
28
28
  year?: number;
29
29
  date?: Date;
30
30
  }): number;
@@ -1,4 +1,15 @@
1
+ import { AcademicPeriod } from "../../common/types.js";
1
2
  import { getMonday } from "./date.js";
3
+ function assertSemesterPeriod(period) {
4
+ if (period !== AcademicPeriod.FallSemester &&
5
+ period !== AcademicPeriod.SpringSemester) {
6
+ throw new RangeError("Academic period must be a semester");
7
+ }
8
+ }
9
+ function calendarDayNumber(date) {
10
+ return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) /
11
+ (24 * 60 * 60 * 1000);
12
+ }
2
13
  function getAcademicYearStartYear(date) {
3
14
  const year = date.getFullYear();
4
15
  const month = date.getMonth();
@@ -6,11 +17,17 @@ function getAcademicYearStartYear(date) {
6
17
  return month >= 8 ? year : year - 1;
7
18
  }
8
19
  function resolveSemesterYear(opts) {
9
- if (opts.year != null)
20
+ assertSemesterPeriod(opts.period);
21
+ if (opts.year != null) {
22
+ if (!Number.isInteger(opts.year))
23
+ throw new RangeError("Invalid semester year");
10
24
  return opts.year;
25
+ }
11
26
  const baseDate = opts.date ?? new Date();
27
+ if (!Number.isFinite(baseDate.getTime()))
28
+ throw new RangeError("Invalid date");
12
29
  const academicYearStart = getAcademicYearStartYear(baseDate);
13
- return opts.period === 1 /* Period.FallSemester */
30
+ return opts.period === AcademicPeriod.FallSemester
14
31
  ? academicYearStart
15
32
  : academicYearStart + 1;
16
33
  }
@@ -23,7 +40,7 @@ function resolveSemesterYear(opts) {
23
40
  */
24
41
  export function getSemesterStart(opts) {
25
42
  const year = resolveSemesterYear(opts);
26
- if (opts.period === 1 /* Period.FallSemester */) {
43
+ if (opts.period === AcademicPeriod.FallSemester) {
27
44
  return new Date(year, 8, 1); // September 1
28
45
  }
29
46
  // Spring: first Monday of February
@@ -40,6 +57,9 @@ export function getSemesterStart(opts) {
40
57
  */
41
58
  export function getSemesterWeeks(opts) {
42
59
  const weekCount = opts.weekCount ?? 17;
60
+ if (!Number.isInteger(weekCount) || weekCount < 1) {
61
+ throw new RangeError("Week count must be a positive integer");
62
+ }
43
63
  const semesterStart = getSemesterStart(opts);
44
64
  const startMonday = getMonday(semesterStart);
45
65
  const weeks = [];
@@ -59,6 +79,6 @@ export function getWeekNumber(opts) {
59
79
  const semesterStart = getSemesterStart({ ...opts, date });
60
80
  const startMonday = getMonday(semesterStart);
61
81
  const targetMonday = getMonday(date);
62
- const diff = targetMonday.getTime() - startMonday.getTime();
63
- return Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1;
82
+ const diff = calendarDayNumber(targetMonday) - calendarDayNumber(startMonday);
83
+ return Math.floor(diff / 7) + 1;
64
84
  }
@@ -1,5 +1,3 @@
1
- import { EducationType } from "../../common/types.js";
2
- import type { Time } from "../../common/types.js";
3
- import type { LessonTimeSlot } from "../types.js";
4
- export declare function getTimeSlots(educationType: EducationType): LessonTimeSlot[];
5
- export declare function getLessonNumber(time: Time, educationType: EducationType): number;
1
+ import { EducationLevel } from "../../common/types.js";
2
+ import type { StandardScheduleBlock } from "../types.js";
3
+ export declare function getStandardScheduleBlocks(educationLevel: EducationLevel): StandardScheduleBlock[];
@@ -1,41 +1,26 @@
1
+ import { EducationLevel } from "../../common/types.js";
1
2
  const VO_TIME_SLOTS = [
2
- { number: 1, start: { hours: 8, minutes: 20 }, end: { hours: 9, minutes: 40 } },
3
- { number: 2, start: { hours: 9, minutes: 50 }, end: { hours: 11, minutes: 10 } },
4
- { number: 3, start: { hours: 11, minutes: 40 }, end: { hours: 13, minutes: 0 } },
5
- { number: 4, start: { hours: 13, minutes: 30 }, end: { hours: 14, minutes: 50 } },
6
- { number: 5, start: { hours: 15, minutes: 0 }, end: { hours: 16, minutes: 20 } },
7
- { number: 6, start: { hours: 16, minutes: 40 }, end: { hours: 18, minutes: 0 } },
8
- { number: 7, start: { hours: 18, minutes: 10 }, end: { hours: 19, minutes: 30 } },
9
- { number: 8, start: { hours: 19, minutes: 40 }, end: { hours: 21, minutes: 0 } },
3
+ { slotNumber: 1, time: { start: { hours: 8, minutes: 20 }, end: { hours: 9, minutes: 40 } } },
4
+ { slotNumber: 2, time: { start: { hours: 9, minutes: 50 }, end: { hours: 11, minutes: 10 } } },
5
+ { slotNumber: 3, time: { start: { hours: 11, minutes: 40 }, end: { hours: 13, minutes: 0 } } },
6
+ { slotNumber: 4, time: { start: { hours: 13, minutes: 30 }, end: { hours: 14, minutes: 50 } } },
7
+ { slotNumber: 5, time: { start: { hours: 15, minutes: 0 }, end: { hours: 16, minutes: 20 } } },
8
+ { slotNumber: 6, time: { start: { hours: 16, minutes: 40 }, end: { hours: 18, minutes: 0 } } },
9
+ { slotNumber: 7, time: { start: { hours: 18, minutes: 10 }, end: { hours: 19, minutes: 30 } } },
10
+ { slotNumber: 8, time: { start: { hours: 19, minutes: 40 }, end: { hours: 21, minutes: 0 } } },
10
11
  ];
11
12
  const SPO_TIME_SLOTS = [
12
- { number: 1, start: { hours: 8, minutes: 10 }, end: { hours: 9, minutes: 40 } },
13
- { number: 2, start: { hours: 9, minutes: 55 }, end: { hours: 11, minutes: 25 } },
14
- { number: 3, start: { hours: 11, minutes: 55 }, end: { hours: 13, minutes: 25 } },
15
- { number: 4, start: { hours: 13, minutes: 40 }, end: { hours: 15, minutes: 10 } },
16
- { number: 5, start: { hours: 15, minutes: 25 }, end: { hours: 16, minutes: 55 } },
17
- { number: 6, start: { hours: 17, minutes: 10 }, end: { hours: 18, minutes: 40 } },
18
- { number: 7, start: { hours: 18, minutes: 55 }, end: { hours: 20, minutes: 25 } },
13
+ { slotNumber: 1, time: { start: { hours: 8, minutes: 10 }, end: { hours: 9, minutes: 40 } } },
14
+ { slotNumber: 2, time: { start: { hours: 9, minutes: 55 }, end: { hours: 11, minutes: 25 } } },
15
+ { slotNumber: 3, time: { start: { hours: 11, minutes: 55 }, end: { hours: 13, minutes: 25 } } },
16
+ { slotNumber: 4, time: { start: { hours: 13, minutes: 40 }, end: { hours: 15, minutes: 10 } } },
17
+ { slotNumber: 5, time: { start: { hours: 15, minutes: 25 }, end: { hours: 16, minutes: 55 } } },
18
+ { slotNumber: 6, time: { start: { hours: 17, minutes: 10 }, end: { hours: 18, minutes: 40 } } },
19
+ { slotNumber: 7, time: { start: { hours: 18, minutes: 55 }, end: { hours: 20, minutes: 25 } } },
19
20
  ];
20
- export function getTimeSlots(educationType) {
21
- return educationType === 2 /* EducationType.VocationalEducation */
21
+ export function getStandardScheduleBlocks(educationLevel) {
22
+ const blocks = educationLevel === EducationLevel.VocationalEducation
22
23
  ? SPO_TIME_SLOTS
23
24
  : VO_TIME_SLOTS;
24
- }
25
- function timeToMinutes(t) {
26
- return t.hours * 60 + t.minutes;
27
- }
28
- export function getLessonNumber(time, educationType) {
29
- const slots = getTimeSlots(educationType);
30
- const target = timeToMinutes(time);
31
- let closest = slots[0];
32
- let minDiff = Math.abs(timeToMinutes(closest.start) - target);
33
- for (let i = 1; i < slots.length; i++) {
34
- const diff = Math.abs(timeToMinutes(slots[i].start) - target);
35
- if (diff < minDiff) {
36
- minDiff = diff;
37
- closest = slots[i];
38
- }
39
- }
40
- return closest.number;
25
+ return structuredClone(blocks);
41
26
  }