app/imports/api/academic-term/AcademicTermCollection.ts
import { Meteor } from 'meteor/meteor';
import SimpleSchema from 'simpl-schema';
import moment from 'moment';
import _ from 'lodash';
import { Slugs } from '../slug/SlugCollection';
import BaseSlugCollection from '../base/BaseSlugCollection';
import { AcademicTerm, AcademicTermDefine, AcademicTermUpdate } from '../../typings/radgrad';
import { RadGradProperties } from '../radgrad/RadGradProperties';
import { OpportunityInstances } from '../opportunity/OpportunityInstanceCollection';
import { CourseInstances } from '../course/CourseInstanceCollection';
/**
* Represents a specific academicTerm, such as "Spring, 2016", "Fall, 2017", or "Summer, 2015".
* @extends api/base.BaseSlugCollection
* @memberOf api/academic-term
*/
class AcademicTermCollection extends BaseSlugCollection {
public SPRING: string;
public SUMMER: string;
public FALL: string;
public WINTER: string;
public readonly terms: string[];
private readonly fallStart: number;
private readonly springStart: number;
private readonly summerStart: number;
/**
* Creates the AcademicTerm collection.
*/
constructor() {
super('AcademicTerm', new SimpleSchema({
term: { type: String },
year: { type: SimpleSchema.Integer, min: 1991, max: 2050, defaultValue: moment().year() },
termNumber: { type: Number },
slugID: { type: SimpleSchema.RegEx.Id },
retired: { type: Boolean, optional: true },
}));
this.SPRING = 'Spring';
this.SUMMER = 'Summer';
this.FALL = 'Fall';
this.WINTER = 'Winter';
// console.log(settingsDoc, Meteor.settings);
if (RadGradProperties.getQuarterSystem()) {
this.terms = [this.FALL, this.WINTER, this.SPRING, this.SUMMER];
this.fallStart = parseInt(moment('09-26-2015', 'MM-DD-YYYY').format('DDD'), 10);
this.springStart = parseInt(moment('04-01-2015', 'MM-DD-YYYY').format('DDD'), 10);
this.summerStart = parseInt(moment('06-20-2015', 'MM-DD-YYYY').format('DDD'), 10);
} else {
this.terms = [this.FALL, this.SPRING, this.SUMMER];
this.fallStart = parseInt(moment('08-15-2015', 'MM-DD-YYYY').format('DDD'), 10);
this.springStart = parseInt(moment('01-01-2015', 'MM-DD-YYYY').format('DDD'), 10);
this.summerStart = parseInt(moment('05-15-2015', 'MM-DD-YYYY').format('DDD'), 10);
}
this.defineSchema = new SimpleSchema({
term: { type: String, allowedValues: this.terms, defaultValue: this.FALL },
year: {
type: SimpleSchema.Integer,
min: moment().year() - 5,
max: moment().year() + 10,
defaultValue: moment().year(),
},
});
this.updateSchema = new SimpleSchema({
retired: Boolean,
});
}
/**
* Returns an object representing the AcademicTerm docID in a format acceptable to define().
* @param docID The docID of a Academic Term.
* @returns { Object } An object representing the definition of docID.
*/
public dumpOne(docID: string): AcademicTermDefine {
const doc = this.findDoc(docID);
const term = doc.term;
const year = doc.year;
const retired = doc.retired;
return { term, year, retired };
}
/**
* Retrieves the docID for the specified Academic Term, or defines it if not yet present.
* Implicitly defines the corresponding slug: Spring, 2016 academicTerm is "Spring-2016".
* @example
* AcademicTerms.define({ term: AcademicTerms.FALL, year: 2015 });
* @param { Object } Object with keys term, academicTerm.
* Term must be one of AcademicTerms.FALL, AcademicTerms.SPRING, or AcademicTerms.SUMMER.
* Year must be between 1990 and 2050.
* @throws { Meteor.Error } If the term or year are not correctly specified.
* @returns The docID for this academicTerm instance.
*/
public define({ term, year, retired = false }: AcademicTermDefine) {
// Check that term and year are valid.
if (this.terms.indexOf(term) < 0) {
throw new Meteor.Error(`Invalid term: ${term}`);
}
if ((year < 1990) || (year > 2050)) {
throw new Meteor.Error(`Invalid year: ${year}`);
}
// Return immediately if academicTerm is already defined.
const doc = this.collection.findOne({ term, year });
if (doc) {
return doc._id;
}
// Otherwise define a new academicTerm and add it to the collection if successful.
// Compute termNumber, another number that puts academicTerms into chronological order.
// Epoch is Fall-2010
let termNumber = 0;
const yearDiff = year - 2010;
if (RadGradProperties.getQuarterSystem()) {
if (term === this.WINTER) {
termNumber = (4 * yearDiff) - 3;
} else if (term === this.SPRING) {
termNumber = (4 * yearDiff) - 2;
} else if (term === this.SUMMER) {
termNumber = (4 * yearDiff) - 1;
} else {
termNumber = 4 * yearDiff;
}
} else if (term === this.SPRING) {
termNumber = (3 * yearDiff) - 2;
} else if (term === this.SUMMER) {
termNumber = (3 * yearDiff) - 1;
} else {
termNumber = 3 * yearDiff;
}
// Determine what the slug looks like.
const slug = `${term}-${year}`;
if (Slugs.isDefined(slug)) {
throw new Meteor.Error(`Slug is already defined for undefined AcademicTerm: ${slug}`);
}
const slugID = Slugs.define({ name: slug, entityName: 'AcademicTerm' });
const termID = this.collection.insert({ term, year, termNumber, slugID, retired });
Slugs.updateEntityID(slugID, termID);
return termID;
}
/**
* Updates the retired flag.
* @param docID the id of the academicTerm.
* @param retired optional boolean.
*/
public update(docID, { retired }: AcademicTermUpdate) {
// console.log(`AcademicTerm.update(${docID}, ${retired})`);
const updateData: AcademicTermUpdate = {};
if (_.isBoolean(retired)) {
updateData.retired = retired;
this.collection.update(docID, { $set: updateData });
}
}
/**
* Ensures the passed object is a Academic Term instance.
* @param term Should be a defined termID or academic term doc.
* @throws {Meteor.Error} If academicTerm is not a Academic Term.
*/
public assertAcademicTerm(term: string) {
if (!term || !this.isDefined(term)) {
throw new Meteor.Error(`${term} is not a valid Academic Term.`);
}
}
public findIdBySlug(slug): { optional: boolean; type: any } | string | any {
// console.log('findIdBySlug', slug);
if (this.isDefined(slug)) {
return super.findIdBySlug(slug);
}
const split = slug.split('-');
const term = split[0];
const year = parseInt(split[1], 10);
return this.define({ term, year });
}
/**
* Returns the termID associated with the current academicTerm based upon the current timestamp.
* See AcademicTerms.FALL_START_DATE, SPRING_START_DATE, and SUMMER_START_DATE.
*/
public getCurrentTermID() {
const year = moment().year();
const day = moment().dayOfYear();
let term = '';
if (RadGradProperties.getQuarterSystem()) {
if (day >= this.fallStart) {
term = this.FALL;
} else if (day >= this.summerStart) {
term = this.SUMMER;
} else if (day >= this.springStart) {
term = this.SPRING;
} else {
term = this.WINTER;
}
} else if (day >= this.fallStart) {
term = this.FALL;
} else if (day >= this.summerStart) {
term = this.SUMMER;
} else {
term = this.SPRING;
}
return this.define({ term, year });
}
public getAcademicTermFromToString(termToString: string) {
const split = termToString.split(' ');
if (split.length !== 2) {
throw new Meteor.Error('Invalid AcademicTerm toString value');
}
const term = split[0];
const year = parseInt(split[1], 10);
return this.findDoc({ term, year });
}
/**
* Returns true if the passed academicTerm occurs now or in the future.
* @param term The academic term (slug or termID).
* @returns True if academic term is in the future.
*/
public isUpcomingTerm(term: string) {
const termID = this.getID(term);
return this.findDoc(termID).termNumber >= this.getCurrentAcademicTermDoc().termNumber;
}
/**
* Returns the academicTerm doc associated with the current academicTerm based upon the current timestamp.
* See AcademicTerms.FALL_START_DATE, SPRING_START_DATE, and SUMMER_START_DATE.
*/
public getCurrentAcademicTermDoc() {
const id = this.getCurrentTermID();
return this.findDoc(id);
}
public getStartOfCurrentAcademicYearTerm(): AcademicTerm {
const currentTerm = this.getCurrentAcademicTermDoc();
const term = currentTerm.term;
const year = currentTerm.year;
if (term === this.SPRING || term === this.SUMMER) {
return this.find({ year: year - 1, term: this.FALL }).fetch()[0];
}
return this.find({ year, term: this.FALL }).fetch()[0];
}
/**
* Returns the current academic term number.
* @return {number} the current academic term number.
*/
public getCurrentAcademicTermNumber(): number {
return this.getCurrentAcademicTermDoc().termNumber;
}
/**
* Returns the academic terms for the next numYears. It returns the terms based upon academic year.
* For example if the current term is Spring 2020 and numYears is 2. This method will return [Spring 2020, Summer 2020,
* Fall 2020, Spring 2021, Summer 2021]
* @param {number} numYears
* @return {AcademicTerm[]}
*/
public getNextYears(numYears: number): AcademicTerm[] {
const numTermsPerYear = RadGradProperties.getQuarterSystem() ? 4 : 3;
const currentTermDoc = this.getCurrentAcademicTermDoc();
const currentTermNumber = currentTermDoc.termNumber;
const startOfYear = this.getStartOfCurrentAcademicYearTerm();
const diff = currentTermNumber - startOfYear.termNumber;
const after = currentTermNumber;
const before = currentTermNumber + numYears * numTermsPerYear - diff;
return this.findNonRetired({ $and: [{ termNumber: { $gte: after } }, { termNumber: { $lt: before } }] }, { sort: { termNumber: 1 } });
}
public getAcademicYearLabel(termID: string): string {
const termDoc = this.findDoc(termID);
if (termDoc.term === this.SPRING || termDoc.term === this.SUMMER) {
return `${termDoc.year - 1}-${termDoc.year}`;
}
return `${termDoc.year}-${termDoc.year + 1}`;
}
/**
* Returns the academicTerm ID corresponding to the given date.
* @param date The date as a string. Must be able to be parsed by moment();
* @returns {String} The termID that the date falls in.
*/
public getAcademicTerm(date: string | Date) {
const d = moment(date);
const year = d.year();
const day = d.dayOfYear();
let term = '';
if (day >= this.fallStart) {
term = this.FALL;
} else if (day >= this.summerStart) {
term = this.SUMMER;
} else if (day >= this.springStart) {
term = this.SPRING;
} else {
term = this.WINTER;
}
return this.define({ term, year });
}
/**
* Returns the academicTerm document corresponding to the given date.
* @param date The date.
* @returns Object The academicTerm that the date falls in.
*/
public getAcademicTermDoc(date: string) {
const id = this.getAcademicTerm(date);
return this.findDoc(id);
}
/**
* Returns the academicTerm docID associated with the passed academicTerm slug or docID.
* If the academicTerm does not exist, it is defined.
* @param academicTerm The Slug or docID associated with a academicTerm
* @returns The academicTerm ID.
* @throws { Meteor.Error } If the passed academicTerm is not a valid academicTerm slug.
*/
public getID(academicTerm: string) {
// console.log('getID', academicTerm);
if (this.isDefined(academicTerm)) {
// console.log('isDefined');
return super.getID(academicTerm);
}
// Otherwise academicTerm should be a slug. Try to define it.
const split = academicTerm.split('-');
const term = split[0];
const year = parseInt(split[1], 10);
// console.log('define', term, year);
return this.define({ term, year });
}
/**
* Returns the passed academicTerm, formatted as a string.
* @param termID The academicTerm.
* @param nospace If true, then term and year are concatenated without a space in between.
* @returns { String } The academicTerm as a string.
*/
public toString(termID: string, nospace?: boolean) {
this.assertAcademicTerm(termID);
const academicTermDoc = this.findDoc(termID);
return (nospace) ? `${academicTermDoc.term}${academicTermDoc.year}` : `${academicTermDoc.term} ${academicTermDoc.year}`;
}
/**
* Returns a four character "shortname" for a academicTerm and year: Fa18, Sp19, Su20
* @param termID The academicTerm
* @returns {string} The shortname.
*/
public getShortName(termID: string) {
this.assertAcademicTerm(termID);
const academicTermDoc = this.findDoc(termID);
const yearString = `${academicTermDoc.year}`.substring(2, 4);
const termString = (academicTermDoc.term === 'Fall') ? 'Fall' : academicTermDoc.term.substring(0, 3);
return `${termString} ${yearString}`;
}
/**
* Remove the Course.
* @param instance The docID or slug of the entity to be removed.
* @throws { Meteor.Error } If docID is not a Course, or if this course has any associated course instances.
*/
public removeIt(instance: string) {
const docID = this.getID(instance);
// Check that this term is not referenced by any Opportunity Instance.
OpportunityInstances.find().map((opportunityInstance) => {
if (opportunityInstance.termID === docID) {
throw new Meteor.Error(`AcademicTerm ${instance} referenced by a opportunity instance.`);
}
return true;
});
// Check that this term in not referenced by any Course Instance
CourseInstances.find().map((courseInstance) => {
if (courseInstance.termID === docID) {
throw new Meteor.Error(`AcademicTerm ${instance} referenced by a course instance.`);
}
return true;
});
return super.removeIt(docID);
}
/**
* Returns an array of strings, each one representing an integrity problem with this collection.
* Returns an empty array if no problems were found.
* Checks slugID.
* @returns {Array} A (possibly empty) array of strings indicating integrity issues.
*/
public checkIntegrity() {
const problems = [];
this.find({}, {}).forEach((doc) => {
if (!Slugs.isDefined(doc.slugID)) {
problems.push(`Bad slugID: ${doc.slugID}`);
}
});
return problems;
}
}
/**
* Provides the singleton instance of this class to all other entities.
* @type {api/academicTerm.AcademicTermCollection}
* @memberOf api/academic-term
*/
export const AcademicTerms = new AcademicTermCollection();