A Timetabling Academic Year Calendar

There are loads of good scripts out there for creating calendar tables so I’ve never felt the need to put up my own. But since I’ve written an article that references the need for one I’ll chuck my own into the mix as well. This will be a timetabling academic year calendar. So rather than just dates it will go down to the smallest timetableable unit – 15 minutes – so each row will be a 15 minute slot. For the whole year there will be about 35,000 rows (365 * 96). The final table will look like this.

create table calendar_timetable
( academic_year int
, semester varchar(20)
, week_number int
, day_number int
, day_name varchar(10)
, time_slot_number int
, time_slot_start datetime primary key clustered
, day_period_type varchar(50)
, time_period_type varchar(50)
)

The day_period_type and time_period_type will allow periods of days and periods of time respectively to be categorised. In order to define these periods I’ll define a new table type to hold them.

CREATE TYPE time_period AS TABLE
(
time_period_name varchar(50)
, time_period_start datetime
, time_period_end datetime
)

Here are some examples for the day_period_type and time_period_type:

declare @time_periods time_period
insert into @time_periods values ('Night', '00:00:00', '07:45:00')
insert into @time_periods values ('Morning', '08:00:00', '11:45:00')
insert into @time_periods values ('Afternoon', '12:00:00', '18:00:00')
insert into @time_periods values ('Evening', '18:15:00', '21:00:00')
insert into @time_periods values ('Night', '21:15:00', '23:45:00')

declare @time_period_types time_period
insert into @time_period_types values ('Before Start', '24-Aug-2020', '13-Sep-2020')
insert into @time_period_types values ('Induction', '14-Sep-2020', '20-Sep-2020')
insert into @time_period_types values ('Progress Week', '02-Nov-2020', '08-Nov-2020')
insert into @time_period_types values ('Christmas', '21-Dec-2020', '03-Jan-2021')
insert into @time_period_types values ('Assessments', '04-Jan-2021', '17-Jan-2021')
insert into @time_period_types values ('Progress Week', '01-Mar-2021', '07-Mar-2021')
insert into @time_period_types values ('Easter', '29-Mar-2021', '11-Apr-2021')
insert into @time_period_types values ('Assessments', '03-May-2021', '16-May-2021')
insert into @time_period_types values ('Reassessments', '05-Jul-2021', '11-Jul-2021')
insert into @time_period_types values ('After End', '12-Jul-2021', '22-Aug-2021')

I’ll also use this type to define the semester start and end dates and set up two variables to hold the academic year start and end dates.

declare @academic_year_start datetime = '24-AUG-2020'
declare @academic_year_end datetime = '22-AUG-2021'

declare @semesters time_period
insert into @semesters values ('Semester 1', '24-AUG-2020', '17-JAN-2021')
insert into @semesters values ('Semester 2', '18-JAN-2021', '06-JUN-2021')

That’s the structure and data defined. Then just the script to generate the calendar. The innermost query generates a date for each day in the defined year using an integers table.

select dateadd(day, i.number - 1, @academic_year_start) calendar_date
, i.number day_number
from integers i
where i.number <= DATEDIFF(day,@academic_year_start,@academic_year_end) + 1

This is then used as the input for the following query that adds in the week number (this assumes the year starts on a Monday!), day, semester and categorisation of periods of days.

select calendar_date
, ((day_number - 1) / 7) + 1 week_number
, day_number
, datename(WEEKDAY, GETDATE()) day_of_week
, s.time_period_name semester
, isnull(pt.time_period_name, 'Teaching') time_period_type
from
( subquery ) sub

left join @semesters s
on sub.calendar_date between s.time_period_start and s.time_period_end

left join @time_period_types pt
on sub.calendar_date between pt.time_period_start and pt.time_period_end

Again this is input to the next query which cross joins to integers to get the 96 fifteen minute records per day.

select *
, dateadd(minute, (i2.number - 1) * 15, calendar_date) time_slot
from
( subquery ) sub2
join integers i2
on i2.number <= 96

And finally add in the time period categories as defined and produce the final output:

select year(@academic_year_start) academic_year
, sub3.semester
, sub3.week_number
, sub3.day_number
, datename(WEEKDAY, sub3.time_slot) day_name
, sub3.number time_slot_number
, sub3.time_slot time_slot_start
, sub3.time_period_type day_period_type
, tp.time_period_name time_period_type
from
( subquery ) sub3

join @time_periods tp
on cast(cast(sub3.time_slot as time) as datetime) between tp.time_period_start and tp.time_period_end

Of course I could define two separate tables to implement a traditional calendar and the timeslot element separately. This would save a few rows but I much prefer a single table with and a single join from the fact table. Below is the full script.

select	year(@academic_year_start) academic_year
,		sub3.semester
,		sub3.week_number
,		sub3.day_number
,		datename(WEEKDAY, sub3.time_slot) day_name
,		sub3.number time_slot_number
,		sub3.time_slot time_slot_start
,		sub3.time_period_type day_period_type
,		tp.time_period_name time_period_type
from	( 

		select	*
		,	dateadd(minute, (i2.number - 1) * 15, calendar_date) time_slot
		from	( 

				select	calendar_date
				,		((day_number - 1) / 7) + 1 week_number
				,		day_number
				,		datename(WEEKDAY, GETDATE()) day_of_week
				,		s.time_period_name semester
				,		isnull(pt.time_period_name, 'Teaching') time_period_type
				from	( 
				
						select	dateadd(day, i.number - 1, @academic_year_start) calendar_date
						,		i.number day_number
						from	integers i
						where	i.number <= DATEDIFF(day,@academic_year_start,@academic_year_end) + 1 
						
						) sub

				left join @semesters s
				on sub.calendar_date between s.time_period_start and s.time_period_end

				left join @time_period_types pt
				on sub.calendar_date between pt.time_period_start and pt.time_period_end
				
				) sub2

		 join integers i2
			on i2.number <= 96

		) sub3

		join @time_periods tp
		on cast(cast(sub3.time_slot as time) as datetime) between tp.time_period_start and tp.time_period_end