@digital-blueprint/lunchlottery-app
Version:
[GitHub Repository](https://github.com/digital-blueprint/lunchlottery-app) | [npmjs package](https://www.npmjs.com/package/@digital-blueprint/lunchlottery-app) | [Unpkg CDN](https://unpkg.com/browse/@digital-blueprint/lunchlottery-app/)
856 lines (765 loc) • 32.2 kB
JavaScript
import {css, html} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
import {ScopedElementsMixin} from '@dbp-toolkit/common';
import * as commonUtils from '@dbp-toolkit/common/utils';
import {Icon, MiniSpinner, LoadingButton} from '@dbp-toolkit/common';
import * as commonStyles from '@dbp-toolkit/common/styles';
import {FORM_IDENTIFIER} from './constants';
import {LunchLotteryDate, LunchLotteryEvent, LunchLotteryTable} from './lunch-lottery';
import {TabulatorTable} from '@dbp-toolkit/tabulator-table';
import DBPLunchlotteryLitElement from './dbp-lunchlottery-lit-element';
const VIEW_INIT = 'init';
const VIEW_SUBMISSIONS = 'submissions';
const VIEW_SETTINGS = 'settings';
const VIEW_RESULTS = 'results';
class LunchLotteryAssignSeats extends ScopedElementsMixin(DBPLunchlotteryLitElement) {
constructor() {
super();
this.name = null;
this.entryPointUrl = null;
// activity
this.view = VIEW_INIT;
this.loading = false;
this.dateOptions = {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
};
// formalize data
this.dates = [];
this.submissions = [];
// expanded formalize data
this.expandedDates = [];
this.expandedSubmissions = [];
// Cache for organization unit codes to avoid repeated API calls
this.orgUnitCodeCache = new Map();
let langs = {
en: {
columns: {
details: this._i18n.t('results.details', {lng: 'en'}),
givenName: this._i18n.t('results.givenName', {lng: 'en'}),
familyName: this._i18n.t('results.familyName', {lng: 'en'}),
email: this._i18n.t('results.email', {lng: 'en'}),
organizationNames: this._i18n.t('results.organizationNames', {lng: 'en'}),
preferredLanguage: this._i18n.t('results.preferredLanguage', {lng: 'en'}),
date: this._i18n.t('results.date', {lng: 'en'}),
table: this._i18n.t('results.table', {lng: 'en'}),
privacyConsent: this._i18n.t('results.privacyConsent', {lng: 'en'}),
},
},
de: {
columns: {
details: this._i18n.t('results.details', {lng: 'de'}),
givenName: this._i18n.t('results.givenName', {lng: 'de'}),
familyName: this._i18n.t('results.familyName', {lng: 'de'}),
email: this._i18n.t('results.email', {lng: 'de'}),
organizationNames: this._i18n.t('results.organizationNames', {lng: 'de'}),
preferredLanguage: this._i18n.t('results.preferredLanguage', {lng: 'de'}),
date: this._i18n.t('results.date', {lng: 'de'}),
table: this._i18n.t('results.table', {lng: 'de'}),
privacyConsent: this._i18n.t('results.privacyConsent', {lng: 'de'}),
},
},
};
// data
this.dataOptions = {
langs: langs,
layout: 'fitDataFill',
responsiveLayout: false,
columns: [],
columnDefaults: {
vertAlign: 'middle',
hozAlign: 'left',
resizable: false,
},
};
this.dataOptionsColumnsPrepend = [
{
title: 'details',
field: 'details',
hozAlign: 'center',
width: 65,
headerHozAlign: 'center',
sorter: 'string',
headerSort: false,
},
{
title: this._i18n.t('results.givenName'),
field: 'givenName',
minWidth: 120,
},
{
title: this._i18n.t('results.familyName'),
field: 'familyName',
minWidth: 120,
},
{title: this._i18n.t('results.email'), field: 'email', visible: 0},
{
title: this._i18n.t('results.organizationNames'),
field: 'organizationNames',
minWidth: 250,
maxWidth: 350,
formatter: function (cell, formatterParams, onRendered) {
return cell.getValue().join(', ');
}.bind(this),
},
{title: this._i18n.t('results.preferredLanguage'), field: 'preferredLanguage'},
{
title: this._i18n.t('results.possibleDates'),
field: 'possibleDates',
width: 120,
visible: false,
formatter: function (cell, formatterParams, onRendered) {
return cell.getValue().join(', ');
}.bind(this),
},
{
title: this._i18n.t('results.date'),
field: 'date',
width: 120,
minWidth: 120,
widthShrink: 0,
formatter: this.dateFormatter.bind(this),
},
{title: this._i18n.t('results.table'), field: 'table'},
];
this.dataOptionsColumnsAppend = [
{
title: this._i18n.t('results.privacyConsent'),
field: 'privacyConsent',
formatter: function (cell, formatterParams, onRendered) {
if (cell.getValue()) {
return this._i18n.t('results.privacyConsent-true');
} else {
return this._i18n.t('results.privacyConsent-false');
}
}.bind(this),
},
];
this.dataRows = [];
// settings
this.processButtonDisabled = true;
this.processButtonDisabledReason = null;
this.tables = {};
// results
this.variants = [];
this.currentVariant = 0;
}
static get scopedElements() {
return {
'dbp-icon': Icon,
'dbp-loading-button': LoadingButton,
'dbp-mini-spinner': MiniSpinner,
'dbp-tabulator-table': TabulatorTable,
};
}
static get properties() {
return {
...super.properties,
name: {type: String},
entryPointUrl: {type: String, attribute: 'entry-point-url'},
loading: {type: Boolean, attribute: false},
processButtonDisabled: {type: Boolean, attribute: false},
processButtonDisabledReason: {type: String, attribute: false},
};
}
initialize() {
super.initialize();
this.loadData();
}
async loadData() {
this.loading = true;
await this.getDates();
this.expandDates();
await this.getSubmissions();
this.expandSubmissions();
this.loadTables();
this.loading = false;
this.displaySubmissions();
}
async fetchForm() {
const response = await this.httpGetAsync(
this.entryPointUrl + '/formalize/forms/' + encodeURIComponent(FORM_IDENTIFIER),
{
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + this.auth.token,
},
},
);
if (!response.ok) {
this.handleErrorResponse(response);
}
return response;
}
async getDates() {
let response = await this.fetchForm();
const responseBody = await response.json();
const formSchema = JSON.parse(responseBody['dataFeedSchema']);
if ('enum' in formSchema['properties']['possibleDates']['items']) {
for (
let i = 0;
i < formSchema['properties']['possibleDates']['items']['enum'].length;
++i
) {
const isoStringWithTimezoneOffset =
formSchema['properties']['possibleDates']['items']['enum'][i];
this.dates.push(isoStringWithTimezoneOffset);
}
}
}
expandDates() {
this.expandedDates = [];
this.dates.forEach((identifier) => {
const item = {
identifier: identifier,
date: new Date(identifier),
};
this.expandedDates.push(item);
});
}
dateFormatter(cell, formatterParams, onRendered) {
let value = cell.getValue();
if (value && typeof value === 'string' && !isNaN(Date.parse(value))) {
const date = new Date(value);
let dateString = '';
dateString += ('0' + date.getDate()).slice(-2) + '.';
dateString += ('0' + (date.getMonth() + 1)).slice(-2) + '.';
dateString += date.getFullYear();
value = dateString;
return value;
}
}
dateTitleFormatter(cell, formatterParams, onRendered) {
const definition = cell.getColumn().getDefinition();
let value = definition.title;
if (value && typeof value === 'string' && !isNaN(Date.parse(value))) {
const date = new Date(value);
let dateString = '';
dateString += ('0' + date.getDate()).slice(-2) + '.';
dateString += ('0' + (date.getMonth() + 1)).slice(-2) + '.';
dateString += date.getFullYear();
value = dateString;
return value;
}
}
async fetchSubmissionsCollection() {
const response = await this.httpGetAsync(
this.entryPointUrl +
'/formalize/submissions?formIdentifier=' +
encodeURIComponent(FORM_IDENTIFIER) +
'&perPage=99999',
{
headers: {
'Content-Type': 'application/ld+json',
Authorization: 'Bearer ' + this.auth.token,
},
},
);
if (!response.ok) {
this.handleErrorResponse(response);
}
return response;
}
async getSubmissions() {
let response = await this.fetchSubmissionsCollection();
const responseBody = await response.json();
let submissions = {};
let submissionsCreated = {};
for (let i = 0; i < responseBody['hydra:member'].length; ++i) {
const item = JSON.parse(responseBody['hydra:member'][i]['dataFeedElement']);
const created = new Date(responseBody['hydra:member'][i]['dateCreated']);
if (
!(item['identifier'] in submissions) ||
created > submissionsCreated[item['identifier']]
) {
submissions[item['identifier']] = item;
submissionsCreated[item['identifier']] = created;
}
}
this.submissions = Object.values(submissions);
}
expandSubmissions() {
this.expandedSubmissions = [];
let availableDates = {};
this.expandedDates.forEach((expandedDate) => {
availableDates[expandedDate['identifier']] = false;
});
this.submissions.forEach((submission) => {
let expandedPossibleDates = {...availableDates};
submission['possibleDates'].forEach((identifier) => {
if (identifier in expandedPossibleDates) {
expandedPossibleDates[identifier] = true;
} else {
console.error({
error: 'possibleDates mismatch',
identifier: identifier,
availableDates: availableDates,
submission: submission,
});
}
});
let item = Object.assign(submission, expandedPossibleDates);
this.expandedSubmissions.push(item);
});
}
updateDataOptionsColumns() {
let dataOptionsColumns = [];
this.expandedDates.forEach((expandedDate) => {
const title = expandedDate['date'].toISOString();
const identifier = expandedDate['identifier'];
dataOptionsColumns.push({
title: title,
field: identifier,
titleFormatter: this.dateTitleFormatter.bind(this),
formatter: function (cell, formatterParams, onRendered) {
if (cell.getValue()) {
return this._i18n.t('results.availableDate-true');
} else {
return this._i18n.t('results.availableDate-false');
}
}.bind(this),
});
});
this.dataOptions['columns'] = [
...this.dataOptionsColumnsPrepend,
...dataOptionsColumns,
...this.dataOptionsColumnsAppend,
];
}
displaySubmissions() {
this.updateDataOptionsColumns();
this.dataRows = this.expandedSubmissions;
this.view = VIEW_SUBMISSIONS;
const tabulatorTable = /** @type {TabulatorTable} */ (
this._('#tabulator-table-submissions')
);
tabulatorTable.options.data = this.dataRows;
tabulatorTable.buildTable();
}
downloadSubmissions() {
let table = this._('#tabulator-table-submissions');
let fileFormat = 'xlsx';
const today = new Date();
let month = today.getMonth() + 1;
month = month.toString();
let day = today.getDate();
day = day.toString();
let year = today.getFullYear();
year = year.toString();
let dataName = 'LLRegistrations' + day + month + year;
table.download(fileFormat, dataName);
}
downloadResults() {
let table = this._('#tabulator-table-results');
let fileFormat = 'xlsx';
const today = new Date();
let month = today.getMonth() + 1;
month = month.toString();
let day = today.getDate();
day = day.toString();
let year = today.getFullYear();
year = year.toString();
let dataName = 'LLSubmissions' + day + month + year;
table.download(fileFormat, dataName);
}
showSettings() {
this.view = VIEW_SETTINGS;
this.requestUpdate();
}
validateSettings() {
if (this.expandedDates.length) {
this.processButtonDisabled = false;
this.processButtonDisabledReason = null;
this.expandedDates.forEach((expandedDate, index) => {
let dateSeats = 0;
if (index in this.tables && this.tables[index].length) {
this.tables[index].forEach((table) => {
const tableSeats = table['number'] * table['seats'];
dateSeats += tableSeats;
});
}
if (dateSeats <= 0) {
this.processButtonDisabled = true;
this.processButtonDisabledReason = this._i18n.t('process.no-seats');
}
});
} else {
this.processButtonDisabled = true;
this.processButtonDisabledReason = this._i18n.t('process.no-dates');
}
}
update(changedProperties) {
super.update(changedProperties);
changedProperties.forEach((oldValue, propName) => {
if (propName === 'lang') {
this.validateSettings();
}
});
}
saveTables() {
localStorage.setItem('tables', JSON.stringify(this.tables));
this.validateSettings();
this.requestUpdate();
}
loadTables() {
const data = localStorage.getItem('tables');
if (data) {
this.tables = JSON.parse(data);
this.validateSettings();
}
}
addTable(dateIndex) {
this.tables[dateIndex].push({
number: 0,
seats: 0,
});
this.saveTables();
}
updateTableNumber(dateIndex, tableIndex, value) {
this.tables[dateIndex][tableIndex]['number'] = value;
this.saveTables();
}
updateTableSeats(dateIndex, tableIndex, value) {
this.tables[dateIndex][tableIndex]['seats'] = value;
this.saveTables();
}
removeTable(dateIndex, tableIndex) {
this.tables[dateIndex].splice(tableIndex, 1);
this.saveTables();
}
getTables(dateIndex) {
if (!(dateIndex in this.tables)) {
this.tables[dateIndex] = [];
this.addTable(dateIndex);
}
return this.tables[dateIndex];
}
createLunchLotteryEvent() {
let lunchLotteryEvent = new LunchLotteryEvent();
this.expandedDates.forEach((date, index) => {
let lunchLotteryDate = new LunchLotteryDate(date['identifier']);
lunchLotteryEvent.addDate(lunchLotteryDate);
this.tables[index].forEach((table) => {
for (let i = parseInt(table['number']); i > 0; --i) {
let lunchLotteryTable = new LunchLotteryTable(parseInt(table['seats']));
lunchLotteryDate.addTable(lunchLotteryTable);
}
});
});
return lunchLotteryEvent;
}
injectOrgUnitCodesIntoSubmission(submission) {
// remove the last character from each org unit code to prevent people under the same parent
// org unit from being seated together
submission.orgUnitCodes = (submission.organizationIds ?? [])
.map((orgUnitId) => String(orgUnitId ?? ''))
.filter((orgUnitId) => orgUnitId.length > 0)
.map((orgUnitId) => (orgUnitId.length > 1 ? orgUnitId.slice(0, -1) : orgUnitId));
return submission;
}
async calculateDistances() {
let lunchLotteryEvent = this.createLunchLotteryEvent();
let submissions = this.expandedSubmissions;
console.log('calculateDistances submissions', submissions);
while (submissions.length) {
let distances = {};
for (let submission of submissions) {
submission = this.injectOrgUnitCodesIntoSubmission(submission);
// console.log('calculateDistances submission', submission);
const [distance, table, date] = lunchLotteryEvent.getShortestDistance(submission);
// we need integer keys for shortest distance; float numbers are casted to string, then it's not working
const distanceKey = Math.floor(distance);
if (!(distanceKey in distances)) {
distances[distanceKey] = [];
}
distances[distanceKey].push({
submission: submission,
table: table,
date: date,
});
}
const shortestDistance = Object.keys(distances)[0];
if (shortestDistance >= 9999) {
break;
}
const randomKey = Math.floor(Math.random() * distances[shortestDistance].length);
const randomItem = distances[shortestDistance][randomKey];
lunchLotteryEvent.assign(
randomItem['date'],
randomItem['table'],
randomItem['submission'],
);
submissions = submissions.filter(
(submission) => submission !== randomItem['submission'],
);
}
if (submissions.length) {
lunchLotteryEvent.setUnassigned(submissions);
}
return lunchLotteryEvent;
}
flattenResults(lunchLotteryEvent) {
let rows = [];
lunchLotteryEvent.dates.forEach((date) => {
date.tables.forEach((table, index) => {
let seat = 1;
table.seats.forEach((assignment) => {
let row = Object.assign({}, assignment); // clone
row['date'] = date.identifier;
row['table'] = index + 1 + ': ' + seat + '/' + table.availableSeats;
rows.push(row);
++seat;
});
});
});
lunchLotteryEvent.unassigned.forEach((submission) => {
rows.push(submission);
});
return rows;
}
async process() {
const lunchLotteryEvent = await this.calculateDistances();
this.dataRows = this.flattenResults(lunchLotteryEvent);
this.variants.push(this.dataRows);
this.currentVariant = this.variants.length - 1;
this.view = VIEW_RESULTS;
this.requestUpdate();
const tabulatorTable = this._('#tabulator-table-results');
tabulatorTable.options.data = this.dataRows;
tabulatorTable.buildTable();
}
static get styles() {
return [
commonStyles.getThemeCSS(),
commonStyles.getGeneralCSS(),
commonStyles.getActivityCSS(),
commonStyles.getButtonCSS(),
css`
#tabulator-table-results,
#tabulator-table-submissions {
--dbp-tabulator-collapse-padding-left: 68px;
}
.hidden {
display: none;
}
.align-right {
text-align: right;
}
.control-button-container {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.date,
.tabulator-table {
display: block;
margin-bottom: 2rem;
}
.date h4 {
margin: 0;
}
input[type='number'] {
padding: calc(0.375em - 1px) 0.75em;
font-family: inherit;
font-size: inherit;
font-weight: bolder;
border-color: var(--dbp-secondary-surface-border-color);
border-width: 1px;
}
td {
padding: 0.375em;
}
td:first-child {
padding-left: 0;
}
.totalSubmission {
display: flex;
justify-content: flex-start;
}
.buttons {
float: right;
}
.inlineElements {
display: inline-block;
}
@media screen and (max-width: 530px) {
.control-button-container {
flex-direction: column;
}
}
`,
];
}
render() {
let i18n = this._i18n;
return html`
<div class="control full-size-spinner ${classMap({hidden: !this.loading})}">
<span class="loading">
<dbp-mini-spinner text=${i18n.t('loading')}></dbp-mini-spinner>
</span>
</div>
<div class="${classMap({hidden: this.loading || this.view !== VIEW_SUBMISSIONS})}">
<p>${i18n.t('submissions.total')} ${this.submissions.length}</p>
<div class="control-button-container">
<button
type="button"
class="button"
title="${i18n.t('process.settings')}"
@click="${() => this.showSettings()}">
<dbp-icon title="${i18n.t('process.settings')}" name="dinner"></dbp-icon>
<span>${i18n.t('process.settings')}</span>
</button>
<button
type="button"
class="button"
title="${i18n.t('process.download')}"
@click="${() => this.downloadSubmissions()}">
<dbp-icon title="${i18n.t('process.download')}" name="download"></dbp-icon>
<span>${i18n.t('process.download')}</span>
</button>
</div>
<dbp-tabulator-table
lang="${this.lang}"
class="tabulator-table"
.options="${this.dataOptions}"
id="tabulator-table-submissions"></dbp-tabulator-table>
</div>
<div class="${classMap({hidden: this.loading || this.view !== VIEW_SETTINGS})}">
${this.expandedDates.map(
(date, dateIndex) => html`
<div class="date">
<h4>
${new Intl.DateTimeFormat(undefined, this.dateOptions).format(
date.date,
)}
</h4>
<table>
<tr>
<th>${i18n.t('table.number')}</th>
<th aria-hidden="true"></th>
<th>${i18n.t('table.seats')}</th>
<th></th>
</tr>
${this.getTables(dateIndex).map(
(table, tableIndex) => html`
<tr>
<td>
<input
type="number"
class="textField"
size="4"
maxlength="2"
min="0"
max="99"
.value=${table.number}
@change=${(e) =>
this.updateTableNumber(
dateIndex,
tableIndex,
e.target.value,
)} />
</td>
<td aria-hidden="true">x</td>
<td>
<input
type="number"
class="textField"
size="2"
maxlength="1"
min="0"
max="9"
.value=${table.seats}
@change=${(e) =>
this.updateTableSeats(
dateIndex,
tableIndex,
e.target.value,
)} />
</td>
<td>
<button
type="button"
class="button"
title="${i18n.t('table.remove')}"
@click="${() =>
this.removeTable(dateIndex, tableIndex)}">
<dbp-icon
title="${i18n.t('table.remove')}"
name="trash"></dbp-icon>
<span>${i18n.t('table.remove')}</span>
</button>
</td>
</tr>
`,
)}
</table>
<button
type="button"
class="button"
title="${i18n.t('table.add')}"
@click="${() => this.addTable(dateIndex)}">
<dbp-icon title="${i18n.t('table.add')}" name="plus"></dbp-icon>
<span>${i18n.t('table.add')}</span>
</button>
</div>
`,
)}
<div>
${
this.processButtonDisabledReason
? html`
<p>
<em>${this.processButtonDisabledReason}</em>
</p>
`
: ''
}
<dbp-loading-button
title="${i18n.t('process.run')}"
?disabled=${this.processButtonDisabled}
@click="${async (e) => {
let button = e.currentTarget;
button.start();
try {
await this.process();
} finally {
button.stop();
}
}}">
<dbp-icon title="${i18n.t('process.run')}" name="dinner"></dbp-icon>
<span>${i18n.t('process.run')}</span>
</dbp-loading-button>
<br><br><p><em>${i18n.t('process.note')}</em></p></em></p>
</div>
</div>
<div class="${classMap({hidden: this.loading || this.view !== VIEW_RESULTS})}">
<div class="control-button-container">
<button
type="button"
class="button"
title="${i18n.t('process.settings')}"
@click="${() => this.showSettings()}">
<dbp-icon title="${i18n.t('process.settings')}" name="dinner"></dbp-icon>
<span>${i18n.t('process.settings')}</span>
</button>
<button
type="button"
class="button"
title="${i18n.t('process.download')}"
@click="${() => this.downloadResults()}">
<dbp-icon title="${i18n.t('process.download')}" name="download"></dbp-icon>
<span>${i18n.t('process.download')}</span>
</button>
</div>
<dbp-tabulator-table
lang="${this.lang}"
class="tabulator-table"
.options="${this.dataOptions}"
id="tabulator-table-results"></dbp-tabulator-table>
</div>
`;
}
}
commonUtils.defineCustomElement('dbp-lunchlottery-assign-seats', LunchLotteryAssignSeats);