refactor(schedule): major ScheduleEntry type refactor

This commit is contained in:
Sarah Vaupel 2020-08-24 10:57:16 +02:00
parent 280a19865c
commit ed5101c26c
7 changed files with 129 additions and 130 deletions

View File

@ -4,7 +4,7 @@ DegreeCourse json -- for which degree programmes this course is appropriate fo
terms StudyTermsId terms StudyTermsId
UniqueDegreeCourse course degree terms UniqueDegreeCourse course degree terms
Course -- Information about a single course; contained info is always visible to all users Course -- Information about a single course; contained info is always visible to all users
name (CI Text) name CourseName
description Html Maybe -- user-defined large Html, ought to contain module description description Html Maybe -- user-defined large Html, ought to contain module description
linkExternal Text Maybe -- arbitrary user-defined url for external course page linkExternal Text Maybe -- arbitrary user-defined url for external course page
shorthand (CI Text) -- practical shorthand of course name, used for identification shorthand (CI Text) -- practical shorthand of course name, used for identification
@ -29,9 +29,9 @@ Course -- Information about a single course; contained info is always visible
TermSchoolCourseName term school name -- name must be unique within school and semester TermSchoolCourseName term school name -- name must be unique within school and semester
deriving Generic deriving Generic
CourseEvent CourseEvent
type (CI Text) type CourseEventType
course CourseId course CourseId
room Text room CourseEventRoom
time Occurrences time Occurrences
note Html Maybe note Html Maybe
lastChanged UTCTime default=now() lastChanged UTCTime default=now()

View File

@ -29,7 +29,7 @@ ExamPart
ExamOccurrence ExamOccurrence
exam ExamId exam ExamId
name ExamOccurrenceName name ExamOccurrenceName
room Text room ExamOccurrenceRoom
capacity Natural capacity Natural
start UTCTime start UTCTime
end UTCTime Maybe end UTCTime Maybe
@ -66,4 +66,4 @@ ExamCorrector
ExamPartCorrector ExamPartCorrector
part ExamPartId part ExamPartId
corrector ExamCorrectorId corrector ExamCorrectorId
UniqueExamPartCorrector part corrector UniqueExamPartCorrector part corrector

View File

@ -1,7 +1,7 @@
Tutorial json Tutorial json
name TutorialName name TutorialName
course CourseId course CourseId
type (CI Text) -- "Tutorium", "Zentralübung", ... type TutorialType -- "Tutorium", "Zentralübung", ...
capacity Int Maybe -- limit for enrolment in this tutorial capacity Int Maybe -- limit for enrolment in this tutorial
room Text Maybe room Text Maybe
time Occurrences time Occurrences
@ -20,4 +20,4 @@ Tutor
TutorialParticipant TutorialParticipant
tutorial TutorialId tutorial TutorialId
user UserId user UserId
UniqueTutorialParticipant tutorial user UniqueTutorialParticipant tutorial user

View File

@ -36,14 +36,18 @@ type SchoolName = CI Text
type SchoolShorthand = CI Text type SchoolShorthand = CI Text
type CourseName = CI Text type CourseName = CI Text
type CourseShorthand = CI Text type CourseShorthand = CI Text
type CourseEventType = CI Text
type CourseEventRoom = Text
type SheetName = CI Text type SheetName = CI Text
type MaterialName = CI Text type MaterialName = CI Text
type UserEmail = CI Email type UserEmail = CI Email
type UserIdent = CI Text type UserIdent = CI Text
type TutorialName = CI Text type TutorialName = CI Text
type TutorialType = CI Text
type ExamName = CI Text type ExamName = CI Text
type ExamPartName = CI Text type ExamPartName = CI Text
type ExamOccurrenceName = CI Text type ExamOccurrenceName = CI Text
type ExamOccurrenceRoom = Text
type AllocationName = CI Text type AllocationName = CI Text
type AllocationShorthand = CI Text type AllocationShorthand = CI Text

View File

