// ==UserScript== // @name Foodsharing Planner // @namespace http://tampermonkey.net/ // @version 0.11 // @updateURL https://github.com/TroogS/userscripts/raw/master/foodsharing_planner.user.js // @downloadURL https://github.com/TroogS/userscripts/raw/master/foodsharing_planner.user.js // @description Generate a calendar like view as addition to the foodsharing website germany, austria and switzerland // @author A. Beging // @match https://foodsharing.de* // @match https://foodsharing.de/* // @match https://foodsharing.at* // @match https://foodsharing.at/* // @match https://foodsharingschweiz.ch* // @match https://foodsharingschweiz.ch/* // @match https://foodsharing.network* // @match https://foodsharing.network/* // @grant none // ==/UserScript== class CacheHelper { static Write(key, value) { var data = {"data" : value}; console.log("storing data", data); var serializedData = JSON.stringify(data); window.localStorage.setItem("fspl_" + key, serializedData); } static Read(key) { var serializedData = window.localStorage.getItem("fspl_" + key); if(!serializedData) return null; var data = JSON.parse(serializedData); return data.data; } static ReadDate(key) { var serializedData = window.localStorage.getItem("fspl_" + key); if(!serializedData) return null; var data = JSON.parse(serializedData); var date = new Date(data.data); return date; } static PickupsValid() { var lastLoadString = this.ReadDate("pickupLoaded"); if(!lastLoadString) return false; var lastLoad = new Date(lastLoadString); var diff = new Date() - lastLoad; diff = diff / 1000; if(diff > 600) return false; var pickupData = this.Read("pickupData"); if(!pickupData) return false; return true; } static InvalidatePickups() { this.Write("pickupLoaded", null); } static async GetStoreDetails(storeId) { var localStorageKey = "storeDetails-" + storeId; var storeDetails = this.Read(localStorageKey); if(storeDetails) return storeDetails; var storeDetailsApi = await Api.GetStoreDetails(storeId); var storeDetails = { id: storeId, name: storeDetailsApi.store.name, group: storeDetailsApi.store.group }; this.Write(localStorageKey, storeDetails); return storeDetails; } static async LoadPickupsAsync() { var valid = this.PickupsValid(); if(valid) { var pickupData = this.Read("pickupData"); pickupData.forEach(pickup => { pickup.pickup.dateObj = new Date(pickup.pickup.date); }); return pickupData; } var pickupData = await Api.LoadPickupsAsync(); this.Write("pickupData", pickupData); this.Write("pickupLoaded", new Date()); return pickupData; } } class Api { static PickupData; static Token; static User; static async BookPickup(data) { var dateText = Convenience.GetDateText(data.pickup.dateObj); var timeText = Convenience.GetTimeText(data.pickup.dateObj); var confResult = confirm("Bitte bestätigen!\nAbholung " + dateText + " - " + timeText + " bei " + data.store.name + " buchen?"); if(confResult) { Convenience.ShowLoadingOverlay(); var endpoint = "stores/" + data.store.id + "/pickups/" + data.pickup.dateObj.toISOString() + "/" + this.User.id; var result = await this.PostAsync(endpoint); // Invalidate and reload CacheHelper.InvalidatePickups(); BuildPlannerAsync(); } } static async GetStoreDetails(storeId) { return await this.GetAsync("stores/" + storeId); } // Api get call function static async GetAsync(endpoint) { try { const res = await window.$.ajax({ url: 'https://' + window.location.hostname + '/api/' + endpoint, type: 'GET', headers: { "accept": "*/*", "X-CSRF-Token": this.Token, }, }); return res; } catch (e) { return false; } } // Api patch call function static async PatchAsync(endpoint, data) { const res = await window.$.ajax({ url: 'https://' + window.location.hostname + '/api/' + endpoint, type: 'PATCH', contentType: "application/json; charset=utf-8", headers: { "accept": "*/*", "X-CSRF-Token": this.Token, }, data : JSON.stringify(data), success: function(response){ } }); return res; } // Api post call function static async PostAsync(endpoint, data) { try { const res = await window.$.ajax({ url: 'https://' + window.location.hostname + '/api/' + endpoint, type: 'POST', contentType: "application/json; charset=utf-8", headers: { "accept": "*/*", "X-CSRF-Token": this.Token, }, data : JSON.stringify(data) }); return res; } catch (e) { return false; } } static async LoadMe() { this.User = await this.GetAsync("user/current"); } // Load and prepare pickup data from api static async LoadPickupsAsync() { let pickupData = new Array(); var apiStoreData = await this.GetAsync("user/current/stores"); await Convenience.AsyncForEach(apiStoreData, async (store) => { var apiPickups = await this.GetAsync('stores/' + store.id + '/pickups'); if(apiPickups.pickups.length > 0) { apiPickups.pickups.forEach(pickup => { pickup.dateObj = new Date(pickup.date); pickup.freeSlots = pickup.totalSlots - pickup.occupiedSlots.length; var obj = { store: { id: store.id, name: store.name }, pickup: pickup }; pickupData.push(obj); }); } }); return pickupData; } // Read token from cookie static ReadToken() { var nameEQ = "CSRF_TOKEN="; var ca = document.cookie.split(';'); for (var i = 0; i < ca.length; i++) { var c = ca[i]; while (c.charAt(0) == ' ') c = c.substring(1, c.length); if (c.indexOf(nameEQ) == 0) { this.Token = c.substring(nameEQ.length, c.length); return this.Token; } } this.Token = null; return null; } } class Convenience { static async AsyncForEach(array, callback) { for (let index = 0; index < array.length; index++) { await callback(array[index], index, array); } } // Get the first day of the current week (monday) static GetFirstDay() { var curr = new Date(); var first = curr.getDate() - curr.getDay() + 1; var firstDay = new Date(curr.setDate(first)); firstDay.setHours(0,0,0,0); return firstDay; } // Get the last day of the given dates week (sunday) static GetLastDay(firstDayDate) { var curr = new Date(firstDayDate); var last = curr.getDate() - curr.getDay() + 7; var lastDay = new Date(curr.setDate(last)); lastDay.setHours(23,59,59,999); return lastDay; } static GetDateText(date) { return Convenience.WithLeadingZeros(date.getDate(), 2) + "." + Convenience.WithLeadingZeros(date.getMonth() + 1, 2) + "." + Convenience.WithLeadingZeros(date.getFullYear(), 4) } static GetTimeText(date) { return Convenience.WithLeadingZeros(date.getHours(), 2) + ":" + Convenience.WithLeadingZeros(date.getMinutes(), 2); } static IsToday(date) { const today = new Date() return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }; static ShowLoadingOverlay() { var loadingOverlay = document.querySelectorAll(".fspl .loading-overlay")[0]; if(loadingOverlay) loadingOverlay.classList.remove('hidden'); } static HideLoadingOverlay() { var loadingOverlay = document.querySelectorAll(".fspl .loading-overlay")[0]; if(loadingOverlay) loadingOverlay.classList.add('hidden'); } static WithLeadingZeros(number, length) { var stringNumber = number.toString(); while(stringNumber.length < length) { stringNumber = "0" + stringNumber; } return stringNumber; } } class StyleHelper { static ApplyStyle() { this.CreateStyleTag(this.Style); } static CreateStyleTag(cssContent) { var newNode = document.createElement ('style'); newNode.textContent = cssContent; document.head.append(newNode); } static Style = ` :root { --fs-red: #dc3545; --fs-yellow: #ffc107; } .fspl { position: fixed; color: var(--fs-brown); background-color: var(--fs-beige); border: 5px solid var(--fs-brown); border-radius: 10px; z-index: 5000; height: calc(100vh - 100px); width: calc(100vw - 100px); left: 50px; display: flex; flex-direction: column; top: 50px; } .fspl .week { height: 100%; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; overflow-y: auto; } .fspl .loading-overlay { position: absolute; width: 100%; height: 100%; background-color: rgba(255, 255, 255, 0.7); display: flex; justify-content: center; align-items: center; } .hidden { display: none !important; } .fspl .loading-overlay > div { font-size: 2rem; } .fspl .loading-overlay i { animation: rotation 2s linear infinite; } .day { flex: 1; border: 1px solid var(--fs-brown); height: max-content; height: 100%; } .day.today { background-color: #ffeeba; } .day .day-title { font-weight: bold; border-bottom: 1px solid black; padding: 5px; } .day .pickup { border: 0px solid transparent; border-left-width: 5px; outline: 1px solid var(--fs-brown); background-color: var(--fs-white); margin: 5px; border-radius: 5px; padding: 5px; } .day .pickup.me { background-color: #d8ffbe; } .day .pickup.highlight { background-color: #9ef6ff; } .day .pickup > a { color: var(--fs-brown); } .day .pickup.pickup-green { border-left-color: var(--fs-green);; } .day .pickup.pickup-yellow { border-left-color: #64ae2466; } .day .pickup.pickup-red { border-left-color: var(--fs-red); } .day .pickup .img-container { display: grid; grid-template-columns: min-content min-content min-content; gap: 5px; } .day .pickup .img-container div.not-confirmed { opacity: .33; } .day .pickup .img-container div.not-confirmed img { border-inline: 1px solid red; } .day .pickup .img-container > div, .day .pickup .img-container .empty-slot{ width: 35px; height: 35px; } .day .pickup .img-container > div img { border-radius: 5px; border: 1px solid transparent; } .day .pickup .img-container .empty-slot { display: flex; justify-content: center; align-items: center; border: 1px solid #533a20; text-decoration: none; border-radius: 5px; } .day .pickup .img-container .empty-slot:hover i { color: #533a20; } .button-red { background-color: var(--fs-red); } .button-red:hover { background-color: #ff6977; } @keyframes rotation { from { transform: rotate(0deg); } to { transform: rotate(359deg); } } `; } class DataStore { static FirstDay; } class DOMHelper { // Create generic element static CreateElement(tagName, classList, content) { var element = document.createElement(tagName); if(classList) element.classList = classList; if(content) element.append(content); return element; } // Create calendar button for navigation bar static CreatePlannerButton() { var i = this.CreateElement("i", "fas fa-calendar-alt"); var a = this.CreateElement("a", "nav-link", i); a.href = "#"; a.addEventListener('click',function () {TogglePlanner();}); var li = this.CreateElement("li", "nav-item", a); var div = this.CreateElement("div", null, li); document.querySelectorAll(".navbar-nav.nav-row")[0].append(div); } static CreateNavigationSeparator(parentElement) { var separatorElement = this.CreateElement("div", "d-inline"); separatorElement.innerHTML = "•"; parentElement.append(separatorElement); } static CreateNavigationButton(parentElement, title, iconClass, callback) { var buttonElement = this.CreateElement("button", "button m-1"); buttonElement.setAttribute("title", title); buttonElement.innerHTML = ''; buttonElement.addEventListener('click',function () { callback(); }); parentElement.append(buttonElement); return buttonElement; } static CreateNavigationTextButton(parentElement, title, callback) { var buttonElement = this.CreateElement("button", "button m-1"); buttonElement.setAttribute("title", title); buttonElement.innerHTML = title; buttonElement.addEventListener('click',function () { callback(); }); parentElement.append(buttonElement); return buttonElement; } static CreateLoadingOverlay(parentElement) { var loadingOverlay = this.CreateElement("div", "loading-overlay hidden"); var spinnerDiv = this.CreateElement("div"); spinnerDiv.innerHTML = ''; spinnerDiv.innerHTML += " Lade..."; loadingOverlay.append(spinnerDiv); parentElement.append(loadingOverlay); } static SetupMainPanel() { var weekPanel = this.CreateElement("div", "week"); var mainPanel = this.CreateElement("div", "fspl d-none", weekPanel); this.CreateLoadingOverlay(mainPanel); CreateNavigationButtons(mainPanel); document.querySelectorAll("body")[0].append(mainPanel); } } // Startup Function (async function() { 'use strict'; // Load Token Api.ReadToken(); if(!Api.Token) return; // Load user await Api.LoadMe(); if(!Api.User) return; DOMHelper.CreatePlannerButton(); StyleHelper.ApplyStyle(); DataStore.FirstDay = Convenience.GetFirstDay(); DOMHelper.SetupMainPanel(); })(); async function BuildPlannerAsync() { Convenience.ShowLoadingOverlay(); var weekPanel = document.querySelectorAll(".fspl .week")[0]; weekPanel.innerHTML = ""; var mon = CreateColumn(0, "Montag"); var tue = CreateColumn(1, "Dienstag"); var wed = CreateColumn(2, "Mittwoch"); var thu = CreateColumn(3, "Donnerstag"); var fri = CreateColumn(4, "Freitag"); var sat = CreateColumn(5, "Samstag"); var sun = CreateColumn(6, "Sonntag"); weekPanel.append(mon); weekPanel.append(tue); weekPanel.append(wed); weekPanel.append(thu); weekPanel.append(fri); weekPanel.append(sat); weekPanel.append(sun); var pickupData = await CacheHelper.LoadPickupsAsync(); var lastDayDate = Convenience.GetLastDay(DataStore.FirstDay); pickupData.sort(function(a, b){return a.pickup.dateObj > b.pickup.dateObj}); await Convenience.AsyncForEach(pickupData, async (pickup) => { var storeDetails = await CacheHelper.GetStoreDetails(pickup.store.id); pickup.store.details = storeDetails; if(pickup.pickup.dateObj > DataStore.FirstDay && pickup.pickup.dateObj < lastDayDate) { var pickupDiv = CreatePickupDiv(pickup); switch (pickup.pickup.dateObj.getDay()) { case 1: mon.append(pickupDiv); break; case 2: tue.append(pickupDiv); break; case 3: wed.append(pickupDiv); break; case 4: thu.append(pickupDiv); break; case 5: fri.append(pickupDiv); break; case 6: sat.append(pickupDiv); break; case 0: sun.append(pickupDiv); break; } } }); // Get distinct list of store groups const groups = []; const map = new Map(); for (const item of pickupData) { if(!map.has(item.store.details.group.id)){ map.set(item.store.details.group.id, true); // set any value to Map groups.push({ id: item.store.details.group.id, name: item.store.details.group.name }); } } if(groups && groups.length > 1) { var groupButtonDiv = document.querySelectorAll("#groupbuttons")[0]; groupButtonDiv.innerHTML = ""; groups.forEach(group => { DOMHelper.CreateNavigationTextButton(groupButtonDiv, group.name, () => { var targetPickups = document.querySelectorAll(".group-" + group.id); targetPickups.forEach(targetPickup => { targetPickup.classList.toggle("hidden"); }); }); }); DOMHelper.CreateNavigationSeparator(groupButtonDiv); } Convenience.HideLoadingOverlay(); } function CreateNavigationButtons(mainPanel) { var navigationPanel = DOMHelper.CreateElement("div", "fspl-nav text-center"); var groupToggleDiv = DOMHelper.CreateElement("div", "d-inline"); groupToggleDiv.setAttribute("id", "groupbuttons"); navigationPanel.append(groupToggleDiv); DOMHelper.CreateNavigationButton(navigationPanel, "Vorherige Woche", "fas fa-arrow-left", () => { DataStore.FirstDay.setDate(DataStore.FirstDay.getDate() - 7); BuildPlannerAsync(); }); DOMHelper.CreateNavigationButton(navigationPanel, "Heute", "fas fa-calendar-day", () => { DataStore.FirstDay = Convenience.GetFirstDay(); BuildPlannerAsync(); }); DOMHelper.CreateNavigationButton(navigationPanel, "Nächste Woche", "fas fa-arrow-right", () => { DataStore.FirstDay.setDate(DataStore.FirstDay.getDate() + 7); BuildPlannerAsync(); }); DOMHelper.CreateNavigationSeparator(navigationPanel); DOMHelper.CreateNavigationButton(navigationPanel, "Toggle Marker", "fas fa-highlighter", () => { var highlightElements = document.querySelectorAll(".highlighter"); highlightElements.forEach(highlightElement => { highlightElement.classList.toggle("hidden"); }); }); DOMHelper.CreateNavigationSeparator(navigationPanel); DOMHelper.CreateNavigationButton(navigationPanel, "Neu laden", "fas fa-sync", () => { CacheHelper.InvalidatePickups(); BuildPlannerAsync(); }); var closeButton = DOMHelper.CreateNavigationButton(navigationPanel, "Heute", "fas fa-times", () => { TogglePlanner(); }); closeButton.classList.add("button-red"); mainPanel.prepend(navigationPanel); } function CreatePickupDiv(data) { var elementClass = "pickup store-" + data.store.id + " group-" + data.store.details.group.id; if(data.pickup.occupiedSlots.length == data.pickup.totalSlots) elementClass += " pickup-green"; if(data.pickup.occupiedSlots.length < data.pickup.totalSlots && data.pickup.occupiedSlots.length > 0) elementClass += " pickup-yellow"; if(data.pickup.occupiedSlots.length == 0) elementClass += " pickup-red"; var element = DOMHelper.CreateElement("div", elementClass); var highlightLink = DOMHelper.CreateElement("a", "highlighter hidden"); highlightLink.innerHTML = ' '; highlightLink.setAttribute("href", "#"); highlightLink.addEventListener('click', function () { var hlPickups = document.querySelectorAll(".pickup.store-" + data.store.id); hlPickups.forEach(hlPickup => { hlPickup.classList.toggle("highlight"); }); }); element.append(highlightLink); var headerSpan = DOMHelper.CreateElement("a", "font-weight-bold", data.store.name); headerSpan.setAttribute("href", "https://foodsharing.de/?page=fsbetrieb&id=" + data.store.id); headerSpan.setAttribute("target", "_blank"); element.append(headerSpan); var timeString = Convenience.GetTimeText(data.pickup.dateObj); var timeSpan = DOMHelper.CreateElement("div", "", timeString); element.append(timeSpan); var imgContainer = DOMHelper.CreateElement("div", "img-container"); // Occupies slots if(data.pickup.occupiedSlots.length > 0) { data.pickup.occupiedSlots.forEach(slot => { var imgUrl = 'https://' + window.location.hostname + '/images/mini_q_' + slot.profile.avatar; if(slot.profile.avatar.startsWith('/api/')) imgUrl = slot.profile.avatar + '?w=35&h=35'; if(slot.profile.id == Api.User.id) { element.classList.add("me"); } var imgClass = ""; if(!slot.isConfirmed) imgClass = "not-confirmed"; var imgDiv = DOMHelper.CreateElement("div", imgClass); imgDiv.innerHTML = ''; imgContainer.append(imgDiv); }); } // Free slots for (let i = 0; i < data.pickup.freeSlots; i++) { var freeSlotA = DOMHelper.CreateElement("a", "empty-slot"); freeSlotA.setAttribute("href", "#"); freeSlotA.addEventListener('click', function () { Api.BookPickup(data); }); freeSlotA.innerHTML = ''; imgContainer.append(freeSlotA); } element.append(imgContainer); return element; } function CreateColumn(dayOffset, title) { var titleDiv = DOMHelper.CreateElement("div", "day-title text-center", title); titleDiv.innerHTML = title; var displayDate = new Date(DataStore.FirstDay); displayDate.setDate(DataStore.FirstDay.getDate() + dayOffset); titleDiv.innerHTML = titleDiv.innerHTML + "
" + Convenience.GetDateText(displayDate); var classes = "day day-" + dayOffset; if(Convenience.IsToday(displayDate)) classes += " today"; var day = DOMHelper.CreateElement("div", classes, titleDiv); return day; } // Toggle planner visibility. Load data on show function TogglePlanner() { var mainPanel = document.querySelectorAll(".fspl")[0]; if(mainPanel.classList.contains('d-none')) { mainPanel.classList.remove("d-none"); BuildPlannerAsync(); } else { mainPanel.classList.add("d-none"); } }