datatracker/client/agenda/store.js
Nicolas Giard aa9490faf6
feat(ui): new dynamic agenda view (#4086)
* feat: agenda page in vue (wip)

* feat: scroll to agenda day

* fix: vue 3 composition api + eslint settings

* fix: agenda day scroll match indicator

* fix: convert vite deps to yarn

* fix: missing lodash + legacy build step

* fix: agenda - move calendar into drawer

* fix: improve agenda filter UI

* fix: download ics + move agenda into own component

* feat: use fullcalendar for agenda calendar view (wip)

* feat: add events to agenda calendar

* feat: agenda filter UI improvements

* feat: agenda add to calendar dropdown

* feat: agenda calendar filter + timezone + event coloring

* feat: agenda calendar color improvements

* chore: exclude dist-neue from git

* feat: agenda calendar event modal

* fix: rebuild yarn deps

* chore: add run migration task to vscode

* fix: agenda buttons display flag

* feat: agenda event modal component

* feat: show calendar event quick info on hover

* fix: clear calendar quick info on timezone change

* feat: agenda list view improvements

* feat: agenda list row coloring

* feat: agenda list note

* feat: agenda list icons for office hours + hackathon

* fix: agenda top links

* refactor: use pinia as store for agenda components

* feat: agenda jump to now

* fix: agenda mobile improvements

* feat: agenda search

* feat: agenda search improvements

* feat: agenda event recordings buttons for post-meeting

* fix: agenda switch to meeting timezone on load

* feat: agenda pre & live session buttons

* fix: remove agenda utc + personalize links in top menu

* feat: add pre-vue loading state on page load

* feat: filter from agenda picker mode

* fix: agenda UI improvements

* fix: django-vite non-dev mode

* chore: update yarn dependencies for vue + vite

* feat: agenda settings panel + UI improvements

* feat: agenda settings colors + import/export feature

* feat: agenda color assignments + responsive UI improvements

* feat: agenda realtime red line + debug datetime offset

* feat: agenda add aria labels for settings

* feat: add new agenda path + pages/menu

* fix: bring base/menu.html up to main

* fix: agenda various fixes

* test: add new agenda item to meetings menu for item count

* chore: restore devcontainer extensions list

* fix: agenda UI improvements + montserrat default font

* feat: agenda bolder text + hide event icons options

* feat: agenda warning badge

* fix: agenda various UI improvements + intersectionObserver fix

* feat: agenda floorplan page + various UI improvements

* feat: agenda floor plan pin

* feat: view floor plan room from agenda

* feat: agenda floor plan mobile optimization

* feat: adjust calendar options + default calendar view in settings

* feat: agenda persist picked events + change base font only on new agenda page

* feat: agenda mobile view optimizations

* fix: add .vite to cached volumes

* fix: mobile view for filters, calendar, settings panels

* test: upgrade cypress existing tests to work on bs5 + update dependencies

* fix: use named url patterns to avoid hardcoded URLs. Add rudimentary test coverage for the neue views.

Co-authored-by: Robert Sparks <rjsparks@nostrum.com>
2022-07-13 16:20:23 -05:00

257 lines
8.6 KiB
JavaScript

import { defineStore } from 'pinia'
import { DateTime } from 'luxon'
import uniqBy from 'lodash/uniqBy'
import murmur from 'murmurhash-js/murmurhash3_gc'
const urlRe = /http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+/
const conferenceDomains = ['webex.com', 'zoom.us', 'jitsi.org', 'meetecho.com', 'gather.town']
export const useAgendaStore = defineStore('agenda', {
state: () => ({
areaIndicatorsShown: true,
bolderText: false,
debugTools: false,
calendarShown: false,
categories: [],
colorLegendShown: true,
colorPickerVisible: false,
colors: [
{ hex: '#0d6efd', tag: 'Interesting' },
{ hex: '#6f42c1', tag: 'Might Attend' },
{ hex: '#d63384', tag: 'Important' },
{ hex: '#ffc107', tag: 'Food' },
{ hex: '#20c997', tag: 'Attended' }
],
colorAssignments: {},
currentTab: 'agenda',
dayIntersectId: '',
defaultCalendarView: 'week',
eventIconsShown: true,
filterShown: false,
floorIndicatorsShown: true,
floors: [],
infoNoteHash: '',
infoNoteShown: true,
isCurrentMeeting: false,
isLoaded: false,
isMobile: /Mobi/i.test(navigator.userAgent),
listDayCollapse: false,
meeting: {},
nowDebugDiff: null,
pickerMode: false,
pickerModeView: false,
pickedEvents: [],
redhandShown: true,
schedule: [],
searchText: '',
searchVisible: false,
selectedCatSubs: [],
settingsShown: false,
timezone: DateTime.local().zoneName,
useHedgeDoc: false,
viewport: Math.round(window.innerWidth),
visibleDays: []
}),
getters: {
isTimezoneLocal (state) {
return state.timezone === DateTime.local().zoneName
},
isTimezoneMeeting (state) {
return state.timezone === state.meeting.timezone
},
scheduleAdjusted (state) {
return state.schedule.filter(s => {
// -> Apply category filters
if (state.selectedCatSubs.length > 0 && !s.filterKeywords.some(k => state.selectedCatSubs.includes(k))) {
return false
}
// -> Don't show events of type lead
if (s.type === 'lead') { return false }
// -> Filter individual events if picker mode active
if (state.pickerMode && state.pickerModeView && !state.pickedEvents.includes(s.id)) {
return false
}
// -> Filter by search text if present
if (state.searchVisible && state.searchText) {
const searchStr = `${s.name} ${s.groupName} ${s.acronym} ${s.room} ${s.note}`
if (searchStr.toLowerCase().indexOf(state.searchText) < 0) {
return false
}
}
return true
}).map(s => {
// -> Adjust times to selected timezone
const eventStartDate = DateTime.fromISO(s.startDateTime, { zone: state.meeting.timezone }).setZone(state.timezone)
const eventEndDate = eventStartDate.plus({ seconds: s.duration })
// -> Find remote call-in URL
let remoteCallInUrl = null
if (s.note) {
remoteCallInUrl = findFirstConferenceUrl(s.note)
}
if (!remoteCallInUrl && s.remoteInstructions) {
remoteCallInUrl = findFirstConferenceUrl(s.remoteInstructions)
}
if (!remoteCallInUrl && s.links.webex) {
remoteCallInUrl = s.links.webex
}
return {
...s,
adjustedStart: eventStartDate,
adjustedEnd: eventEndDate,
adjustedStartDate: eventStartDate.toISODate(),
adjustedStartDateTime: eventStartDate.toISO(),
adjustedEndDateTime: eventEndDate.toISO(),
links: {
recordings: s.links.recordings,
videoStream: formatLinkUrl(s.links.videoStream, s, state.meeting.number),
onsiteTool: formatLinkUrl(s.links.onsiteTool, s, state.meeting.number),
audioStream: formatLinkUrl(s.links.audioStream, s, state.meeting.number),
remoteCallIn: remoteCallInUrl,
calendar: s.links.calendar
},
sessionKeyword: s.sessionToken ? `${s.groupAcronym}-${s.sessionToken}` : s.groupAcronym
}
})
},
meetingDays () {
return uniqBy(this.scheduleAdjusted, 'adjustedStartDate').sort().map(s => ({
slug: s.id.toString(),
ts: s.adjustedStartDate,
label: this.viewport < 1350 ? DateTime.fromISO(s.adjustedStartDate).toFormat('ccc LLL d') : DateTime.fromISO(s.adjustedStartDate).toLocaleString(DateTime.DATE_HUGE)
}))
},
isMeetingLive (state) {
const current = (state.nowDebugDiff ? DateTime.local().minus(state.nowDebugDiff) : DateTime.local()).setZone(state.timezone)
const isAfterStart = this.scheduleAdjusted.some(s => s.adjustedStart < current)
const isBeforeEnd = this.scheduleAdjusted.some(s => s.adjustedEnd > current)
return isAfterStart && isBeforeEnd
}
},
actions: {
fetch () {
const agendaData = JSON.parse(document.getElementById('agenda-data').textContent)
// -> Switch to meeting timezone
this.timezone = agendaData.meeting.timezone
// -> Load meeting data
this.categories = agendaData.categories
this.floors = agendaData.floors
this.isCurrentMeeting = agendaData.isCurrentMeeting
this.meeting = agendaData.meeting
this.schedule = agendaData.schedule
this.useHedgeDoc = agendaData.useHedgeDoc
// -> Compute current info note hash
this.infoNoteHash = murmur(agendaData.meeting.infoNote, 0).toString()
// -> Load meeting-specific preferences
this.infoNoteShown = !(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.hideInfo`) === this.infoNoteHash)
this.colorAssignments = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.colorAssignments`) || '{}')
this.pickedEvents = JSON.parse(window.localStorage.getItem(`agenda.${agendaData.meeting.number}.pickedEvents`) || '[]')
this.isLoaded = true
},
persistMeetingPreferences () {
if (this.infoNoteShown) {
window.localStorage.removeItem(`agenda.${this.meeting.number}.hideInfo`)
} else {
window.localStorage.setItem(`agenda.${this.meeting.number}.hideInfo`, this.infoNoteHash)
}
window.localStorage.setItem(`agenda.${this.meeting.number}.colorAssignments`, JSON.stringify(this.colorAssignments))
window.localStorage.setItem(`agenda.${this.meeting.number}.pickedEvents`, JSON.stringify(this.pickedEvents))
},
findCurrentEventId () {
const current = (this.nowDebugDiff ? DateTime.local().minus(this.nowDebugDiff) : DateTime.local()).setZone(this.timezone)
// -> Find last event before current time
let lastEvent = {}
for(const sh of this.scheduleAdjusted) {
if (sh.adjustedStart <= current && sh.adjustedEnd > current) {
// -> Use the first event of multiple events having identical times
if (lastEvent.start === sh.adjustedStart.toMillis()) {
continue
} else {
lastEvent = {
id: sh.id,
start: sh.adjustedStart.toMillis(),
end: sh.adjustedEnd.toMillis()
}
}
}
// -> Skip future events
if (sh.adjustedStart > current) {
break
}
}
return lastEvent.id || null
},
hideLoadingScreen () {
// -> Hide loading screen
const loadingRef = document.querySelector('#app-meeting-loading')
if (loadingRef) {
loadingRef.remove()
}
}
},
persist: {
enabled: true,
strategies: [
{
storage: localStorage,
paths: [
'areaIndicatorsShown',
'bolderText',
'colorLegendShown',
'colors',
'defaultCalendarView',
'eventIconsShown',
'floorIndicatorsShown',
'listDayCollapse',
'redhandShown'
]
}
]
}
})
/**
* Format URL by replacing inline variables
*
* @param {String} url
* @param {Object} session
* @param {String} meetingNumber
* @returns Formatted URL
*/
function formatLinkUrl (url, session, meetingNumber) {
return url ? url.replace('{meeting.number}', meetingNumber)
.replace('{group.acronym}', session.groupAcronym)
.replace('{short}', session.short)
.replace('{order_number}', session.orderInMeeting) : url
}
/**
* Find the first URL in text matching a conference domain
*
* @param {String} txt
* @returns First URL found
*/
function findFirstConferenceUrl (txt) {
try {
const fUrl = txt.match(urlRe)
if (fUrl && fUrl[0].length > 0) {
const pUrl = new URL(fUrl[0])
if (conferenceDomains.some(d => pUrl.hostname.endsWith(d))) {
return fUrl[0]
}
}
} catch (err) { }
return null
}