@ -1,41 +1,28 @@
module Utils.Schedule.Types module Utils.Schedule.Types
( ScheduleEntry(..) ( ScheduleEntry(..)
, ScheduleEntryType(..)
, ScheduleEntryRoom
, ScheduleEntryOccurrence
, ScheduleEntryExamOccurrence(..)
) where ) where
import Import import Import
data ScheduleEntry = ScheduleEntry data ScheduleEntry = ScheduleCourseEvent
{ seCourse :: Entity Course -- TODO: just course?; TODO: Maybe? { sceCourse :: Entity Course -- TODO: just course?
, seType :: ScheduleEntryType , sceType :: CourseEventType
, seRooms :: [ScheduleEntryRoom] -- multiple rooms in case of multiple parallel exam occurrences, , sceRoom :: CourseEventRoom
-- no room in case of no room info (Nothing) for tutorials , sceOccurrence :: Either OccurrenceException OccurrenceSchedule
-- TODO: encode in ScheduleEntryType instead }
, seOccurrence :: ScheduleEntryOccurrence | ScheduleTutorial
} { stCourse :: Entity Course
deriving (Generic, Typeable) , stName :: TutorialName
, stType :: TutorialType
data ScheduleEntryType = SETCourseEvent { setceType :: CI Text , stRoom :: Maybe Text -- TODO: introduce TutorialRoom type synonym
} -- TODO: CourseEventType not possible here (comes from data family instance) , stOccurrence :: Either OccurrenceException OccurrenceSchedule
| SETTutorial { settType :: CI Text }
, settName :: TutorialName | ScheduleExamOccurrence
} -- TODO: TutorialType not possible here (comes from data family instance) { seoCourse :: Entity Course
| SETExamOccurrence { seteoExamName :: ExamName , seoExamName :: ExamName
} , seoRooms :: NonEmpty ExamOccurrenceRoom
deriving (Eq, Ord, Show, Read, Generic, Typeable) , seoStart :: UTCTime
, seoEnd :: Maybe UTCTime
type ScheduleEntryRoom = Text }
deriving (Generic, Typeable)
-- TODO: maybe introduce sum new type instead
type ScheduleEntryOccurrence = Either ScheduleEntryExamOccurrence (Either OccurrenceException OccurrenceSchedule)
-- Similar to OccurrenceException, but with Maybe as end
data ScheduleEntryExamOccurrence = ScheduleEntryExamOccurrence
{ seeoStart :: UTCTime
, seeoEnd :: Maybe UTCTime
}
deriving (Eq, Ord, Show, Read, Generic, Typeable)

View File

