From 0f60ab2a544912a4cbe7c47168e409bda860fb2a Mon Sep 17 00:00:00 2001 From: cgfhtw Date: Mon, 6 Feb 2023 08:43:43 +0100 Subject: [PATCH] Stundenplan & Calendar --- application/controllers/Cis/Stundenplan.php | 31 ++++ .../components/Cis/Stundenplan.php | 68 ++++++++ .../models/ressource/Reservierung_model.php | 21 +++ .../models/ressource/Stundenplan_model.php | 21 +++ application/views/Cis/Stundenplan.php | 16 ++ public/css/components/calendar.css | 160 +++++++++++++++++ public/js/apps/Cis/Stundenplan.js | 53 ++++++ public/js/components/Calendar/Abstract.js | 17 ++ public/js/components/Calendar/Calendar.js | 131 ++++++++++++++ public/js/components/Calendar/Header.js | 23 +++ public/js/components/Calendar/Minimized.js | 29 ++++ public/js/components/Calendar/Month.js | 69 ++++++++ public/js/components/Calendar/Month/Page.js | 75 ++++++++ public/js/components/Calendar/Months.js | 34 ++++ public/js/components/Calendar/Pane.js | 55 ++++++ public/js/components/Calendar/Week.js | 48 ++++++ public/js/components/Calendar/Week/Page.js | 115 +++++++++++++ public/js/components/Calendar/Weeks.js | 39 +++++ public/js/components/Calendar/Years.js | 59 +++++++ public/js/components/Calendar/Years/Page.js | 29 ++++ public/js/composables/CalendarDate.js | 161 ++++++++++++++++++ 21 files changed, 1254 insertions(+) create mode 100644 application/controllers/Cis/Stundenplan.php create mode 100644 application/controllers/components/Cis/Stundenplan.php create mode 100644 application/views/Cis/Stundenplan.php create mode 100644 public/css/components/calendar.css create mode 100644 public/js/apps/Cis/Stundenplan.js create mode 100644 public/js/components/Calendar/Abstract.js create mode 100644 public/js/components/Calendar/Calendar.js create mode 100644 public/js/components/Calendar/Header.js create mode 100644 public/js/components/Calendar/Minimized.js create mode 100644 public/js/components/Calendar/Month.js create mode 100644 public/js/components/Calendar/Month/Page.js create mode 100644 public/js/components/Calendar/Months.js create mode 100644 public/js/components/Calendar/Pane.js create mode 100644 public/js/components/Calendar/Week.js create mode 100644 public/js/components/Calendar/Week/Page.js create mode 100644 public/js/components/Calendar/Weeks.js create mode 100644 public/js/components/Calendar/Years.js create mode 100644 public/js/components/Calendar/Years/Page.js create mode 100644 public/js/composables/CalendarDate.js diff --git a/application/controllers/Cis/Stundenplan.php b/application/controllers/Cis/Stundenplan.php new file mode 100644 index 000000000..1f1eca42e --- /dev/null +++ b/application/controllers/Cis/Stundenplan.php @@ -0,0 +1,31 @@ + ['student/anrechnung_beantragen:r','user:r'] // TODO(chris): permissions? + ]); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Public methods + + /** + * @return void + */ + public function index() + { + $this->load->view('Cis/Stundenplan'); + } + +} diff --git a/application/controllers/components/Cis/Stundenplan.php b/application/controllers/components/Cis/Stundenplan.php new file mode 100644 index 000000000..2b35e11ca --- /dev/null +++ b/application/controllers/components/Cis/Stundenplan.php @@ -0,0 +1,68 @@ + ['student/anrechnung_beantragen:r','user:r'], // TODO(chris): permissions? + 'Reservierungen' => ['student/anrechnung_beantragen:r','user:r'], // TODO(chris): permissions? + 'Stunden' => ['student/anrechnung_beantragen:r','user:r'] // TODO(chris): permissions? + ]); + } + + //------------------------------------------------------------------------------------------------------------------ + // Public methods + + /** + */ + public function index() + { + $this->load->model('ressource/Stundenplan_model', 'StundenplanModel'); + + $result = $this->StundenplanModel->loadForUid(get_uid()); + + if (isError($result)) + return $this->outputJsonError(getError($result)); + + $this->outputJsonSuccess(getData($result)); + } + + /** + */ + public function Reservierungen() + { + $this->load->model('ressource/Reservierung_model', 'ReservierungModel'); + + $result = $this->ReservierungModel->loadForUid(get_uid()); + + if (isError($result)) + return $this->outputJsonError(getError($result)); + + $this->outputJsonSuccess(getData($result)); + } + + /** + */ + public function Stunden() + { + $this->load->model('ressource/Stunde_model', 'StundeModel'); + + $result = $this->StundeModel->load(); + + if (isError($result)) + return $this->outputJsonError(getError($result)); + + $this->outputJsonSuccess(getData($result)); + } + +} diff --git a/application/models/ressource/Reservierung_model.php b/application/models/ressource/Reservierung_model.php index 37d0cb376..7a3d1a4a4 100644 --- a/application/models/ressource/Reservierung_model.php +++ b/application/models/ressource/Reservierung_model.php @@ -11,4 +11,25 @@ class Reservierung_model extends DB_Model $this->dbTable = 'campus.tbl_reservierung'; $this->pk = 'reservierung_id'; } + + /** + * @param $uid + * + * @return stdClass + */ + public function loadForUid($uid) + { + $this->addSelect('r.*'); + $this->db->join('public.tbl_benutzergruppe bg', 'r.gruppe_kurzbz=bg.gruppe_kurzbz AND bg.uid=?', 'LEFT', false); + $this->addJoin('public.tbl_studiensemester ss1', 'bg.studiensemester_kurzbz=ss1.studiensemester_kurzbz AND ss1.start<=r.datum AND ss1.ende>=r.datum', 'LEFT'); + $this->db->join('public.tbl_studentlehrverband slv', "r.studiengang_kz=slv.studiengang_kz AND slv.student_uid=? AND (slv.semester=r.semester OR r.semester IS NULL) AND (slv.verband=r.verband OR r.verband IS NULL OR r.verband='' OR r.verband='0') AND (slv.gruppe=r.gruppe OR r.gruppe IS NULL OR r.gruppe='' OR r.gruppe='0') AND r.gruppe_kurzbz IS NULL", 'LEFT', false); + $this->addJoin('public.tbl_studiensemester ss2', 'slv.studiensemester_kurzbz=ss2.studiensemester_kurzbz AND ss2.start<=r.datum AND ss2.ende>=r.datum', 'LEFT'); + $this->db->or_where('ss1.studiensemester_kurzbz IS NOT NULL', null, false); + $this->db->or_where('ss2.studiensemester_kurzbz IS NOT NULL', null, false); + + $query = $this->db->get_compiled_select('campus.vw_reservierung r'); + + return $this->execQuery($query, [$uid, $uid]); + } + } diff --git a/application/models/ressource/Stundenplan_model.php b/application/models/ressource/Stundenplan_model.php index be3d520b5..5ec6ea302 100644 --- a/application/models/ressource/Stundenplan_model.php +++ b/application/models/ressource/Stundenplan_model.php @@ -11,4 +11,25 @@ class Stundenplan_model extends DB_Model $this->dbTable = 'lehre.tbl_stundenplan'; $this->pk = 'stundenplan_id'; } + + /** + * @param string $uid + * + * @return stdClass + */ + public function loadForUid($uid) + { + $this->addSelect('sp.*'); + $this->db->join('public.tbl_benutzergruppe bg', 'sp.gruppe_kurzbz=bg.gruppe_kurzbz AND bg.uid=?', 'LEFT', false); + $this->addJoin('public.tbl_studiensemester ss1', 'bg.studiensemester_kurzbz=ss1.studiensemester_kurzbz AND ss1.start<=sp.datum AND ss1.ende>=sp.datum', 'LEFT'); + $this->db->join('public.tbl_studentlehrverband slv', "sp.studiengang_kz=slv.studiengang_kz AND slv.student_uid=? AND (slv.semester=sp.semester OR sp.semester IS NULL) AND (slv.verband=sp.verband OR sp.verband IS NULL OR sp.verband='' OR sp.verband='0') AND (slv.gruppe=sp.gruppe OR sp.gruppe IS NULL OR sp.gruppe='' OR sp.gruppe='0') AND sp.gruppe_kurzbz IS NULL", 'LEFT', false); + $this->addJoin('public.tbl_studiensemester ss2', 'slv.studiensemester_kurzbz=ss2.studiensemester_kurzbz AND ss2.start<=sp.datum AND ss2.ende>=sp.datum', 'LEFT'); + $this->db->or_where('ss1.studiensemester_kurzbz IS NOT NULL', null, false); + $this->db->or_where('ss2.studiensemester_kurzbz IS NOT NULL', null, false); + + $query = $this->db->get_compiled_select('lehre.vw_stundenplan sp'); + + return $this->execQuery($query, [$uid, $uid]); + } + } diff --git a/application/views/Cis/Stundenplan.php b/application/views/Cis/Stundenplan.php new file mode 100644 index 000000000..f4b3dbc15 --- /dev/null +++ b/application/views/Cis/Stundenplan.php @@ -0,0 +1,16 @@ + ['public/js/apps/Cis/Stundenplan.js'], + 'customCSSs' => ['public/css/components/calendar.css'] +); + +$this->load->view('templates/CISHTML-Header', $includesArray); +?> + +
+

