chuvsu-js 4.2.0 → 5.0.1

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 (80) hide show
  1. package/README.md +165 -193
  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 +2 -2
  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 +47 -15
  42. package/dist/tt/parse/entry-parts.d.ts +1 -0
  43. package/dist/tt/parse/entry-parts.js +5 -2
  44. package/dist/tt/parse/full-schedule.d.ts +9 -4
  45. package/dist/tt/parse/full-schedule.js +32 -41
  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 +25 -18
  54. package/dist/tt/parse/teacher.d.ts +2 -3
  55. package/dist/tt/parse/teacher.js +14 -56
  56. package/dist/tt/parse/webinars.js +3 -4
  57. package/dist/tt/types.d.ts +54 -69
  58. package/dist/tt/utils/date.d.ts +4 -0
  59. package/dist/tt/utils/date.js +30 -1
  60. package/dist/tt/utils/index.d.ts +2 -3
  61. package/dist/tt/utils/index.js +2 -3
  62. package/dist/tt/utils/period.d.ts +4 -4
  63. package/dist/tt/utils/period.js +9 -8
  64. package/dist/tt/utils/semester.d.ts +4 -4
  65. package/dist/tt/utils/semester.js +25 -5
  66. package/dist/tt/utils/time-slots.d.ts +3 -5
  67. package/dist/tt/utils/time-slots.js +19 -34
  68. package/dist/tt/webinars.d.ts +7 -0
  69. package/dist/tt/webinars.js +38 -0
  70. package/docs/fixture-review.md +56 -0
  71. package/docs/testing.md +23 -0
  72. package/docs/v5-architecture.md +144 -0
  73. package/docs/v5-migration.md +451 -0
  74. package/package.json +17 -7
  75. package/dist/shared.d.ts +0 -9
  76. package/dist/shared.js +0 -7
  77. package/dist/tt/schedule.d.ts +0 -55
  78. package/dist/tt/schedule.js +0 -233
  79. package/dist/tt/utils/lessons.d.ts +0 -16
  80. package/dist/tt/utils/lessons.js +0 -173
@@ -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,56 +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;
108
+ date?: LocalDate;
103
109
  /** True when portal marks this weekday as a self-study day. */
104
110
  isSelfStudyDay?: boolean;
105
- slots: FullScheduleSlot[];
106
- }
107
- export interface LessonTimeSlot {
108
- number: number;
109
- start: Time;
110
- end: Time;
111
- }
112
- export interface LessonTime {
113
- date: Date;
114
- hours: number;
115
- minutes: number;
116
- }
117
- export interface Lesson {
118
- number: number;
119
- start: LessonTime;
120
- end: LessonTime;
121
- subject: string;
122
- type: string;
123
- room: string;
124
- teacher: Teacher;
125
- /** Group names for this lesson. See {@link ScheduleEntry.groups}. */
126
- groups: string[];
127
- weeks: WeekRange;
128
- subgroup?: number;
129
- weekParity?: "even" | "odd";
130
- /** True when the lesson is explicitly marked as дистанционно / ДОТ. */
131
- isDistance?: boolean;
132
- /** Matched webinar metadata, if attached by `attachWebinarsToLessons`. */
133
- webinar?: Webinar;
134
- /** If a substitution was applied, the original room. */
135
- originalRoom?: string;
136
- /** If a substitution was applied, the original teacher. */
137
- originalTeacher?: Teacher;
138
- /** Transfer info if this lesson was moved from another date/slot. */
139
- transfer?: TransferInfo;
140
- /** If this lesson is a substitute (замена вместо), the original teacher. */
141
- substituteFor?: SubstituteForInfo;
142
- /** Whether this lesson is marked as potentially changing. */
143
- possibleChanges?: boolean;
111
+ blocks: ParsedScheduleBlock[];
112
+ }
113
+ export interface StandardScheduleBlock {
114
+ slotNumber: number;
115
+ time: TimeRange;
144
116
  }
145
117
  /** Teacher info from the schedule page header. */
