{
48 | payload: {
49 | pathname: P;
50 | search: S;
51 | queries: any,
52 | hash: H,
53 | };
54 | }
55 | export function locationChange(_: { pathname: P, search: S, hash: H }): locationChangeAction
;
56 |
57 | export interface State {
58 | pathname: Pathname;
59 | search: Search;
60 | queries: any;
61 | hash: Hash;
62 | }
63 |
64 | export const routerReducer: Reducer;
65 |
66 | export function routerMiddleware(history: History): Middleware;
67 | }
--------------------------------------------------------------------------------
/src/@types/school.test.ts:
--------------------------------------------------------------------------------
1 | import School from "./school";
2 | import { school, bellSchedule, schoolJSON, schoolId, schoolEndpoint, schoolName, schoolAcronym, passingPeriodName, schoolTimezone, currentDate, inClass, afterSchoolHours, schoolOwnerId, beforeSchoolHours, noSchool, betweenClass } from '../utils/testconstants';
3 |
4 | const schoolNoSchedules = new School(
5 | schoolId,
6 | schoolOwnerId,
7 | schoolName,
8 | schoolAcronym,
9 | schoolEndpoint,
10 | schoolTimezone,
11 | [],
12 | passingPeriodName,
13 | currentDate,
14 | currentDate
15 | );
16 |
17 |
18 | describe("School", () => {
19 |
20 | it("should get from JSON", () => {
21 | let constructed = School.fromJson(schoolJSON)
22 | //TODO: the start and end times should be plain HH:MM, not full datetimes
23 | expect(constructed.getName()).toEqual(school.getName());
24 | expect(constructed.getAcronym()).toEqual(school.getAcronym());
25 | expect(constructed.getSchedules()).toEqual(school.getSchedules());
26 | expect(constructed.getTimezone()).toEqual(school.getTimezone());
27 | expect(constructed.getPassingTimeName()).toEqual(school.getPassingTimeName());
28 | expect(constructed.getIdentifier()).toEqual(school.getIdentifier());
29 | expect(constructed.getEndpoint()).toEqual(school.getEndpoint());
30 | expect(constructed.getAcronym()).toEqual(school.getAcronym());
31 |
32 | });
33 |
34 | //assuming constructor works, although maybe it could be tested against the fromJSON method?
35 |
36 | it("can return identifier", () => {
37 | expect(school.getIdentifier()).toBe(schoolId);
38 | });
39 |
40 | it("can return API endpoint", () => {
41 | expect(school.getEndpoint()).toBe(schoolEndpoint);
42 | });
43 |
44 | it("can return schedules", () => {
45 | expect(school.getSchedules()).toEqual([bellSchedule]);
46 | });
47 |
48 | it("can return name", () => {
49 | expect(school.getName()).toBe(schoolName);
50 | });
51 |
52 | it("can return acronym", () => {
53 | expect(school.getAcronym()).toBe(schoolAcronym);
54 | });
55 |
56 | it("can return passing time name", () => {
57 | expect(school.getPassingTimeName()).toBe(passingPeriodName);
58 | });
59 |
60 | it("can return timezone", () => {
61 | expect(school.getTimezone()).toBe(schoolTimezone);
62 | });
63 |
64 | it("can return creation date", () => {
65 | expect(school.getCreationDate()).toEqual(currentDate);
66 | });
67 |
68 | //these cases already covered by tests for a class they extend
69 | // it("can return date last updated", () => {
70 | // expect(school.lastUpdated()).toEqual(currentDate);
71 | // });
72 |
73 | // it("can Test if it has changed since a given date", () => {
74 | // //school was last updated on currentDate
75 | // expect(school.hasChangedSince(currentDate.minus({ hours: 1 }))).toBe(true);
76 | // expect(school.hasChangedSince(currentDate.plus({ hours: 1 }))).toBe(false);
77 |
78 | // expect(school.hasChangedSince(DateTime.fromISO("2019-07-28T07:07:50.634", { zone: schoolTimezone }))).toBe(true);
79 | // // "2019-07-28T07:37:50.634"
80 | // expect(school.hasChangedSince(DateTime.fromISO("2019-07-28T08:07:50.635", { zone: schoolTimezone }))).toBe(false);
81 | // });
82 |
83 | it("can get schedule for date", () => {
84 | expect(school.getScheduleForDate(currentDate)).toEqual(
85 | bellSchedule
86 | );
87 |
88 | expect(schoolNoSchedules.getScheduleForDate(currentDate)).toBe(null);
89 | });
90 |
91 | it("can check if it has schedules", () => {
92 | expect(school.hasSchedules()).toBe(true);
93 | expect(schoolNoSchedules.hasSchedules()).toBe(false);
94 | });
95 |
96 | it("can check if school is in session", () => {
97 | expect(school.isInSession(inClass)).toBe(true);
98 | expect(school.isInSession(beforeSchoolHours)).toBe(false);
99 | expect(school.isInSession(noSchool)).toBe(false);
100 | expect(school.isInSession(afterSchoolHours)).toBe(false);
101 | expect(school.isInSession(betweenClass)).toBe(true);
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/src/@types/school.ts:
--------------------------------------------------------------------------------
1 | import {
2 | checkTimeRange,
3 | getValueIfKeyInList,
4 | sortClassesByStartTime
5 | } from "../utils/helpers";
6 | import { DateTime } from "luxon";
7 | import { TimeComparisons } from "../utils/enums";
8 | import BellSchedule from "./bellschedule";
9 | import find from 'lodash.find'
10 | import UpdateTimestampedObject from "./updateTimestampedObject";
11 | import Time from "./time";
12 |
13 | export default class School extends UpdateTimestampedObject {
14 | public static fromJson(json: any) {
15 | const schedules = getValueIfKeyInList(["schedules"], json);
16 | return new School(
17 | getValueIfKeyInList(["id", "identifier"], json),
18 | getValueIfKeyInList(["ownerId", "owner_id"], json),
19 | getValueIfKeyInList(["name", "fullName", "full_name"], json),
20 | getValueIfKeyInList(["acronym"], json),
21 | getValueIfKeyInList(["endpoint"], json),
22 | getValueIfKeyInList(["timezone"], json),
23 | schedules
24 | ? schedules.map((schedule: any) => BellSchedule.fromJson(schedule))
25 | : undefined,
26 | getValueIfKeyInList(["alternate_freeperiod_name", "passingPeriodName"], json),
27 | DateTime.fromISO(getValueIfKeyInList(["creation_date", "creationDate"], json), { zone: 'utc' }),
28 | DateTime.fromISO(getValueIfKeyInList(["last_modified", "lastModified"], json), { zone: 'utc' })
29 | );
30 | }
31 |
32 | private id: string;
33 | private ownerId: string;
34 | private endpoint?: string;
35 | private fullName: string;
36 | private acronym: string;
37 | private timeZone: string;
38 | private schedules?: BellSchedule[];
39 | private passingPeriodName?: string;
40 | private creationDate?: DateTime;
41 |
42 | constructor(
43 | id: string,
44 | ownerId: string,
45 | fullName: string,
46 | acronym: string,
47 | endpoint: string,
48 | timeZone: string,
49 | schedules?: BellSchedule[],
50 | passingPeriodName?: string,
51 | creationDate?: DateTime,
52 | lastUpdatedDate?: DateTime
53 | ) {
54 | super(lastUpdatedDate)
55 | this.id = id;
56 | this.ownerId = ownerId;
57 | this.endpoint = endpoint;
58 | this.fullName = fullName;
59 | this.acronym = acronym;
60 | this.timeZone = timeZone;
61 | this.schedules = schedules;
62 | this.passingPeriodName = passingPeriodName;
63 | this.creationDate = creationDate;
64 |
65 | }
66 |
67 | public getIdentifier(): string {
68 | return this.id;
69 | }
70 |
71 | public getOwnerIdentifier(): string {
72 | return this.ownerId;
73 | }
74 |
75 | public getEndpoint() {
76 | return this.endpoint;
77 | }
78 |
79 | public getSchedules() {
80 | return this.schedules;
81 | }
82 |
83 | public getSchedule(id: string) {
84 | if (!this.schedules){
85 | return
86 | } else {
87 | return find(this.schedules, schedule => { return schedule.getIdentifier() === id; });
88 | }
89 | }
90 |
91 | public getName() {
92 | return this.fullName;
93 | }
94 |
95 | public getAcronym() {
96 | return this.acronym;
97 | }
98 |
99 | public getPassingTimeName() {
100 | return this.passingPeriodName;
101 | }
102 |
103 | public getTimezone() {
104 | return this.timeZone;
105 | }
106 |
107 | public getCreationDate() {
108 | return this.creationDate;
109 | }
110 |
111 | //can also be used as isNoSchoolDay() by checking for undefined
112 | public getScheduleForDate(date: DateTime) {
113 | if (this.schedules) {
114 | for (const schedule of this.schedules) {
115 | if (schedule.getDate(date)) {
116 | return schedule;
117 | }
118 | }
119 | return null; //no schedule today
120 | }
121 | return; // no schedules defined
122 | }
123 |
124 | //remove
125 | public hasSchedules() {
126 | return this.schedules !== undefined && this.schedules.length > 0;
127 | }
128 |
129 | //change input to a time
130 | //seems like te current schedule depends on this
131 | /**
132 | * Checks whether school is currently "in session", meaning that school is currently happening for the day (aka a time is between the start of the first class and the end of the last class)
133 | * @param date the time to check
134 | * @returns true if school is in session, false otherwise
135 | */
136 | public isInSession(date: DateTime): boolean {
137 | const currentSchedule = this.getScheduleForDate(date);
138 | if (!currentSchedule) {
139 | return false;
140 | }
141 |
142 | const sortedClasses = sortClassesByStartTime(currentSchedule.getAllClasses())
143 | const firstClass = sortedClasses[0]
144 | const lastClass = sortedClasses[currentSchedule.numberOfClasses()]
145 | return (
146 | checkTimeRange(
147 | Time.fromDateTime(date, this.timeZone),
148 | firstClass.getStartTime(),
149 | lastClass.getEndTime()
150 | ) == TimeComparisons.IS_DURING_OR_EXACTLY
151 | );
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/@types/time.test.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from "luxon";
2 | import Time from "./time";
3 | import {school} from "../utils/testconstants";
4 |
5 | const thisTime: Time = Time.fromTime(9, 31, 41);
6 | const thisTimeUTC: Time = Time.fromISO("2022-06-16T09:31:41Z");
7 | const preTime: Time = Time.fromTime(5, 18, 43);
8 | const postTime: Time = Time.fromTime(13, 44, 39);
9 |
10 | describe("Time", () => {
11 | it("can instantiate from milliseconds", () => {
12 | expect(Time.fromMilliseconds(34301000)).toEqual(thisTime);
13 | });
14 |
15 | it("can instantiate from DateTime", () => {
16 | let testTime1 = Time.fromDateTime(DateTime.fromISO("2022-06-16T09:31:41", { zone: school.getTimezone() }), school.getTimezone());
17 | let testTime2 = Time.fromDateTime(DateTime.fromISO("2022-07-16T09:31:41", { zone: school.getTimezone()}), school.getTimezone())
18 |
19 | expect(testTime1.hours).toEqual(thisTime.hours);
20 | expect(testTime1.minutes).toEqual(thisTime.minutes);
21 | expect(testTime1.seconds).toEqual(thisTime.seconds);
22 |
23 | //ensure dates are correctly stripped out so two different timestamps
24 | //can represent the same date
25 | expect(testTime1).toEqual(testTime2);
26 |
27 | });
28 |
29 | it("should get from string", () => {
30 | expect(Time.fromString("9:31:41")).toEqual(thisTime);
31 | });
32 |
33 | it("should get from string with leading zeroes", () => {
34 | expect(Time.fromString("08:04:09")).toEqual(Time.fromTime(8, 4, 9));
35 | });
36 |
37 | it("should get from string without seconds", () => {
38 | expect(Time.fromString("08:04")).toEqual(Time.fromTime(8, 4, 0));
39 | });
40 |
41 | it("should correct for values that are too large", () => {
42 | expect(Time.fromTime(45, 130, 118)).toEqual(Time.fromTime(21, 10, 58));
43 | });
44 |
45 | it("should correct for negative values", () => {
46 | expect(Time.fromTime(-9, -31, -41)).toEqual(thisTime);
47 | });
48 |
49 | it("should correct for values that are too large and negative", () => {
50 | expect(Time.fromTime(-45, -130, -118)).toEqual(Time.fromTime(21, 10, 58));
51 | });
52 |
53 | it("should return hours", () => {
54 | expect(thisTime.hours).toBe(9);
55 | // expect(thisTime.getHours()).toBe(9);
56 | });
57 |
58 | it("should return minutes", () => {
59 | expect(thisTime.minutes).toBe(31);
60 | // expect(thisTime.getMinutes()).toBe(31);
61 | });
62 |
63 | it("should return seconds", () => {
64 | expect(thisTime.seconds).toBe(41);
65 | // expect(thisTime.getSeconds()).toBe(41);
66 | });
67 |
68 | it("can detect if a time is before it", () => {
69 | expect(thisTime.isAfter(preTime)).toBe(true);
70 | expect(thisTime.isAfter(postTime)).toBe(false);
71 | });
72 |
73 | it("can detect if a time is after it", () => {
74 | expect(thisTime.isBefore(preTime)).toBe(false);
75 | expect(thisTime.isBefore(postTime)).toBe(true);
76 | });
77 |
78 | it("should get the number of milliseconds to a future time", () => {
79 | expect(thisTime.getMillisecondsTo(postTime)).toBe(15178000);
80 | });
81 |
82 | it("should get the number of milliseconds to a past time", () => {
83 | expect(thisTime.getMillisecondsTo(preTime)).toBe(-15178000);
84 | });
85 |
86 | it("should get the number of milliseconds to the same time", () => {
87 | expect(thisTime.getMillisecondsTo(thisTime)).toBe(0);
88 | expect(preTime.getMillisecondsTo(preTime)).toBe(0);
89 | expect(postTime.getMillisecondsTo(postTime)).toBe(0);
90 | });
91 |
92 | it("returns the correct time delta", () => {
93 | expect(thisTime.getTimeDeltaTo(preTime)).toEqual(Time.fromMilliseconds(15178000));
94 | expect(thisTime.getTimeDeltaTo(postTime)).toEqual(
95 | Time.fromMilliseconds(15178000)
96 | );
97 | });
98 |
99 | it("can return times as strings", () => {
100 | expect(thisTime.toString()).toBe("09:31:41");
101 | expect(preTime.toString()).toBe("05:18:43");
102 | expect(postTime.toString()).toBe("13:44:39");
103 | });
104 |
105 | it("can get formatted strings in the morning", () => {
106 | expect(thisTime.getFormattedString(true, true)).toBe("09:31");
107 | expect(thisTime.getFormattedString(true, false)).toBe("09:31 AM");
108 | expect(thisTime.getFormattedString(false, true)).toBe("09:31:41");
109 | expect(thisTime.getFormattedString(false, false)).toBe("09:31:41 AM");
110 | });
111 |
112 | it("can get formatted strings in the afternoon", () => {
113 | expect(postTime.getFormattedString(true, true)).toBe("13:44");
114 | expect(postTime.getFormattedString(true, false)).toBe("01:44 PM");
115 | expect(postTime.getFormattedString(false, true)).toBe("13:44:39");
116 | expect(postTime.getFormattedString(false, false)).toBe("01:44:39 PM");
117 | });
118 |
119 | it("serializes to a string", () => {
120 | expect(thisTime.toJSON()).toBe("09:31:41");
121 | });
122 |
123 | it("has a json representation matching that of the string output", () => {
124 | expect(thisTime.toJSON()).toEqual(thisTime.getFormattedString(false, true));
125 | expect(thisTimeUTC.toJSON()).toEqual(thisTimeUTC.getFormattedString(false, true));
126 |
127 | });
128 | });
129 |
--------------------------------------------------------------------------------
/src/@types/time.ts:
--------------------------------------------------------------------------------
1 | import { DateTime, Duration } from "luxon";
2 |
3 | /**
4 | * A representation of a Time without an associated date.
5 | * This is used to simplify the JSON data since the same time ranges for a class
6 | * often apply to multiple days.
7 | *
8 | * The "standard" way to represent it in serialized/JSON form is as a series of
9 | * Two or three, colon-separated, two-digit numbers representing a 24-hour time.
10 | * examples: 09:35:00, 13:30:00, 09:35, 13:30
11 | *
12 | * Times in this standard format should be considered to be in the timezone of
13 | * the school that they are part of.
14 | *
15 | * A Time object should always represent this standard form of the
16 | *
17 | * @export
18 | * @class Time
19 | */
20 | export default class Time {
21 |
22 | private duration: Duration;
23 |
24 | constructor(duration: Duration) {
25 | this.duration = duration
26 | }
27 |
28 | public get hours() {
29 | return this.duration.hours
30 | }
31 |
32 | public get minutes() {
33 | return this.duration.minutes
34 | }
35 |
36 | public get seconds() {
37 | return this.duration.seconds
38 | }
39 |
40 | /**
41 | * Create a time instance from the number of milliseconds since the beginning of the day.
42 | *
43 | * @static
44 | * @param {number} milliseconds the number of milliseconds since the beginning of the day
45 | * @returns {Time}
46 | * @memberof Time
47 | */
48 | public static fromMilliseconds(milliseconds: number): Time {
49 | return new Time(Duration.fromMillis(milliseconds).shiftTo("hours", "minutes", "seconds"));
50 | }
51 |
52 | //Deprecated
53 | // public static fromJSDate(date: Date, toLocalTime=false) {
54 | // if (toLocalTime) {
55 | // return new Time(date.getHours(), date.getMinutes(), date.getSeconds(), 'local');
56 | // } else {
57 | // return new Time(date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), 'utc');
58 | // }
59 | // }
60 |
61 | public static fromISO(time: string) {
62 | return new Time(Duration.fromISO(time))
63 | }
64 |
65 | /**
66 | * Create a time instance from the JSON-serialized data produced by `toJSON()`
67 | * @param time a time value represented as a string in standard format to deserialize to a Time Object
68 | * @returns a n instance of Time representing the same time
69 | */
70 | public static fromJson(time: string) {
71 | const parts = time.split(":");
72 | if (parts.length < 2 || parts.length > 3) {
73 | //TODO: error here
74 | }
75 | return Time.fromTime(
76 | parseInt(parts[0], 10),
77 | parseInt(parts[1], 10),
78 | parts.length === 3 ? parseInt(parts[2], 10) : undefined
79 | );
80 | }
81 |
82 | public static fromString(time: string) {
83 | // const smalltime = DateTime.fromFormat(time, "H:mm")
84 | // const bigtime = DateTime.fromISO(time)
85 | // if (smalltime.isValid) {
86 | // return Time.fromDateTime(smalltime)// .toUTC()
87 | // } else if (bigtime.isValid) {
88 | // return Time.fromDateTime(bigtime)// .toUTC()
89 | // }
90 |
91 | const parts = time.split(":");
92 | if (parts.length < 2 || parts.length > 3) {
93 | //TODO: error here
94 | }
95 | return Time.fromTime(
96 | parseInt(parts[0], 10),
97 | parseInt(parts[1], 10),
98 | parts.length === 3 ? parseInt(parts[2], 10) : undefined
99 | );
100 | }
101 |
102 | /**
103 | * Create a Time from the time portion of the given DateTime
104 | * @param time the DateTime
105 | * @returns a Time object
106 | */
107 | public static fromDateTime(time: DateTime, schoolTimezone: string) {
108 | time = time.setZone(schoolTimezone)
109 |
110 | return new Time(time.diff(time.startOf('day')).shiftTo("hours", "minutes", "seconds"))
111 | }
112 |
113 | public static fromTime(hours: number, minutes: number, seconds?: number): Time {
114 | // super()
115 | // why do we need timezone,
116 | //is storing the time as a DateTime internally just super overkill?
117 | // probably to allow conversion later?
118 | let timeObj = {
119 | hour: Math.abs((hours || 0) % 24),
120 | minute: Math.abs((minutes || 0) % 60),
121 | second: Math.abs((seconds || 0) % 60)
122 | }
123 | return new Time(Duration.fromObject(timeObj).shiftTo("hours", "minutes", "seconds"))
124 | }
125 |
126 | public getMillisecondsTo(otherTime: Time): number {
127 | return otherTime.duration.minus(this.duration).toMillis()
128 | }
129 |
130 | public isBefore(time:Time): boolean {
131 | return this.duration < time.duration
132 | }
133 |
134 | public isAfter(time: Time): boolean {
135 | return this.duration > time.duration
136 | }
137 |
138 | public isEqualTo(time: Time): boolean {
139 | return this.duration == time.duration
140 | }
141 |
142 | public getTimeDeltaTo(otherTime: Time): Time {
143 | return Time.fromMilliseconds(Math.abs(this.getMillisecondsTo(otherTime)??0));
144 | }
145 |
146 | public toString(excludeSeconds = false, use24HourTime = true) {
147 | const seconds = excludeSeconds ? "" : ":ss"
148 | const format = "hh:mm" + seconds
149 | let meridiem = ""
150 | let duration = this.duration
151 |
152 | if (!use24HourTime && duration.hours > 12) {
153 | duration = duration.minus(Duration.fromObject({ hours: 12 }))
154 | meridiem = "PM"
155 | } else {
156 | meridiem = "AM"
157 | }
158 |
159 | return duration.toFormat(format) + ((use24HourTime)? "" : " " + meridiem);
160 | }
161 |
162 | /**
163 | * Convert this datetime into a formatted string.
164 | * Leaving values at the defaults should generate a "standard" string with
165 | * three parts
166 | * @param excludeSeconds whether to exclude the seconds values, default false.
167 | * @param use24HourTime whether to use 24 hour time or 12 hour time. Default true.
168 | * @returns a string representing the current time
169 | */
170 | public getFormattedString(excludeSeconds = false, use24HourTime = false) {
171 | return this.toString(excludeSeconds, use24HourTime)
172 | }
173 |
174 | /**
175 | * Returns the standard HH:mm:ss representation of a time object as a string for serializing.
176 | *
177 | * this overrides the automatic serialization of Time Objects and makes them return a string and not a plain object (which is more annoying to parse back in and would require an extra factory method)
178 | */
179 | public toJSON(): string {
180 | return this.duration.toFormat("hh:mm:ss");
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/src/@types/updateTimestampedObject.test.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from "luxon";
2 | import { schoolTimezone } from "../utils/testconstants";
3 | import UpdateTimestampedObject from "./updateTimestampedObject";
4 |
5 | let lastUpdated = DateTime.fromISO("2019-07-28T07:37:50.634Z")
6 | let sut = new UpdateTimestampedObject(lastUpdated)
7 |
8 |
9 | describe("UpdateTimestampedObect", () => {
10 |
11 | it("can return date last updated", () => {
12 | expect(sut.lastUpdated()).toEqual(lastUpdated);
13 | });
14 |
15 | it("can Test if it has changed since a given date", () => {
16 | //school was last updated on currentDate
17 | expect(sut.hasChangedSince(lastUpdated.minus({ hours: 1 }))).toBe(true);
18 | expect(sut.hasChangedSince(lastUpdated.plus({ hours: 1 }))).toBe(false);
19 |
20 | expect(sut.hasChangedSince(DateTime.fromISO("2019-07-28T00:07:50.634", { zone: schoolTimezone }))).toBe(true);
21 | expect(sut.hasChangedSince(DateTime.fromISO("2019-07-28T08:07:50.635", { zone: schoolTimezone }))).toBe(false);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/@types/updateTimestampedObject.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from "luxon";
2 |
3 |
4 | /**
5 | * An object that knows when it was last updated
6 | *
7 | * @export
8 | * @class UpdateTimestampedObject
9 | */
10 | export default class UpdateTimestampedObject {
11 | protected lastUpdatedDate?: DateTime;
12 |
13 | constructor(lastUpdatedDate?:DateTime){
14 | this.lastUpdatedDate = lastUpdatedDate;
15 | }
16 |
17 | public lastUpdated() {
18 | return this.lastUpdatedDate;
19 | }
20 |
21 | public hasChangedSince(date: DateTime) {
22 | // date = date.toUTC();
23 | if (this.lastUpdatedDate !== undefined) {
24 | return date.toMillis() < this.lastUpdatedDate.toMillis();
25 | } else {
26 | return undefined;
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/src/components/Block/Block.css:
--------------------------------------------------------------------------------
1 | .infoBlock {
2 | margin: 10px 0;
3 | }
4 |
5 | .infoBlock * {
6 | margin: 1px 0;
7 | padding: 0;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/Block/Block.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import renderer from "react-test-renderer";
3 | import Block from "./Block";
4 |
5 |
6 | describe("Block Component", () => {
7 | it("renders correctly", () => {
8 | const component = renderer.create( );
9 | const tree = component.toJSON();
10 | expect(tree).toMatchSnapshot();
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/src/components/Block/Block.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, CSSProperties } from "react";
2 | import "./Block.css";
3 |
4 | interface ITextProps {
5 | className?: string;
6 | style?: CSSProperties;
7 | }
8 |
9 | /**
10 | * A block represents a single piece of information in the main classclock app interface, such as the time left in this class, what the next class is, what the current class is .etc
11 | */
12 | export default class Block extends Component {
13 | render() {
14 | return (
15 |
21 | {this.props.children}
22 |
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Block/__snapshots__/Block.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Block Component renders correctly 1`] = `
4 |
7 | `;
8 |
--------------------------------------------------------------------------------
/src/components/BlockLink.tsx:
--------------------------------------------------------------------------------
1 | import Link, { ILinkProps } from "./Link"
2 |
3 |
4 | const BlockLink = (props: ILinkProps) => {
5 |
6 | return
7 | {props.children}
8 |
9 | }
10 |
11 | export default BlockLink;
--------------------------------------------------------------------------------
/src/components/Icon.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import renderer from "react-test-renderer";
3 | import Icon from "./Icon";
4 |
5 | describe("Icon", () => {
6 | it("renders correctly", () => {
7 | const component = renderer.create(
8 |
9 | );
10 | const tree = component.toJSON();
11 | expect(tree).toMatchSnapshot();
12 |
13 | });
14 | });
--------------------------------------------------------------------------------
/src/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 |
3 | interface IIconProps {
4 | icon: string;
5 | regularStyle?:boolean
6 | }
7 |
8 | const Icon = (props: IIconProps) => {
9 | const style = props.regularStyle? "far ": "fas ";
10 | return ;
11 | }
12 |
13 | export default Icon;
--------------------------------------------------------------------------------
/src/components/Link.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import renderer from "react-test-renderer";
3 | import Link from "./Link";
4 |
5 |
6 | describe("Link Component", () => {
7 | it("renders correctly with a static link", () => {
8 | const component = renderer.create(
9 |
10 | );
11 | const tree = component.toJSON();
12 | expect(tree).toMatchSnapshot();
13 |
14 | // // manually trigger the callback
15 | // tree.props.onMouseEnter();
16 | // re-rendering
17 | // tree = component.toJSON();
18 | // expect(tree).toMatchSnapshot();
19 | });
20 |
21 | it("renders correctly with a function", () => {
22 | let pass = false;
23 | const onClick = () => {pass = true}
24 | const component = renderer.create( );
25 | const tree = component.toJSON();
26 | expect(tree).toMatchSnapshot();
27 |
28 | if (tree != null) {
29 | // manually trigger the callback
30 | component.root.findByType('a').props.onClick();
31 | }
32 |
33 |
34 | expect(pass).toBeTruthy();
35 |
36 | });
37 |
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/Link.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, ReactNode } from "react";
2 |
3 | export interface ILinkProps {
4 | destination: any;
5 | className?: string;
6 | style?: CSSProperties;
7 | title?: string;
8 | id?: string;
9 | children?: ReactNode | ReactNode[];
10 | }
11 |
12 | const Link = (props: ILinkProps) => {
13 |
14 | return (
15 |
31 | {props.children}
32 |
33 | );
34 | }
35 |
36 | export default Link;
37 |
--------------------------------------------------------------------------------
/src/components/List/List.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export interface IListProps {
4 | items: JSX.Element[];
5 | }
6 |
7 | const List = (props: IListProps) => {
8 | return {props.items}
;
9 | };
10 |
11 | export default List;
12 |
--------------------------------------------------------------------------------
/src/components/ScheduleEntry/ScheduleEntry.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export interface IScheduleEntryProps {
4 | name: string;
5 | startTime: string;
6 | endTime: string;
7 | }
8 |
9 | const ScheduleEntry = (props: IScheduleEntryProps) => {
10 | return (
11 |
12 | {props.name}: {props.startTime} - {props.endTime}
13 |
14 | );
15 | };
16 |
17 | export default ScheduleEntry;
18 |
--------------------------------------------------------------------------------
/src/components/SelectionList/SelectionList.css:
--------------------------------------------------------------------------------
1 | ul.selectionList {
2 | padding: 0; /* have no clue whats causing the random left padding of 20px on s */
3 | }
4 | ul.selectionList > li {
5 | list-style-type: none;
6 | padding: 5px;
7 | margin: 2px;
8 | background-color: rgba(0, 0, 0, 0.1);
9 | text-align: left;
10 | }
11 |
12 | ul.selectionList > li > .schoolAcronym {
13 | font-size: 2em;
14 | opacity: 0.5;
15 | }
16 | ul.selectionList > li > .schoolName {
17 | font-size: 0.8em;
18 | }
19 | ul.selectionList li:hover {
20 | background-color: rgba(0, 0, 0, 0.2);
21 | margin: 5px 0;
22 | cursor: pointer;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/SelectionList/SelectionList.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactElement } from "react";
2 | import "../../global.css";
3 | import "./SelectionList.css";
4 |
5 | export interface ISelectProps {
6 | loading: boolean;
7 | error?: string;
8 | className?:string;
9 | children?: ReactElement[];
10 | }
11 |
12 | const SelectionList = (props: ISelectProps) => {
13 |
14 | const makeIntoListItems = (nodeList: ReactElement[] | undefined ) => {
15 | if (!nodeList || nodeList.length === 0) {
16 | return (No Items. )
17 | }
18 |
19 | return nodeList.map((node, index) => {
20 | if (node.type !== "li") {
21 | // When you don’t have stable IDs for rendered items, you may use the item index as a key as a last resort
22 | // https://reactjs.org/docs/lists-and-keys.html#keys
23 | return {node}
24 | } else {
25 | return node
26 | }
27 | })
28 |
29 | };
30 |
31 | if (props.loading) {
32 | return Loading...
33 | } else if (props.error) {
34 | return An Error Occurred
35 | } else {
36 | return
37 | {makeIntoListItems(props.children)}
38 |
39 | }
40 | };
41 |
42 | export default SelectionList;
--------------------------------------------------------------------------------
/src/components/SocialIcons.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { faGithub, faBluesky , faInstagram, faDiscord } from "@fortawesome/free-brands-svg-icons";
3 | import { URLs } from "../utils/constants";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import Link from "../components/Link";
6 |
7 |
8 | const SocialIcons = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default SocialIcons;
--------------------------------------------------------------------------------
/src/components/StatusIndicator.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from "react";
2 | import StopLight from "./StopLight";
3 |
4 | interface IStatusIndicatorProps {
5 | color: string;
6 | children?: ReactNode | ReactNode[];
7 | }
8 |
9 | const StatusIndicator = (props: IStatusIndicatorProps) => {
10 | const size = "15px"
11 | return
12 |
13 | {props.children}
14 | ;
15 | }
16 |
17 | export default StatusIndicator;
--------------------------------------------------------------------------------
/src/components/StopLight.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface IStopLightProps {
4 | color: string;
5 | }
6 |
7 | const StopLight = (props: IStopLightProps) => {
8 | const size = "15px"
9 | return ;
10 | }
11 |
12 | export default StopLight;
--------------------------------------------------------------------------------
/src/components/__snapshots__/Icon.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Icon renders correctly 1`] = `
4 |
7 | `;
8 |
--------------------------------------------------------------------------------
/src/components/__snapshots__/Link.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Link Component renders correctly with a function 1`] = `
4 |
12 | `;
13 |
14 | exports[`Link Component renders correctly with a static link 1`] = `
15 |
23 | `;
24 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | @-webkit-keyframes AnimationName {
2 | 0% {
3 | background-position: 0% 50%;
4 | }
5 | 50% {
6 | background-position: 100% 50%;
7 | }
8 | 100% {
9 | background-position: 0% 50%;
10 | }
11 | }
12 | @-moz-keyframes AnimationName {
13 | 0% {
14 | background-position: 0% 50%;
15 | }
16 | 50% {
17 | background-position: 100% 50%;
18 | }
19 | 100% {
20 | background-position: 0% 50%;
21 | }
22 | }
23 | @keyframes AnimationName {
24 | 0% {
25 | background-position: 0% 50%;
26 | }
27 | 50% {
28 | background-position: 100% 50%;
29 | }
30 | 100% {
31 | background-position: 0% 50%;
32 | }
33 | }
34 |
35 | html,
36 | body {
37 | margin: 0;
38 | padding: 0;
39 | background: linear-gradient(270deg, #5e8596, #829ea9, #b06673, #ce6e70, #7f5468);
40 | background-size: 1000% 1000%;
41 | -webkit-animation: AnimationName 30s ease infinite;
42 | -moz-animation: AnimationName 30s ease infinite;
43 | animation: AnimationName 30s ease infinite;
44 | }
45 | body {
46 | font-family: "Source Sans Pro", Arial, Helvetica, sans-serif;
47 | color: white;
48 | font-size: 20px;
49 | /* this doesnt do much */
50 | /* display: flex;
51 | flex-direction: column; */
52 | }
53 |
54 | div#root {
55 |
56 | display: block;
57 | text-align: center;
58 | }
59 |
60 | div#root > * {
61 | margin-left: auto;
62 | margin-right: auto;
63 | }
64 |
65 | div#schoolOptions {
66 | display: flex;
67 | flex-direction: row;
68 | flex-wrap: wrap;
69 | justify-content: center;
70 | align-items: stretch;
71 | max-width: 500px;
72 | }
73 |
74 | /* from editableField Component */
75 | .editableField {
76 | display: inline-flex;
77 | justify-content: space-between;
78 | /* width: 100%; */
79 | flex-grow: 1;
80 | text-align: left;
81 | margin: 2px
82 |
83 | }
84 | .editableFieldLabel {
85 | margin: 5px;
86 | }
87 |
88 | div#page {
89 | flex: 1;
90 | width: 100%;
91 | height: 100%;
92 | padding: 0;
93 | margin: 0;
94 | }
95 |
96 | .timeFont {
97 | font-family: "Play", Courier, monospace;
98 | font-weight: bold;
99 | }
100 |
101 | .centeredInline {
102 | margin: 0 auto;
103 | text-align: center;
104 | }
105 |
106 | .bottomSpace {
107 | margin-bottom: 14;
108 | }
109 | .topSpace {
110 | margin-top: 14;
111 | }
112 |
113 | .cornerNavButton {
114 | font-size: 32px; /*Default size */
115 | position: absolute;
116 |
117 | padding-right: 0.15em;
118 | padding-left: 0.15em;
119 | }
120 |
121 | .statusIndicator {
122 | /* font-size: 32px; Default size */
123 | position: absolute;
124 | color: whitesmoke;
125 | background-color: gray;
126 |
127 | padding-right: 0.15em;
128 | padding-left: 0.15em;
129 | }
130 | .statusIndicator > * {
131 | margin: .1em;
132 | }
133 |
134 | .cornerNavButton:hover {
135 | background-color: grey;
136 | }
137 |
138 |
139 | .cornerNavTop {
140 | top: 0;
141 | }
142 |
143 | .cornerNavBottom {
144 | bottom: 0;
145 | }
146 |
147 | .cornerNavLeft {
148 | left: 0;
149 | border-bottom-left-radius: 0;
150 | }
151 |
152 | .cornerNavRight {
153 | right: 0;
154 | border-bottom-right-radius: 0;
155 | }
156 |
157 | .cornerNavTop.cornerNavLeft {
158 | border-bottom-right-radius: 10px;
159 | }
160 |
161 | .cornerNavTop.cornerNavRight {
162 | border-bottom-left-radius: 10px;
163 | }
164 |
165 | .cornerNavBottom.cornerNavLeft {
166 | border-top-right-radius: 10px;
167 | }
168 |
169 | .cornerNavBottom.cornerNavRight {
170 | border-top-left-radius: 10px;
171 | }
172 |
173 | .smallerText {
174 | font-size: 0.85em;
175 | }
176 |
177 | .smallIcon {
178 | font-size: 32px;
179 | }
180 |
181 | .mediumicon {
182 | font-size: 48px;
183 | }
184 |
185 | .largeIcon {
186 | font-size: 64px;
187 | }
188 |
189 | a:hover,
190 | a:active {
191 | font-weight: bold;
192 | }
193 |
194 | a {
195 | color: whitesmoke;
196 | text-decoration: underline;
197 | }
198 |
199 | /* exclude anything under full calendar (in the admin panel) from this styling */
200 | .fc a {
201 | color:initial;
202 | text-decoration: none;
203 | }
204 |
205 | h1.bigger {
206 | /* regular h1's are 2em */
207 | font-size: 3em;
208 | }
209 |
210 | section#credits > a {
211 | margin: 5px;
212 | }
213 |
214 | section#options > * {
215 | display: block;
216 | margin-top: 2vw;
217 | }
218 |
219 | /* #flash {
220 | /* flex: 1; *
221 | display: none;
222 | margin: 0 auto;
223 | display: block;
224 | text-align: center;
225 | width: 100vw;
226 | /* border-bottom-right-radius: .5rem;
227 | border-bottom-left-radius: .5rem; *
228 | border-bottom: 1px solid rgba(0, 0, 0, 0.5);
229 | }
230 |
231 | #flash a {
232 | font-weight: 700;
233 | text-decoration: none;
234 | }
235 | #flash a:hover {
236 | text-decoration: underline;
237 | }
238 |
239 | #flash.success {
240 | background-color: #d4edda; /* copied from bootstrap *
241 | color: #155724;
242 | }
243 |
244 | #flash.info {
245 | color: #0c5460; /* copied from bootstrap *
246 | background-color: #a7d6f5;
247 | }
248 |
249 | #flash.info a {
250 | color: #002752;
251 | }
252 |
253 | #flash.warning {
254 | background-color: #faf0ce;
255 | color: #856404;
256 | }
257 |
258 | #flash.danger {
259 | background-color: #f38c95;
260 | color: #721c24; /* copied from bootstrap *
261 | }
262 |
263 | .alert-link {
264 | font-weight: 700;
265 | }*/
266 |
267 |
268 | .blockButton {
269 | padding: 5px;
270 | margin: 2px;
271 | background-color: rgba(0, 0, 0, 0.1);
272 | display: block;
273 | }
274 |
275 | .verticalFlex {
276 | display: flex;
277 | flex-direction: column;
278 | }
279 |
280 | .horizontalFlex {
281 | display: flex;
282 | justify-content: center;
283 | }
284 |
285 | .horizontalFlex > * {
286 | /* Important is used to override inline styles for the table and key */
287 | margin: .5em !important;
288 | }
289 |
290 | .footer__social {
291 | list-style: none;
292 | margin: 0;
293 | padding: 0;
294 | }
295 |
296 | .footer__social li {
297 | display: inline-block;
298 | font-size: 15px;
299 | padding: 0;
300 | margin-right: 12px;
301 | }
302 |
303 | .footer__social li a {
304 | display: block;
305 | font-size: 48px;
306 | /* line-height: 32px; */
307 | text-align: center;
308 | /* background-color: rgba(255, 255, 255, 0.03); */
309 | color: #ffffff;
310 | /* border-radius: 50%; */
311 | }
312 |
313 | .footer__social li a:hover,
314 | .footer__social li a:focus,
315 | .footer__social li a:active {
316 | /* background-color: #5b4f96; */
317 | color: #c7c7c7;
318 | font-weight: normal;
319 | }
320 |
321 | .footer__social li:last-child {
322 | margin-right: 0;
323 | }
324 |
325 | .e-mail::after {
326 | content: "\0061\0064\0072\0069\0061\006E\0040\0061\0064\0072\0069\0061\006E\0063\0065\0064\0077\0061\0072\0064\0073\002E\0063\006F\006D";
327 | }
328 |
329 |
330 | .mediumVerticalSpacer {
331 | height:10vh;
332 | }
333 |
334 | @media (min-height: 350px) {
335 | .mediumVerticalSpacer {
336 | height: 25vh;
337 | }
338 | }
339 |
340 | @media (min-height: 500px) {
341 | .mediumVerticalSpacer {
342 | height: 50vh;
343 | }
344 | }
345 |
346 |
347 | .centeredWidth {
348 | margin: 0 auto;
349 | width: 95vw;
350 | }
351 |
352 | @media (min-width: 768px) {
353 | .centeredWidth {
354 | width: 60vw;
355 | }
356 |
357 | }
358 |
359 | @media (min-width: 992px) {
360 | .centeredWidth {
361 | width: 40vw;
362 | }
363 | }
364 |
365 | @media (min-width: 1400px) {
366 | .centeredWidth {
367 | width: 30vw;
368 | }
369 | }
370 | .circle {
371 | border-radius: 50%;
372 | display: inline-block;
373 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import * as Sentry from "@sentry/react";
4 | import { BrowserTracing } from "@sentry/tracing";
5 | import "./index.css";
6 | import * as serviceWorker from "./serviceWorker";
7 | import UniversalRouter, {Context} from "universal-router";
8 | import { Provider } from "react-redux";
9 | import { PersistGate } from "redux-persist/integration/react";
10 | import { pages } from "./utils/constants";
11 | import { routes } from "./utils/routes";
12 | import { PageNotFound } from "./pages/errors/PageNotFound";
13 | import { ServerError } from "./pages/errors/ServerError";
14 | import {history, configuredStore} from "./store/store";
15 | import Auth0ProviderWithHistory from "./services/auth0-provider-with-history";
16 | import { Store } from "redux";
17 | import { History, BrowserHistory, Update } from "history";
18 | import { locationChange } from "redux-first-routing";
19 |
20 | if (process.env.REACT_APP_SENTRY_DSN && process.env.REACT_APP_SENTRY_DSN != "") {
21 | Sentry.init({
22 | dsn: process.env.REACT_APP_SENTRY_DSN,
23 | integrations: [new BrowserTracing()],
24 |
25 | // Set tracesSampleRate to 1.0 to capture 100%
26 | // of transactions for performance monitoring.
27 | // We recommend adjusting this value in production
28 | tracesSampleRate: 1.0,
29 | });
30 | }
31 |
32 | function startListener(history: History, store:Store) {
33 | store.dispatch(locationChange({
34 | pathname: history.location.pathname,
35 | search: history.location.search,
36 | hash: history.location.hash,
37 | }));
38 |
39 | history.listen((update: Update) => {
40 | store.dispatch(locationChange({
41 | pathname: update.location.pathname,
42 | search: update.location.search,
43 | hash: update.location.hash,
44 | }));
45 | });
46 | }
47 |
48 |
49 | // Start the history listener, which automatically dispatches actions to keep the store in sync with the history
50 | startListener(history, configuredStore.store);
51 |
52 | const options = {
53 | errorHandler(error: { status?: number; }, context: Context) {
54 | console.error(error)
55 | console.info(context)
56 |
57 | return error.status === 404
58 | ?
59 | :
60 | }
61 | }
62 |
63 | // Create the router
64 | const router = new UniversalRouter(routes, options);
65 |
66 | // Create the reactive render function
67 | function render(pathname: string) {
68 | router.resolve(pathname).then((component: any) => {
69 | const core =
70 | {component}
71 |
72 |
73 | //react-admin detects if its in a provider, so those pages cane be shown with the existing provider
74 | if (!pathname.includes(pages.admin)) {
75 |
76 | ReactDOM.render(
77 |
78 |
79 | {core}
80 |
81 | ,
82 | document.getElementById("root")
83 | );
84 | } else {
85 | ReactDOM.render(core,
86 | document.getElementById("root")
87 | );
88 | }
89 | });
90 | }
91 |
92 | // Get the current pathname
93 | let currentLocation = configuredStore.store.getState().router.pathname;
94 |
95 | // Subscribe to the store location
96 | const unsubscribe = configuredStore.store.subscribe(() => {
97 | const previousLocation = currentLocation;
98 | currentLocation = configuredStore.store.getState().router.pathname;
99 |
100 | if (previousLocation !== currentLocation) {
101 | console.log(
102 | "Some deep nested property changed from",
103 | previousLocation,
104 | "to",
105 | currentLocation
106 | );
107 | render(currentLocation);
108 | }
109 | });
110 |
111 | render(currentLocation);
112 |
113 | // If you want your app to work offline and load faster, you can change
114 | // unregister() to register() below. Note this comes with some pitfalls.
115 | // Learn more about service workers: https://bit.ly/CRA-PWA
116 | serviceWorker.unregister();
117 |
--------------------------------------------------------------------------------
/src/package.alias.json:
--------------------------------------------------------------------------------
1 | ../package.json
--------------------------------------------------------------------------------
/src/pages/Admin/AdminPage.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Admin as RAdmin, Resource, ListGuesser, EditGuesser, EditButton, DeleteButton, CustomRoutes} from "react-admin";
3 | import ccDataProvider from "../../services/classclock-dataprovider"
4 | import authProvider from "../../pages/Admin/authProvider";
5 | import { useAuth0 } from "@auth0/auth0-react";
6 | import ClassClockService from "../../services/classclock";
7 | import { BrowserRouter } from 'react-router-dom';
8 | import LoginRedirect from "./LoginRedirect";
9 | import { BellScheduleCreate, BellscheduleEdit, BellScheduleList } from "./resources";
10 | import { Route } from "react-router-dom";
11 |
12 | import { Card, CardContent } from '@mui/material';
13 | import { Title } from 'react-admin';
14 |
15 |
16 | const AdminPage = () => {
17 |
18 | const {
19 | isAuthenticated,
20 | logout,
21 | isLoading,
22 | user,
23 | getAccessTokenSilently
24 | } = useAuth0();
25 |
26 | const customAuthProvider = authProvider(isAuthenticated, isLoading, logout, user);
27 |
28 | return (
29 |
31 |
32 |
33 |
34 |
35 |
37 |
38 | Welcome to the ClassClock Admin page.
39 | Use the menu on the left to select what part of the app you would like to edit
40 |
41 | This admin page is designed to work on desktop devices. Use on a mobile device is not currently supported.
42 |
43 | } />
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default AdminPage;
--------------------------------------------------------------------------------
/src/pages/Admin/CalendarDates.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useListContext,
3 | RaRecord,
4 | useDataProvider,
5 | useResourceContext
6 | } from 'react-admin';
7 | import { DateTime } from 'luxon';
8 | import FullCalendar, { EventApi, EventChangeArg, EventClickArg, EventDropArg, EventSourceInput } from '@fullcalendar/react';
9 | import dayGridPlugin from '@fullcalendar/daygrid';
10 | import interactionPlugin, { Draggable } from '@fullcalendar/interaction';
11 | import { useEffect, useRef, useState } from 'react';
12 | import React from 'react';
13 |
14 | interface CalendarDatesProps {
15 | [key: string]: any; //https://bobbyhadz.com/blog/react-typescript-pass-object-as-props, this probably allows other props to be passed down
16 | children?: JSX.Element,
17 | // actions,
18 | // filters,
19 | title: string
20 | recordTransformer: (data: RaRecord[]) => EventSourceInput,
21 | fromDate: DateTime,
22 | toDate: DateTime
23 |
24 | }
25 |
26 |
27 | // https://marmelab.com/react-admin/ListBase.html
28 | const CalendarDates = (props:CalendarDatesProps) => {
29 | const { data, isLoading } = useListContext(props);
30 | const dataProvider = useDataProvider();
31 | const resource = useResourceContext();
32 | const {title, recordTransformer, fromDate, toDate, children, ...rest } = props;
33 | const [draggableInitialized, setDraggableInitialized] = useState(false)
34 |
35 | const calendarRef: React.MutableRefObject = useRef(null);
36 |
37 | useEffect(() => {
38 | // const elements = document.getElementsByClassName('draggableEvents')
39 | const draggableContainerEl = document.getElementById('draggable_calendar_items')
40 | if (!draggableInitialized) {
41 | if (draggableContainerEl != null) {
42 | setDraggableInitialized(true)
43 | new Draggable(draggableContainerEl, {
44 | itemSelector: '.draggableEvent',
45 | eventData: function (eventEl) {
46 | return {
47 | title: eventEl.innerText,
48 | schedule_id: eventEl.getAttribute('data-record-id')
49 | }
50 | }
51 | });
52 | } else {
53 | // trigger a re-run maybe?
54 | setDraggableInitialized(false)
55 | }
56 | }
57 | }, [data, draggableInitialized])
58 |
59 |
60 |
61 | /// This moves the date in ClassClock's representation of the dates
62 | ///
63 | /// This is effectively a remove followed by an add.
64 | /// it's combined into one for efficiency when things get moved.
65 | /// if both source_date_str and destination_date_str are provided, this function will remove the source and add the destination.
66 | /// if either is undefined, then it will simply be an add or remove depending on which one is undefined/null
67 | const moveDate = (schedule_id: string, source_date_str?: string, destination_date_str?: string) => {
68 | let schedule = data.find((value) => value.id == schedule_id)
69 | console.log(schedule.dates)
70 | if (source_date_str != null) {
71 | let sourceDatePresent = schedule.dates.find((value: string) => value == source_date_str) != undefined
72 | if (sourceDatePresent) {
73 | // keep everything except the source date string
74 | schedule.dates =
75 | schedule.dates.filter((input:string) => input != source_date_str)
76 | }
77 | }
78 |
79 | if (destination_date_str != null) {
80 | let destinationDatePresent = schedule.dates.find((value: string) => value == destination_date_str) != undefined
81 |
82 | if (!destinationDatePresent) {
83 | schedule.dates.push(destination_date_str)
84 | }
85 | }
86 |
87 | if (source_date_str == null && destination_date_str == null){
88 | return
89 | }
90 | console.log(schedule.dates)
91 | dataProvider.update(resource, {
92 | id: schedule.id,
93 | data: schedule,
94 | previousData: undefined
95 | })
96 |
97 | }
98 |
99 | let events: EventSourceInput = {};
100 | if (!isLoading) {
101 | events = recordTransformer(data)
102 | } else {
103 | return <>Loading...>;
104 | }
105 |
106 | return {
116 | // this is called when an external (i.e. new) event is dropped onto the calendar
117 | moveDate(info.event.extendedProps.schedule_id, undefined, info.event.startStr)
118 | }}
119 | eventClick={(arg:EventClickArg) => {
120 | //color the event red for a set amount of time
121 | // if clicked during this time, delete the event
122 | // otherwise change the color back
123 | let origBG = arg.el.style.backgroundColor;
124 | let origOnclick = arg.el.onclick;
125 | arg.el.style.backgroundColor = 'red';
126 |
127 | arg.el.onclick = () => {
128 | moveDate(arg.event.extendedProps.schedule_id, arg.event.startStr, undefined)
129 | arg.event.remove()
130 | }
131 | setTimeout(() => {
132 | arg.el.style.backgroundColor = origBG;
133 | arg.el.onclick = origOnclick;
134 | }, 2500);
135 |
136 |
137 | }}
138 | eventChange={(arg: EventChangeArg) => {
139 | //this is called when an existing event changes on the calendar
140 | moveDate(arg.event.extendedProps.schedule_id, undefined, arg.event.startStr)
141 | }}
142 | eventDrop={(arg: EventDropArg) => {
143 | const calref = calendarRef.current;
144 | var eClone = {
145 | title: arg.oldEvent.title,
146 | start: arg.oldEvent.startStr,
147 | }
148 | if (calref) {
149 | const api = calref.getApi()
150 | api.addEvent(eClone)
151 | }
152 |
153 | }}
154 | //see https://stackoverflow.com/a/73015507, this may not be the best solution
155 | eventsSet={(arg: EventApi[]) => {
156 | for (const event of arg) {
157 | let argsThisDay = arg.filter((value) => value.startStr == event.startStr)
158 | let argsThisDaycount = argsThisDay? argsThisDay.length: undefined;
159 | if (argsThisDaycount == 2 && event.id == "") {
160 | event.remove()
161 | }
162 | }
163 | }}
164 | nowIndicator={true}
165 | validRange={{
166 | start: fromDate.toFormat("yyyy-MM-dd"),
167 | end: toDate.toFormat("yyyy-MM-dd")
168 | }}
169 | {...rest}
170 | />;
171 | }
172 | export default CalendarDates;
--------------------------------------------------------------------------------
/src/pages/Admin/LoginRedirect.tsx:
--------------------------------------------------------------------------------
1 | // in src/LoginRedirect.js
2 | import React, { useState } from 'react';
3 | import { useLogin, useNotify } from 'react-admin';
4 | import { useAuth0 } from '@auth0/auth0-react';
5 | import {pages} from "../../utils/constants";
6 |
7 | const LoginRedirect = () => {
8 | const [email, setEmail] = useState('');
9 | const [password, setPassword] = useState('');
10 | const login = useLogin();
11 | const notify = useNotify();
12 | const submit = (e:any) => {
13 | e.preventDefault();
14 | login({ email, password }).catch(() =>
15 | notify('Invalid email or password')
16 | );
17 | };
18 |
19 | const { isAuthenticated, loginWithRedirect, isLoading } = useAuth0();
20 | if (!isAuthenticated && isLoading == false) {
21 | loginWithRedirect({
22 | appState: { targetUrl: pages.adminBellSchedule }
23 | })
24 | }
25 | return (
26 | Redirecting you to the login page...
27 | );
28 | };
29 |
30 | export default LoginRedirect;
--------------------------------------------------------------------------------
/src/pages/Admin/authProvider.ts:
--------------------------------------------------------------------------------
1 |
2 | import { LogoutOptions } from '@auth0/auth0-react';
3 | import { UserIdentity } from 'react-admin';
4 |
5 | const authProvider = (
6 | isAuthenticated: boolean,
7 | loading: boolean,
8 | logout: (o?: LogoutOptions) => void,
9 | user:any,
10 | ) => ({
11 | login: () => {
12 | console.log("login")
13 | return (isAuthenticated && loading === false ? Promise.resolve() : Promise.reject())
14 | },
15 | // TODO: maybe use login: () => Promise.resolve(),//unused because login is handled by auth0
16 | logout: () => {
17 | console.log("logout")
18 | logout(
19 | // TODO: is this needed?
20 | //federated: true // have to be enabled to invalidate refresh token
21 | )
22 | return (isAuthenticated && loading === false ? Promise.reject() : Promise.resolve())
23 | },
24 | checkError: ({ status }: {status:number}) => {
25 | console.log("checkError")
26 | if (status === 401 || status === 403) {
27 | return Promise.reject();
28 | }
29 | return Promise.resolve();
30 | },
31 | checkAuth: () => {
32 | console.log("checkAuth: " + isAuthenticated)
33 | return ((isAuthenticated || loading) ? Promise.resolve() : Promise.reject())
34 | // TODO: is this a good idea/necessary? return Promise.reject({ redirectTo: '/nologin' })
35 | },
36 | getPermissions: () => {
37 | console.log("getPermissions")
38 | return ((isAuthenticated || loading) ? Promise.resolve() : Promise.reject())
39 | },
40 | getIdentity: (): Promise =>
41 | Promise.resolve({
42 | id: user.id,
43 | fullName: user.name,
44 | avatar: user.picture,
45 | }),
46 | });
47 |
48 | export default authProvider;
49 |
--------------------------------------------------------------------------------
/src/pages/Admin/resources.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ArrayField, ArrayInput, ChipField, Create, Datagrid, DateField, Edit, List, RaRecord, ReferenceField, ReferenceInput, SelectInput, SimpleForm, SimpleFormIterator, SingleFieldList, TextField, TextInput, useRecordContext, maxLength, required } from "react-admin";
3 | import CalendarDates from "./CalendarDates";
4 | import { DateTime, Duration } from 'luxon';
5 | import { EventInput } from '@fullcalendar/react';
6 |
7 | export const SchoolList = (props: any) => (
8 |
9 |
10 |
11 | {/* */}
12 |
13 |
14 |
15 |
16 | {/* */}
17 |
18 |
19 | );
20 |
21 | const recordsToEvents = (data: RaRecord[]) => {
22 | if (data == null) {
23 | return []
24 | }
25 |
26 | let events: EventInput[] = []
27 |
28 | for (let schedule of data) {
29 | let scheduleTemplateEvent = {
30 | title: schedule["name"],
31 | extendedProps: {
32 | schedule_id: schedule["id"]
33 | }
34 | }
35 | schedule['dates'].forEach((datestr: string) => {
36 | events.push(Object.assign({}, scheduleTemplateEvent, { date: datestr, id: schedule["id"] + datestr}))
37 | });
38 |
39 | }
40 | return events;
41 | };
42 |
43 | const DraggableNameField = (props: { source: string | undefined; }) => {
44 | const record = useRecordContext();
45 | return ;
55 | }
56 |
57 |
58 | export const BellScheduleList = (props: any) => (
59 | <>
60 |
61 | <>
62 |
67 |
68 |
72 | {/* */}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | {/* */}
82 | {/* */}
83 | {/* */}
84 |
85 | {/* */}
86 |
87 |
88 | >
89 |
90 | >
91 | );
92 |
93 | export const DateList = (props: any) => (
94 |
95 |
96 | {/* */}
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | {/* */}
106 | {/* */}
107 | {/* */}
108 |
109 | {/* */}
110 |
111 |
112 |
113 | );
114 |
115 | const validateBellScheduleName = [maxLength(75)];
116 | const validateBellScheduleDisplayName = [maxLength(20)];
117 |
118 | export const BellscheduleEdit = (props: any) => (
119 |
120 |
121 |
122 | {/* */}
123 |
124 | {/* */}
125 | {/* */}
126 |
127 |
128 | {/*
129 |
130 | */}
131 |
132 |
133 |
134 |
135 |
136 | {/* */}
137 |
138 |
139 |
140 |
141 |
142 |
143 | );
144 |
145 |
146 | export const BellScheduleCreate = (props: any) => (
147 |
148 |
149 |
150 | {/* */}
151 |
152 |
153 |
154 |
155 | {/* */}
156 | {/* */}
157 |
158 |
159 | {/*
160 |
161 | */}
162 |
163 |
164 |
165 |
166 |
167 |
168 | {/* */}
169 | {/* */}
170 |
171 |
172 | )
--------------------------------------------------------------------------------
/src/pages/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import renderer from "react-test-renderer";
4 | import { App } from './App';
5 | import { beforeSchoolHours, noSchool, inClass, betweenClass, afterSchoolHours, mockSchoolState } from '../utils/testconstants';
6 | import MockDate from 'mockdate';
7 |
8 | // make a fake dispatch function, it doesnt need to do anything, because we assume that has already been tested in the redux library
9 | const mockDispatch = jest.fn();
10 |
11 | const rawComponent =
12 |
13 |
14 |
15 | // Returns a TestInstance#find() predicate that passes
16 | // all test instance children (including text nodes) through
17 | // the supplied predicate, and returns true if one of the
18 | // children passes the predicate.
19 | // Source: https://billgranfield.com/2018/03/28/react-test-renderer.html
20 | function findInChildren(predicate:any) {
21 | return (testInstance: { children: any; }) => {
22 | const children = testInstance.children
23 | return Array.isArray(children)
24 | ? children.some(predicate)
25 | : predicate(children)
26 | }
27 | }
28 |
29 | function findTextInChildren(text:string) {
30 | return findInChildren((node: string) =>
31 | typeof node === 'string' &&
32 | node.toLowerCase() === text.toLowerCase()
33 | )
34 | }
35 |
36 |
37 |
38 | describe("App", () => {
39 | it('renders without crashing', () => {
40 | const div = document.createElement('div');
41 | ReactDOM.render(rawComponent, div);
42 | ReactDOM.unmountComponentAtNode(div);
43 | });
44 |
45 | it("shows the correct screen before school hours", () => {
46 | MockDate.set(beforeSchoolHours.toJSDate());
47 | const root = renderer.create(rawComponent).root
48 | expect(root.find(findTextInChildren("Transition Time"))).toBeTruthy();
49 | });
50 |
51 | it("shows the correct screen on no school days", () => {
52 | MockDate.set(noSchool.toJSDate());
53 | const root = renderer.create(rawComponent).root
54 | expect(root.find(findTextInChildren("No School Today"))).toBeTruthy();
55 |
56 | });
57 |
58 | it("shows the correct screen when class is in session", () => {
59 | MockDate.set(inClass.toJSDate());
60 | const root = renderer.create(rawComponent).root
61 | expect(root.find(findTextInChildren("first period"))).toBeTruthy();
62 |
63 | });
64 |
65 | it("shows the correct screen between class", () => {
66 | MockDate.set(betweenClass.toJSDate());
67 | const root = renderer.create(rawComponent).root
68 | expect(root.find(findTextInChildren("Transition Time"))).toBeTruthy();
69 | });
70 |
71 | it("shows the correct screen after school", () => {
72 | MockDate.set(afterSchoolHours.toJSDate());
73 | const root = renderer.create(rawComponent).root
74 | expect(root.find(findTextInChildren("no class"))).toBeTruthy();
75 | });
76 |
77 | });
78 |
79 |
80 |
--------------------------------------------------------------------------------
/src/pages/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, useEffect, useState } from "react";
2 | import { connect } from "react-redux";
3 | import { push } from "redux-first-routing";
4 | import "../global.css";
5 | import Link from "../components/Link";
6 | import Block from "../components/Block/Block";
7 | import { DateTime } from "luxon";
8 | import School from "../@types/school";
9 | import { pages } from "../utils/constants";
10 | import { ISchoolsState, SelectedSchoolState } from "../store/schools/types";
11 | import { getCurrentDate } from "../utils/helpers";
12 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
13 | import { faCog } from "@fortawesome/free-solid-svg-icons";
14 | import {faRectangleList} from "@fortawesome/free-regular-svg-icons";
15 | import { ISettingsState, IUserSettings } from "../store/usersettings/types";
16 | import StatusIndicator from "../components/StatusIndicator";
17 | import ClassClockService from "../services/classclock";
18 | import { selectSchool } from "../store/schools/actions";
19 | import ClassPeriod from "../@types/classperiod";
20 | import Time from "../@types/time";
21 |
22 | export interface IAppProps {
23 | selectedSchool: SelectedSchoolState;
24 | userSettings: IUserSettings;
25 | error: string;
26 | dispatch: any;
27 | }
28 |
29 | export const App = (props: IAppProps) => {
30 | const [currentDate, setDate] = useState(getCurrentDate());
31 | const [online, setOnline] = useState(true);
32 | const [connected, setConnected] = useState(false);
33 |
34 | const navigate = (to: string) => {
35 | props.dispatch(push(to));
36 | };
37 |
38 | const updateConnectionState = () => {
39 | ClassClockService.isReachable().then((reachable) => {
40 | setConnected(reachable)
41 | })
42 | }
43 |
44 | useEffect(() => {
45 | const timingInterval: NodeJS.Timeout = setInterval(() => {
46 | setDate(getCurrentDate());
47 | }, 500);
48 |
49 | //set the connected state immediately on pageload
50 | updateConnectionState()
51 |
52 | //then schedule the connection state to be updated every 2 min
53 | const connectivityInterval: NodeJS.Timeout = setInterval(() => {
54 | updateConnectionState()
55 | }, 120000);
56 |
57 | //check when the schedule was last updated
58 | const dataAge = DateTime.local().toMillis() - props.selectedSchool.lastUpdated
59 |
60 | // if data is > 12 hours old 43200000
61 | if (dataAge > 43200000){
62 | props.dispatch(selectSchool(props.selectedSchool.data.getIdentifier()))
63 | }
64 |
65 | return () => {
66 | clearInterval(timingInterval)
67 | clearInterval(connectivityInterval)
68 | };
69 | }, []);
70 |
71 | window.addEventListener('online', () => {
72 | setOnline(true)
73 | updateConnectionState()
74 | });
75 | window.addEventListener('offline', () => {setOnline(false)});
76 |
77 | //If this is not present here, then there will be an error when the next lines run in the event data is being fetched and there is no stored school.
78 | // this guards against trying to call a function on an empty data object which may be present if the school is being fetched for the first time. Or if the user has just cleared their browsers localStorage
79 | if (props.selectedSchool.isFetching) {
80 | return Fetching...
81 | }
82 | const currentSchedule = props.selectedSchool?.data?.getScheduleForDate(currentDate);
83 | const schoolTimezone = props.selectedSchool?.data?.getTimezone();
84 |
85 | const getContent = () => {
86 | switch (currentSchedule) {
87 | case undefined:
88 | if (!props.selectedSchool.isFetching) {
89 | props.dispatch(push(pages.welcome));
90 | }
91 | return
92 | case null:
93 | return No School Today
;
94 | default:
95 |
96 | let nextClass: ClassPeriod | undefined = currentSchedule.getClassStartingAfter(currentDate, schoolTimezone);
97 | let nextImportantTime: Time | undefined;
98 |
99 | const currentClass = currentSchedule.getClassPeriodForTime(currentDate, schoolTimezone);
100 |
101 | if (currentClass){
102 | nextImportantTime = currentClass.getEndTime()
103 | } else if (nextClass) {
104 | nextImportantTime = nextClass.getStartTime()
105 | } else {
106 | return School's Out!
;
107 | }
108 |
109 | return (
110 | <>
111 |
112 |
113 | Today is a{" "}
114 | navigate(pages.fullSchedule)}
117 | id="viewScheduleLink"
118 | >
119 | {currentSchedule.getName()}
120 |
121 |
122 |
123 |
124 | You are currently in:
125 |
126 |
127 | {currentClass !== undefined
128 | ? currentClass.getName()
129 | : props.selectedSchool.data.getPassingTimeName()}
130 |
131 |
132 |
133 |
134 | ...which ends in:
135 | {/* */}
136 |
137 |
138 | {nextImportantTime
139 | ? nextImportantTime.getTimeDeltaTo(Time.fromDateTime(currentDate, props.selectedSchool.data.getTimezone())).getFormattedString(false, true)
140 | : "No Class"}
141 |
142 |
143 | Your next class period is:
144 |
145 | {nextClass ? nextClass.getName() : "No Class"}
146 |
147 |
148 | >
149 | );
150 | }
151 | }
152 |
153 | const getStatus = () => {
154 | let content: JSX.Element = <>>;
155 | let color = "";
156 |
157 | if (props.selectedSchool.isFetching){
158 | color = "yellow";
159 | content = <>Refreshing...>
160 | } else if (props.error != "") {
161 | color = "red";
162 | content = <>Error>
163 | } else if (!online) {
164 | color = "orange";
165 | content = <>Offline>
166 | } else {
167 | content = connected? <>Connected> : <>Online>;
168 | color = "green";
169 | }
170 |
171 | return {content}
172 | }
173 |
174 | return (
175 | <>
176 |
177 |
navigate(pages.settings)}
181 | >
182 |
183 |
184 |
navigate(pages.fullSchedule)}
188 | >
189 |
190 |
191 |
192 |
193 | It is currently:
194 |
195 | {currentDate.toLocaleString({ hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: !props.userSettings.use24HourTime }) }
196 |
197 |
198 | on{" "}
199 |
200 | {currentDate.toLocaleString({
201 | weekday: "long",
202 | year: "numeric",
203 | month: "short",
204 | day: "numeric"
205 | })}
206 |
207 |
208 |
209 |
210 | {getContent()}
211 |
212 | {getStatus()}
213 | >
214 | );
215 | };
216 |
217 | const mapStateToProps = (state: ISchoolsState & ISettingsState & {error: string}) => {
218 | const { selectedSchool, userSettings, error } = state;
219 | return { selectedSchool, userSettings, error };
220 | };
221 |
222 | export default connect(mapStateToProps)(App);
223 |
--------------------------------------------------------------------------------
/src/pages/Schedule.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import renderer from "react-test-renderer";
4 | import { Schedule } from './Schedule';
5 | import { school, beforeSchoolHours, noSchool, inClass, betweenClass, afterSchoolHours } from '../utils/testconstants';
6 | import MockDate from 'mockdate';
7 |
8 | // make a fake dispatch function, it doesnt need to do anything, because we assume that has already been tested in the redux library
9 | const mockDispatch = jest.fn();
10 |
11 | const mockState = {
12 | isFetching: false,
13 | didInvalidate: false,
14 | data: school
15 | };
16 |
17 | const rawComponent =
18 |
19 |
20 |
21 |
22 | describe("Schedule", () => {
23 | it('renders without crashing', () => {
24 | const div = document.createElement('div');
25 | ReactDOM.render(rawComponent, div);
26 | ReactDOM.unmountComponentAtNode(div);
27 | });
28 |
29 | it("shows the day's schedule regardless of the time", () => {
30 | MockDate.set(beforeSchoolHours.toJSDate());
31 | let tree = renderer.create(rawComponent).toJSON();
32 | expect(tree).toMatchSnapshot();
33 |
34 | MockDate.set(noSchool.toJSDate());
35 | tree = renderer.create(rawComponent).toJSON();
36 | expect(tree).toMatchSnapshot();
37 |
38 | MockDate.set(inClass.toJSDate());
39 | tree = renderer.create(rawComponent).toJSON();
40 | expect(tree).toMatchSnapshot();
41 |
42 | MockDate.set(betweenClass.toJSDate());
43 | tree = renderer.create(rawComponent).toJSON();
44 | expect(tree).toMatchSnapshot();
45 |
46 | MockDate.set(afterSchoolHours.toJSDate());
47 | tree = renderer.create(rawComponent).toJSON();
48 | expect(tree).toMatchSnapshot();
49 | });
50 |
51 | });
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/pages/Schedule.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import { connect } from "react-redux";
3 | import { push } from "redux-first-routing";
4 |
5 | import "../global.css";
6 | import School from "../@types/school";
7 | import { ISchoolsState } from "../store/schools/types";
8 | import Link from "../components/Link";
9 | import { pages } from "../utils/constants";
10 | import { getCurrentDate, sortClassesByStartTime } from "../utils/helpers";
11 | import ClassPeriod from "../@types/classperiod";
12 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
13 | import { faHome } from "@fortawesome/free-solid-svg-icons";
14 |
15 | export interface IAppProps {
16 | selectedSchool: {
17 | isFetching: boolean;
18 | didInvalidate: boolean;
19 | data: School;
20 | };
21 | dispatch: any;
22 | }
23 |
24 | export const Schedule = (props: IAppProps) => {
25 | let content: JSX.Element = <>>;
26 | const currentSchedule = props.selectedSchool?.data?.getScheduleForDate(
27 | getCurrentDate()
28 | );
29 |
30 | switch (currentSchedule) {
31 | case undefined:
32 | props.dispatch(push(pages.selectSchool));
33 | break;
34 | case null:
35 | content = No School Today
;
36 | break;
37 | default:
38 | content = (
39 | <>
40 | {currentSchedule.getName()}
41 | {/*
*/}
42 |
43 |
44 |
45 |
46 | Class
47 |
48 |
49 | Time
50 |
51 |
52 |
53 |
54 | {sortClassesByStartTime(currentSchedule.getAllClasses()).map(
55 | (value: ClassPeriod) => (
56 |
57 | {value.getName()}
58 |
59 | {value.getStartTime().toString()} -{" "}
60 | {value.getEndTime().toString()}
61 |
62 |
63 | )
64 | )}
65 |
66 |
67 | >
68 | );
69 | break;
70 | }
71 |
72 | return (
73 |
74 |
props.dispatch(push(pages.main))}
78 | >
79 |
80 |
81 |
82 |
83 | {props.selectedSchool.data.getName()}
84 |
85 |
86 | {content}
87 |
88 | );
89 | };
90 |
91 | const mapStateToProps = (state: ISchoolsState) => {
92 | const { selectedSchool } = state;
93 | return { selectedSchool };
94 | };
95 |
96 | export default connect(mapStateToProps)(Schedule);
97 |
--------------------------------------------------------------------------------
/src/pages/SchoolSelect.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { connect } from "react-redux";
3 | import { push } from "redux-first-routing";
4 | import "../global.css";
5 | import { getSchoolsList, selectSchool } from "../store/schools/actions";
6 | import School from "../@types/school";
7 | import { pages } from "../utils/constants";
8 | import { ISchoolListState, SchoolListState } from "../store/schools/types";
9 | import SelectionList from "../components/SelectionList/SelectionList";
10 | import { DateTime } from "luxon";
11 | import Link from "../components/Link";
12 |
13 | export interface ISelectProps {
14 | schoolList: SchoolListState;
15 | error: string;
16 | dispatch: any;
17 | }
18 |
19 | const SchoolSelect = (props: ISelectProps) => {
20 |
21 | useEffect(() => {
22 | const controller = new AbortController();
23 | const signal = controller.signal;
24 |
25 | const lastUpdate = props.schoolList.lastUpdated || 0
26 |
27 | if (
28 |
29 | DateTime.local().toMillis() - lastUpdate > 120000 //120000 ms = 2 min
30 | ) {
31 |
32 | props.dispatch(getSchoolsList(signal))
33 |
34 | }
35 |
36 | return () => {
37 | controller.abort();
38 | };
39 | }, []);
40 |
41 |
42 | //schoolList.length is used here because the app can potentially get stuck on isFetching = true if, for example, the page gets closed while a request is in progress. maybe mitigate this with a timestamp of when the requet started and add a timeout to change it back to false automatically?
43 | const isFetching = () => props.schoolList.isFetching
44 | // props.selectedSchool.isFetching === false
45 |
46 |
47 | const makeSchoolElement = (school:School, dispatch: any) => {
48 | const id = school.getIdentifier();
49 |
50 | return ( {
53 | dispatch(selectSchool(id));
54 | dispatch(push(pages.main));
55 | }}
56 | >
57 | {school.getAcronym()}
58 |
59 | {school.getName()}
60 | )
61 | }
62 |
63 | let schoolList: JSX.Element[] = [];
64 |
65 | if (props.schoolList && props.schoolList.data) {
66 | schoolList = props.schoolList.data.map((school) => {
67 | school = School.fromJson(school);
68 |
69 | return makeSchoolElement(school, props.dispatch)
70 | })
71 | }
72 |
73 |
74 | return (
75 | <>
76 | Please select a school
77 | Can't find your school? Nominate it!
81 |
82 | {schoolList}
83 |
84 | >
85 | );
86 | };
87 |
88 | const mapStateToProps = (state: ISchoolListState & { error: string }) => {
89 | const { schoolList, error } = state;
90 | return { schoolList, error };
91 | };
92 | export default connect(mapStateToProps)(SchoolSelect);
93 |
--------------------------------------------------------------------------------
/src/pages/Settings/Settings.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import renderer from "react-test-renderer";
3 | import { mockSchoolState } from '../../utils/testconstants';
4 | import { Settings } from './Settings';
5 |
6 |
7 | // make a fake dispatch function, it doesnt need to do anything, because we assume that has already been tested in the redux library
8 | const mockDispatch = jest.fn();
9 |
10 | describe("Settings", () => {
11 |
12 | it("renders correctly", () => {
13 | const tree = renderer.create( ).toJSON();
14 | expect(tree).toMatchSnapshot();
15 | });
16 | });
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/pages/Settings/Settings.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 | import { push } from "redux-first-routing";
4 | import "../../global.css";
5 | import "./settings.css";
6 | import { ISchoolsState, SelectedSchoolState } from "../../store/schools/types";
7 | import { ISettingsState, IUserSettings } from "../../store/usersettings/types";
8 | import School from "../../@types/school";
9 | import Link from "../../components/Link";
10 | import { pages } from "../../utils/constants";
11 | import { setTimeFormatPreference } from "../../store/usersettings/actions";
12 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
13 | import { faHome } from "@fortawesome/free-solid-svg-icons";
14 | import distanceInWords from "date-fns/distance_in_words";
15 | import { selectSchool } from "../../store/schools/actions";
16 | import packageJson from '../../package.alias.json';
17 | import SocialIcons from "../../components/SocialIcons";
18 |
19 | export interface ISettingProps {
20 | selectedSchool: SelectedSchoolState;
21 | userSettings: IUserSettings;
22 | error: string;
23 | dispatch: any;
24 | }
25 |
26 | export const Settings = (props: ISettingProps) => {
27 |
28 | const navigate = (to: string) => {
29 | props.dispatch(push(to));
30 | };
31 |
32 | if (!props.selectedSchool.data && !props.selectedSchool.isFetching) {
33 | navigate(pages.selectSchool);
34 | }
35 |
36 | const getVersionHTML = () => {
37 | if (process.env.REACT_APP_VERCEL_GIT_COMMIT_SHA) {
38 |
39 |
40 | const version_sha = process.env.REACT_APP_VERCEL_GIT_COMMIT_SHA.substring(0, 6)
41 |
42 | const githubURL = "https://" + packageJson.repository.replace(":", ".com/") + "/commit/"
43 |
44 | return <>({version_sha} )>
45 | } else {
46 | return <>>
47 | }
48 | }
49 |
50 | const selectedSchoolInfo = () => {
51 | if (props.selectedSchool.isFetching) {
52 | return Loading School...
53 | } else if (props.error) {
54 | return An Error Occurred
55 | } else {
56 | return <>
57 | navigate(pages.selectSchool)}
60 | title="Change School"
61 | >
62 | {props.selectedSchool.data.getName() + " "}
63 |
64 |
65 |
66 | Last updated{" "}
67 | {distanceInWords(
68 | new Date(),
69 | new Date(props.selectedSchool.lastUpdated)
70 | ) + " ago "}
71 |
72 |
73 |
74 |
77 | props.dispatch(
78 | selectSchool(
79 | props.selectedSchool.data.getIdentifier()
80 | )
81 | )
82 | }
83 | title="Reload Schedule"
84 | >
85 | Refresh
86 |
87 |
88 | >
89 | }
90 | }
91 |
92 | return (
93 |
94 |
navigate(pages.main)}
98 | >
99 |
100 |
101 |
102 |
Settings
103 |
Selected School:
104 |
105 | {selectedSchoolInfo()}
106 |
107 |
108 |
109 |
Time Display:
110 |
111 | Use 24-hour Time?{" "}
112 | {
117 | props.dispatch(
118 | setTimeFormatPreference(!props.userSettings.use24HourTime)
119 | );
120 | }}
121 | />
122 |
123 |
124 |
125 |
126 | Settings are automatically saved on your device
127 |
128 |
129 |
130 |
Follow ClassClock:
131 |
132 |
133 | Created by: Adrian Edwards {" "}
134 | and Nick DeGroot
135 |
136 | Idea by: Dan Kumprey
137 |
138 |
ClassClock version {packageJson.version} {getVersionHTML()}
139 |
140 | );
141 | };
142 |
143 | const mapStateToProps = (state: ISchoolsState & ISettingsState & {error: string}) => {
144 | const { selectedSchool, userSettings, error } = state;
145 | return {
146 | selectedSchool: Object.assign({}, selectedSchool, {
147 | data: selectedSchool.data
148 | }),
149 | userSettings,
150 | error
151 | };
152 | };
153 |
154 | export default connect(mapStateToProps)(Settings);
155 |
--------------------------------------------------------------------------------
/src/pages/Settings/__snapshots__/Settings.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Settings renders correctly 1`] = `
4 |
5 |
14 |
25 |
30 |
31 |
32 |
33 | Settings
34 |
35 |
38 | Selected School:
39 |
40 |
78 |
81 | Time Display:
82 |
83 |
84 | Use 24-hour Time?
85 |
86 |
91 |
92 |
93 |
94 |
97 | Settings are automatically saved on your device
98 |
99 |
100 |
107 | Follow ClassClock:
108 |
109 |
225 |
228 | Created by:
229 |
232 | Adrian Edwards
233 |
234 |
235 | and
236 |
239 | Nick DeGroot
240 |
241 |
242 | Idea by:
243 |
246 | Dan Kumprey
247 |
248 |
249 |
256 | ClassClock version
257 | 0.4.1
258 |
259 |
260 |
261 | `;
262 |
--------------------------------------------------------------------------------
/src/pages/Settings/settings.css:
--------------------------------------------------------------------------------
1 | .settingsHeader {
2 | font-weight: bold;
3 | text-align: left;
4 | /* margin-left: 1em; */
5 | font-size: 1.2em;
6 | margin-bottom: 0;
7 | padding-bottom: 0;
8 | }
--------------------------------------------------------------------------------
/src/pages/Welcome.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, useEffect } from "react";
2 | import { connect } from "react-redux";
3 | import { push } from "redux-first-routing";
4 | import "../global.css";
5 | import { pages } from "../utils/constants";
6 | import BlockLink from '../components/BlockLink';
7 |
8 | export interface IWelcomeProps {
9 | dispatch: any;
10 | }
11 |
12 | const Welcome = (props: IWelcomeProps) => {
13 | return (
14 |
15 |
Welcome to ClassClock!
16 |
An open source school scheduling assistant built by students.
17 |
18 |
props.dispatch(push(pages.selectSchool))} title={"Find your Schedules"}>Find your Schedules
19 |
20 |
Learn more about ClassClock
21 |
22 | );
23 | };
24 |
25 | export default connect()(Welcome);
26 |
27 | // export default Welcome;
28 |
--------------------------------------------------------------------------------
/src/pages/__snapshots__/Schedule.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Schedule shows the day's schedule regardless of the time 1`] = `
4 |
5 |
14 |
25 |
30 |
31 |
32 |
33 |
40 |
41 | The High School
42 |
43 |
44 |
45 | Regular Schedule
46 |
47 |
48 |
49 |
50 |
51 |
52 | Class
53 |
54 |
55 |
56 |
57 | Time
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | First Period
66 |
67 |
68 | 08:25:00
69 | -
70 |
71 | 09:55:00
72 |
73 |
74 |
75 |
76 | Second Period
77 |
78 |
79 | 10:05:00
80 | -
81 |
82 | 11:35:00
83 |
84 |
85 |
86 |
87 |
88 | `;
89 |
90 | exports[`Schedule shows the day's schedule regardless of the time 2`] = `
91 |
92 |
101 |
112 |
117 |
118 |
119 |
120 |
127 |
128 | The High School
129 |
130 |
131 |
132 | No School Today
133 |
134 |
135 | `;
136 |
137 | exports[`Schedule shows the day's schedule regardless of the time 3`] = `
138 |
139 |
148 |
159 |
164 |
165 |
166 |
167 |
174 |
175 | The High School
176 |
177 |
178 |
179 | Regular Schedule
180 |
181 |
182 |
183 |
184 |
185 |
186 | Class
187 |
188 |
189 |
190 |
191 | Time
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 | First Period
200 |
201 |
202 | 08:25:00
203 | -
204 |
205 | 09:55:00
206 |
207 |
208 |
209 |
210 | Second Period
211 |
212 |
213 | 10:05:00
214 | -
215 |
216 | 11:35:00
217 |
218 |
219 |
220 |
221 |
222 | `;
223 |
224 | exports[`Schedule shows the day's schedule regardless of the time 4`] = `
225 |
226 |
235 |
246 |
251 |
252 |
253 |
254 |
261 |
262 | The High School
263 |
264 |
265 |
266 | Regular Schedule
267 |
268 |
269 |
270 |
271 |
272 |
273 | Class
274 |
275 |
276 |
277 |
278 | Time
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 | First Period
287 |
288 |
289 | 08:25:00
290 | -
291 |
292 | 09:55:00
293 |
294 |
295 |
296 |
297 | Second Period
298 |
299 |
300 | 10:05:00
301 | -
302 |
303 | 11:35:00
304 |
305 |
306 |
307 |
308 |
309 | `;
310 |
311 | exports[`Schedule shows the day's schedule regardless of the time 5`] = `
312 |
313 |
322 |
333 |
338 |
339 |
340 |
341 |
348 |
349 | The High School
350 |
351 |
352 |
353 | Regular Schedule
354 |
355 |
356 |
357 |
358 |
359 |
360 | Class
361 |
362 |
363 |
364 |
365 | Time
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 | First Period
374 |
375 |
376 | 08:25:00
377 | -
378 |
379 | 09:55:00
380 |
381 |
382 |
383 |
384 | Second Period
385 |
386 |
387 | 10:05:00
388 | -
389 |
390 | 11:35:00
391 |
392 |
393 |
394 |
395 |
396 | `;
397 |
--------------------------------------------------------------------------------
/src/pages/errors/PageNotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import SocialIcons from "../../components/SocialIcons";
3 |
4 | export const PageNotFound = () => {
5 | return (
6 | <>
7 | Page Not Found!
8 | Why not try another page or reach out to us
9 |
10 | >
11 | );
12 | };
--------------------------------------------------------------------------------
/src/pages/errors/ServerError.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import SocialIcons from "../../components/SocialIcons";
3 |
4 | export const ServerError = () => {
5 | return (
6 | <>
7 | Oops! Something went wrong
8 | Please reach out if this happens repeatedly. Your detailed feedback helps get things fixed faster
9 |
10 | >
11 | );
12 | };
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl)
112 | .then(response => {
113 | // Ensure service worker exists, and that we really are getting a JS file.
114 | const contentType = response.headers.get('content-type');
115 | if (
116 | response.status === 404 ||
117 | (contentType != null && contentType.indexOf('javascript') === -1)
118 | ) {
119 | // No service worker found. Probably a different app. Reload the page.
120 | navigator.serviceWorker.ready.then(registration => {
121 | registration.unregister().then(() => {
122 | window.location.reload();
123 | });
124 | });
125 | } else {
126 | // Service worker found. Proceed as normal.
127 | registerValidSW(swUrl, config);
128 | }
129 | })
130 | .catch(() => {
131 | console.log(
132 | 'No internet connection found. App is running in offline mode.'
133 | );
134 | });
135 | }
136 |
137 | export function unregister() {
138 | if ('serviceWorker' in navigator) {
139 | navigator.serviceWorker.ready.then(registration => {
140 | registration.unregister();
141 | });
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/services/auth0-provider-with-history.tsx:
--------------------------------------------------------------------------------
1 | // src/auth/auth0-provider-with-history.js
2 | //from https://auth0.com/blog/complete-guide-to-react-user-authentication/
3 | import React from 'react';
4 | // import { useHistory } from 'react-router-dom';
5 | import { Auth0Provider } from '@auth0/auth0-react';
6 | import { configuredStore } from '../store/store';
7 | import { pages } from '../utils/constants';
8 | import { replace } from 'redux-first-routing';
9 |
10 | const Auth0ProviderWithHistory = ({ children }: {children: React.ReactElement}) => {
11 | // Attempt to grab the values from environment variables.
12 | // default values are provided largely to satisfy the type checking and really should not be relied on.
13 | const domain = process.env.REACT_APP_AUTH0_DOMAIN || "classclock.auth0.com";
14 | const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID || "someclientidthatshouldneverexist";
15 | const audience = process.env.REACT_APP_AUTH0_AUDIENCE || "https://api.classclock.app";
16 | const env = process.env.REACT_APP_VERCEL_ENV; //https://vercel.com/docs/concepts/projects/environment-variables#system-environment-variables
17 |
18 | // A function that routes the user to the right place
19 | // after login
20 | const onRedirectCallback = (appState: any) => {
21 | // Temporary Firefox workaround: https://github.com/auth0/auth0-spa-js/blob/master/FAQ.md
22 | window.location.hash = window.location.hash; // eslint-disable-line no-self-assign
23 |
24 | configuredStore.store.dispatch(
25 | replace(
26 | appState && appState.targetUrl ? appState.targetUrl : window.location.pathname
27 | )
28 | );
29 | };
30 |
31 | let redirectCallbackUrl = "http://localhost:3000";
32 |
33 | //development is the default case
34 | if (env === 'preview') {
35 | redirectCallbackUrl = "https://beta.web.classclock.app"
36 | } else if (env === 'production' || process.env.NODE_ENV === 'production') {
37 | redirectCallbackUrl = "https://web.classclock.app"
38 | }
39 |
40 | redirectCallbackUrl = redirectCallbackUrl + pages.loginCallback
41 |
42 | return (
43 |
51 | { children }
52 |
53 | );
54 | };
55 |
56 | export default Auth0ProviderWithHistory;
--------------------------------------------------------------------------------
/src/services/classclock-dataprovider.ts:
--------------------------------------------------------------------------------
1 |
2 | import { GetTokenSilentlyOptions } from '@auth0/auth0-react';
3 | import { fetchUtils, DataProvider } from 'ra-core';
4 | import ClassClockService from './classclock';
5 |
6 | import {DateTime, Interval} from 'luxon';
7 | import { CreateParams, CreateResult, DeleteManyParams, DeleteManyResult, DeleteParams, DeleteResult, GetListParams, GetManyReferenceParams, GetManyReferenceResult, RaRecord, UpdateManyParams, UpdateManyResult, UpdateParams } from 'react-admin';
8 |
9 | import { stringify } from 'querystring';
10 | /**
11 | * Maps react-admin queries to the ClassClock API
12 | *
13 | *
14 | * @example
15 | *
16 | * getList => GET http://my.api.url/posts?_sort=title&_order=ASC&_start=0&_end=24
17 | * getOne => GET http://my.api.url/posts/123
18 | * getManyReference => GET http://my.api.url/posts?author_id=345
19 | * getMany => GET http://my.api.url/posts?id=123&id=456&id=789
20 | * create => POST http://my.api.url/posts/123
21 | * update => PUT http://my.api.url/posts/123
22 | * updateMany => PUT http://my.api.url/posts/123, PUT http://my.api.url/posts/456, PUT http://my.api.url/posts/789
23 | * delete => DELETE http://my.api.url/posts/123
24 | *
25 | * @example
26 | *
27 | * import * as React from "react";
28 | * import { Admin, Resource } from 'react-admin';
29 | * import jsonServerProvider from 'ra-data-json-server';
30 | *
31 | * import { PostList } from './posts';
32 | *
33 | * const App = () => (
34 | *
35 | *
36 | *
37 | * );
38 | *
39 | * export default App;
40 | */
41 | export default (apiUrl: string, getTokenSilently: (o?: GetTokenSilentlyOptions) => Promise, httpClient = ClassClockService.makeAPICall): DataProvider => ({
42 | getList: async (resource: string, params:GetListParams) => {
43 | // const { page, perPage } = params.pagination;
44 | // const { field, order } = params.sort;
45 | // const query = {
46 | // ...fetchUtils.flattenObject(params.filter),
47 | // _sort: field,
48 | // _order: order,
49 | // _start: (page - 1) * perPage,
50 | // _end: page * perPage,
51 | // };
52 | const token: string = await getTokenSilently()
53 | const url = `${apiUrl}/${resource}s`;
54 |
55 | return httpClient("GET", url, token).then(async response =>
56 | // if (!headers.has('X-Total-Count')) {
57 | // throw new Error(
58 | // 'The X-Total-Count header is missing in the HTTP Response. The jsonServer Data Provider expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?'
59 | // );
60 | // }
61 | // var data = await //.data
62 | Object.assign({},
63 | await response.json(),
64 | {
65 | total: parseInt(
66 | (response.headers.get('X-Total-Count')?? "1").split('/').pop()?? "1",
67 | 10
68 | ),
69 | }
70 | )
71 | );
72 | },
73 |
74 | getOne: async (resource: string, params) =>{
75 | const token: string = await getTokenSilently()
76 | return httpClient("GET", `${apiUrl}/${resource}/${params.id}`, token)
77 | .then(async response => response.json())
78 | .then(async response => {
79 | // sort meeting times so they appear int he right order. this is a workaround while waiting for https://github.com/marmelab/react-admin/issues/6601 to be fixed
80 | if (response.data.hasOwnProperty("meeting_times")) {
81 | response.data.meeting_times = response.data.meeting_times.sort(function (a: { start_time: string }, b: { start_time: string }) {
82 | var end = DateTime.fromFormat(a.start_time, "hh:mm:ss");
83 | var start = DateTime.fromFormat(b.start_time, "hh:mm:ss");
84 | var i = Interval.fromDateTimes(start, end);
85 | return i.length('seconds');
86 | })
87 | }
88 | return response
89 | });
90 | },
91 | //make separate queries for each item because the classclock API doesnt support getting a specific set at once
92 | getMany: async (resource: string, params) => {
93 | const token: string = await getTokenSilently();
94 | return Promise.all(
95 | params.ids.map(id => {
96 | return ClassClockService.makeAPICall("GET", `${apiUrl}/${resource}/${id}`, token)
97 | .then(async response => response.json());
98 | })
99 | ).then(responses =>
100 | // responses is an array of all the promise responses
101 | ({ data: responses.map((item) => item.data) }))
102 | },
103 |
104 | getManyReference: async (resource: string, params: GetManyReferenceParams): Promise => {
105 | const { page, perPage } = params.pagination;
106 | const { field, order } = params.sort;
107 | const query = {
108 | ...fetchUtils.flattenObject(params.filter),
109 | [params.target]: params.id,
110 | _sort: field,
111 | _order: order,
112 | _start: (page - 1) * perPage,
113 | _end: page * perPage,
114 | };
115 | const url = `${apiUrl}/${resource}s?${stringify(query)}`;
116 |
117 | const response = await httpClient("GET", url);
118 | if (!response.headers.has('x-total-count')) {
119 | throw new Error(
120 | 'The X-Total-Count header is missing in the HTTP Response. The jsonServer Data Provider expects responses for lists of resources to contain this header with the total number of results to build the pagination. If you are using CORS, did you declare X-Total-Count in the Access-Control-Expose-Headers header?'
121 | );
122 | }
123 | return await response.json();
124 | },
125 |
126 | update: async (resource: string, params: UpdateParams) => {
127 | const token: string = await getTokenSilently();
128 |
129 | if (resource === 'bellschedule') {
130 | delete params.data.last_modified;
131 | delete params.data.creation_date;
132 | }
133 |
134 | return httpClient("PATCH", `${apiUrl}/${resource}/${params.id}`, token, {
135 | body: JSON.stringify(params.data),
136 | }).then((resp) => resp.json())//.then(({ json }) => ({ data: json() }))
137 | },
138 |
139 | // json-server doesn't handle filters on UPDATE route, so we fallback to calling UPDATE n times instead
140 | updateMany: async (resource: string, params: UpdateManyParams): Promise =>{
141 | const token: string = await getTokenSilently();
142 | return Promise.all(
143 | params.ids.map(id =>
144 | httpClient("PATCH", `${apiUrl}/${resource}/${id}`, token, {
145 | body: JSON.stringify(params.data),
146 | })
147 | )
148 | ).then(responses => ({ data: responses.map(response => response.json()) }))
149 | },
150 |
151 | create: async (resource: string, params: CreateParams): Promise =>{
152 | const token: string = await getTokenSilently();
153 |
154 | return httpClient("POST", `${apiUrl}/${resource}`, token, {
155 | body: JSON.stringify(params.data),
156 | }).then( response => response.json())
157 | },
158 |
159 | delete: async (resource: string, params: DeleteParams): Promise =>{
160 | const token: string = await getTokenSilently();
161 |
162 | return httpClient("DELETE", `${apiUrl}/${resource}/${params.id}`, token).then(({ json }) => ({ data: json }))
163 | },
164 |
165 | // json-server doesn't handle filters on DELETE route, so we fallback to calling DELETE n times instead
166 | deleteMany: async (resource: string, params: DeleteManyParams): Promise =>{
167 | const token: string = await getTokenSilently();
168 |
169 | return Promise.all(
170 | params.ids.map(id =>
171 | httpClient("DELETE", `${apiUrl}/${resource}/${id}`, token)
172 | )
173 | ).then(responses => ({ data: responses.map((response) => response.json()) }))
174 | },
175 | });
--------------------------------------------------------------------------------
/src/services/classclock.test.ts:
--------------------------------------------------------------------------------
1 | import fetchMock from "fetch-mock";
2 | import { fakeSchoolResponse, fakeBellScheduleListResponse, schoolId, schoolEndpoint, bellScheduleEndpoint } from "../utils/testconstants";
3 | import ClassClockService from "./classclock";
4 |
5 | const headers = { "Content-Type": "application/json" }
6 |
7 | describe("ClassClock API service", () => {
8 | afterEach(() => {
9 | fetchMock.restore();
10 | });
11 |
12 | // it("can request the schools list", () => {
13 | // fetchMock
14 | // .getOnce(ClassClockService.baseURL + "/schools/", {
15 | // body: fakeSchoolListResponse,
16 | // headers
17 | // });
18 |
19 | // ClassClockService.getSchoolsList("1234");
20 | // expect(fetchMock.done()).toBeTruthy();
21 | // });
22 |
23 | it("can request detailed info for a particular school", () => {
24 | fetchMock
25 | .getOnce(schoolEndpoint, {
26 | body: fakeSchoolResponse,
27 | headers
28 | });
29 |
30 | ClassClockService.getSchool(schoolId);
31 | expect(fetchMock.done()).toBeTruthy();
32 | });
33 |
34 | it("can request a list of schedules for a particular school", () => {
35 | fetchMock
36 | .getOnce(bellScheduleEndpoint, {
37 | body: fakeBellScheduleListResponse,
38 | headers
39 | });
40 |
41 | ClassClockService.getSchedulesListForSchool(schoolId);
42 | expect(fetchMock.done()).toBeTruthy();
43 | });
44 |
45 | it("can validate responses from the API", async () => {
46 | fetchMock
47 | .getOnce(schoolEndpoint, {
48 | body: fakeSchoolResponse,
49 | headers
50 | });
51 |
52 | const validatedResponse = await ClassClockService.validateResponse(ClassClockService.getSchool(schoolId));
53 | expect(validatedResponse).toEqual(fakeSchoolResponse);
54 | });
55 |
56 | });
--------------------------------------------------------------------------------
/src/services/classclock.ts:
--------------------------------------------------------------------------------
1 | import BellSchedule from "../@types/bellschedule";
2 | import { objectKeysToSnakeCase, parseRateLimitTime, promiseRetry } from "../utils/helpers";
3 | import { DateTime } from "luxon";
4 | import { RateLimitError } from "../utils/errors";
5 |
6 | export default class ClassClockService {
7 | public static baseURL: string = (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') ? "http://localhost:8000/v0" : "https://api.classclock.app/v0";
8 |
9 | static getSchoolsList = async (params?: any): Promise => {
10 | return await fetch(
11 | ClassClockService.baseURL + "/schools/",
12 | ClassClockService.getHeaders("GET", params)
13 | );
14 | };
15 |
16 | static getSchool = async (schoolId: string, params?: any): Promise => {
17 | return await fetch(
18 | ClassClockService.baseURL + "/school/" + schoolId + "/",
19 | ClassClockService.getHeaders("GET", params)
20 | );
21 | };
22 |
23 | static getSchedulesListForSchool = async (
24 | schoolId: string,
25 | params?: any
26 | ): Promise => {
27 | return await fetch(
28 | ClassClockService.baseURL + "/bellschedules/" + schoolId + "/",
29 | ClassClockService.getHeaders("GET", params)
30 | );
31 | };
32 |
33 | /**
34 | * Validates responses to ClassClock Service API calls and applies things like request retrying, some error handling, and converting the response body to a usable format
35 | *
36 | * in future it may be useful to move this to a more generic helper function (makeAPICall?) and use generic types to indicate which class's static methods to reference
37 | *
38 | * @static
39 | * @memberof ClassClockService
40 | */
41 | static validateResponse = async (
42 | call: Promise,
43 | onError?: (error: Error) => void
44 | ): Promise => {
45 |
46 | const promise = call.then((response: Response) => {
47 | if (response.ok) {
48 | return ClassClockService.handleBodyConversion(response)
49 | }
50 | //response was successful, but received a less-than-desirable response code
51 | else if (response.status == 429) { //rate-limited
52 | const secondsToWait = parseRateLimitTime(response) || 1
53 | throw new RateLimitError("A rate limit was reached", secondsToWait * 1000)
54 | }
55 | });
56 | return promiseRetry(promise).catch(error => {
57 | onError ? onError(error.message) : console.error(error);
58 | throw error
59 | });
60 | };
61 |
62 | //this is mostly here to make the response handling more generic
63 | private static handleBodyConversion = (response: Response) => response.json()
64 |
65 | static updateBellSchedule = async (
66 | schedule: BellSchedule,
67 | authToken: string
68 | ) => {
69 | return await fetch(
70 | ClassClockService.baseURL +
71 | "/bellschedule/" +
72 | schedule.getIdentifier() +
73 | "/",
74 | ClassClockService.getHeaders("PATCH", authToken, {
75 | body: JSON.stringify(schedule, ClassClockService.jsonifyReplacer)
76 | })
77 | );
78 | // return await response.json(); // parses JSON response into native JavaScript objects
79 |
80 | }
81 |
82 | static makeAPICall = (method: string, url: string, authToken?: string, params?: object ) => {
83 | return fetch(url, ClassClockService.getHeaders(method, authToken, params))
84 | }
85 |
86 | //sets up request headers for outgoing API calls
87 | private static getHeaders = (
88 | method: string,
89 | authToken?: string,
90 | params?: object
91 | ): { method: string; headers: Headers } => {
92 |
93 | let headers = new Headers({
94 | Accept: "application/json",
95 | "Content-Type": "application/json"
96 | })
97 |
98 | if (authToken) {
99 | headers.append("Authorization", "Bearer " + authToken)
100 | }
101 |
102 | return Object.assign(
103 | {},
104 | {
105 | method,
106 | headers: headers
107 | },
108 | params
109 | );
110 | };
111 |
112 | // prepares an object for being sent to the ClassClock API
113 | private static jsonifyReplacer(key: string, value: any) {
114 | console.log(key, value)
115 | if (key == 'date') {
116 | return value.toFormat('YYYY-MM-DD');
117 | } else if (key == 'dates') {
118 |
119 | return value.map((currentValue: DateTime) => {
120 | return currentValue.toFormat('YYYY-MM-DD');
121 | })
122 | } else if (Object.prototype.toString.call(value) === '[object Array]') {
123 |
124 | var copyArray: any[] = [];
125 |
126 | for (const element in copyArray) {
127 | if (typeof element === 'object') {
128 | copyArray.push(objectKeysToSnakeCase(element));
129 | } else {
130 | copyArray.push(element);
131 | }
132 | }
133 |
134 | return copyArray;
135 | } else if (typeof value === 'object') {
136 | return objectKeysToSnakeCase(value);
137 | }
138 | return value;
139 | }
140 |
141 |
142 | /**
143 | * performs a HEAD request to the baseURL to confirm that the domain is reachable
144 | *
145 | * based on https://stackoverflow.com/a/44766737
146 | * @static
147 | * @returns true if the service is reachable, false otherwise
148 | * @memberof ClassClockService
149 | */
150 | public static async isReachable() {
151 | // TODO: handle request redirects across this whole file
152 | const headers = ClassClockService.getHeaders("HEAD", undefined, {mode: 'no-cors'})
153 | return await fetch(ClassClockService.baseURL + "/ping/", headers).then((resp) => {
154 | return resp && (resp.ok || resp.type === 'opaque');
155 | }).catch((err) => {
156 | console.warn('[conn test failure]:', err);
157 | return false
158 | });
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/store/schools/actions.test.ts:
--------------------------------------------------------------------------------
1 | import * as actions from "./actions";
2 | import * as types from './types';
3 | import configureMockStore from "redux-mock-store";
4 | import thunk, { ThunkDispatch } from "redux-thunk";
5 | import fetchMock from "fetch-mock";
6 | import { fakeSchoolResponse, fakeBellScheduleListResponse, bellScheduleId, fakeBellScheduleFullResponse, school, schoolId, schoolEndpoint } from "../../utils/testconstants";
7 |
8 |
9 | // import { ISchoolsState } from "./types";
10 | import { AnyAction } from "redux";
11 | import ClassClockService from "../../services/classclock";
12 |
13 | type DispatchExts = ThunkDispatch;
14 |
15 | const middleware = [thunk];
16 | const mockStore = configureMockStore(middleware);
17 |
18 | // const middlewares = [thunk];
19 | // const mockStore = (middlewares);
20 |
21 | //see: https://redux.js.org/recipes/writing-tests
22 |
23 | describe("school async actions", () => {
24 | afterEach(() => {
25 | fetchMock.restore();
26 | });
27 |
28 | it("creates RECEIVE_SCHOOL when fetching schools has been done", () => {
29 | fetchMock
30 | .getOnce(schoolEndpoint, {
31 | body: fakeSchoolResponse,
32 | headers: { "Content-Type": "application/json" }
33 | })
34 | .getOnce(ClassClockService.baseURL + "/bellschedules/" + schoolId + "/", {
35 | body: fakeBellScheduleListResponse,
36 | headers: { "Content-Type": "application/json" }
37 | })
38 | .getOnce(ClassClockService.baseURL +
39 | "/bellschedule/" +
40 | bellScheduleId +
41 | "/",
42 | {
43 | body: fakeBellScheduleFullResponse,
44 | headers: { "Content-Type": "application/json" }
45 | }
46 | );
47 |
48 | const expectedActions = [
49 | { type: types.SELECT_SCHOOL },
50 | { type: types.RECEIVE_SCHOOL, school, receivedAt: 1234 }
51 | ];
52 | const store = mockStore();
53 | /*{
54 | selectedSchool: {
55 | isFetching: false,
56 | didInvalidate: false,
57 | data: {}
58 | }
59 | }*/
60 |
61 | return store.dispatch(actions.selectSchool(schoolId)).then(() => {
62 | // return of async actions
63 | expect(fetchMock.done()).toBe(true);
64 | expect(store.getActions()).toEqual(expectedActions);
65 | });
66 | });
67 | });
68 |
69 |
70 | /* it("should create an action to select a school", () => {
71 | const expectedAction = {
72 | type: types.SELECT_SCHOOL,
73 | };
74 | expect(actions.selectSchool()).toEqual(expectedAction);
75 | });
76 | });
77 |
78 |
79 |
80 |
81 |
82 | describe("async actions", () => {*/
--------------------------------------------------------------------------------
/src/store/schools/actions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SELECT_SCHOOL,
3 | SchoolActionTypes,
4 | FETCH_ERROR,
5 | RECEIVE_SCHOOL,
6 | REQUEST_SCHOOL,
7 | INVALIDATE_SCHOOL,
8 | REQUEST_SCHOOL_LIST,
9 | SchoolListActionTypes,
10 | INVALIDATE_SCHOOL_LIST,
11 | RECEIVE_SCHOOL_LIST
12 | } from "./types";
13 | import { Dispatch } from "redux";
14 | import ClassClockService from "../../services/classclock";
15 | import School from "../../@types/school";
16 | import { DateTime } from "luxon";
17 |
18 | function requestSchool(): SchoolActionTypes {
19 | return {
20 | type: SELECT_SCHOOL
21 | };
22 | }
23 |
24 | export function invalidateSchool(): SchoolActionTypes {
25 | return {
26 | type: INVALIDATE_SCHOOL
27 | };
28 | }
29 |
30 | function receiveSchool(json: any): SchoolActionTypes {
31 | return {
32 | type: RECEIVE_SCHOOL,
33 | school: School.fromJson(json),
34 | receivedAt: DateTime.local().toMillis()
35 | };
36 | }
37 |
38 | function fetchError(message: string): SchoolActionTypes {
39 | return {
40 | type: FETCH_ERROR,
41 | message
42 | };
43 | }
44 |
45 | export function selectSchool(schoolId: string) {
46 | return async (dispatch: Dispatch) => {
47 | dispatch(requestSchool());
48 |
49 | const onError = (error: Error) => {
50 | console.log("Caught an error: ", error.message);
51 | dispatch(fetchError(error.message));
52 | };
53 |
54 | const school = ClassClockService.validateResponse(
55 | ClassClockService.getSchool(schoolId),
56 | onError
57 | );
58 |
59 | const schedules = ClassClockService.validateResponse(
60 | ClassClockService.getSchedulesListForSchool(schoolId),
61 | onError
62 | );
63 |
64 | Promise.all([school, schedules]).then(
65 | (result: any) => {
66 | const [schoolResult, scheduleResult] = result;
67 |
68 | //result = [school() result, schedules() result]
69 | //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all#Using_Promise.all
70 |
71 | schoolResult.data.schedules = scheduleResult.data;
72 | dispatch(
73 | receiveSchool(schoolResult.data)
74 | );
75 | },
76 | (error: Error) => onError(error)
77 | );
78 | };
79 | }
80 |
81 | function requestSchoolList(): SchoolListActionTypes {
82 | return {
83 | type: REQUEST_SCHOOL_LIST
84 | };
85 | }
86 |
87 | export function invalidateSchoolList(): SchoolListActionTypes {
88 | return {
89 | type: INVALIDATE_SCHOOL_LIST
90 | };
91 | }
92 |
93 | function receiveSchoolList(json: any): SchoolListActionTypes {
94 | return {
95 | type: RECEIVE_SCHOOL_LIST,
96 | schools: json.map((sch: string) => School.fromJson(sch)),
97 | receivedAt: DateTime.local().toMillis()
98 | };
99 | }
100 |
101 | //TODO: Reduce duplication somehow
102 | export function getSchoolsList(abortSignal?: AbortSignal) {
103 | return async (dispatch: Dispatch) => {
104 | dispatch(requestSchoolList());
105 |
106 | const onError = (error: Error) => {
107 | console.log("Caught an error: ", error.message);
108 | if (error.message) dispatch(fetchError(error.message));
109 | dispatch(invalidateSchoolList());
110 | };
111 |
112 | const schoolList = ClassClockService.validateResponse(
113 | ClassClockService.getSchoolsList(
114 | abortSignal ? {signal: abortSignal}: undefined
115 | ),
116 | onError
117 | );
118 |
119 | schoolList.then(
120 | (result: any) => {
121 | dispatch(
122 | receiveSchoolList(result.data)
123 | );
124 | },
125 | (error: Error) => onError(error)
126 | );
127 | };
128 | }
--------------------------------------------------------------------------------
/src/store/schools/reducer.test.ts:
--------------------------------------------------------------------------------
1 | import { selectedSchoolReducer } from "./reducer";
2 | import * as types from "./types";
3 | import { school } from "../../utils/testconstants";
4 |
5 | describe("school reducer", () => {
6 |
7 | it("should return the initial state", () => {
8 | expect(selectedSchoolReducer(undefined, {
9 | type: "FETCH_ERROR", // this activates the default case in the selectedSchoolReducer... maybe not the best way to test it...
10 | message: ""
11 | })).toEqual({
12 | isFetching: false,
13 | data: {}
14 | });
15 | });
16 |
17 | it("should handle SELECT_SCHOOL", () => {
18 | expect(
19 | selectedSchoolReducer(undefined, {
20 | type: types.SELECT_SCHOOL
21 | })
22 | ).toEqual({
23 | isFetching: true,
24 | data: {}
25 | });
26 | });
27 |
28 |
29 | it("should handle RECEIVE_SCHOOL", () => {
30 | expect(
31 | selectedSchoolReducer(undefined, {
32 | type: types.RECEIVE_SCHOOL,
33 | school,
34 | receivedAt: 1234
35 | })
36 | ).toEqual({
37 | isFetching: false,
38 | data: school,
39 | lastUpdated: 1234
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/store/schools/reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SELECT_SCHOOL,
3 | SchoolActionTypes,
4 | FETCH_ERROR,
5 | RECEIVE_SCHOOL,
6 | INVALIDATE_SCHOOL,
7 | SchoolListActionTypes,
8 | INVALIDATE_SCHOOL_LIST,
9 | RECEIVE_SCHOOL_LIST,
10 | REQUEST_SCHOOL_LIST
11 | // ISchoolsByIdState
12 | } from "./types";
13 |
14 | export function selectedSchoolReducer(
15 | state = {
16 | isFetching: false,
17 | data: undefined
18 | },
19 | action: SchoolActionTypes
20 | ) {
21 | switch (action.type) {
22 | case SELECT_SCHOOL:
23 | return Object.assign({}, state, {
24 | isFetching: true
25 | });
26 |
27 | case RECEIVE_SCHOOL:
28 | return Object.assign({}, state, {
29 | isFetching: false,
30 | data: action.school,
31 | lastUpdated: action.receivedAt
32 | });
33 |
34 | case INVALIDATE_SCHOOL:
35 | return Object.assign({}, state, {
36 | isFetching: false,
37 | data: {}
38 | });
39 |
40 | default:
41 | return state;
42 | }
43 | }
44 |
45 | export function schoolListReducer(
46 | state = {
47 | isFetching: false,
48 | data: []
49 | },
50 | action: SchoolListActionTypes
51 | ) {
52 | switch (action.type) {
53 | case REQUEST_SCHOOL_LIST:
54 | return Object.assign({}, state, {
55 | isFetching: true
56 | });
57 |
58 | case RECEIVE_SCHOOL_LIST:
59 | return Object.assign({}, state, {
60 | isFetching: false,
61 | data: action.schools,
62 | lastUpdated: action.receivedAt
63 | });
64 |
65 | case INVALIDATE_SCHOOL_LIST:
66 | return Object.assign({}, state, {
67 | isFetching: false,
68 | data: []
69 | });
70 |
71 | default:
72 | return state;
73 | }
74 | }
75 |
76 | export function fetchErrorReducer(state = "", action: SchoolActionTypes) {
77 | if (action.type === FETCH_ERROR) {
78 | return action.message;
79 | } else {
80 | return state;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/store/schools/types.ts:
--------------------------------------------------------------------------------
1 | import School from "../../@types/school";
2 |
3 | //API actions
4 | export const REQUEST_SCHOOL = "FETCH_SCHOOL";
5 | export const REQUEST_SCHOOL_LIST = "FETCH_SCHOOL_LIST";
6 | export const RECEIVE_SCHOOL = "RECEIVE_SCHOOL";
7 | export const RECEIVE_SCHOOL_LIST = "RECEIVE_SCHOOL_LIST";
8 |
9 | export const FETCH_ERROR = "FETCH_ERROR";
10 |
11 | // UI actions
12 | export const SELECT_SCHOOL = "SELECT_SCHOOL";
13 | export const LIST_SCHOOLS = "LIST_SCHOOLS";
14 | export const INVALIDATE_SCHOOL = "INVALIDATE_SCHOOL";
15 | export const INVALIDATE_SCHOOL_LIST = "INVALIDATE_SCHOOL_LIST";
16 |
17 | export interface ISchoolsState {
18 | selectedSchool: SelectedSchoolState;
19 | }
20 |
21 | export type SelectedSchoolState = SelectedSchoolData & SchoolMeta
22 |
23 | export interface SelectedSchoolData {
24 | data: School;
25 | }
26 |
27 | export interface SchoolMeta {
28 | isFetching: boolean;
29 | didInvalidate: boolean;
30 | lastUpdated: number
31 | }
32 |
33 | interface ISelectSchoolAction {
34 | type: typeof SELECT_SCHOOL;
35 | }
36 |
37 | interface IReceiveSchoolAction {
38 | type: typeof RECEIVE_SCHOOL;
39 | school: School;
40 | receivedAt: number;
41 | }
42 |
43 | interface IRequestSchoolAction {
44 | type: typeof REQUEST_SCHOOL;
45 | }
46 |
47 | interface IInvalidateSchoolAction {
48 | type: typeof INVALIDATE_SCHOOL;
49 | }
50 |
51 | interface IFetchErrorAction {
52 | type: typeof FETCH_ERROR;
53 | message: string;
54 | }
55 |
56 | // interface DeleteMessageAction {
57 | // type: typeof DELETE_MESSAGE;
58 | // meta: {
59 | // timestamp: number;
60 | // };
61 | // }
62 |
63 | export type SchoolActionTypes =
64 | | ISelectSchoolAction
65 | | IReceiveSchoolAction
66 | | IRequestSchoolAction
67 | | IInvalidateSchoolAction
68 | | IFetchErrorAction;
69 | // | IReceiveSchoolsAction; // | DeleteMessageAction;
70 |
71 |
72 | export interface ISchoolListState {
73 | schoolList: SchoolListState;
74 | }
75 |
76 | export type SchoolListState = SchoolListData & SchoolMeta
77 |
78 | export interface SchoolListData {
79 | data: School[];
80 | }
81 |
82 | interface IListSchoolsAction {
83 | type: typeof LIST_SCHOOLS;
84 | }
85 |
86 | interface IReceiveSchoolListAction {
87 | type: typeof RECEIVE_SCHOOL_LIST;
88 | schools: School[];
89 | receivedAt: number;
90 | }
91 |
92 | interface IRequestSchoolListAction {
93 | type: typeof REQUEST_SCHOOL_LIST;
94 | }
95 |
96 | interface IInvalidateSchoolListAction {
97 | type: typeof INVALIDATE_SCHOOL_LIST;
98 | }
99 |
100 | export type SchoolListActionTypes =
101 | | IListSchoolsAction
102 | | IReceiveSchoolListAction
103 | | IRequestSchoolListAction
104 | | IInvalidateSchoolListAction;
105 |
--------------------------------------------------------------------------------
/src/store/store.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers, applyMiddleware, createStore } from "redux";
2 | import { routerReducer, routerMiddleware } from "redux-first-routing";
3 | import { createBrowserHistory, History } from 'history';
4 | import thunk from "redux-thunk";
5 | import { persistStore, persistReducer } from "redux-persist";
6 | import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
7 | import logger from "redux-logger";
8 |
9 | import { selectedSchoolReducer, fetchErrorReducer, schoolListReducer } from "./schools/reducer";
10 | import SchoolTransform from "../utils/typetransform";
11 | import { userSettingsReducer } from "./usersettings/reducer";
12 |
13 |
14 | // Create the history object
15 | export const history:History = createBrowserHistory();
16 |
17 | //connect the data provider to the REST endpoint
18 |
19 | const persistConfig = {
20 | key: "root",
21 | storage,
22 | transforms: [SchoolTransform],
23 | blacklist: ["router", "error"]
24 | };
25 |
26 | export const configureStore = (hist: any, initialState = {}) => {
27 | // Add the reducer, which adds location state to the store
28 | const rootReducer = combineReducers({
29 | selectedSchool: selectedSchoolReducer,
30 | schoolList: schoolListReducer,
31 | userSettings: userSettingsReducer,
32 | error: fetchErrorReducer,
33 | router: routerReducer // Convention is to use the "router" property
34 | });
35 |
36 | const persistedReducer = persistReducer(persistConfig, rootReducer);
37 | // Create the store
38 | const store = createStore(
39 | persistedReducer,
40 | initialState,
41 | applyMiddleware(logger, routerMiddleware(hist), thunk)
42 | );
43 |
44 | const persistor = persistStore(store);
45 | return { store, persistor };
46 | };
47 |
48 |
49 | // Create the store, passing it the history object
50 | export const configuredStore = configureStore(history); //createStore(combineReducers(reducers), applyMiddleware(thunk));
51 |
--------------------------------------------------------------------------------
/src/store/usersettings/actions.ts:
--------------------------------------------------------------------------------
1 | import { UserSettingActionTypes, SET_TIME_FORMAT } from "./types";
2 |
3 | export function setTimeFormatPreference(use24HourTime: boolean): UserSettingActionTypes {
4 | return {
5 | type: SET_TIME_FORMAT,
6 | use24HourTime
7 | };
8 | }
9 |
--------------------------------------------------------------------------------
/src/store/usersettings/reducer.ts:
--------------------------------------------------------------------------------
1 | import { UserSettingActionTypes, SET_TIME_FORMAT } from "./types";
2 |
3 | export function userSettingsReducer(
4 | state = {
5 | use24HourTime: false
6 | },
7 | action: UserSettingActionTypes
8 | ) {
9 | switch (action.type) {
10 | case SET_TIME_FORMAT:
11 | return Object.assign({}, state, {
12 | use24HourTime: action.use24HourTime
13 | });
14 | default:
15 | return state;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/store/usersettings/types.ts:
--------------------------------------------------------------------------------
1 | // UI actions
2 | export const SET_TIME_FORMAT = "SET_TIME_FORMAT";
3 |
4 | export interface ISettingsState {
5 | userSettings: IUserSettings
6 | }
7 | export interface IUserSettings {
8 | use24HourTime: boolean;
9 | };
10 |
11 | interface ISetTimeFormatAction {
12 | type: typeof SET_TIME_FORMAT;
13 | use24HourTime: boolean;
14 | }
15 |
16 | // interface IReceiveSchoolAction {
17 | // type: typeof RECEIVE_SCHOOL;
18 | // school: School;
19 | // receivedAt: number;
20 | // }
21 |
22 | // interface IRequestSchoolAction {
23 | // type: typeof REQUEST_SCHOOL;
24 | // }
25 |
26 | // interface IFetchErrorAction {
27 | // type: typeof FETCH_ERROR;
28 | // message: string;
29 | // }
30 |
31 | // // interface DeleteMessageAction {
32 | // // type: typeof DELETE_MESSAGE;
33 | // // meta: {
34 | // // timestamp: number;
35 | // // };
36 | // // }
37 |
38 | export type UserSettingActionTypes = ISetTimeFormatAction;
39 |
--------------------------------------------------------------------------------
/src/utils/IPageInterface.ts:
--------------------------------------------------------------------------------
1 | export default interface IPageInterface {
2 | dispatch: any;
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/constants.ts:
--------------------------------------------------------------------------------
1 |
2 | export const pages = {
3 | main: "/",
4 | fullSchedule: "/schedule",
5 | settings: "/settings",
6 | fullScheduleLegacy: "/schedule.html",
7 | settingsLegacy: "/settings.html",
8 | selectSchool: "/select",
9 | loginCallback: "/admin/callback",
10 | welcome: "/welcome",
11 | admin: "/admin",
12 | adminBellSchedule: "/admin/bellschedule"
13 | };
14 |
15 | export const URLs = {
16 | github: "https://github.com/MoralCode/ClassClock",
17 | slack:
18 | "https://join.slack.com/t/classclock/shared_invite/enQtNTE0MDkyNzAwNzU3LWNhMGUwODU2ZjhkYTYxMTgzNDE1OWEyMGY2OGNiNTBhOWM5NDVhZGUzNDVlNzRiZTE3NTNmODFjYWNkNDhmMDU",
19 | twitter: "https://twitter.com/classclockapp",
20 | instagram: "https://www.instagram.com/classclockapp/",
21 | bluesky: "https://bsky.app/profile/did:plc:7nnfhmgll4buy4za6uyhlpjg",
22 | discord: "https://discord.classclock.app"
23 | };
24 |
--------------------------------------------------------------------------------
/src/utils/enums.ts:
--------------------------------------------------------------------------------
1 | export enum TimeStates {
2 | DAY_OFF = "day off",
3 | OUTSIDE_SCHOOL_HOURS = "outside school hours",
4 | SCHOOL_IN_CLASS_OUT = "school is in session, but class is not",
5 | CLASS_IN_SESSION = "class is in session"
6 | }
7 |
8 | export enum TimeComparisons {
9 | IS_BEFORE = -1, //"before",
10 | IS_DURING_OR_EXACTLY = 0, //"during/exactly",
11 | IS_AFTER = 1 //"after"
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | // errors.ts
2 | // This file contains classes that represent error conditions
3 | // @author Adrian Edwards
4 |
5 |
6 |
7 | /**
8 | * This custom Error class is intened to be used for passing a minimum wait
9 | * time to the .catch() handlers responsible for retrying requests so requests
10 | * arent made during a rate-limit coolown period
11 | *
12 | * This class was created with the help of https://javascript.info/custom-errors
13 | *
14 | * @export
15 | * @class RateLimitError
16 | * @extends {Error}
17 | */
18 | export class RateLimitError extends Error {
19 | wait: number;
20 | constructor(message:string, msToWait: number) {
21 | super(message);
22 | this.name = "RateLimitError";
23 | this.wait = msToWait;
24 | }
25 | }
--------------------------------------------------------------------------------
/src/utils/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getValueIfKeyInList,
3 | sortClassesByStartTime,
4 | getTimeStateForDateAtSchool,
5 | checkTimeRange
6 | } from "./helpers";
7 | import { beforeSchoolHours, school, betweenClass, inClass, noSchool, afterSchoolHours, bellScheduleClasses, duringClass, startTime, endTime, startTime2, beforeClass, endTime2, afterClass } from "./testconstants";
8 | import { TimeStates, TimeComparisons } from "./enums";
9 |
10 | test("get value if key in list", () => {
11 | const object1 = { value1: "foo" };
12 | const object2 = { value_1: "foo" };
13 | const list = ["value_1", "value1"];
14 |
15 | expect(getValueIfKeyInList(list, object1)).toBe("foo");
16 | expect(getValueIfKeyInList(list, object2)).toBe("foo");
17 | expect(getValueIfKeyInList(["doesNotExist"], object1)).toBeFalsy();
18 | });
19 |
20 | //ignoring getCurrentDate
21 |
22 | test("sort classes by start time", () => {
23 | expect(sortClassesByStartTime(bellScheduleClasses.reverse())[0]).toEqual(bellScheduleClasses[0]);
24 | expect(sortClassesByStartTime(bellScheduleClasses.reverse())[1]).toEqual(bellScheduleClasses[1]);
25 | expect(sortClassesByStartTime(bellScheduleClasses.reverse())[0].getName()).toEqual("First Period");
26 | });
27 |
28 |
29 | test("get time states for given date and school", () => {
30 | expect(getTimeStateForDateAtSchool(beforeSchoolHours, school)).toBe(
31 | TimeStates.OUTSIDE_SCHOOL_HOURS
32 | );
33 |
34 | expect(getTimeStateForDateAtSchool(noSchool, school)).toBe(
35 | TimeStates.DAY_OFF
36 | );
37 |
38 | expect(getTimeStateForDateAtSchool(betweenClass, school)).toBe(
39 | TimeStates.SCHOOL_IN_CLASS_OUT
40 | );
41 |
42 | expect(getTimeStateForDateAtSchool(inClass, school)).toBe(
43 | TimeStates.CLASS_IN_SESSION
44 | );
45 |
46 | expect(getTimeStateForDateAtSchool(afterSchoolHours, school)).toBe(
47 | TimeStates.OUTSIDE_SCHOOL_HOURS
48 | );
49 |
50 | });
51 |
52 | test("check time range", () => {
53 |
54 | expect(checkTimeRange(duringClass, startTime, endTime)).toBe(
55 | TimeComparisons.IS_DURING_OR_EXACTLY
56 | );
57 |
58 | expect(checkTimeRange(duringClass, endTime, startTime2)).toBe(
59 | TimeComparisons.IS_BEFORE
60 | );
61 |
62 | expect(checkTimeRange(beforeClass, startTime, endTime2)).toBe(
63 | TimeComparisons.IS_BEFORE
64 | );
65 |
66 | expect(checkTimeRange(startTime, startTime, endTime)).toBe(
67 | TimeComparisons.IS_DURING_OR_EXACTLY
68 | );
69 |
70 | expect(checkTimeRange(endTime, startTime, endTime)).toBe(
71 | TimeComparisons.IS_DURING_OR_EXACTLY
72 | );
73 |
74 | expect(checkTimeRange(afterClass, startTime, endTime)).toBe(
75 | TimeComparisons.IS_AFTER
76 | );
77 |
78 | expect(checkTimeRange(afterClass, startTime, endTime2)).toBe(
79 | TimeComparisons.IS_DURING_OR_EXACTLY
80 | );
81 | });
82 |
83 | test("check time range with start and end swapped", () => {
84 |
85 | expect(checkTimeRange(duringClass, endTime, startTime)).toBe(
86 | TimeComparisons.IS_DURING_OR_EXACTLY
87 | );
88 |
89 | expect(checkTimeRange(duringClass, startTime2, endTime)).toBe(
90 | TimeComparisons.IS_BEFORE
91 | );
92 |
93 | expect(checkTimeRange(beforeClass, endTime2, startTime)).toBe(
94 | TimeComparisons.IS_BEFORE
95 | );
96 |
97 | expect(checkTimeRange(startTime, endTime, startTime)).toBe(
98 | TimeComparisons.IS_DURING_OR_EXACTLY
99 | );
100 |
101 | expect(checkTimeRange(endTime, endTime, startTime)).toBe(
102 | TimeComparisons.IS_DURING_OR_EXACTLY
103 | );
104 |
105 | expect(checkTimeRange(afterClass, endTime, startTime)).toBe(
106 | TimeComparisons.IS_AFTER
107 | );
108 |
109 | expect(checkTimeRange(afterClass, endTime2, startTime)).toBe(
110 | TimeComparisons.IS_DURING_OR_EXACTLY
111 | );
112 | });
--------------------------------------------------------------------------------
/src/utils/helpers.tsx:
--------------------------------------------------------------------------------
1 | import { DateTime } from "luxon";
2 | import School from "../@types/school";
3 | //todo, replace timeComparisons with luxon Interval
4 | import { TimeComparisons, TimeStates } from "./enums";
5 | import ClassPeriod from "../@types/classperiod";
6 | import { useState } from "react";
7 | import { RateLimitError } from "./errors";
8 | import Time from "../@types/time";
9 |
10 | //https://stackoverflow.com/a/55862077
11 | export const useForceUpdate = () => {
12 | const [, setTick] = useState(0);
13 | const update = () => {
14 | setTick((tick: number) => tick + 1);
15 | };
16 | return update;
17 | };
18 |
19 | export function getValueIfKeyInList(list: string[], object: any) {
20 | for (const key of list) {
21 | if (object.hasOwnProperty(key)) {
22 | return object[key];
23 | }
24 | }
25 | }
26 |
27 | export function objectKeysToSnakeCase(object: object) {
28 | let copyObject: any = Object.assign({}, object);
29 |
30 | //iterate over object
31 | for (const [objKey, objValue] of Object.entries(copyObject)) {
32 | // if (value.hasOwnProperty(objKey))
33 | const snake = toSnakeCase(objKey);
34 | if (objKey !== snake) {
35 | copyObject[snake] = objValue;
36 | delete copyObject[objKey];
37 | }
38 | }
39 | return copyObject;
40 | }
41 |
42 | // https://stackoverflow.com/a/54246525/
43 | export function toSnakeCase(input: string) {
44 | // https://stackoverflow.com/a/55521416/
45 | const ALPHA = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
46 |
47 | function isAlpha(char: string) {
48 | return ALPHA.has(char);
49 | }
50 |
51 | return input.split('').map((character, index) => {
52 | if (character == character.toUpperCase() && isAlpha(character)) {
53 | return (index != 0 ? '_': '') + character.toLowerCase();
54 | } else {
55 | return character;
56 | }
57 | }).join('');
58 | }
59 |
60 | export function getCurrentDate() {
61 | return DateTime.local();
62 | }
63 |
64 | export function sortClassesByStartTime(classes: ClassPeriod[]) {
65 | return classes.sort((a, b) => b.getStartTime().getMillisecondsTo(a.getStartTime()));
66 | }
67 |
68 | /**
69 | * @returns a flag that represents the current chunk of time categorically
70 | */
71 | export function getTimeStateForDateAtSchool(date: DateTime, school: School) {
72 | const currentBellSchedule = school.getScheduleForDate(date);
73 |
74 | //there is no schedule that applies today
75 | if (!currentBellSchedule) {
76 | return TimeStates.DAY_OFF;
77 | }
78 |
79 | const currentClassPeriod = currentBellSchedule.getClassPeriodForTime(date, school.getTimezone());
80 |
81 | //it is a school day but it is not school hours
82 | if (!school.isInSession(date)) {
83 | return TimeStates.OUTSIDE_SCHOOL_HOURS;
84 | }
85 |
86 | //the current time lies between the start of the first schedules class and the end of the last
87 | else if (school.isInSession(date) && currentClassPeriod === undefined) {
88 | return TimeStates.SCHOOL_IN_CLASS_OUT;
89 | }
90 |
91 | //the current time lies within a scheduled class period
92 | else if (currentClassPeriod !== undefined) {
93 | return TimeStates.CLASS_IN_SESSION;
94 | }
95 | }
96 |
97 | /**
98 | * This export function checks if the current time is between the two given times
99 | * This is useful for checking which class period you are currently in or for checking if school is in session.
100 | *
101 | * @param {*} checkTime the time that the check results are returned for
102 | * @param {*} startTime the start time of the range to check
103 | * @param {*} endTime the end time of the range to check
104 | *
105 | * @returns -1 if checkTime is before range, 0 if checkTime is within range, 1 if checkTime is after range
106 | */
107 | export function checkTimeRange(checkTime: Time, startTime: Time, endTime: Time): TimeComparisons {
108 |
109 | // swap the values if startTime is after end time
110 | if (startTime.isAfter(endTime)) {
111 | let t = startTime
112 | startTime = endTime
113 | endTime = t
114 | }
115 |
116 | if (checkTime.isBefore(startTime)) {
117 | return TimeComparisons.IS_BEFORE;
118 | } else if (checkTime.isAfter(endTime)) {
119 | return TimeComparisons.IS_AFTER;
120 | } else {
121 | return TimeComparisons.IS_DURING_OR_EXACTLY
122 | }
123 | }
124 |
125 |
126 | /**
127 | * Calculates the larger of either the exponential backoff or the given minimum delay
128 | *
129 | * modified from https://medium.com/swlh/retrying-and-exponential-backoff-with-promises-1486d3c259
130 | * @export
131 | * @param {number} retryCount the number of times the request has already been re-tried
132 | * @param {number} [minDelay=0] the minimum amount of delay
133 | * @returns a number that can be used as the delay (units are arbitrary)
134 | */
135 | export function calculateDelay(retryCount: number, minDelay:number = 0) {
136 | //exponential backoff
137 | return Math.max(10 ** retryCount, minDelay)
138 | }
139 |
140 | /**
141 | * Delays by the given number of milliseconds
142 | *
143 | * modified from https://medium.com/swlh/retrying-and-exponential-backoff-with-promises-1486d3c259
144 | * @export
145 | * @param {number} duration the number of milliseconds to delay
146 | * @returns
147 | */
148 | export const delay = async (duration:number) =>
149 | new Promise(resolve => setTimeout(resolve, duration));
150 |
151 | /**
152 | * Retries a promise a given number of times if it rejects
153 | *
154 | * based on https://dev.to/ycmjason/javascript-fetch-retry-upon-failure-3p6g
155 | * @export
156 | * @param {Promise} promise the promise to retry on rejection
157 | * @param {number} [maxRetries=5] the maximum number of times to retry before giving up
158 | * @param {number} [tryCount=1] the number of tries that have already been attempted
159 | * @param {number} [minimumWait=100] the minimum amount of time to wait between requests in milleseconds
160 | * @returns {Promise} the given promise with the capability to retry in case it rejects
161 | */
162 | export async function promiseRetry(promise: Promise, maxRetries = 5, tryCount=1, minimumWait = 100): Promise {
163 | return promise.catch(async function (error) {
164 | if (maxRetries === tryCount) throw error;
165 | if (error instanceof RateLimitError) {
166 | minimumWait = error.wait
167 | }
168 | await delay(calculateDelay(tryCount, minimumWait))
169 | return promiseRetry(promise, maxRetries, tryCount + 1);
170 | });
171 | }
172 |
173 | /**
174 | * Calculates how long to wait before sending retrying request when receiving 429 Too Many Requests
175 | *
176 | * see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
177 | *
178 | * @export
179 | * @param {Response} response the 429 response to calculate the delay for
180 | * @returns the number of seconds to wait, or undefined if the Retry-After header is not present
181 | */
182 | export function parseRateLimitTime(response: Response) {
183 | const after = response.headers.get("Retry-After")
184 |
185 | if (!after) return
186 |
187 | //try to parse it as an integer
188 | let secondsToWait = parseInt(after)
189 | if (isNaN(secondsToWait)) {
190 | //if number parsing failed, parse as date
191 |
192 | const date = new Date(secondsToWait)
193 |
194 | const now = new Date()
195 | //round the time down by zeroing milliseconds
196 | now.setMilliseconds(0)
197 |
198 | secondsToWait = (date.getTime() - now.getTime())/1000
199 | }
200 | return secondsToWait
201 | }
202 |
--------------------------------------------------------------------------------
/src/utils/routes.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import App from "../pages/App";
3 | import Schedule from "../pages/Schedule";
4 | import Settings from "../pages/Settings/Settings";
5 | import SchoolSelect from "../pages/SchoolSelect";
6 | import { pages } from "./constants";
7 | import Welcome from "../pages/Welcome";
8 | import AdminPage from "../pages/Admin/AdminPage";
9 |
10 | export const routes = [
11 | {
12 | path: pages.main,
13 | action: () =>
14 | },
15 | {
16 | path: pages.fullSchedule,
17 | action: () =>
18 | },
19 | {
20 | path: pages.settings,
21 | action: () =>
22 | },
23 | {
24 | path: pages.fullScheduleLegacy,
25 | action: () =>
26 | },
27 | {
28 | path: pages.settingsLegacy,
29 | action: () =>
30 | },
31 | {
32 | path: pages.selectSchool,
33 | action: () =>
34 | },
35 | {
36 | path: pages.welcome,
37 | action: () =>
38 | },
39 | {
40 | path: pages.admin,
41 | children: [],
42 | action: () =>
43 | },
44 | {
45 | path: pages.loginCallback,
46 | action: () => Redirecting...
47 | }
48 | ];
49 |
--------------------------------------------------------------------------------
/src/utils/typetransform.ts:
--------------------------------------------------------------------------------
1 | import { createTransform } from "redux-persist";
2 | import { ISchoolsState } from "../store/schools/types";
3 | import School from "../@types/school";
4 |
5 | const SchoolTransform = createTransform(
6 | // transform state on its way to being serialized and persisted.
7 | (inboundState: ISchoolsState, key: any) => {
8 | // convert mySet to an Array.
9 | return {
10 | ...inboundState,
11 | selectedSchool: JSON.stringify(inboundState.selectedSchool)
12 | };
13 | },
14 | // transform state being rehydrated
15 | (outboundState: any, key: any) => {
16 | // convert mySet back to a Set.
17 | return {
18 | ...outboundState,
19 | data: School.fromJson(outboundState.data)
20 | };
21 | },
22 | // define which reducers this transform gets called for.
23 | { whitelist: ["selectedSchool"] }
24 | );
25 |
26 | export default SchoolTransform;
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "preserve",
21 | "plugins": [
22 | {
23 | "name": "typescript-tslint-plugin"
24 | }
25 | ]
26 | },
27 | "include": [
28 | "src"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
4 | "jsRules": {},
5 | "rules": {
6 | "comment-format": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": false
9 | },
10 | "rulesDirectory": []
11 | }
12 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
--------------------------------------------------------------------------------