commit f6e83226cb93cdf78d8c9ecf688453be01fed9f9
parent 198477b7c8bfd67dd51f6dc57aa6d7eff792ac43
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Mon, 17 Nov 2025 01:47:08 +0100
parent 198477b7c8bfd67dd51f6dc57aa6d7eff792ac43
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Mon, 17 Nov 2025 01:47:08 +0100
settings: refactor settingsStore logic, remove `zustand` dependency
13 files changed, 796 insertions(+), 613 deletions(-)
M
|
1219
+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
diff --git a/flake.nix b/flake.nix @@ -53,7 +53,7 @@ oeffisearch = final.stdenv.mkDerivation (finalAttrs: { pname = "oeffisearch"; version = finalAttrs.env.GIT_VERSION; - npmHash = "sha256-27P06lkKqApo32eL8GPh19OE9rT0d4NLopu2+ezWSps="; + npmHash = "sha256-VakvVABhQEZyPi2Ssota3CaYPXiouhJEfA72U0KNr9s="; src = inputs.self;
diff --git a/package.json b/package.json @@ -17,8 +17,7 @@ "hafas-client": "^6.3.5", "ics": "^3.8.1", "idb": "^8.0.3", - "lit": "^3.3.0", - "zustand": "^5.0.5" + "lit": "^3.3.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: lit: specifier: ^3.3.0 version: 3.3.0 - zustand: - specifier: ^5.0.5 - version: 5.0.5 devDependencies: '@rollup/plugin-commonjs': specifier: ^28.0.3 @@ -2707,24 +2704,6 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} - zustand@5.0.5: - resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '>=18.0.0' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - snapshots: '@ampproject/remapping@2.3.0': @@ -5724,5 +5703,3 @@ snapshots: tiny-case: 1.0.3 toposort: 2.0.2 type-fest: 2.19.0 - - zustand@5.0.5: {}
diff --git a/src/app_functions.js b/src/app_functions.js @@ -1,5 +1,5 @@ import { db } from './dataStorage.js'; -import { settingsState } from './settings.js'; +import { settings } from './settings.js'; import { getHafasClient, client } from './hafasClient.js'; import { trainsearchToHafas, hafasToTrainsearch } from './refresh_token/index.js'; import { CustomDate } from './helpers.js'; @@ -56,9 +56,9 @@ const journeySettings = () => { language: t('backendLang'), }; - if (settingsState.profile === 'db') { - params.loyaltyCard = settingsState.loyaltyCard; - params.ageGroup = settingsState.ageGroup; + if (settings.profile === 'db') { + params.loyaltyCard = settings.loyaltyCard; + params.ageGroup = settings.ageGroup; } return params; @@ -101,7 +101,7 @@ export const newJourneys = async params => { data.indexOffset = 0; data.params = params; data.settings = journeySettingsObj; - data.profile = settingsState.profile; + data.profile = settings.profile; await addJourneys(data);
diff --git a/src/baseView.js b/src/baseView.js @@ -30,7 +30,7 @@ export class BaseView extends LitElement { this.isOffline = !navigator.onLine; this.isUpdating = false; - this.settingsState = settings.getState(); + this.settingsState = settings; this.overlayState = { type: 'plain', visible: false, @@ -45,7 +45,7 @@ export class BaseView extends LitElement { this._unsubscribeSettingsState = settings.subscribe(state => { this.settingsState = state; - this.performUpdate(); + this.requestUpdate(); }); window.addEventListener('online', this.connectionHandler);
diff --git a/src/formatters.js b/src/formatters.js @@ -1,6 +1,6 @@ import { getDS100byIBNR } from './ds100.js'; import { padZeros } from './helpers.js'; -import { settingsState } from './settings.js'; +import { settings } from './settings.js'; export const formatPoint = point => { switch (point.type) { @@ -8,7 +8,7 @@ export const formatPoint = point => { case 'station': let station = point.name; - if (settingsState.showDS100) { + if (settings.showDS100) { const ds100 = getDS100byIBNR(point.id); if (ds100 !== null) station += ` (${ds100})`; }
diff --git a/src/helpers.js b/src/helpers.js @@ -1,6 +1,7 @@ export const sleep = delay => new Promise((resolve) => setTimeout(resolve, delay)); export const isEmptyObject = obj => Object.keys(obj).length === 0; export const padZeros = str => (('00' + str).slice(-2)); +export const upperFirst = str => str[0].toUpperCase() + str.slice(1); export const setThemeColor = color => document.querySelector('meta[name="theme-color"]').setAttribute('content', color); export const queryBackgroundColor = (target, query) => window.getComputedStyle(target.querySelector(query)).getPropertyValue('background-color');
diff --git a/src/languages.js b/src/languages.js @@ -86,7 +86,8 @@ export const languages = { 'minTransferTime': 'Transfer time (Minutes)', 'trainType': 'Train type', 'close': 'Close', - 'addCalendar': 'Export as ICS' + 'addCalendar': 'Export as ICS', + 'history': 'History' }, 'de': { @@ -177,7 +178,8 @@ export const languages = { 'price': 'Preis', 'refresh': 'Aktualisieren', 'back': 'Zurück', - 'addCalendar': 'Als ICS exportieren' + 'addCalendar': 'Als ICS exportieren', + 'history': 'Verlauf' }, 'nl': {
diff --git a/src/main.js b/src/main.js @@ -3,7 +3,7 @@ import { cache } from 'lit/directives/cache.js'; import { initDataStorage } from './dataStorage.js'; import { initHafasClient } from './hafasClient.js'; -import { initSettingsState, settingsState } from './settings.js' +import { initSettings, settings } from './settings.js' import { baseStyles } from './styles.js'; @@ -70,9 +70,9 @@ class Oeffisearch extends LitElement { customElements.define('oeffi-search', Oeffisearch); window.addEventListener('load', async () => { - await initSettingsState(); + await initSettings(); await initDataStorage(); - await initHafasClient(settingsState.profile); + await initHafasClient(settings.profile); const style = document.createElement('style'); style.type = 'text/css';
diff --git a/src/searchView.js b/src/searchView.js @@ -1,522 +1,705 @@ -import { LitElement, html, nothing } from 'lit'; -import { classMap } from 'lit/directives/class-map.js'; -import { when } from 'lit/directives/when.js'; -import { BaseView } from './baseView.js'; - -import { db } from './dataStorage.js'; -import { t } from './translate.js'; -import { client } from './hafasClient.js'; -import { getIBNRbyDS100 } from './ds100.js'; -import { formatPoint } from './formatters.js'; -import { newJourneys } from './app_functions.js'; -import { CustomDate, sleep, queryBackgroundColor, setThemeColor } from './helpers.js'; - -import { searchViewStyles } from './styles.js'; +import { LitElement, html, nothing } from "lit"; +import { classMap } from "lit/directives/class-map.js"; +import { when } from "lit/directives/when.js"; +import { BaseView } from "./baseView.js"; + +import { db } from "./dataStorage.js"; +import { t } from "./translate.js"; +import { client } from "./hafasClient.js"; +import { getIBNRbyDS100 } from "./ds100.js"; +import { formatPoint } from "./formatters.js"; +import { newJourneys } from "./app_functions.js"; +import { + CustomDate, + sleep, + queryBackgroundColor, + setThemeColor, +} from "./helpers.js"; + +import { searchViewStyles } from "./styles.js"; class SearchView extends BaseView { - static properties = { - date: { state: true }, - numEnter: { state: true }, - noTransfers: { state: true }, - isArrival: { state: true }, - showHistory: { state: true }, - history: { state: true }, - location: { state: true }, - }; - - static styles = [ - super.styles, - searchViewStyles - ]; - - constructor () { - super(); - - this.date = new CustomDate(); - this.numEnter = 0; - this.noTransfers = false, - this.isArrival = false, - this.showHistory = false; - this.history = []; - this.location = { - from: { - value: '', - suggestionsVisible: false, - suggestionSelected: null, - suggestion: null, - suggestions: [], - }, - to: { - value: '', - suggestionsVisible: false, - suggestionSelected: null, - suggestion: null, - suggestions: [], - }, - via: { - value: '', - suggestionsVisible: false, - suggestionSelected: null, - suggestion: null, - suggestions: [], - }, - }; - } - - async connectedCallback () { - super.connectedCallback(); - - this.history = (await db.getHistory(this.settingsState.profile)).slice().reverse(); - - await sleep(200); - setThemeColor(queryBackgroundColor(document, 'body')); - await sleep(200); - this.renderRoot.querySelector('input[name=from]').focus(); - } - - async willUpdate (previous) { - if ( - previous.has('settingsState') && - previous.get('settingsState') !== undefined && - previous.get('settingsState').profile !== this.settingsState.profile - ) await this.profileChangeHandler(); - } - - updated (previous) { - super.updated(previous, 'SearchView'); - } - - renderView = () => { - return html` - <div class="container"> - <div class="title flex-center"><h1>${APP_NAME}</h1></div> - - <form class="center" id="form" @submit=${this.submitHandler}> - ${['from', 'via', 'to'].map(name => html` - <div class="flex-row nowrap ${name === 'via' && !this.settingsState.showVia ? 'hidden' : ''}"> - <input type="text" class="flex-grow" name="${name}" title="${t(name)}" placeholder="${t(name)}" .value=${this.location[name].value} - @focus=${this.focusHandler} - @blur=${this.blurHandler} - @keydown=${this.keydownHandler} - @keyup=${this.keyupHandler} - @input=${this.inputHandler} - autocomplete="off" ?required=${name !== 'via'}> - - ${when( - name === 'from', - () => html` - <div class="button icon-arrow ${classMap({ flipped: this.settingsState.showVia })}" tabindex="0" title="${t('via')}" - @keydown=${this.keyClickHandler} @click=${this.settingsState.toggleShowVia}></div> - ` - )} - ${when( - name === 'via', - () => html`<div class="button icon-arrow invisible"></div>` - )} - ${when( - name === 'to', - () => html` - <div class="button icon-swap" tabindex="0" title=${t('swap')} - @keydown=${this.keyClickHandler} @click=${this.swapFromTo}></div> - ` - )} - </div> - - <div class="suggestions ${this.location[name].suggestionsVisible ? '' : 'hidden'}"> - ${this.location[name].suggestions.map((suggestion, index) => html` - <p class="${index !== this.location[name].suggestion ? nothing : 'selected'}" - @click=${(event) => this.setSuggestion(name, index, event.pointerType)} - @mouseover=${() => this.mouseOverHandler(name)} - @mouseout=${() => this.mouseOutHandler(name)}>${formatPoint(suggestion)}</p> - `)} - </div> - `)} - - <div class="flex-row"> - <div class="selector"> - <input type="radio" id="departure" name="isArrival" value="0" @change=${this.changeHandler} .checked=${!this.isArrival}> - <label for="departure" tabindex=0 @keydown=${this.keyClickHandler}>${t('departure')}</label> - <input type="radio" id="arrival" name="isArrival" value="1" @change=${this.changeHandler} .checked=${this.isArrival}> - <label for="arrival" tabindex=0 @keydown=${this.keyClickHandler}>${t('arrival')}</label> - </div> - - <div class="button now" tabindex=0 title="${t('titleSetDateTimeNow')}" - @keydown=${this.keyClickHandler} @click=${this.resetDate}>${t('now')}</div> - ${!this.settingsState.combineDateTime ? html` - <input type="time" name="time" title="${t('time')}" class="flex-grow" @change=${this.changeHandler} .value=${this.date.formatTime()} required> - <input type="date" name="date" title="${t('date')}" class="flex-grow" @change=${this.changeHandler} .value=${this.date.formatISODate()} required> - ` : html` - <input type="datetime-local" name="dateTime" title="${t('date')} & ${t('time')}" class="flex-grow" - @change=${this.changeHandler} .value=${this.date.formatISODateTime()} required> - `} - </div> - - <div class="flex-row"> - <div class="selector rectangular"> - ${client.profile.products.map(product => html` - <input type="checkbox" name="${product.id}" id="${product.id}" - @change=${(event) => this.settingsState.toggleProduct(event.target.name)} .checked=${this.settingsState.products[product.id] ?? true}> - <label class="${this.iconForProduct(product.id)}" for="${product.id}" title="${t('product')}: ${product.name}"></label> - `)} - </div> - - <div class="selector rectangular"> - <input type="checkbox" id="bikeFriendly" name="bikeFriendly" @change=${this.settingsState.toggleBikeFriendly} .checked=${this.settingsState.bikeFriendly}> - <label class="icon-bike" for="bikeFriendly" title="${t('titleBikeFriendly')}"></label> - </div> - - <div class="selector rectangular"> - <input type="checkbox" id="noTransfers" name="noTransfers" @change=${this.changeHandler} .checked=${this.noTransfers}> - <label class="icon-seat" for="noTransfers" title="${t('titleNoTransfers')}"></label> - </div> - - <div class="filler"></div> - - <div class="button icon-settings" title="${t('settings')}" @click=${this.showSettings}></div> - <button type="submit" tabindex="0" id="submit">${t('search')}</button> - </div> - - ${this.history.length !== 0 ? html` - <div id="historyButton" class="arrowButton icon-arrow ${classMap({ flipped: this.showHistory })}" title=${t('history')} @click=${this.toggleHistory}></div> - ` : nothing} - </form> - - ${when( - this.showHistory, - () => html` - <div id="history" class="history center"> - ${this.history.map((element, index) => html` - <div class="flex-row" @click="${() => this.journeysHistoryAction(index)}"> - <div class="from"> - <small>${t('from')}:</small> - ${formatPoint(element.fromPoint)} - ${element.viaPoint ? html`<div class="via">${t('via')} ${formatPoint(element.viaPoint)}</div>` : nothing} - </div> - <div class="icon-arrow1"></div> - <div class="to"> - <small>${t('to')}:</small> - ${formatPoint(element.toPoint)} - </div> - </div> - `)} - </div> - ` - )} - <footer-component></footer-component> - </div> - `; - }; - - swapFromTo = () => { - this.location.from = [this.location.to, this.location.to = this.location.from][0]; - this.requestUpdate(); - }; - - resetDate = () => { - this.date = new CustomDate(); - this.requestUpdate(); - }; - - showSettings = () => this.showDialogOverlay('settings', html`<settings-view></settings-view>`); - toggleHistory = () => this.showHistory = !this.showHistory; - - journeysHistoryAction = num => { - const element = this.history[num]; - - const options = [ - {'label': t('setfromto'), 'action': () => { this.setFromHistory(element.key); this.hideOverlay(); }}, - {'label': t('journeyoverview'), 'action': () => { window.location = `#/${element.slug}/${this.settingsState.journeysViewMode}`; this.hideOverlay(); }} - ]; - - if (element.lastSelectedJourneyId !== undefined) { - options.push({ - 'label': t('lastSelectedJourney'), - 'action': () => { window.location = `#/j/${this.settingsState.profile}/${element.lastSelectedJourneyId}`; this.hideOverlay(); } - }); - } - - this.showSelectOverlay(options); - }; - - setFromHistory = async id => { - const entry = await db.getHistoryEntry(id); - if (!entry) return; - - [ 'from', 'via', 'to' ].forEach(mode => { - if ( entry[`${mode}Point`] === null) return false; - if (mode === 'via') this.settingsState.setShowVia(true); - - this.location[mode].value = formatPoint(entry[`${mode}Point`]); - this.location[mode].suggestionSelected = entry[`${mode}Point`]; - }); - - this.requestUpdate(); - }; - - iconForProduct = id => { - const productIcons = { - // DB - "nationalExpress": "icon-ice", - "national": "icon-ic", - "regionalExpress": "icon-dzug", - - // BVG - "express": "icon-icice", - - // nahsh - "interregional": "icon-dzug", - "onCall": "icon-taxi", - - // SNCB - "intercity-p": "icon-ic", - "s-train": "icon-suburban", - - // RMV - "express-train": "icon-ice", - "long-distance-train": "icon-ic", - "regiona-train": "icon-regional", - "s-bahn": "icon-suburban", - "u-bahn": "icon-subway", - "watercraft": "icon-ferry", - "ast": "icon-taxi", - - // Rejseplanen - "national-train": "icon-ic", - "national-train-2": "icon-icl", - "local-train": "icon-re", - "o": "icon-o", - "s-tog": "icon-suburban", - }; - - return productIcons[id] || `icon-${id}`; - }; - - focusNextElement = currentElementId => { - switch (currentElementId) { - case 'from': - this.renderRoot.querySelector('input[name=to]').focus(); - - if (this.settingsState.showVia) this.renderRoot.querySelector('input[name=via]').focus(); - break; - - case 'via': - this.renderRoot.querySelector('input[name=to]').focus(); - break; - - case 'to': - this.renderRoot.querySelector('[type=submit]').focus(); - break; - } - }; - - setSuggestion = (name, num, pointerType) => { - this.location[name].value = formatPoint(this.location[name].suggestions[num]); - this.location[name].suggestionSelected = this.location[name].suggestions[num]; - this.location[name].suggestionsVisible = false; - this.requestUpdate(); - if (pointerType) this.focusNextElement(name); - }; - - profileChangeHandler = async () => { - [ 'from', 'via','to' ].forEach(name => { - this.location[name] = { - value: '', - suggestionsVisible: false, - suggestionSelected: null, - suggestion: null, - suggestions: [], - }; - }); - - this.history = (await db.getHistory(this.settingsState.profile)).slice().reverse(); - }; - - submitHandler = async event => { - event.preventDefault(); - - if (this.isOffline !== false) { - this.showAlertOverlay(t('offline')); - return; - } - - const params = { - from: null, - to: null, - via: null, - results: 6, - products: {}, - bike: this.settingsState.bikeFriendly, - transferTime: this.settingsState.transferTime, - }; - - await Promise.all([ 'from', 'via', 'to' ].map(async mode => { - if (this.location[mode].value !== '') { - if (mode === 'via' && !this.settingsState.showVia) return false; - - if (!this.location[mode].suggestionSelected) { - if (this.location[mode].suggestions.length !== 0) { - params[mode] = this.location[mode].suggestions[0] - } else { - const data = await client.locations(this.location[mode].value, {'results': 1}); - if (!data[0]) return false; - params[mode] = data[0]; - } - } else { - params[mode] = this.location[mode].suggestionSelected; - } - } - })); - - if (!params.from || !params.to) return false; - - if (formatPoint(params.from) === formatPoint(params.to) && params.via === null) { - this.showAlertOverlay('From and To are the same place.'); - return false; - }; - - client.profile.products.forEach(product => { - params.products[product.id] = this.settingsState.products[product.id] ?? true; - }); - - if (this.noTransfers) params.transfers = 0; - - if (!this.isArrival) params.departure = this.date.getTime(); - else params.arrival = this.date.getTime(); - - if (this.settingsState.profile !== 'db') { - params.accessibility = this.settingsState.accessibility; - params.walkingSpeed = this.settingsState.walkingSpeed; - } - - if (isDevServer) console.info('SearchView(params):',params); - - try { - this.showLoaderOverlay(); - const responseData = await newJourneys(params); - - window.location = `#/${responseData.slug}/${this.settingsState.journeysViewMode}`; - this.hideOverlay(); - } catch(e) { - this.showAlertOverlay(e.toString()); - console.error(e); - } - }; - - mouseOverHandler = name => { this.location[name].suggestionsFocused = true; }; - mouseOutHandler = name => { this.location[name].suggestionsFocused = false; }; - - focusHandler = event => { - const name = event.target.name; - - this.location[name].suggestionsVisible = true; - this.requestUpdate(); - }; - - blurHandler = event => { - const name = event.target.name; - - if (!this.location[name].suggestionsFocused) { - this.location[name].suggestionsVisible = false; - this.requestUpdate(); - } - }; - - keyupHandler = event => { - const name = event.target.name; - const value = event.target.value; - - if (event.key !== 'Enter') return true; - - if (this.numEnter === 2 && value === formatPoint(this.location[name].suggestionSelected)) { - this.numEnter = 0; - this.focusNextElement(name); - } - }; - - keydownHandler = event => { - const name = event.target.name; - - if (this.location[name].suggestions.length === 0) return true; - - if (event.key === 'Enter') { - event.preventDefault(); - this.numEnter++; - this.setSuggestion(name, this.location[name].suggestion); - return true; - }; - - if (!this.location[name].suggestionsVisible) { - this.numEnter = 0; - this.location[name].suggestionsVisible = true; - this.requestUpdate(); - return true; - } - - if (['Escape', 'Tab'].includes(event.key)) { - this.location[name].suggestionsVisible = false; - } - - if (['ArrowUp', 'ArrowDown'].includes(event.key) && !event.shiftKey) { - event.preventDefault(); - - const numSuggesttions = this.location[name].suggestions.length-1; - - if (event.key === 'ArrowUp') { - if (this.location[name].suggestion === 0) { - this.location[name].suggestion = numSuggesttions; - } else { - this.location[name].suggestion--; - } - } else { - if (this.location[name].suggestion === numSuggesttions) { - this.location[name].suggestion = 0; - } else { - this.location[name].suggestion++; - } - } - } - - this.requestUpdate(); - }; - - inputHandler = async event => { - const name = event.target.name; - const value = event.target.value.trim(); - - if (['from', 'via', 'to'].includes(name)) { - if (this.isOffline !== false) return; - if (value === '') return; - - this.location[name].value = value; - - let suggestions = []; - const ds100Result = getIBNRbyDS100(value.toUpperCase()); - - if (ds100Result !== null) suggestions = await client.locations(ds100Result, {'results': 1}) - - suggestions = suggestions.concat(await client.locations(value, {'results': 10})); - - this.location[name].suggestionSelected = null; - this.location[name].suggestion = 0; - this.location[name].suggestions = suggestions; - - this.requestUpdate(); - }; - }; - - changeHandler = event => { - const name = event.target.name; - const value = event.target.value; - - if (name === 'noTransfers') this.noTransfers = !this.noTransfers; - if (name === 'isArrival') this.isArrival = !this.isArrival; - if (name === 'dateTime') this.date.setDateTime(value); - if (name === 'date') this.date.setDate(value); - if (name === 'time') this.date.setTime(value); - - this.requestUpdate(); - }; - + static properties = { + date: { state: true }, + numEnter: { state: true }, + noTransfers: { state: true }, + isArrival: { state: true }, + showHistory: { state: true }, + history: { state: true }, + location: { state: true }, + }; + + static styles = [super.styles, searchViewStyles]; + + constructor() { + super(); + + this.date = new CustomDate(); + this.numEnter = 0; + ((this.noTransfers = false), + (this.isArrival = false), + (this.showHistory = false)); + this.history = []; + this.location = { + from: { + value: "", + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + to: { + value: "", + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + via: { + value: "", + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }, + }; + } + + async connectedCallback() { + super.connectedCallback(); + + this.history = (await db.getHistory(this.settingsState.profile)) + .slice() + .reverse(); + + await sleep(200); + setThemeColor(queryBackgroundColor(document, "body")); + await sleep(200); + this.renderRoot.querySelector("input[name=from]").focus(); + } + + async willUpdate(previous) { + if ( + previous.has("settingsState") && + previous.get("settingsState") !== undefined && + previous.get("settingsState").profile !== this.settingsState.profile + ) + await this.profileChangeHandler(); + } + + updated(previous) { + super.updated(previous, "SearchView"); + } + + renderView = () => { + return html` + <div class="container"> + <div class="title flex-center"><h1>${APP_NAME}</h1></div> + + <form class="center" id="form" @submit=${this.submitHandler}> + ${["from", "via", "to"].map( + (name) => html` + <div + class="flex-row nowrap ${name === "via" && + !this.settingsState.showVia + ? "hidden" + : ""}" + > + <input + type="text" + class="flex-grow" + name="${name}" + title="${t(name)}" + placeholder="${t(name)}" + .value=${this.location[name].value} + @focus=${this.focusHandler} + @blur=${this.blurHandler} + @keydown=${this.keydownHandler} + @keyup=${this.keyupHandler} + @input=${this.inputHandler} + autocomplete="off" + ?required=${name !== "via"} + /> + + ${when( + name === "from", + () => html` + <div + class="button icon-arrow ${classMap({ + flipped: this.settingsState.showVia, + })}" + tabindex="0" + title="${t("via")}" + @keydown=${this.keyClickHandler} + @click=${this.settingsState.toggleShowVia} + ></div> + `, + )} + ${when( + name === "via", + () => html`<div class="button icon-arrow invisible"></div>`, + )} + ${when( + name === "to", + () => html` + <div + class="button icon-swap" + tabindex="0" + title=${t("swap")} + @keydown=${this.keyClickHandler} + @click=${this.swapFromTo} + ></div> + `, + )} + </div> + + <div + class="suggestions ${this.location[name].suggestionsVisible + ? "" + : "hidden"}" + > + ${this.location[name].suggestions.map( + (suggestion, index) => html` + <p + class="${index !== this.location[name].suggestion + ? nothing + : "selected"}" + @click=${(event) => + this.setSuggestion(name, index, event.pointerType)} + @mouseover=${() => this.mouseOverHandler(name)} + @mouseout=${() => this.mouseOutHandler(name)} + > + ${formatPoint(suggestion)} + </p> + `, + )} + </div> + `, + )} + + <div class="flex-row"> + <div class="selector"> + <input + type="radio" + id="departure" + name="isArrival" + value="0" + @change=${this.changeHandler} + .checked=${!this.isArrival} + /> + <label + for="departure" + tabindex="0" + @keydown=${this.keyClickHandler} + >${t("departure")}</label + > + <input + type="radio" + id="arrival" + name="isArrival" + value="1" + @change=${this.changeHandler} + .checked=${this.isArrival} + /> + <label for="arrival" tabindex="0" @keydown=${this.keyClickHandler} + >${t("arrival")}</label + > + </div> + + <div + class="button now" + tabindex="0" + title="${t("titleSetDateTimeNow")}" + @keydown=${this.keyClickHandler} + @click=${this.resetDate} + > + ${t("now")} + </div> + ${!this.settingsState.combineDateTime + ? html` + <input + type="time" + name="time" + title="${t("time")}" + class="flex-grow" + @change=${this.changeHandler} + .value=${this.date.formatTime()} + required + /> + <input + type="date" + name="date" + title="${t("date")}" + class="flex-grow" + @change=${this.changeHandler} + .value=${this.date.formatISODate()} + required + /> + ` + : html` + <input + type="datetime-local" + name="dateTime" + title="${t("date")} & ${t("time")}" + class="flex-grow" + @change=${this.changeHandler} + .value=${this.date.formatISODateTime()} + required + /> + `} + </div> + + <div class="flex-row"> + <div class="selector rectangular"> + ${client.profile.products.map( + (product) => html` + <input + type="checkbox" + name="${product.id}" + id="${product.id}" + @click=${() => this.settingsState.toggleProduct(product.id)} + .checked=${this.settingsState.products[product.id] ?? true} + /> + <label + class="${this.iconForProduct(product.id)}" + for="${product.id}" + title="${t("product")}: ${product.name}" + ></label> + `, + )} + </div> + + <div class="selector rectangular"> + <input + type="checkbox" + id="bikeFriendly" + name="bikeFriendly" + @change=${this.settingsState.toggleBikeFriendly} + .checked=${this.settingsState.bikeFriendly} + /> + <label + class="icon-bike" + for="bikeFriendly" + title="${t("titleBikeFriendly")}" + ></label> + </div> + + <div class="selector rectangular"> + <input + type="checkbox" + id="noTransfers" + name="noTransfers" + @change=${this.changeHandler} + .checked=${this.noTransfers} + /> + <label + class="icon-seat" + for="noTransfers" + title="${t("titleNoTransfers")}" + ></label> + </div> + + <div class="filler"></div> + + <div + class="button icon-settings" + title="${t("settings")}" + @click=${this.showSettings} + ></div> + <button type="submit" tabindex="0" id="submit"> + ${t("search")} + </button> + </div> + </form> + + ${when( + this.history.length !== 0, + () => html` + <div id="history" class="history center"> + <h3>${t('history')}</h3> + ${this.history.map( + (element, index) => html` + <div + class="flex-row" + @click="${() => this.journeysHistoryAction(index)}" + > + <div class="from"> + <small>${t("from")}:</small> + ${formatPoint(element.fromPoint)} + ${element.viaPoint + ? html`<div class="via"> + ${t("via")} ${formatPoint(element.viaPoint)} + </div>` + : nothing} + </div> + <div class="icon-arrow1"></div> + <div class="to"> + <small>${t("to")}:</small> + ${formatPoint(element.toPoint)} + </div> + </div> + `, + )} + </div> + `, + )} + <footer-component></footer-component> + </div> + `; + }; + + swapFromTo = () => { + this.location.from = [ + this.location.to, + (this.location.to = this.location.from), + ][0]; + this.requestUpdate(); + }; + + resetDate = () => { + this.date = new CustomDate(); + this.requestUpdate(); + }; + + showSettings = () => + this.showDialogOverlay("settings", html`<settings-view></settings-view>`); + toggleHistory = () => (this.showHistory = !this.showHistory); + + journeysHistoryAction = (num) => { + const element = this.history[num]; + + const options = [ + { + label: t("setfromto"), + action: () => { + this.setFromHistory(element.key); + this.hideOverlay(); + }, + }, + { + label: t("journeyoverview"), + action: () => { + window.location = `#/${element.slug}/${this.settingsState.journeysViewMode}`; + this.hideOverlay(); + }, + }, + ]; + + if (element.lastSelectedJourneyId !== undefined) { + options.push({ + label: t("lastSelectedJourney"), + action: () => { + window.location = `#/j/${this.settingsState.profile}/${element.lastSelectedJourneyId}`; + this.hideOverlay(); + }, + }); + } + + this.showSelectOverlay(options); + }; + + setFromHistory = async (id) => { + const entry = await db.getHistoryEntry(id); + if (!entry) return; + + ["from", "via", "to"].forEach((mode) => { + if (entry[`${mode}Point`] === null) return false; + if (mode === "via") this.settingsState.setShowVia(true); + + this.location[mode].value = formatPoint(entry[`${mode}Point`]); + this.location[mode].suggestionSelected = entry[`${mode}Point`]; + }); + + this.requestUpdate(); + }; + + iconForProduct = (id) => { + const productIcons = { + // DB + nationalExpress: "icon-ice", + national: "icon-ic", + regionalExpress: "icon-dzug", + + // BVG + express: "icon-icice", + + // nahsh + interregional: "icon-dzug", + onCall: "icon-taxi", + + // SNCB + "intercity-p": "icon-ic", + "s-train": "icon-suburban", + + // RMV + "express-train": "icon-ice", + "long-distance-train": "icon-ic", + "regiona-train": "icon-regional", + "s-bahn": "icon-suburban", + "u-bahn": "icon-subway", + watercraft: "icon-ferry", + ast: "icon-taxi", + + // Rejseplanen + "national-train": "icon-ic", + "national-train-2": "icon-icl", + "local-train": "icon-re", + o: "icon-o", + "s-tog": "icon-suburban", + }; + + return productIcons[id] || `icon-${id}`; + }; + + focusNextElement = (currentElementId) => { + switch (currentElementId) { + case "from": + this.renderRoot.querySelector("input[name=to]").focus(); + + if (this.settingsState.showVia) + this.renderRoot.querySelector("input[name=via]").focus(); + break; + + case "via": + this.renderRoot.querySelector("input[name=to]").focus(); + break; + + case "to": + this.renderRoot.querySelector("[type=submit]").focus(); + break; + } + }; + + setSuggestion = (name, num, pointerType) => { + this.location[name].value = formatPoint( + this.location[name].suggestions[num], + ); + this.location[name].suggestionSelected = + this.location[name].suggestions[num]; + this.location[name].suggestionsVisible = false; + this.requestUpdate(); + if (pointerType) this.focusNextElement(name); + }; + + profileChangeHandler = async () => { + ["from", "via", "to"].forEach((name) => { + this.location[name] = { + value: "", + suggestionsVisible: false, + suggestionSelected: null, + suggestion: null, + suggestions: [], + }; + }); + + this.history = (await db.getHistory(this.settingsState.profile)) + .slice() + .reverse(); + }; + + submitHandler = async (event) => { + event.preventDefault(); + + if (this.isOffline !== false) { + this.showAlertOverlay(t("offline")); + return; + } + + const params = { + from: null, + to: null, + via: null, + results: 6, + products: {}, + bike: this.settingsState.bikeFriendly, + transferTime: this.settingsState.transferTime, + }; + + await Promise.all( + ["from", "via", "to"].map(async (mode) => { + if (this.location[mode].value !== "") { + if (mode === "via" && !this.settingsState.showVia) return false; + + if (!this.location[mode].suggestionSelected) { + if (this.location[mode].suggestions.length !== 0) { + params[mode] = this.location[mode].suggestions[0]; + } else { + const data = await client.locations(this.location[mode].value, { + results: 1, + }); + if (!data[0]) return false; + params[mode] = data[0]; + } + } else { + params[mode] = this.location[mode].suggestionSelected; + } + } + }), + ); + + if (!params.from || !params.to) return false; + + if ( + formatPoint(params.from) === formatPoint(params.to) && + params.via === null + ) { + this.showAlertOverlay("From and To are the same place."); + return false; + } + + client.profile.products.forEach((product) => { + params.products[product.id] = + this.settingsState.products[product.id] ?? true; + }); + + if (this.noTransfers) params.transfers = 0; + + if (!this.isArrival) params.departure = this.date.getTime(); + else params.arrival = this.date.getTime(); + + if (this.settingsState.profile !== "db") { + params.accessibility = this.settingsState.accessibility; + params.walkingSpeed = this.settingsState.walkingSpeed; + } + + if (isDevServer) console.info("SearchView(params):", params); + + try { + this.showLoaderOverlay(); + const responseData = await newJourneys(params); + + window.location = `#/${responseData.slug}/${this.settingsState.journeysViewMode}`; + this.hideOverlay(); + } catch (e) { + this.showAlertOverlay(e.toString()); + console.error(e); + } + }; + + mouseOverHandler = (name) => { + this.location[name].suggestionsFocused = true; + }; + mouseOutHandler = (name) => { + this.location[name].suggestionsFocused = false; + }; + + focusHandler = (event) => { + const name = event.target.name; + + this.location[name].suggestionsVisible = true; + this.requestUpdate(); + }; + + blurHandler = (event) => { + const name = event.target.name; + + if (!this.location[name].suggestionsFocused) { + this.location[name].suggestionsVisible = false; + this.requestUpdate(); + } + }; + + keyupHandler = (event) => { + const name = event.target.name; + const value = event.target.value; + + if (event.key !== "Enter") return true; + + if ( + this.numEnter === 2 && + value === formatPoint(this.location[name].suggestionSelected) + ) { + this.numEnter = 0; + this.focusNextElement(name); + } + }; + + keydownHandler = (event) => { + const name = event.target.name; + + if (this.location[name].suggestions.length === 0) return true; + + if (event.key === "Enter") { + event.preventDefault(); + this.numEnter++; + this.setSuggestion(name, this.location[name].suggestion); + return true; + } + + if (!this.location[name].suggestionsVisible) { + this.numEnter = 0; + this.location[name].suggestionsVisible = true; + this.requestUpdate(); + return true; + } + + if (["Escape", "Tab"].includes(event.key)) { + this.location[name].suggestionsVisible = false; + } + + if (["ArrowUp", "ArrowDown"].includes(event.key) && !event.shiftKey) { + event.preventDefault(); + + const numSuggesttions = this.location[name].suggestions.length - 1; + + if (event.key === "ArrowUp") { + if (this.location[name].suggestion === 0) { + this.location[name].suggestion = numSuggesttions; + } else { + this.location[name].suggestion--; + } + } else { + if (this.location[name].suggestion === numSuggesttions) { + this.location[name].suggestion = 0; + } else { + this.location[name].suggestion++; + } + } + } + + this.requestUpdate(); + }; + + inputHandler = async (event) => { + const name = event.target.name; + const value = event.target.value.trim(); + + if (["from", "via", "to"].includes(name)) { + if (this.isOffline !== false) return; + if (value === "") return; + + this.location[name].value = value; + + let suggestions = []; + const ds100Result = getIBNRbyDS100(value.toUpperCase()); + + if (ds100Result !== null) + suggestions = await client.locations(ds100Result, { results: 1 }); + + suggestions = suggestions.concat( + await client.locations(value, { results: 10 }), + ); + + this.location[name].suggestionSelected = null; + this.location[name].suggestion = 0; + this.location[name].suggestions = suggestions; + + this.requestUpdate(); + } + }; + + changeHandler = (event) => { + const name = event.target.name; + const value = event.target.value; + + if (name === "noTransfers") this.noTransfers = !this.noTransfers; + if (name === "isArrival") this.isArrival = !this.isArrival; + if (name === "dateTime") this.date.setDateTime(value); + if (name === "date") this.date.setDate(value); + if (name === "time") this.date.setTime(value); + + this.requestUpdate(); + }; } -customElements.define('search-view', SearchView); +customElements.define("search-view", SearchView);
diff --git a/src/settings.js b/src/settings.js @@ -1,59 +1,80 @@ -import { createStore } from 'zustand/vanilla'; -import { persist, createJSONStorage } from 'zustand/middleware' - -import { db, initDataStorage } from './dataStorage.js'; +import { upperFirst } from './helpers.js'; import { getDefaultLanguage } from './translate.js'; import { getDefaultProfile } from './hafasClient.js'; -export let settingsState; -export const settings = createStore()( - persist( - (set, get) => ({ - language: getDefaultLanguage(), - profile: getDefaultProfile(), - products: {}, - accessibility: 'none', - walkingSpeed: 'normal', - transferTime: 0, - loyaltyCard: 'NONE', - ageGroup: 'E', - journeysViewMode: 'canvas', - bikeFriendly: false, - combineDateTime: false, - showVia: false, - showPrices: true, - showDS100: true, - - setJourneysViewMode: (journeysViewMode) => set({ journeysViewMode }), - setLanguage: (language) => set({ language }), - setProfile: (profile) => set({ profile }), - setTransferTime: (transferTime) => set({ transferTime }), - setAgeGroup: (ageGroup) => set({ ageGroup }), - setLoyaltyCard: (loyaltyCard) => set({ loyaltyCard }), - setAccessibility: (accessibility) => set({ accessibility }), - setWalkingSpeed: (walkingSpeed) => set({ walkingSpeed }), - setShowVia: (showVia) => set({ showVia }), - - toggleCombineDateTime: () => set((state) => ({ combineDateTime: !state.combineDateTime })), - toggleShowPrices: () => set((state) => ({ showPrices: !state.showPrices })), - toggleShowDS100: () => set((state) => ({ showDS100: !state.showDS100 })), - toggleShowVia: () => set((state) => ({ showVia: !state.showVia })), - toggleBikeFriendly: () => set((state) => ({ bikeFriendly: !state.bikeFriendly })), - toggleProduct: (key) => set((state) => { - state.products[key] = !state.products[key]; - return { products: state.products }; - }) - }), - { - name: 'settings' +let state = {}; +const subscribers = new Set(); +const defaultSettings = { + language: getDefaultLanguage(), + profile: getDefaultProfile(), + products: {}, + accessibility: 'none', + walkingSpeed: 'normal', + transferTime: 0, + loyaltyCard: 'NONE', + ageGroup: 'E', + journeysViewMode: 'canvas', + bikeFriendly: false, + combineDateTime: false, + showVia: false, + showPrices: true, + showDS100: true, +}; + +export const settings = {}; +export const initSettings = async () => { + let properties = { + subscribe: { value: callback => { + subscribers.add(callback); + return () => subscribers.delete(callback); + }}, + toggleProduct: { + value: product => { + let products = state.products; + products[product] = !products[product]; + localStorage[`products.${product}`] = JSON.stringify(products[product]); + state.products = products; + }, + }, + }; + + Object.keys(defaultSettings).forEach(key => { + if (typeof defaultSettings[key] === 'object') { + let prefix = `${key}.`; + state[key] = {}; + + Object.keys(localStorage) + .filter(element => element.startsWith(prefix)) + .forEach(element => { + state[key][element.slice(prefix.length)] = JSON.parse(localStorage[element]); + }); + } else { + state[key] = localStorage[key] ? JSON.parse(localStorage[key]) : defaultSettings[key]; } - ) -) -export const initSettingsState = async () => { - settingsState = settings.getState(); + properties[key] = { + enumerable: true, + get: () => state[key], + set: newValue => { + state[key] = newValue; + if (typeof newValue === 'object') { + Object.keys(newValue).forEach(objKey => { + localStorage[`${key}.${objKey}`] = JSON.stringify(newValue[objKey]); + }); + } else { + localStorage[key] = JSON.stringify(newValue); + } + subscribers.forEach(callback => callback(settings)); + }, + }; + + properties[`set${upperFirst(key)}`] = { + value: newValue => { settings[key] = newValue; }, + }; - settings.subscribe(state => { - settingsState = state; + if (typeof defaultSettings[key] === 'boolean') + properties[`toggle${upperFirst(key)}`] = { value: () => { settings[key] = !settings[key]; } }; }); + + Object.defineProperties(settings, properties); };
diff --git a/src/templates.js b/src/templates.js @@ -1,7 +1,7 @@ import { html, css, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import { settingsState } from './settings.js'; +import { settings } from './settings.js'; import { getDS100byIBNR } from './ds100.js'; import { CustomDate } from './helpers.js'; @@ -33,7 +33,7 @@ export const remarksModal = (element, remarks) => element.showDialogOverlay('rem export const stopTemplate = (profile, stop) => { let stopName = stop.name; - if (settingsState.showDS100) { + if (settings.showDS100) { const ds100 = getDS100byIBNR(stop.id); if (ds100 !== null) stopName += ` (${ds100})`; }
diff --git a/src/translate.js b/src/translate.js @@ -1,4 +1,4 @@ -import { settingsState } from './settings.js'; +import { settings } from './settings.js'; import { languages } from './languages.js'; export const getDefaultLanguage = () => { @@ -12,7 +12,7 @@ export const getDefaultLanguage = () => { export const getLanguages = () => Object.keys(languages); export const t = (key, ...params) => { - let translation = languages[settingsState.language][key]; + let translation = languages[settings.language][key]; if (!translation) translation = languages['en'][key] if (!translation) return key;