146
118
  export interface TeacherInfo {
@@ -162,10 +134,9 @@ export interface Webinar {
162
134
  idType: number;
163
135
  /** True for "Вебинары по расписанию"; false for external webinars. */
164
136
  scheduled: boolean;
165
- date?: Date;
137
+ scheduledDate?: LocalDate;
166
138
  slotNumber?: number;
167
- timeStart: Time;
168
- timeEnd: Time;
139
+ time: TimeRange;
169
140
  subject: string;
170
141
  type: string;
171
142
  teacher: Teacher;
@@ -180,18 +151,32 @@ export interface CacheConfig {
180
151
  schedule?: number;
181
152
  faculties?: number;
182
153
  groups?: number;
183
- audiences?: number;
184
- audienceNames?: number;
154
+ rooms?: number;
155
+ roomNames?: number;
185
156
  teachers?: number;
186
157
  teacherInfo?: number;
187
158
  teacherPhotos?: number;
188
- audienceInfo?: number;
189
- audienceImages?: number;
159
+ roomInfo?: number;
160
+ roomImages?: number;
190
161
  webinars?: number;
191
162
  }
192
- export interface TtClientOptions {
193
- educationType?: EducationType;
163
+ export interface TimetableClientOptions {
164
+ educationLevel?: EducationLevel;
194
165
  cache?: number | CacheConfig;
195
166
  cacheAdapter?: CacheAdapter;
196
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[];
197
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
  }
@@ -0,0 +1,7 @@
1
+ import type { LessonOccurrence } from "./domain/types.js";
2
+ import type { Webinar } from "./types.js";
3
+ export type LessonWithWebinar = LessonOccurrence & {
4
+ webinar?: Webinar;
5
+ };
6
+ export declare function findWebinar(lesson: LessonOccurrence, webinars: Webinar[]): Webinar | undefined;
7
+ export declare function attachWebinars(lessons: LessonOccurrence[], webinars: Webinar[]): LessonWithWebinar[];
@@ -0,0 +1,38 @@
1
+ function normalize(value) {
2
+ return value
3
+ .toLocaleLowerCase("ru-RU")
4
+ .replace(/ё/gu, "е")
5
+ .replace(/\s+/gu, " ")
6
+ .trim();
7
+ }
8
+ export function findWebinar(lesson, webinars) {
9
+ return webinars.find((webinar) => {
10
+ if (!webinar.scheduled)
11
+ return false;
12
+ if (webinar.scheduledDate &&
13
+ webinar.scheduledDate !== lesson.scheduledDate)
14
+ return false;
15
+ if (webinar.slotNumber != null &&
16
+ webinar.slotNumber !== lesson.slotNumber) {
17
+ return false;
18
+ }
19
+ if (!lesson.time ||
20
+ webinar.time.start.hours !== lesson.time.start.hours ||
21
+ webinar.time.start.minutes !== lesson.time.start.minutes ||
22
+ webinar.time.end.hours !== lesson.time.end.hours ||
23
+ webinar.time.end.minutes !== lesson.time.end.minutes) {
24
+ return false;
25
+ }
26
+ if (normalize(webinar.subject) !== normalize(lesson.subject))
27
+ return false;
28
+ if (webinar.type && lesson.type && webinar.type !== lesson.type)
29
+ return false;
30
+ return true;
31
+ });
32
+ }
33
+ export function attachWebinars(lessons, webinars) {
34
+ return lessons.map((lesson) => {
35
+ const webinar = findWebinar(lesson, webinars);
36
+ return webinar ? { ...lesson, webinar } : lesson;
37
+ });
38
+ }
@@ -0,0 +1,56 @@
1
+ # Журнал ручной проверки полных фикстур
2
+
3
+ Дата проверки: 1 сентября 2026 года.
4
+
5
+ Каждая строка каждого expected JSON лично сопоставлена с исходным HTML. При
6
+ проверке учитывались метаданные источника, позиция записи, предмет, тип, день,
7
+ слот, время, недели, чередование, аудитория, преподаватель и его реквизиты,
8
+ группы, подгруппа, ДОТ, замены и переносы. Генератор expected-файлов и отдельный
9
+ DOM-аудитор не использовались.
10
+
11
+ | Эталон | Занятий | Результат |
12
+ |---|---:|---|
13
+ | group-10042-period-1.json | 38 | проверен |
14
+ | group-10209-period-1.json | 22 | проверен |
15
+ | group-10307-period-1.json | 31 | проверен |
16
+ | group-10383-period-1.json | 27 | проверен |
17
+ | group-10403-period-1.json | 5 | проверен |
18
+ | group-10428-period-1.json | 30 | проверен |
19
+ | group-10443-period-1.json | 19 | проверен |
20
+ | group-10462-period-1.json | 26 | проверен |
21
+ | group-10468-period-1.json | 20 | проверен |
22
+ | group-10481-period-1.json | 4 | проверен |
23
+ | group-10483-period-1.json | 4 | проверен |
24
+ | group-10488-period-1.json | 4 | проверен |
25
+ | group-7698-period-1.json | 21 | проверен |
26
+ | group-8035-period-1.json | 9 | проверен |
27
+ | group-8107-period-1.json | 21 | проверен |
28
+ | group-8203-period-1.json | 26 | проверен |
29
+ | group-8219-period-1.json | 22 | проверен |
30
+ | group-8231-period-1.json | 49 | проверен |
31
+ | group-8499-period-1.json | 28 | проверен |
32
+ | group-8827-period-1.json | 65 | проверен, исправлено `к.иск.` |
33
+ | group-8844-period-1.json | 25 | проверен |
34
+ | group-8912-period-1.json | 42 | проверен |
35
+ | group-8917-period-1.json | 43 | проверен |
36
+ | group-8919-period-1.json | 59 | проверен |
37
+ | group-9075-period-1.json | 40 | проверен |
38
+ | group-9332-period-1.json | 24 | проверен |
39
+ | group-9347-period-1.json | 22 | проверен |
40
+ | group-9438-period-1.json | 19 | проверен |
41
+ | group-9491-period-1.json | 16 | проверен |
42
+ | group-9520-period-1.json | 26 | проверен |
43
+ | group-9588-period-1.json | 8 | проверен |
44
+ | group-9698-period-1.json | 37 | проверен, включая перенос |
45
+ | group-9719-period-1.json | 44 | проверен |
46
+ | group-9843-period-1.json | 24 | проверен |
47
+ | teacher-1317-period-1.json | 7 | проверен, восстановлена ученая степень |
48
+ | teacher-1907-period-1.json | 8 | проверен |
49
+ | teacher-2317-period-1.json | 10 | проверен |
50
+ | teacher-543-period-1.json | 39 | проверен, восстановлена ученая степень |
51
+ | room-1438-period-1.json | 75 | проверен |
52
+ | room-803-period-1.json | 48 | проверен, включая два переноса |
53
+ | room-811-period-1.json | 54 | проверен |
54
+ | room-814-period-1.json | 43 | проверен, исправлено ложное ДОТ у Федотовой |
55
+
56
+ Итого: 42 файла, 1184 занятия.
@@ -0,0 +1,23 @@
1
+ # Проверка расписания v5
2
+
3
+ Эталонные JSON поддерживаются и изменяются только вручную. Генератора эталонов
4
+ в репозитории нет. Все 42 полных JSON-эталона (1184 занятия) вручную сверены с
5
+ соответствующими HTML-страницами. Проверены владелец, учебный год, период,
6
+ позиция строки, предмет, тип, день, слот, фактическое время, недели,
7
+ чередование, аудитория, преподаватель, группы, подгруппа, ДОТ, замены и
8
+ переносы. Журнал проверки: [`fixture-review.md`](fixture-review.md).
9
+
10
+ Для каждой страницы тест заново получает канонический результат и целиком
11
+ сравнивает его с зафиксированным JSON. Детерминированный генератор ID в тесте
12
+ нужен только для стабильных идентификаторов; он не создает и не изменяет
13
+ эталоны. Дополнительно весь корпус проверяет объединение общих пар между
14
+ страницами групп, преподавателей и аудиторий.
15
+
16
+ Точечные примеры проверяют семестр, сессию, экзамены, консультации, подгруппы,
17
+ ДОТ, замены, преподавателя «вместо», переносы и отсутствие данных. Доменные
18
+ тесты проверяют ID, неоднозначность, объединение проекций, применение изменений
19
+ и календарное раскрытие. `pnpm test:coverage` требует для ядра расписания не
20
+ меньше 95% строк, 94% функций и 80% ветвей.
21
+
22
+ Live-тест сравнивает парсер с текущими страницами портала, но не заменяет
23
+ зафиксированные HTML и ручные эталоны.