Stundenplan

+
+ +
+ +load->view('templates/CISHTML-Footer', $includesArray); ?> diff --git a/public/css/components/calendar.css b/public/css/components/calendar.css new file mode 100644 index 000000000..f401f9cb7 --- /dev/null +++ b/public/css/components/calendar.css @@ -0,0 +1,160 @@ +.fhc-calendar-week-page-header { + background-color: #e9ecef; +} + +.fhc-calendar-months .col-4, +.fhc-calendar-years .col-4 { + padding: 0.1875em 0; +} +.show-weeks .fhc-calendar-months .col-4, +.show-weeks .fhc-calendar-years .col-4 { + padding: 0.1875em 0.25em; +} +.fhc-calendar-months .col-4 button, +.fhc-calendar-years .col-4 button { + aspect-ratio: 28/18; +} + +.fhc-calendar-month-page { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: 1.5em repeat(6, 1fr); +} +.fhc-calendar-month-page.show-weeks { + grid-template-columns: 1.5em repeat(7, 1fr); + grid-template-rows: 1.5em repeat(6, 1fr); +} + +.fhc-calendar-month-page-weekday, +.fhc-calendar-month-page-day { + color: inherit; +} + +.fhc-calendar-week .carousel-item { + padding: 0.75em; +} + +.fhc-calendar-week-page { + aspect-ratio: 7/6; + min-height: 0; +} +.fhc-calendar-week-page > div { + transform: translate(-0.75em, -0.75em); + width: calc(100% + 1.5em); + height: calc(100% + 1.5em); +} +.fhc-calendar-week-page > div > div { + padding-left: 3em; +} +.fhc-calendar-week-page .events { + display: grid; + grid-template-columns: 3em repeat(7, 1fr); + margin-left: -3em; + min-height: 266.6666666667%; + background-image: linear-gradient(#f8f9fa calc(100% / 24 - .5px), #dee2e6 calc(100% / 24 - .5px), #dee2e6 calc(100% / 24 + .5px), #f8f9fa calc(100% / 24 + .5px), #f8f9fa calc(200% / 24 - .5px), #dee2e6 calc(200% / 24 - .5px), #dee2e6 calc(200% / 24 + .5px), #f8f9fa calc(200% / 24 + .5px), #f8f9fa calc(300% / 24 - .5px), #dee2e6 calc(300% / 24 - .5px), #dee2e6 calc(300% / 24 + .5px), #f8f9fa calc(300% / 24 + .5px), #f8f9fa calc(400% / 24 - .5px), #dee2e6 calc(400% / 24 - .5px), #dee2e6 calc(400% / 24 + .5px), #f8f9fa calc(400% / 24 + .5px), #f8f9fa calc(500% / 24 - .5px), #dee2e6 calc(500% / 24 - .5px), #dee2e6 calc(500% / 24 + .5px), #f8f9fa calc(500% / 24 + .5px), #f8f9fa calc(600% / 24 - .5px), #dee2e6 calc(600% / 24 - .5px), #dee2e6 calc(600% / 24 + .5px), #f8f9fa calc(600% / 24 + .5px), #f8f9fa calc(700% / 24 - .5px), #dee2e6 calc(700% / 24 - .5px), #dee2e6 calc(700% / 24 + .5px), #f8f9fa calc(700% / 24 + .5px), #f8f9fa calc(800% / 24 - .5px), #dee2e6 calc(800% / 24 - .5px), #dee2e6 calc(800% / 24 + .5px), #fff calc(800% / 24 + .5px), #fff calc(900% / 24 - .5px), #dee2e6 calc(900% / 24 - .5px), #dee2e6 calc(900% / 24 + .5px), #fff calc(900% / 24 + .5px), #fff calc(1000% / 24 - .5px), #dee2e6 calc(1000% / 24 - .5px), #dee2e6 calc(1000% / 24 + .5px), #fff calc(1000% / 24 + .5px), #fff calc(1100% / 24 - .5px), #dee2e6 calc(1100% / 24 - .5px), #dee2e6 calc(1100% / 24 + .5px), #fff calc(1100% / 24 + .5px), #fff calc(1200% / 24 - .5px), #dee2e6 calc(1200% / 24 - .5px), #dee2e6 calc(1200% / 24 + .5px), #fff calc(1200% / 24 + .5px), #fff calc(1300% / 24 - .5px), #dee2e6 calc(1300% / 24 - .5px), #dee2e6 calc(1300% / 24 + .5px), #fff calc(1300% / 24 + .5px), #fff calc(1400% / 24 - .5px), #dee2e6 calc(1400% / 24 - .5px), #dee2e6 calc(1400% / 24 + .5px), #fff calc(1400% / 24 + .5px), #fff calc(1500% / 24 - .5px), #dee2e6 calc(1500% / 24 - .5px), #dee2e6 calc(1500% / 24 + .5px), #fff calc(1500% / 24 + .5px), #fff calc(1600% / 24 - .5px), #dee2e6 calc(1600% / 24 - .5px), #dee2e6 calc(1600% / 24 + .5px), #fff calc(1600% / 24 + .5px), #fff calc(1700% / 24 - .5px), #dee2e6 calc(1700% / 24 - .5px), #dee2e6 calc(1700% / 24 + .5px), #f8f9fa calc(1700% / 24 + .5px), #f8f9fa calc(1800% / 24 - .5px), #dee2e6 calc(1800% / 24 - .5px), #dee2e6 calc(1800% / 24 + .5px), #f8f9fa calc(1800% / 24 + .5px), #f8f9fa calc(1900% / 24 - .5px), #dee2e6 calc(1900% / 24 - .5px), #dee2e6 calc(1900% / 24 + .5px), #f8f9fa calc(1900% / 24 + .5px), #f8f9fa calc(2000% / 24 - .5px), #dee2e6 calc(2000% / 24 - .5px), #dee2e6 calc(2000% / 24 + .5px), #f8f9fa calc(2000% / 24 + .5px), #f8f9fa calc(2100% / 24 - .5px), #dee2e6 calc(2100% / 24 - .5px), #dee2e6 calc(2100% / 24 + .5px), #f8f9fa calc(2100% / 24 + .5px), #f8f9fa calc(2200% / 24 - .5px), #dee2e6 calc(2200% / 24 - .5px), #dee2e6 calc(2200% / 24 + .5px), #f8f9fa calc(2200% / 24 + .5px), #f8f9fa calc(2300% / 24 - .5px), #dee2e6 calc(2300% / 24 - .5px), #dee2e6 calc(2300% / 24 + .5px), #f8f9fa calc(2300% / 24 + .5px)); +} +.fhc-calendar-week-page .events .day { + gap: 1px; +} +.fhc-calendar-week-page .events .hours, .fhc-calendar-week-page .events .day { + display: grid; +} + +.fhc-calendar-lg .fhc-calendar-month-page-day, +.fhc-calendar-lg .fhc-calendar-month-page-weekday, +.fhc-calendar-md .fhc-calendar-month-page-day, +.fhc-calendar-md .fhc-calendar-month-page-weekday { + border: solid 1px #dee2e6; +} +.fhc-calendar-lg .fhc-calendar-month-page-day, +.fhc-calendar-md .fhc-calendar-month-page-day { + aspect-ratio: 1; + display: flex; + flex-direction: column; +} +.fhc-calendar-lg .fhc-calendar-month-page-day.active, +.fhc-calendar-md .fhc-calendar-month-page-day.active { + border-color: var(--bs-secondary); +} +.fhc-calendar-lg .fhc-calendar-month-page-day .events, +.fhc-calendar-md .fhc-calendar-month-page-day .events { + display: block; + overflow: scroll; + font-size: 0.7em; +} +.fhc-calendar-lg .fhc-calendar-month-page-day .events span, +.fhc-calendar-md .fhc-calendar-month-page-day .events span { + display: block; + margin: 0.2em; + padding: 0.1em 0.4em; + border-radius: 0.1em; +} +.fhc-calendar-lg .fhc-calendar-years .col-4, +.fhc-calendar-md .fhc-calendar-years .col-4 { + padding: 0.09375em 0; +} +.show-weeks .fhc-calendar-lg .fhc-calendar-years .col-4, +.show-weeks .fhc-calendar-md .fhc-calendar-years .col-4 { + padding: 0.09375em 0.25em; +} +.fhc-calendar-lg .fhc-calendar-years .col-4 button, +.fhc-calendar-md .fhc-calendar-years .col-4 button { + aspect-ratio: 28/9; +} + +.fhc-calendar-xs { + font-size: 0.6em; +} + +.fhc-calendar-sm .fhc-calendar-month-page { + font-size: 0.8em; +} + +.fhc-calendar-sm .fhc-calendar-month-page-weekday, +.fhc-calendar-xs .fhc-calendar-month-page-weekday { + font-style: italic; + display: flex; + align-items: center; + justify-content: end; +} +.fhc-calendar-sm .fhc-calendar-month-page-day, +.fhc-calendar-xs .fhc-calendar-month-page-day { + position: relative; + aspect-ratio: 1; +} +.fhc-calendar-sm .fhc-calendar-month-page-day.active .no, +.fhc-calendar-xs .fhc-calendar-month-page-day.active .no { + background-color: rgba(var(--bs-info-rgb), 0.25); + border-radius: 50%; + font-weight: bold; +} +.fhc-calendar-sm .fhc-calendar-month-page-day:not(.active):hover .no, +.fhc-calendar-xs .fhc-calendar-month-page-day:not(.active):hover .no { + background-color: rgba(var(--bs-secondary-rgb), 0.25); + border-radius: 50%; +} +.fhc-calendar-sm .fhc-calendar-month-page-day .no, +.fhc-calendar-xs .fhc-calendar-month-page-day .no { + display: flex; + align-items: center; + justify-content: center; + width: 80%; + height: 80%; + margin: 10%; +} +.fhc-calendar-sm .fhc-calendar-month-page-day .events, +.fhc-calendar-xs .fhc-calendar-month-page-day .events { + position: absolute; + bottom: 0; + left: 10%; + width: 80%; + height: 10%; + overflow: hidden; + display: flex; +} +.fhc-calendar-sm .fhc-calendar-month-page-day .events span, +.fhc-calendar-xs .fhc-calendar-month-page-day .events span { + overflow: hidden; +} + +/*# sourceMappingURL=calendar.css.map */ diff --git a/public/js/apps/Cis/Stundenplan.js b/public/js/apps/Cis/Stundenplan.js new file mode 100644 index 000000000..eaa75bca3 --- /dev/null +++ b/public/js/apps/Cis/Stundenplan.js @@ -0,0 +1,53 @@ +import FhcCalendar from "../../components/Calendar/Calendar.js"; + +const app = Vue.createApp({ + components: { + FhcCalendar + }, + data() { + return { + stunden: [], + events: null + } + }, + created() { + axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Stundenplan/Stunden').then(res => { + res.data.retval.forEach(std => { + this.stunden[std.stunde] = std; // TODO(chris): geht besser + }); + axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Stundenplan').then(res => { + let events; + if (res.data.retval && res.data.retval.forEach) { + res.data.retval.forEach((el, i) => { + el.id = i; + el.color = '#' + (el.farbe || 'CCCCCC'); + el.start = new Date(el.datum + ' ' + this.stunden[el.stunde].beginn); + el.end = new Date(el.datum + ' ' + this.stunden[el.stunde].ende); + el.title = el.lehrfach; + if (el.lehrform) + el.title += '-' + el.lehrform; + }); + events = res.data.retval; + } + // TODO(chris): do we need that + axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Stundenplan/Reservierungen').then(res => { + if (res.data.retval && res.data.retval.forEach) { + res.data.retval.forEach((el, i) => { + el.id = i + events.length; + el.color = '#CCCCCC'; + el.start = new Date(el.datum + ' ' + this.stunden[el.stunde].beginn); + el.end = new Date(el.datum + ' ' + this.stunden[el.stunde].ende); + el.title = el.lehrfach; + if (el.lehrform) + el.title += '-' + el.lehrform; + }); + events = [...events, ...res.data.retval]; + } + this.events = events; + }); + }); + }); + } +}); +app.config.unwrapInjectedRef = true; +app.mount('#content'); \ No newline at end of file diff --git a/public/js/components/Calendar/Abstract.js b/public/js/components/Calendar/Abstract.js new file mode 100644 index 000000000..07eb03933 --- /dev/null +++ b/public/js/components/Calendar/Abstract.js @@ -0,0 +1,17 @@ +import CalendarHeader from './Header'; + +export default { + components: { + CalendarHeader + }, + inject: [ + 'date', + 'focusDate', + 'size' + ], + emits: [ + 'update:mode', + 'change:range', + 'input' + ] +} diff --git a/public/js/components/Calendar/Calendar.js b/public/js/components/Calendar/Calendar.js new file mode 100644 index 000000000..73e11fa20 --- /dev/null +++ b/public/js/components/Calendar/Calendar.js @@ -0,0 +1,131 @@ +import CalendarMonth from './Month.js'; +import CalendarMonths from './Months.js'; +import CalendarYears from './Years.js'; +import CalendarWeek from './Week.js'; +import CalendarWeeks from './Weeks.js'; +import CalendarMinimized from './Minimized.js'; +import CalendarDate from '../../composables/CalendarDate.js'; + +// TODO(chris): week/month toggle + +export default { + components: { + CalendarMonth, + CalendarMonths, + CalendarYears, + CalendarWeek, + CalendarWeeks, + CalendarMinimized + }, + provide() { + return { + date: this.date, + focusDate: this.focusDate, + size: Vue.computed({ get: () => this.size, set: v => this.size = v}), + events: Vue.computed(() => this.eventsPerDay), + minimized: Vue.computed({ get: () => this.minimized, set: v => this.$emit('update:minimized',v) }), + showWeeks: this.showWeeks, + noMonthView: this.noMonthView, + noWeekView: this.noWeekView, + eventsAreNull: Vue.computed(() => this.events === null) + }; + }, + props: { + events: Array, + showWeeks: { + type: Boolean, + default: true + }, + initialMode: { + type: String, + default: 'month' + }, + minimized: Boolean, + noWeekView: Boolean, + noMonthView: Boolean + }, + emits: [ + 'select:day', + 'select:event', + 'change:range', + 'update:minimized' + ], + data() { + return { + header: '', + prevMode: null, + currMode: null, + date: new CalendarDate(), + focusDate: new CalendarDate(), + size: 0 + } + }, + computed: { + sizeClass() { + return 'fhc-calendar-' + ['xs','sm','md','lg'][this.size]; + }, + mode: { + get() { return this.minimized ? 'minimized' : this.currMode; }, + set(v) { + if (!v && this.prevMode) { + this.currMode = this.prevMode; + this.prevMode = null; + } else { + this.prevMode = this.currMode; + this.currMode = v; + } + } + }, + eventsPerDay() { + if (!this.events) + return {}; + return this.events.reduce((result, event) => { + let days = Math.ceil((event.end - event.start) / 86400000) || 1; + while (days-- > 0) { + let day = (new Date(event.start.getFullYear(), event.start.getMonth(), event.start.getDate() + days)).toDateString(); + if (!result[day]) + result[day] = []; + result[day].push(event); + } + return result; + }, {}); + } + }, + methods: { + handleInput(day) { + this.$emit(day[0], day[1]); + } + }, + created() { + const allowedInitialModes = ['years']; + if (!this.noWeekView) + allowedInitialModes.push('week'); + if (!this.noMonthView) + allowedInitialModes.push('month'); + this.mode = allowedInitialModes[allowedInitialModes.indexOf(this.initialMode)] || allowedInitialModes.pop(); + this.date.set(new Date(2023,0,1)); + this.focusDate.set(this.date); + }, + mounted() { + if (this.$refs.container) { + new ResizeObserver(entries => { + for (const entry of entries) { + let w = entry.contentBoxSize ? entry.contentBoxSize[0].inlineSize : entry.contentRect.width; + // TODO(chris): rework sizing + if (w > 600) + this.size = 3; + else if (w > 350) + this.size = 2; + else if (w > 250) + this.size = 1; + else + this.size = 0; + } + }).observe(this.$refs.container); + } + }, + template: ` +
+ +
` +} diff --git a/public/js/components/Calendar/Header.js b/public/js/components/Calendar/Header.js new file mode 100644 index 000000000..0ead55215 --- /dev/null +++ b/public/js/components/Calendar/Header.js @@ -0,0 +1,23 @@ +export default { + inject: [ + 'eventsAreNull', + 'size' + ], + props: { + title: String + }, + emits: [ + 'prev', + 'next', + 'click' + ], + template: ` +
+ + + +
` +} diff --git a/public/js/components/Calendar/Minimized.js b/public/js/components/Calendar/Minimized.js new file mode 100644 index 000000000..dac648324 --- /dev/null +++ b/public/js/components/Calendar/Minimized.js @@ -0,0 +1,29 @@ +import CalendarAbstract from './Abstract.js'; + +export default { + mixins: [ + CalendarAbstract + ], + inject: [ + 'size', + 'minimized', + 'date' + ], + data() { + return { + start: 0 + } + }, + methods: { + maximize() { + // NOTE(chris): set "app.config.unwrapInjectedRef = true" for this to work + this.minimized = false; + } + }, + template: ` +
+
+ +
+
` +} diff --git a/public/js/components/Calendar/Month.js b/public/js/components/Calendar/Month.js new file mode 100644 index 000000000..a86ce10b6 --- /dev/null +++ b/public/js/components/Calendar/Month.js @@ -0,0 +1,69 @@ +import CalendarAbstract from './Abstract.js'; +import CalendarPane from './Pane.js'; +import CalendarMonthPage from './Month/Page.js'; + +export default { + mixins: [ + CalendarAbstract + ], + components: { + CalendarMonthPage, + CalendarPane + }, + data() { + return { + syncOnNextChange: false + } + }, + computed: { + title() { + return this.focusDate.format({month: ['short','long','long','long'][this.size], year: 'numeric'}); + } + }, + methods: { + paneChanged(dir) { + if (this.syncOnNextChange) { + this.syncOnNextChange = false; + this.focusDate.set(this.date); + } else { + this.focusDate.m += dir; + } + this.$emit('change:range', { + start: new Date(this.focusDate.y, this.focusDate.m, 1), + end: new Date(this.focusDate.y, this.focusDate.m+1, 0) + }); + }, + prev() { + this.$refs.pane.prev(); + }, + next() { + this.$refs.pane.next(); + }, + selectDay(day) { + let m = day.getMonth(); + if (this.focusDate.m != m) { + this.syncOnNextChange = true; + if (this.focusDate.m-1 == m || (m == 11 && !this.focusDate.m)) + this.$refs.pane.prev(); + else + this.$refs.pane.next(); + } else { + this.focusDate.set(this.date); + } + this.$emit('input', ['select:day',day]) + } + }, + created() { + this.$emit('change:range', { + start: new Date(this.focusDate.y, this.focusDate.m, 1), + end: new Date(this.focusDate.y, this.focusDate.m+1, 0) + }); + }, + template: ` +
+ + + + +
` +} diff --git a/public/js/components/Calendar/Month/Page.js b/public/js/components/Calendar/Month/Page.js new file mode 100644 index 000000000..5cb9cdc53 --- /dev/null +++ b/public/js/components/Calendar/Month/Page.js @@ -0,0 +1,75 @@ +import CalendarDate from '../../../composables/CalendarDate.js'; + +export default { + inject: [ + 'date', + 'focusDate', + 'size', + 'events', + 'showWeeks', + 'noWeekView' + ], + props: { + year: Number, + month: Number + }, + emits: [ + 'update:mode', + 'page:back', + 'page:forward', + 'input' + ], + computed: { + weeks() { + let firstDayOfMonth = new CalendarDate(this.year, this.month, 1); + let startDay = firstDayOfMonth.firstDayOfCalendarMonth; + let endDay = firstDayOfMonth.lastDayOfCalendarMonth; + + let res = []; + let week = {no:0,y:0,days:[]}; + while (startDay <= endDay) { + week.days.push(new Date(startDay)); + if (week.days.length == 7) { + let d = new CalendarDate(week.days[res.length ? 0 : 6]); + week.no = d.w; + week.y = d.y; + res.push(week); + week = {no:0,y:0,days:[]}; + } + startDay.setDate(startDay.getDate() + 1); + } + return res; + } + }, + methods: { + selectDay(day) { + this.date.set(day); + this.$emit('input', day); + }, + changeToWeek(week) { + if (!this.noWeekView) { + if (!this.focusDate.isInWeek(week.no, week.y)) + this.focusDate.set(week.days[0]); + this.$emit('update:mode', 'week'); + } + } + }, + template: ` +
+
+
+ {{day.toLocaleString(undefined, {weekday: size < 1 ? 'narrow' : (size < 3 ? 'short' : 'long')})}} +
+ +
` +} diff --git a/public/js/components/Calendar/Months.js b/public/js/components/Calendar/Months.js new file mode 100644 index 000000000..7dcb96d87 --- /dev/null +++ b/public/js/components/Calendar/Months.js @@ -0,0 +1,34 @@ +import CalendarAbstract from './Abstract.js'; + +export default { + mixins: [ + CalendarAbstract + ], + inject: [ + 'size' + ], + data() { + return { + monthIndices: [...Array(12).keys()] + } + }, + computed: { + title() { + return this.focusDate.format({year: 'numeric'}); + }, + months() { + return this.monthIndices.map(i => (new Date(0, i, 1)).toLocaleString(undefined, {month: this.size < 2 ? 'short' : 'long'})); + } + }, + template: ` +
+ +
+
+ +
+
+
` +} diff --git a/public/js/components/Calendar/Pane.js b/public/js/components/Calendar/Pane.js new file mode 100644 index 000000000..bbc21af0c --- /dev/null +++ b/public/js/components/Calendar/Pane.js @@ -0,0 +1,55 @@ +export default { + emits: [ + 'slid' + ], + data() { + return { + carousel: null, + queue: 0, + offset: 0 + } + }, + computed: { + offsets() { + return [...Array(3).keys()].map(i => (3+i-this.offset)%3-1); + } + }, + methods: { + prev() { + if (!this.queue--) + this.carousel.prev(); + }, + next() { + if (!this.queue++) + this.carousel.next(); + }, + slid(evt) { + let dir = evt.direction == 'left' ? 1 : -1; + this.queue -= dir; + this.$emit('slid', dir); + this.offset = (3+this.offset+dir)%3; + if (this.queue) { + if (this.queue > 0) + this.carousel.next(); + else + this.carousel.prev(); + } + } + }, + mounted() { + if (this.$refs.carousel) { + this.$refs.carousel.children[0].children[1].classList.add('active'); + this.carousel = new window.bootstrap.Carousel(this.$refs.carousel, { + interval: false + }); + } + }, + template: ` + ` +} diff --git a/public/js/components/Calendar/Week.js b/public/js/components/Calendar/Week.js new file mode 100644 index 000000000..ca91382f5 --- /dev/null +++ b/public/js/components/Calendar/Week.js @@ -0,0 +1,48 @@ +import CalendarAbstract from './Abstract.js'; +import CalendarPane from './Pane.js'; +import CalendarWeekPage from './Week/Page.js'; + +export default { + mixins: [ + CalendarAbstract + ], + components: { + CalendarWeekPage, + CalendarPane + }, + computed: { + title() { + return this.focusDate.format({year: 'numeric'}) + ' KW ' + this.focusDate.w; + } + }, + methods: { + paneChanged(dir) { + this.focusDate.d += dir * 7; + this.emitRangeChanged(); + }, + emitRangeChanged() { + let start = this.focusDate.firstDayOfWeek; + let end = this.focusDate.lastDayOfWeek; + this.$emit('change:range', { start, end }); + }, + prev() { + this.$refs.pane.prev(); + }, + next() { + this.$refs.pane.next(); + }, + selectEvent(event) { + this.$emit('input', ['select:event',event]); + } + }, + created() { + this.emitRangeChanged(); + }, + template: ` +
+ + + + +
` +} diff --git a/public/js/components/Calendar/Week/Page.js b/public/js/components/Calendar/Week/Page.js new file mode 100644 index 000000000..47d48b7f7 --- /dev/null +++ b/public/js/components/Calendar/Week/Page.js @@ -0,0 +1,115 @@ +import CalendarDate from '../../../composables/CalendarDate.js'; + +function ggt(m,n) { return n==0 ? m : ggt(n, m%n); } +function kgv(m,n) { return (m*n) / ggt(m,n); } + +export default { + inject: [ + 'date', + 'focusDate', + 'size', + 'events', + 'noMonthView' + ], + props: { + year: Number, + week: Number + }, + emits: [ + 'update:mode', + 'page:back', + 'page:forward', + 'input' + ], + data() { + return { + hours: [...Array(24).keys()] + }; + }, + computed: { + days() { + let tmpDate = new CalendarDate(this.year, 1, 1); // NOTE(chris): somewhere in the middle of the year + tmpDate.w = this.week; + let startDay = tmpDate.firstDayOfWeek; + let result = []; + for (let i = 0; i < 7; i++) { + result.push(new Date(startDay.getFullYear(), startDay.getMonth(), startDay.getDate() + i)); + } + return result; + }, + eventsPerDayAndHour() { + const res = {}; + this.days.forEach(day => { + let key = day.toDateString(); + + let nextDay = new Date(day); + nextDay.setDate(nextDay.getDate()+1); + nextDay.setMilliseconds(nextDay.getMilliseconds()-1); + let d = {events:[],lanes:1}; + if (this.events[key]) { + this.events[key].forEach(evt => { + let event = {orig:evt,lane:1,maxLane:1,start: evt.start < day ? day : evt.start, end: evt.end > nextDay ? nextDay : evt.end,shared:[],setSharedMaxRecursive(doneItems) { + this.maxLane = Math.max(doneItems[0].maxLane, this.maxLane); + doneItems.push(this); + this.shared.filter(other => !doneItems.includes(other)).forEach(i => i.setSharedMaxRecursive(doneItems)); + }}; + event.shared = d.events.filter(other => other.start < event.end && other.end > event.start); + event.shared.forEach(other => other.shared.push(event)); + let occupiedLanes = event.shared.map(other => other.lane); + while (occupiedLanes.includes(event.lane)) + event.lane++; + event.maxLane = Math.max(...[event.lane], ...occupiedLanes); + if (event.maxLane > 1) { + event.setSharedMaxRecursive([event]); + } + d.events.push(event); + }); + d.lanes = d.events.map(e => e.maxLane).reduce((res, i) => kgv(res, i), 1); + } + res[key] = d; + }); + return res; + }, + smallestTimeFrame() { + return [30,15,10,5][this.size]; + } + }, + methods: { + changeToMonth(day) { + if (!this.noMonthView) { + this.date.set(day); + this.focusDate.set(day); + this.$emit('update:mode', 'month'); + } + }, + dateToMinutesOfDay(day) { + return Math.floor((day.getHours() * 60 + day.getMinutes()) / this.smallestTimeFrame) + 1; + } + }, + mounted() { + setTimeout(() => this.$refs.eventcontainer.scrollTop = this.$refs.eventcontainer.scrollHeight / 3 + 1, 0); + }, + template: ` +
+
+
+ +
+
+
+
+
{{hour}}:00
+
+ +
+
+
+
` +} diff --git a/public/js/components/Calendar/Weeks.js b/public/js/components/Calendar/Weeks.js new file mode 100644 index 000000000..1ec8545bc --- /dev/null +++ b/public/js/components/Calendar/Weeks.js @@ -0,0 +1,39 @@ +import CalendarAbstract from './Abstract.js'; + +export default { + mixins: [ + CalendarAbstract + ], + inject: [ + 'size', + 'focusDate' + ], + data() { + return { + weeks: [...Array(this.focusDate.numWeeks).keys()].map(i => i+1) + } + }, + computed: { + title() { + return this.focusDate.format({year: 'numeric'}); + } + }, + methods: { + setWeek(week) { + // TODO(chris): test is there a week jump on year select? => yes there is if the same month/day are in different weeks ... should we prevent that? + this.focusDate.w = week; + this.$emit('update:mode', 'week'); + } + }, + template: ` +
+ +
+
+ +
+
+
` +} diff --git a/public/js/components/Calendar/Years.js b/public/js/components/Calendar/Years.js new file mode 100644 index 000000000..a372f748d --- /dev/null +++ b/public/js/components/Calendar/Years.js @@ -0,0 +1,59 @@ +import CalendarAbstract from './Abstract.js'; +import CalendarPane from './Pane.js'; +import CalendarYearsPage from './Years/Page.js'; + +export default { + mixins: [ + CalendarAbstract + ], + components: { + CalendarYearsPage, + CalendarPane + }, + inject: [ + 'size' + ], + data() { + return { + start: 0 + } + }, + computed: { + range() { + switch (this.size) { + case 3: + // eslint-disable-next-line + case 2: + return 24; + } + return 12; + }, + end() { + return this.start + this.range - 1; + }, + title() { + return this.start + ' - ' + this.end; + } + }, + methods: { + paneChanged(dir) { + this.start += this.range * dir; + }, + prev() { + this.$refs.pane.prev(); + }, + next() { + this.$refs.pane.next(); + } + }, + created() { + this.start = this.focusDate.y - this.focusDate.y%this.range; + }, + template: ` +
+ + + + +
` +} diff --git a/public/js/components/Calendar/Years/Page.js b/public/js/components/Calendar/Years/Page.js new file mode 100644 index 000000000..b65471f82 --- /dev/null +++ b/public/js/components/Calendar/Years/Page.js @@ -0,0 +1,29 @@ +export default { + inject: [ + 'focusDate' + ], + props: { + start: Number, + end: Number + }, + emits: [ + 'update:mode' + ], + data() { + return { + } + }, + computed: { + years() { + return [...Array(this.end - this.start).keys()].map(i => i + this.start); + } + }, + template: ` +
+
+ +
+
` +} diff --git a/public/js/composables/CalendarDate.js b/public/js/composables/CalendarDate.js new file mode 100644 index 000000000..1b311739c --- /dev/null +++ b/public/js/composables/CalendarDate.js @@ -0,0 +1,161 @@ +class CalendarDate { + constructor(y, m, d) { + this.weekStart = CalendarDate.getWeekStart(); + this.set(y, m, d); + this._clean(); + } + get y() { return this._y } + set y(v) { this._y = v; this._clean() } + get m() { return this._m } + set m(v) { this._m = v; this._clean() } + get d() { return this._d } + set d(v) { this._d = v; this._clean() } + /** + * @see https://www.smart-rechner.de/kalenderwochen/rechner.php + */ + get w() { + if (this._w === null) { + if (this.weekStart == 1 && this._m == 11 && this._d > 28 && this.wd <= this._d-29) { + this._w = 1; + } else if (this.weekStart == 1 && this._m == 0 && this._d < 4 && 3-this.wd <= -this._d) { + let weekStartOfTheYear = new Date(this.y-1, 0, this.weekStart == 1 ? 4 : 1); + weekStartOfTheYear.setDate(weekStartOfTheYear.getDate() - (weekStartOfTheYear.getDay() + 7 - this.weekStart)%7); + this._w = Math.ceil((Math.floor((new Date(this.y, this.m, this.d) - weekStartOfTheYear) / 86400000) + 1) / 7); + } else { + let weekStartOfTheYear = new Date(this.y, 0, this.weekStart == 1 ? 4 : 1); + weekStartOfTheYear.setDate(weekStartOfTheYear.getDate() - (weekStartOfTheYear.getDay() + 7 - this.weekStart)%7); + this._w = Math.ceil((Math.floor((new Date(this.y, this.m, this.d) - weekStartOfTheYear) / 86400000) + 1) / 7); + } + } + return this._w; + } + set w(v) { + if (this.w != v) { + let lw = this.numWeeks; + + this.d += (v - this.w) * 7; + + if (v > 0 && v <= lw && this.w != v) { + if (this.weekStart != 1) { + if (this.w == 1) { + this.set(this.firstDayOfWeek); + } else { + this.set(this.lastDayOfWeek); + } + } + if (this.w != v) { + console.error('couldn\'t set the week', this, v); + } + } + } + } + get wd() { + if (this._wd === null) { + this._wd = ((new Date(this.y, this.m, this.d)).getDay()+7-this.weekStart)%7; + } + return this._wd; + } + get firstDayOfWeek() { + let firstDayOfWeek = new Date(this.y, this.m, this.d); + firstDayOfWeek.setDate(this.d -(firstDayOfWeek.getDay()+7-this.weekStart)%7); + return firstDayOfWeek; + } + get cdFirstDayOfWeek() { + return new CalendarDate(this.firstDayOfWeek); + } + get lastDayOfWeek() { + let lastDayOfWeek = new Date(this.y, this.m, this.d); + lastDayOfWeek.setDate(this.d -(lastDayOfWeek.getDay()+7-this.weekStart)%7 +6); + return lastDayOfWeek; + } + get cdLastDayOfWeek() { + return new CalendarDate(this.lastDayOfWeek); + } + get firstDayOfCalendarMonth() { + let firstDayOfMonth = new Date(this.y, this.m, 1); + return new Date(this.y, this.m, 1-(firstDayOfMonth.getDay() + 7 - this.weekStart)%7); + } + get cdFirstDayOfCalendarMonth() { + let firstDayOfMonth = new Date(this.y, this.m, 1); + return new CalendarDate(this.y, this.m, 1-(firstDayOfMonth.getDay() + 7 - this.weekStart)%7); + } + get lastDayOfCalendarMonth() { + let lastDayOfMonth = new Date(this.y, this.m+1, 0); + return new Date(lastDayOfMonth.getFullYear(), lastDayOfMonth.getMonth(), lastDayOfMonth.getDate()+6-(lastDayOfMonth.getDay() + 7 - this.weekStart)%7); + } + get cdLastDayOfCalendarMonth() { + let lastDayOfMonth = new Date(this.y, this.m+1, 0); + return new CalendarDate(lastDayOfMonth.getFullYear(), lastDayOfMonth.getMonth(), lastDayOfMonth.getDate()+6-(lastDayOfMonth.getDay() + 7 - this.weekStart)%7); + } + get numWeeks() { + return (new CalendarDate(this.y+1,0,this.weekStart == 1 ? -3 : 0)).w; + } + set(y,m,d,noClean) { + if (y !== undefined && (m === undefined || m === true) && d === undefined) { + if (Object.prototype.toString.call(y) === '[object Date]') + return this.set(y.getFullYear(), y.getMonth(), y.getDate(), m); + if (y.y !== undefined && y.m !== undefined && y.d !== undefined) + return this.set(y.y, y.m, y.d, m); + } + [this._y,this._m,this._d] = [y || 0, m || 0, d || 0]; + if (!noClean) + this._clean(); + } + _clean() { + this.set(new Date(this._y, this._m, this._d), true); + this._w = null; + this._wd = null; + } + format(options) { + return (new Date(this._y, this._m, this._d)).toLocaleString(undefined, options); + } + compare(d) { + if (Object.prototype.toString.call(d) === '[object Date]') + return (this.y === d.getFullYear() && this.m === d.getMonth() && this.d === d.getDate()); + return (this.y === d.y && this.m === d.m && this.d === d.d); + } + isInWeek(w, y) { + if (this.y == y && this.w == w) + return true; + if (this.weekStart == 1) + return false; + let edgeDay = this.cdFirstDayOfWeek;console.log(edgeDay); + if (edgeDay.y == y && edgeDay.w == w) + return true; + edgeDay = this.cdLastDayOfWeek; + if (edgeDay.y == y && edgeDay.w == w) + return true; + return false; + } + setLocale(locale) { + this.weekStart = CalendarDate.getWeekStart(locale); + } +} +/** + * Returns the weekday number (Date.getDay()) on which the week starts depending on the locale. + * This can be Saturday(6), Sunday(0) or Monday(1) + * + * @see https://stackoverflow.com/questions/53382465/how-can-i-determine-if-week-starts-on-monday-or-sunday-based-on-locale-in-pure-j + * + * @param string locale + * + * @return integer + */ +CalendarDate.getWeekStart = function(locale) { + locale = locale || navigator.language; + const parts = locale.match(/^([a-z]{2,3})(?:-([a-z]{3})(?=$|-))?(?:-([a-z]{4})(?=$|-))?(?:-([a-z]{2}|\d{3})(?=$|-))?/i); + const regionSat = 'AEAFBHDJDZEGIQIRJOKWLYOMQASDSY'.match(/../g); + const regionSun = 'AGARASAUBDBRBSBTBWBZCACNCODMDOETGTGUHKHNIDILINJMJPKEKHKRLAMHMMMOMTMXMZNINPPAPEPHPKPRPTPYSASGSVTHTTTWUMUSVEVIWSYEZAZW'.match(/../g); + const languageSat = ['ar','arq','arz','fa']; + const languageSun = 'amasbndzengnguhehiidjajvkmknkolomhmlmrmtmyneomorpapssdsmsnsutatethtnurzhzu'.match(/../g); + + return ( + parts[4] ? ( + regionSun.includes(parts[4]) ? 0 : + regionSat.includes(parts[4]) ? 6 : 1) : ( + languageSun.includes(parts[1]) ? 0 : + languageSat.includes(parts[1]) ? 6 : 1)); +} + + +export default CalendarDate \ No newline at end of file