@ -26,6 +26,8 @@ weekSchedule uid dayOffset = do
tz <- liftIO getCurrentTimeZone tz <- liftIO getCurrentTimeZone
ata <- getSessionActiveAuthTags ata <- getSessionActiveAuthTags
let dayNowOffset = fromMaybe 0 dayOffset `addDays` utctDay now
-- TODO: single runDB for all fetches below? -- TODO: single runDB for all fetches below?
activeTerms <- liftHandler . runDB $ E.select $ E.from $ \term -> do activeTerms <- liftHandler . runDB $ E.select $ E.from $ \term -> do
@ -59,7 +61,6 @@ weekSchedule uid dayOffset = do
return (course, tutorial) return (course, tutorial)
-- TODO: this makes the exam table partly redundant => maybe remove? -- TODO: this makes the exam table partly redundant => maybe remove?
-- TODO: for lecturers, do not display one entry for each exam occurrences, but instead collect all occurrences happening at the same time in a list
examOccurrences <- liftHandler . runDB $ E.select $ E.from $ \(course `E.InnerJoin` exam `E.InnerJoin` examOccurrence) -> do examOccurrences <- liftHandler . runDB $ E.select $ E.from $ \(course `E.InnerJoin` exam `E.InnerJoin` examOccurrence) -> do
E.on $ course E.^. CourseId E.==. exam E.^. ExamCourse E.on $ course E.^. CourseId E.==. exam E.^. ExamCourse
E.on $ exam E.^. ExamId E.==. examOccurrence E.^. ExamOccurrenceExam E.on $ exam E.^. ExamId E.==. examOccurrence E.^. ExamOccurrenceExam
@ -74,29 +75,25 @@ weekSchedule uid dayOffset = do
let let
courseEventToScheduleEntries :: (Entity Course, Entity CourseEvent) -> [ScheduleEntry] courseEventToScheduleEntries :: (Entity Course, Entity CourseEvent) -> [ScheduleEntry]
courseEventToScheduleEntries (seCourse@(Entity _ Course{..}), Entity _ CourseEvent{courseEventType,courseEventRoom,courseEventTime=Occurrences{..}}) = courseEventToScheduleEntries (sceCourse@(Entity _ Course{..}), Entity _ CourseEvent{courseEventType=sceType,courseEventRoom=sceRoom,courseEventTime=Occurrences{..}}) =
let seType = SETCourseEvent { setceType = courseEventType } let scheduleds
seRooms = pure $ courseEventRoom -- omit regular occurrences if the course term is not currently active
scheduleds
-- omit regular occurrences if the course's term is not currently active
| not (courseTerm `elem` (E.unValue <$> activeTerms)) = mempty | not (courseTerm `elem` (E.unValue <$> activeTerms)) = mempty
| otherwise = Set.toList occurrencesScheduled <&> \scheduled -> | otherwise = Set.toList occurrencesScheduled <&> \scheduled ->
let seOccurrence = Right (Right scheduled) in ScheduleEntry{..} let sceOccurrence = Right scheduled in ScheduleCourseEvent{..}
exceptions = Set.toList occurrencesExceptions <&> \exception -> exceptions = Set.toList occurrencesExceptions <&> \exception ->
let seOccurrence = Right (Left exception) in ScheduleEntry{..} let sceOccurrence = Left exception in ScheduleCourseEvent{..}
in scheduleds <> exceptions in scheduleds <> exceptions
tutorialToScheduleEntries :: (Entity Course, Entity Tutorial) -> [ScheduleEntry] tutorialToScheduleEntries :: (Entity Course, Entity Tutorial) -> [ScheduleEntry]
tutorialToScheduleEntries (seCourse@(Entity _ Course{..}), Entity _ Tutorial{tutorialType,tutorialName,tutorialRoom,tutorialTime=Occurrences{..}}) = tutorialToScheduleEntries (stCourse@(Entity _ Course{..}), Entity _ Tutorial{tutorialName=stName,tutorialType=stType,tutorialRoom=stRoom,tutorialTime=Occurrences{..}}) =
let seType = SETTutorial { settType = tutorialType, settName = tutorialName } let scheduleds
seRooms = maybe mempty pure tutorialRoom -- omit regular occurrences if the course term is not currently active
scheduleds
-- omit regular occurrences if the course's term is not currently active
| not (courseTerm `elem` (E.unValue <$> activeTerms)) = mempty | not (courseTerm `elem` (E.unValue <$> activeTerms)) = mempty
| otherwise = Set.toList occurrencesScheduled <&> \scheduled -> | otherwise = Set.toList occurrencesScheduled <&> \scheduled ->
let seOccurrence = Right (Right scheduled) in ScheduleEntry{..} let stOccurrence = Right scheduled in ScheduleTutorial{..}
exceptions = Set.toList occurrencesExceptions <&> \exception -> exceptions = Set.toList occurrencesExceptions <&> \exception ->
let seOccurrence = Right (Left exception) in ScheduleEntry{..} let stOccurrence = Left exception in ScheduleTutorial{..}
in scheduleds <> exceptions in scheduleds <> exceptions
-- TODO: introduce type synonym for (Entity Course, Entity Exam, Entity ExamOccurrence)? -- TODO: introduce type synonym for (Entity Course, Entity Exam, Entity ExamOccurrence)?
@ -111,58 +108,68 @@ weekSchedule uid dayOffset = do
&& examOccurrenceStart occ == examOccurrenceStart occ' && examOccurrenceEnd occ == examOccurrenceEnd occ' && examOccurrenceStart occ == examOccurrenceStart occ' && examOccurrenceEnd occ == examOccurrenceEnd occ'
examOccurrenceToScheduleEntry :: (Entity Course, Entity Exam, NonEmpty (Entity ExamOccurrence)) -> ScheduleEntry examOccurrenceToScheduleEntry :: (Entity Course, Entity Exam, NonEmpty (Entity ExamOccurrence)) -> ScheduleEntry
examOccurrenceToScheduleEntry (seCourse@(Entity _ Course{}), Entity _ Exam{..}, examOccs@((Entity _ occ):|_)) = examOccurrenceToScheduleEntry (seoCourse@(Entity _ Course{}), Entity _ Exam{examName=seoExamName}, examOccs@((Entity _ occ):|_)) =
let seType = SETExamOccurrence let seoRooms = (examOccurrenceRoom . entityVal) <$> examOccs
{ seteoExamName = examName seoStart = examOccurrenceStart occ -- multiple exam occurrences are joined on equality of start and end,
} seoEnd = examOccurrenceEnd occ -- so taking the timestamps of the first occurrence suffices
seRooms = toList $ (examOccurrenceRoom . entityVal) <$> examOccs in ScheduleExamOccurrence{..}
seOccurrence = Left $ ScheduleEntryExamOccurrence -- multiple exam occurrences are joined on equality
{ seeoStart = examOccurrenceStart occ -- of start and end, so taking the timstamps of the first
, seeoEnd = examOccurrenceEnd occ -- occurrence suffices
}
in ScheduleEntry{..}
seOccurrenceIsInSlot :: Day -> TimeSlot -> ScheduleEntryOccurrence -> Bool seIsInSlot :: Day -> TimeSlot -> ScheduleEntry -> Bool
seOccurrenceIsInSlot day slot = \case seIsInSlot day slot =
Right occurrence -> occDay == day && occTime `isInTimeSlot` slot where let occurrenceIsInSlot occurrence = occDay == day && occTime `isInTimeSlot` slot where
(occDay,occTime) = case occurrence of (occDay, occTime) = case occurrence of
Right ScheduleWeekly{..} -> (scheduleDayOfWeek `dayOfWeekToDayWith` now, scheduleStart) Right ScheduleWeekly{..} -> (scheduleDayOfWeek `dayOfWeekToDayWith` dayNowOffset, scheduleStart)
Left ExceptOccur{..} -> (exceptDay, exceptStart) Left ExceptOccur{..} -> (exceptDay, exceptStart)
Left ExceptNoOccur{exceptTime=LocalTime{..}} -> (localDay, localTimeOfDay) Left ExceptNoOccur{exceptTime=LocalTime{..}} -> (localDay, localTimeOfDay)
Left ScheduleEntryExamOccurrence{..} -> let slotUTCTime = timeSlotToUTCTime tz day slot in \case
nextSlotUTCTime = timeSlotToUTCTime tz day (slot+slotStep) ScheduleCourseEvent{sceOccurrence} -> occurrenceIsInSlot sceOccurrence
in slotUTCTime <= seeoStart ScheduleTutorial{stOccurrence} -> occurrenceIsInSlot stOccurrence
&& seeoStart < nextSlotUTCTime ScheduleExamOccurrence{seoStart} -> let slotTime = timeSlotToUTCTime tz day slot
nextSlotTime = timeSlotToUTCTime tz day (slot+slotStep)
in slotTime <= seoStart
&& seoStart < nextSlotTime
events' :: Map Day (Map Int [ScheduleEntry]) events' :: Map Day (Map TimeSlot [ScheduleEntry])
events' = Map.fromList $ week <&> \day -> events' = Map.fromList $ week <&> \day ->
( day ( day
, Map.fromList $ slotsToDisplay <&> \slot -> , Map.fromList $ slotsToDisplay <&> \slot ->
( slot ( slot
, filter (seOccurrenceIsInSlot day slot . seOccurrence) $ join $ , filter (seIsInSlot day slot) $ join $
(courseEventToScheduleEntries <$> courseEvents) (courseEventToScheduleEntries <$> courseEvents)
<> (tutorialToScheduleEntries <$> tutorials) <> (tutorialToScheduleEntries <$> tutorials)
<> pure (examOccurrenceToScheduleEntry <$> (joinParallelExamOccurrences examOccurrences)) <> pure (examOccurrenceToScheduleEntry <$> (joinParallelExamOccurrences examOccurrences))
) )
) )
events :: Map Day (Map Int [ScheduleEntry]) getRegulars :: [ScheduleEntry] -> [OccurrenceSchedule]
getRegulars = catMaybes . (goRegular <$>) where
goRegular ScheduleCourseEvent{sceOccurrence=Right schedule} = Just schedule
goRegular ScheduleTutorial{stOccurrence=Right schedule} = Just schedule
goRegular _ = Nothing
getNoOccurs :: [ScheduleEntry] -> [OccurrenceException]
getNoOccurs = catMaybes . (goNoOccur <$>) where
goNoOccur ScheduleCourseEvent{sceOccurrence=Left noOccur} = Just noOccur
goNoOccur ScheduleTutorial{stOccurrence=Left noOccur} = Just noOccur
goNoOccur _ = Nothing
events :: Map Day (Map TimeSlot [ScheduleEntry])
events = events' <&> \slotsPerDay -> slotsPerDay <&> \occurrencesInSlot -> events = events' <&> \slotsPerDay -> slotsPerDay <&> \occurrencesInSlot ->
let let
isRegularWithoutException :: ScheduleEntry -> Bool isRegularWithoutException :: ScheduleEntry -> Bool
isRegularWithoutException = \case isRegularWithoutException =
-- remove regular occurrences if there is a NoOccur exception for the occurrence of this week let -- remove regular occurrences if there is a NoOccur exception for the occurrence of this week
ScheduleEntry{seOccurrence=Right (Right ScheduleWeekly{..})} -> goPrune (Right ScheduleWeekly{..}) = not $ ExceptNoOccur (LocalTime (scheduleDayOfWeek `dayOfWeekToDayWith` dayNowOffset) scheduleStart) `elem` (getNoOccurs occurrencesInSlot)
not $ Right (Left $ ExceptNoOccur $ LocalTime (scheduleDayOfWeek `dayOfWeekToDayWith` now) scheduleStart) -- remove NoOccur exceptions if there is no regular occurrence to override
`elem` (seOccurrence <$> occurrencesInSlot) goPrune (Left ExceptNoOccur{exceptTime=LocalTime{..}}) =
-- remove NoOccur exceptions if there is no regular occurrence to override any (\ScheduleWeekly{..} -> scheduleDayOfWeek `dayOfWeekToDayWith` dayNowOffset == localDay
ScheduleEntry{seOccurrence=Right (Left ExceptNoOccur{exceptTime=LocalTime{..}})} -> && scheduleStart == localTimeOfDay
any (\case ) (getRegulars occurrencesInSlot)
Right (Right ScheduleWeekly{..}) -> scheduleDayOfWeek `dayOfWeekToDayWith` now == localDay goPrune _ = True -- TODO: maybe filter NoOccur exceptions in general? (Should NoOccur exceptions be displayed?)
&& scheduleStart == localTimeOfDay in \case
_ -> False ScheduleCourseEvent{sceOccurrence} -> goPrune sceOccurrence
) (seOccurrence <$> occurrencesInSlot) ScheduleTutorial{stOccurrence} -> goPrune stOccurrence
_ -> True -- TODO: maybe filter out ExceptNoOccurs? (Should NoOccurs be displayed or not?) _ -> True
in filter isRegularWithoutException occurrencesInSlot in filter isRegularWithoutException occurrencesInSlot
-- TODO: Internationalize default week start (and/or make configurable) -- TODO: Internationalize default week start (and/or make configurable)
@ -181,27 +188,26 @@ weekSchedule uid dayOffset = do
-- | To which route should each schedule entry link to? -- | To which route should each schedule entry link to?
scheduleEntryToHref :: ScheduleEntry -> Route UniWorX scheduleEntryToHref :: ScheduleEntry -> Route UniWorX
scheduleEntryToHref ScheduleEntry{seCourse=Entity _ Course{..},seType} = case seType of scheduleEntryToHref = \case
SETCourseEvent{} -> CourseR courseTerm courseSchool courseShorthand CShowR -- TODO: link to events table? (TODO currently has no id) ScheduleCourseEvent{sceCourse=(Entity _ Course{..})} -> CourseR courseTerm courseSchool courseShorthand CShowR -- TODO: link to events table? (currently has no id)
SETTutorial{} -> CourseR courseTerm courseSchool courseShorthand CShowR -- TODO: link to table with id "tutorials"? ScheduleTutorial{stCourse=(Entity _ Course{..})} -> CourseR courseTerm courseSchool courseShorthand CShowR -- TODO: link to table with id "tutorials"?
SETExamOccurrence{..} -> CExamR courseTerm courseSchool courseShorthand seteoExamName EShowR ScheduleExamOccurrence{seoCourse=(Entity _ Course{..}),seoExamName} -> CExamR courseTerm courseSchool courseShorthand seoExamName EShowR
-- | Calls formatTimeRangeW with the correct arguments and prepends an occurrence descriptor based on the occurrence type -- | Calls formatTimeRangeW with the correct arguments and prepends an occurrence descriptor based on the occurrence type
formatOccurrenceW :: ScheduleEntryOccurrence -> Widget formatEitherOccurrenceW :: Either OccurrenceException OccurrenceSchedule -> Widget
formatOccurrenceW = \case formatEitherOccurrenceW = \case
Right (Right ScheduleWeekly{..}) -> [whamlet| _{MsgScheduleTime}: |] <> formatTimeRangeW SelFormatTime scheduleStart (Just scheduleEnd) Right ScheduleWeekly{..} -> [whamlet| _{MsgScheduleTime}: |] <> formatTimeRangeW SelFormatTime scheduleStart (Just scheduleEnd)
Right (Left ExceptOccur{..}) -> [whamlet| _{MsgScheduleOccur}: |] <> formatTimeRangeW SelFormatTime (LocalTime exceptDay exceptStart) (Just $ LocalTime exceptDay exceptEnd) Left ExceptOccur{..} -> [whamlet| _{MsgScheduleOccur}: |] <> formatTimeRangeW SelFormatTime (LocalTime exceptDay exceptStart) (Just $ LocalTime exceptDay exceptEnd)
Right (Left ExceptNoOccur{}) -> [whamlet| _{MsgScheduleNoOccur} |] -- <> formatTimeW SelFormatDateTime exceptTime Left ExceptNoOccur{} -> [whamlet| _{MsgScheduleNoOccur} |] -- <> formatTimeW SelFormatDateTime exceptTime
Left ScheduleEntryExamOccurrence{..} -> [whamlet| _{MsgScheduleOccur}: |] <> formatTimeRangeW selFormat seeoStart seeoEnd
where selFormat = bool SelFormatDateTime SelFormatTime $ maybe True ((utctDay seeoStart ==) . utctDay) seeoEnd
-- | Uniquely identify each day as table head -- | Uniquely identify each day as table head
-- | This avoids constantly hiding e.g. some DayOfWeek (which would interfere with day offsets) -- | This avoids constantly hiding e.g. some DayOfWeek (which would interfere with day offsets)
dayTableHeadIdent :: Day -> Text dayTableHeadIdent :: Day -> Text
dayTableHeadIdent = tshow . toModifiedJulianDay dayTableHeadIdent = tshow . toModifiedJulianDay
-- | Convert from DayOfWeek to Day of this week using the current time (as UTCTime) -- | Convert from DayOfWeek to Day of this week using the current day
dayOfWeekToDayWith :: DayOfWeek -> UTCTime -> Day dayOfWeekToDayWith :: DayOfWeek -> Day -> Day
dayOfWeekToDayWith weekDay = go . utctDay where dayOfWeekToDayWith weekDay = go where
go day | weekDay' == weekDay = day go day | weekDay' == weekDay = day
| weekDay' > weekDay = go $ pred day | weekDay' > weekDay = go $ pred day
| otherwise = go $ succ day | otherwise = go $ succ day

View File

@ -18,26 +18,28 @@ $newline never
<div .table__td-content> <div .table__td-content>
$maybe dayEvents <- Map.lookup day events $maybe dayEvents <- Map.lookup day events
$maybe slotEvents <- Map.lookup slot dayEvents $maybe slotEvents <- Map.lookup slot dayEvents
$forall se@ScheduleEntry{seCourse=Entity _ Course{courseName},seType,seRooms,seOccurrence} <- slotEvents $forall scheduleEntry <- slotEvents
<a href=@{scheduleEntryToHref se} .schedule--entry-link> <a href=@{scheduleEntryToHref scheduleEntry} .schedule--entry-link>
<div .schedule--entry> <div .schedule--entry>
#{CI.original courseName}: # $case scheduleEntry
$case seType $of ScheduleCourseEvent{sceCourse=Entity _ Course{courseName},sceType,sceRoom,sceOccurrence}
$of SETCourseEvent{..} #{CI.original courseName}: #{CI.original sceType} <br/>
#{CI.original setceType} _{MsgScheduleRoom}: #{sceRoom} <br/>
$of SETTutorial{..} ^{formatEitherOccurrenceW sceOccurrence}
#{settName} # $of ScheduleTutorial{stCourse=Entity _ Course{courseName},stName,stType,stRoom,stOccurrence}
(#{CI.original settType}) #{CI.original courseName}: #{stName} (#{CI.original stType}) <br/>
$of SETExamOccurrence{..} _{MsgScheduleRoom}: #{stRoom} <br/>
#{seteoExamName} # ^{formatEitherOccurrenceW stOccurrence}
<br> $of ScheduleExamOccurrence{seoCourse=Entity _ Course{courseName},seoExamName,seoRooms,seoStart,seoEnd}
#{CI.original courseName}: #{seoExamName} <br/>
$case seRooms $case toList seoRooms
$of [] $of [room]
$of [room] _{MsgScheduleRoom}: #{room}
_{MsgScheduleRoom}: #{room} $of more
$of rooms _{MsgScheduleRooms}: #{intercalate ", " more}
_{MsgScheduleRooms}: #{intercalate ", " rooms} <br>
<br> _{MsgScheduleOccur}: #
$if Just (utctDay seoStart) == fmap utctDay seoEnd
^{formatOccurrenceW seOccurrence} ^{formatTimeRangeW SelFormatTime seoStart seoEnd}
$else
^{formatTimeRangeW SelFormatDateTime seoStart seoEnd}