diff --git a/index.html b/index.html index 820f7b637..5dc146fbc 100644 --- a/index.html +++ b/index.html @@ -21,20 +21,20 @@ Vite Ma Dose : trouvez un créneau de vaccination COVID-19 - - + + - + - + @@ -63,7 +63,7 @@

Quel est l’intérêt de Vite Ma Dose de Vaccin ?

Vite Ma Dose est une plateforme qui permet de trouver un rendez-vous de vaccination rapidement dans son département. Sont actuellement recensés les créneaux de vaccination des plateformes de santé - Doctolib, Keldoc, Maiia, Ordoclic, MaPharma. La réservation d’un créneau d’injection de vaccin dans un centre de vaccination s’effectue via les pages de ces plateformes de santé. + Doctolib, Keldoc, Maiia, Ordoclic et MaPharma. La réservation d’un créneau d’injection de vaccin dans un centre de vaccination s’effectue via les pages de ces plateformes de santé.


@@ -72,7 +72,7 @@

Comment fonctionne Vite Ma Dose ?


Vite Ma Dose est-il à but lucratif ?

- Non, Vite Ma Dose n’est pas à but lucratif. La plateforme est gratuite, sans pub et il n’est pas nécessaire de s’inscrire pour l’utiliser. Le code informatique est disponible en Open Source sur internet. + Non, Vite Ma Dose n’est pas à but lucratif. La plateforme est gratuite, sans pub et il n’est pas nécessaire de s’inscrire pour l’utiliser. Le code informatique est disponible en Open Source sur Internet.

En savoir plus sur la vaccination contre la Covid-19 @@ -82,35 +82,33 @@

Qui peut se faire vacciner contre la Covid-19 ?

De nombreuses personnes sont éligibles à la vaccination contre la Covid-19. On peut être éligible à la vaccination selon son âge, sa santé, ou sa profession. Voici la liste des personnes qui peuvent dès à présent être vaccinées :

Les professionnels de santé et du secteur médico-social sont également éligibles à la vaccination contre la Covid-19.


Où se faire vacciner ?

-

Si on est éligible, on peut être vacciné chez son médecin généraliste, dans un centre de vaccination, à la pharmacie ou dans tout établissement de santé ou médico-social proposant la vaccination contre la Covid-19. L’ensemble des centres de vaccination sont à retrouver sur Vite Ma Dose.

+

Si on est éligible, on peut être vacciné chez son médecin généraliste, dans un centre de vaccination, à la pharmacie ou dans tout établissement de santé ou médico-social proposant la vaccination contre la Covid-19. L’ensemble des centres de vaccination est à retrouver sur Vite Ma Dose.


Comment réserver un rendez-vous de vaccination ?

On peut se faire vacciner contre le coronavirus en réservant un rendez-vous grâce à Vite Ma Dose. - On peut aussi prendre rendez-vous directement via les plateformes Doctolib, Maiia, MaPharma, ou Keldoc, ainsi que chez son médecin généraliste ou en pharmacie. + On peut aussi prendre rendez-vous directement via les plateformes Doctolib, Keldoc, Maiia, Ordoclic ou MaPharma, ainsi que chez son médecin généraliste ou en pharmacie.


Pourquoi se faire vacciner ?

-

Le vaccin contre le Covid-19 protège des formes graves de la maladie. Il semble aussi limiter les contaminations.

+

Le vaccin contre la Covid-19 protège des formes graves de la maladie. Il semble aussi limiter les contaminations.


diff --git a/public/assets/images/png/marker-icon-2x.png b/public/assets/images/png/marker-icon-2x.png new file mode 100644 index 000000000..88f9e5018 Binary files /dev/null and b/public/assets/images/png/marker-icon-2x.png differ diff --git a/src/components/vmd-appointment-card.component.scss b/src/components/vmd-appointment-card.component.scss index 0f3f951cd..07b434726 100644 --- a/src/components/vmd-appointment-card.component.scss +++ b/src/components/vmd-appointment-card.component.scss @@ -56,11 +56,6 @@ } } -.doses-dipos { - font-style: italic; - font-size: 1.5rem; -} - vmd-appointment-metadata [slot='content'] a { &:hover { color: $link-color @@ -72,3 +67,16 @@ vmd-appointment-metadata [slot='content'] a { text-decoration-width: 1px; } } + +a:hover, a:focus { + color: $link-color; +} + +.btn-primary.disabled { + background-color: tint-color($primary, 20%); +} + +.btn-tel { + background-color: tint-color($primary, 80%); + color: $primary; +} diff --git a/src/components/vmd-appointment-card.component.ts b/src/components/vmd-appointment-card.component.ts index e395c5cc8..311b6b46b 100644 --- a/src/components/vmd-appointment-card.component.ts +++ b/src/components/vmd-appointment-card.component.ts @@ -1,11 +1,20 @@ import {css, customElement, html, LitElement, property, unsafeCSS} from 'lit-element'; import {classMap} from "lit-html/directives/class-map"; -import {Lieu, Plateforme, PLATEFORMES, TYPES_LIEUX} from "../state/State"; +import { + Lieu, + LieuAffichableAvecDistance, + Plateforme, + PLATEFORMES, + typeActionPour, + TYPES_LIEUX +} from "../state/State"; import {Router} from "../routing/Router"; import {Dates} from "../utils/Dates"; import appointmentCardCss from "./vmd-appointment-card.component.scss"; import globalCss from "../styles/global.scss"; import {Strings} from "../utils/Strings"; +import {TemplateResult} from "lit-html"; +import {styleMap} from "lit-html/directives/style-map"; type LieuCliqueContext = {lieu: Lieu}; export type LieuCliqueCustomEvent = CustomEvent; @@ -21,14 +30,7 @@ export class VmdAppointmentCardComponent extends LitElement { ` ]; - @property({type: Object, attribute: false}) lieu!: Lieu; - @property({type: Number, attribute: false}) distance!: number; - /* dunno why, but boolean string is not properly converted to boolean when using attributes */ - @property({type: Boolean, attribute: false }) rdvPossible!: boolean; - - private get estCliquable() { - return !!this.lieu.url; - } + @property({type: Object, attribute: false}) lieu!: LieuAffichableAvecDistance; constructor() { super(); @@ -47,22 +49,100 @@ export class VmdAppointmentCardComponent extends LitElement { } render() { - if(this.rdvPossible) { const plateforme: Plateforme|undefined = PLATEFORMES[this.lieu.plateforme]; - let distance: any = this.distance - if (distance >= 10) { - distance = distance.toFixed(0) - } else if (distance) { - distance = distance.toFixed(1) + let distance: string|undefined; + if (this.lieu.distance && this.lieu.distance >= 10) { + distance = this.lieu.distance.toFixed(0) + } else if (this.lieu.distance) { + distance = this.lieu.distance.toFixed(1) } - return html` -
+ + let cardConfig: {cardLink:(content: TemplateResult) => TemplateResult, estCliquable: boolean, disabledBG: boolean, actions: TemplateResult|undefined, libelleDateAbsente: string }; + let typeLieu = typeActionPour(this.lieu); + if(typeLieu === 'actif-via-plateforme' || typeLieu === 'inactif-via-plateforme') { + let specificCardConfig: { disabledBG: boolean, libelleDateAbsente: string, libelleBouton: string, typeBouton: 'btn-info'|'btn-primary', onclick: ()=>void }; + if(typeLieu === 'inactif-via-plateforme') { + specificCardConfig = { + disabledBG: true, + libelleDateAbsente: 'Aucun rendez-vous', + libelleBouton: 'Vérifier le centre de vaccination', + typeBouton: 'btn-info', + onclick: () => this.verifierRdv() + }; + } else { + specificCardConfig = { + disabledBG: false, + libelleDateAbsente: 'Date inconnue', + libelleBouton: 'Prendre rendez-vous', + typeBouton: 'btn-primary', + onclick: () => this.prendreRdv() + }; + } + cardConfig = { + estCliquable: true, + disabledBG: specificCardConfig.disabledBG, + libelleDateAbsente: specificCardConfig.libelleDateAbsente, + cardLink: (content) => + html`${content}`, + actions: html` + + ${specificCardConfig.libelleBouton} + +
+
+ ${this.lieu.appointment_count.toLocaleString()} créneau${Strings.plural(this.lieu.appointment_count, "x")} +
+ ${this.lieu.plateforme?html` + | +
+ ${plateforme?html` + + `:html` + ${this.lieu.plateforme} + `} +
+ `:html``} +
+ ` + }; + } else if(typeLieu === 'actif-via-tel') { + cardConfig = { + estCliquable: true, + disabledBG: false, + libelleDateAbsente: 'Réservation tél uniquement', + cardLink: (content) => html` + + ${content} + `, + actions: html` + + Appeler le ${Strings.toNormalizedPhoneNumber(this.lieu.metadata.phone_number)} + + ` + }; + } else if(typeLieu === 'inactif') { + cardConfig = { + estCliquable: false, + disabledBG: true, + libelleDateAbsente: 'Aucun rendez-vous', + cardLink: (content) => content, + actions: undefined + }; + } else { + throw new Error(`Unsupported typeLieu : ${typeLieu}`) + } + + return cardConfig.cardLink(html` +
-
${Dates.isoToFRDatetime(this.lieu.prochain_rdv)}${distance ? `- ${distance} km` : ''}
+
+ ${this.lieu.prochain_rdv?Dates.isoToFRDatetime(this.lieu.prochain_rdv):cardConfig.libelleDateAbsente} + ${distance ? `- ${distance} km` : ''} +
@@ -74,7 +154,7 @@ export class VmdAppointmentCardComponent extends LitElement { + @click="${(e: Event) => { e.stopImmediatePropagation(); }}"> ${Strings.toNormalizedPhoneNumber(this.lieu.metadata.phone_number)} @@ -88,58 +168,15 @@ export class VmdAppointmentCardComponent extends LitElement {
- ${this.estCliquable?html` + ${cardConfig.actions?html`
- Prendre rendez-vous -
-
- ${this.lieu.appointment_count.toLocaleString()} dose${Strings.plural(this.lieu.appointment_count)} -
- ${this.lieu.plateforme?html` - | -
- ${plateforme?html` - - `:html` - ${this.lieu.plateforme} - `} -
- `:html``} -
+ ${cardConfig.actions}
`:html``}
- `; - } else { - return html` -
-
-
-
-
Aucun rendez-vous
- -
- ${this.lieu.nom} -
- ${this.lieu.metadata.address} -
-
-
- - ${this.estCliquable?html` - - `:html``} -
-
-
- `; - } + `); } connectedCallback() { diff --git a/src/components/vmd-commune-selector.component.ts b/src/components/vmd-commune-selector.component.ts index 545335b03..3143bcf8a 100644 --- a/src/components/vmd-commune-selector.component.ts +++ b/src/components/vmd-commune-selector.component.ts @@ -1,4 +1,12 @@ -import {css, customElement, html, LitElement, property, unsafeCSS} from 'lit-element'; +import { + css, + customElement, + html, + internalProperty, + LitElement, + property, + unsafeCSS +} from 'lit-element'; import {classMap} from "lit-html/directives/class-map"; import {Commune, Departement} from "../state/State"; import {repeat} from "lit-html/directives/repeat"; @@ -26,12 +34,12 @@ export class VmdCommuneSelectorComponent extends LitElement { @property({type: String}) codeCommuneSelectionne: string | undefined = undefined; - @property({type: Boolean, attribute: false}) inputHasFocus: boolean = false; + @internalProperty() inputHasFocus: boolean = false; @property({type: Boolean, attribute: false}) inputModeFixedToText = true; @property({type: String, attribute: false}) inputMode: 'numeric'|'text' = 'numeric'; @property({type: Array, attribute: false}) autocompleteTriggers: Set|undefined; - @property({type: Boolean, attribute: false}) recuperationCommunesEnCours: boolean = false; + @internalProperty() recuperationCommunesEnCours: boolean = false; @property({type: Array, attribute: false}) set communesDisponibles(cd: Commune[]|undefined) { if(cd !== this._communesDisponibles) { this._communesDisponibles = cd; @@ -43,14 +51,19 @@ export class VmdCommuneSelectorComponent extends LitElement { get communesDisponibles(): Commune[]|undefined{ return this._communesDisponibles; } private _communesDisponibles: Commune[]|undefined = undefined; - @property({type: Array, attribute: false}) communesAffichees: Commune[]|undefined = undefined; - @property({type: String, attribute: false}) filter: string = ""; + @internalProperty() communesAffichees: Commune[]|undefined = undefined; + @internalProperty() filter: string = ""; private filterMatchingAutocomplete: string|undefined = undefined; get showDropdown() { return this.inputHasFocus - && ((this.inputMode === 'text' && this.communesAffichees && this.communesAffichees.length) + && this.filter + // This one is done because otherwise we would start showing some departments matching + // first digit, and this would encourage search by department (whereas search by commune + // is by far better) + && this.filter.length >= 2 + && ((this.inputMode === 'text' && !this.dropDownVide()) || this.inputMode === 'numeric'); } @@ -62,6 +75,14 @@ export class VmdCommuneSelectorComponent extends LitElement { return undefined; } + protected aucuneCommuneAffichee(): boolean { + return !this.communesAffichees || !this.communesAffichees.length; + } + + protected dropDownVide(): boolean { + return this.aucuneCommuneAffichee(); + } + private filtrerCommunesAffichees() { // /!\ important note : this is important to have the same implementation of toFullTextSearchableString() // function here, than the one used in communes-import.js tooling @@ -175,7 +196,7 @@ export class VmdCommuneSelectorComponent extends LitElement { `:html``} ${this.showDropdown?html`
    - ${(this.inputMode==='numeric' && (!this.communesAffichees || !this.communesAffichees.length))?html` + ${(this.inputMode==='numeric' && this.aucuneCommuneAffichee())?html`
  • Je ne connais pas le code postal
  • `:html``} ${this.renderListItems()} @@ -212,9 +233,21 @@ export class VmdCommuneSelectorComponent extends LitElement { } +type DepartementRecherchable = Departement & { + fullTextSearchableNom: string; + fullTextSearchableCodeDepartement: string; +}; + @customElement('vmd-commune-or-departement-selector') export class VmdCommuneOrDepartmentSelectorComponent extends VmdCommuneSelectorComponent { - @property({type: Array, attribute: false}) departementsDisponibles: Departement[] = []; + @property({type: Array, attribute: false}) set departementsDisponibles(dpts: Departement[]) { + this.departementsCherchables = dpts.map(d => ({...d, + fullTextSearchableCodeDepartement: Strings.toFullTextSearchableString(d.code_departement), + fullTextSearchableNom: Strings.toFullTextSearchableString(d.nom_departement) + })); + } + @internalProperty() departementsCherchables: DepartementRecherchable[] = []; + @property({type: Array, attribute: false}) departementsAffiches: Departement[] = []; departementSelectionne(dpt: Departement) { @@ -235,15 +268,17 @@ export class VmdCommuneOrDepartmentSelectorComponent extends VmdCommuneSelectorC this.filtrerDepartementsAffichees(); } + protected dropDownVide(): boolean { + return this.aucuneCommuneAffichee() && !this.departementsAffiches.length; + } + private filtrerDepartementsAffichees() { const fullTextSearchableQuery = Strings.toFullTextSearchableString(this.filter) - this.departementsAffiches = this.departementsDisponibles?this.departementsDisponibles.filter(dpt => { - const fullTextSearchableNomCommune = Strings.toFullTextSearchableString(dpt.nom_departement) - - return dpt.code_departement.indexOf(fullTextSearchableQuery) === 0 - || fullTextSearchableNomCommune.indexOf(fullTextSearchableQuery) !== -1; - }):[]; + this.departementsAffiches = this.departementsCherchables.filter(dpt => { + return dpt.fullTextSearchableCodeDepartement.indexOf(fullTextSearchableQuery) === 0 + || dpt.fullTextSearchableNom.indexOf(fullTextSearchableQuery) !== -1; + }); } renderListItems(): TemplateResult|DirectiveFn { diff --git a/src/state/State.ts b/src/state/State.ts index 952a71d0c..48c3adfa9 100644 --- a/src/state/State.ts +++ b/src/state/State.ts @@ -75,6 +75,7 @@ export type Lieu = { location: Coordinates, nom: string; url: string; + appointment_by_phone_only: boolean; plateforme: string; prochain_rdv: ISODateString|null; metadata: { @@ -113,11 +114,24 @@ export type LieuxParDepartement = { }; export type LieuxParDepartements = Map; -export type LieuAvecDistance = Lieu & { distance: number|undefined }; -export type LieuxAvecDistanceParDepartement = LieuxParDepartement & { - lieuxDisponibles: LieuAvecDistance[]; - lieuxIndisponibles: LieuAvecDistance[]; +export type LieuAffichableAvecDistance = Lieu & { disponible: boolean, distance: number|undefined }; +export type LieuxAvecDistanceParDepartement = { + lieuxAffichables: LieuAffichableAvecDistance[]; + codeDepartements: CodeDepartement[]; + derniereMiseAJour: ISODateString; }; +export function typeActionPour(lieuAffichable: LieuAffichableAvecDistance): 'actif-via-plateforme'|'inactif-via-plateforme'|'actif-via-tel'|'inactif' { + const phoneOnly = lieuAffichable.appointment_by_phone_only && lieuAffichable.metadata.phone_number; + if(phoneOnly) { // Phone only may have url, but we should ignore it ! + return 'actif-via-tel'; + } else if(lieuAffichable && lieuAffichable.appointment_count !== 0){ + return 'actif-via-plateforme'; + } else if(lieuAffichable && lieuAffichable.appointment_count === 0){ + return 'inactif-via-plateforme'; + } else { + return 'inactif'; + } +} function convertDepartementForSort(codeDepartement: CodeDepartement) { switch(codeDepartement) { @@ -173,8 +187,8 @@ export class State { } private _lieuxParDepartement: LieuxParDepartements = new Map(); - async lieuxPour(codeDepartement: CodeDepartement): Promise { - if(this._lieuxParDepartement.has(codeDepartement)) { + async lieuxPour(codeDepartement: CodeDepartement, avoidCache: boolean = false): Promise { + if(this._lieuxParDepartement.has(codeDepartement) && !avoidCache) { return Promise.resolve(this._lieuxParDepartement.get(codeDepartement)!); } else { const resp = await fetch(`${VMD_BASE_URL}/${codeDepartement}.json`) diff --git a/src/utils/Analytics.ts b/src/utils/Analytics.ts index 4fa5a09c8..6885f8d05 100644 --- a/src/utils/Analytics.ts +++ b/src/utils/Analytics.ts @@ -2,7 +2,7 @@ import { CodeDepartement, CodeTriCentre, Commune, Lieu, - LieuxAvecDistanceParDepartement, + LieuxAvecDistanceParDepartement, typeActionPour, } from "../state/State"; @@ -61,9 +61,15 @@ export class Analytics { 'event': commune?'search_by_commune':'search_by_departement', 'search_departement': codeDepartement, 'search_commune' : commune?`${commune.codePostal} - ${commune.nom} (${commune.code})`:undefined, - 'search_nb_doses' : resultats?resultats.lieuxDisponibles.reduce((totalDoses, lieu) => totalDoses+lieu.appointment_count, 0):undefined, - 'search_nb_lieu_vaccination' : resultats?resultats.lieuxDisponibles.length:undefined, - 'search_nb_lieu_vaccination_inactive' : resultats?resultats.lieuxIndisponibles.length:undefined, + // kept for legacy reasons + 'search_nb_doses' : resultats?resultats.lieuxAffichables.reduce((totalDoses, lieu) => totalDoses+lieu.appointment_count, 0):undefined, + 'search_nb_appointments' : resultats?resultats.lieuxAffichables.reduce((totalDoses, lieu) => totalDoses+lieu.appointment_count, 0):undefined, + 'search_nb_lieu_vaccination' : resultats?resultats.lieuxAffichables + .filter(l => typeActionPour(l) === 'actif-via-plateforme' || typeActionPour(l) === 'actif-via-tel') + .length:undefined, + 'search_nb_lieu_vaccination_inactive' : resultats?resultats.lieuxAffichables + .filter(l => typeActionPour(l) === 'inactif') + .length:undefined, 'search_filter_type': (this as any).critèreDeTri || 'date' }); } diff --git a/src/utils/Arrays.ts b/src/utils/Arrays.ts new file mode 100644 index 000000000..dc088c7da --- /dev/null +++ b/src/utils/Arrays.ts @@ -0,0 +1,38 @@ + + + +export class ArrayBuilder { + constructor(private array: T[]) { + } + + concat(array: T[]) { + this.array = this.array.concat(array); + return this; + } + + map(mapper: (start: T, index?: number, array?: T[]) => R) { + return new ArrayBuilder(this.array.map(mapper)); + } + + filter(predicate: (v: T, index: number) => boolean) { + this.array = this.array.filter(predicate); + return this; + } + + sort(comparator: (v1: T, v2: T) => number) { + this.array.sort(comparator); + return this; + } + + sortBy(sortableDataExtractor: (v: T) => string) { + return this.sort((v1: T, v2: T) => sortableDataExtractor(v1).localeCompare(sortableDataExtractor(v2))); + } + + build() { + return this.array; + } + + public static from(array: T[]) { + return new ArrayBuilder(array); + } +} diff --git a/src/utils/Schedulers.ts b/src/utils/Schedulers.ts new file mode 100644 index 000000000..b9db494bf --- /dev/null +++ b/src/utils/Schedulers.ts @@ -0,0 +1,17 @@ + + +export const setDebouncedInterval = function(handler: Function, duration?: number, ...args: any[]) { + let ongoingHandler = false; + return setInterval(async () => { + if(ongoingHandler) { + console.warn("Skipped setDebouncedInterval()'s handler as ongoing already"); + return; + } + try { + ongoingHandler = true; + await handler(...args); + } finally { + ongoingHandler = false; + } + }, duration); +} diff --git a/src/views/vmd-home.view.scss b/src/views/vmd-home.view.scss index f4dc436f0..fe1792d16 100644 --- a/src/views/vmd-home.view.scss +++ b/src/views/vmd-home.view.scss @@ -36,7 +36,7 @@ } } -.searchDose { +.searchAppointment { display: flex; flex-direction: column; padding: 9vh 3.2rem; @@ -86,7 +86,7 @@ } } -.searchDoseForm { +.searchAppointmentForm { &-fields { max-width: 75vw; @@ -110,7 +110,7 @@ } } -.searchAppointment { +.platforms { &-logo { margin: { top: 1.6rem; @@ -118,6 +118,7 @@ bottom: 1.6rem; left: 3.2rem; }; + max-width: 75vw; &._doctolib { max-height: 6rem; diff --git a/src/views/vmd-home.view.ts b/src/views/vmd-home.view.ts index 1e490aa8a..a9e4e35d7 100644 --- a/src/views/vmd-home.view.ts +++ b/src/views/vmd-home.view.ts @@ -80,13 +80,13 @@ export class VmdHomeView extends LitElement { render() { return html` -
    -
    +
    +
    -
    -
    +
    +
    @@ -106,14 +106,14 @@ export class VmdHomeView extends LitElement {
    -
    +
    Trouvez vos rendez-vous avec
    ${Object.values(PLATEFORMES).filter(p => p.promoted).map(plateforme => { return html`
    - +
    ` })} @@ -180,7 +180,10 @@ export class VmdHomeView extends LitElement {
    ${this.statsLieu?this.statsLieu.global.creneaux.toLocaleString():""}
    -

    Créneaux de vaccination disponibles

    +

    + Créneaux de vaccination disponibles dans les prochaines semaines +

    + Ce nombre ne correspond pas au nombre de doses disponibles
    diff --git a/src/views/vmd-rdv.view.ts b/src/views/vmd-rdv.view.ts index d12fd6ab6..001e71cb2 100644 --- a/src/views/vmd-rdv.view.ts +++ b/src/views/vmd-rdv.view.ts @@ -12,9 +12,9 @@ import { Departement, libelleUrlPathDeCommune, libelleUrlPathDuDepartement, - Lieu, LieuxAvecDistanceParDepartement, + Lieu, LieuAffichableAvecDistance, LieuxAvecDistanceParDepartement, LieuxParDepartement, - State, + State, TriCentre, TRIS_CENTRE } from "../state/State"; import {Dates} from "../utils/Dates"; @@ -29,6 +29,8 @@ import {ValueStrCustomEvent} from "../components/vmd-selector.component"; import {TemplateResult} from "lit-html"; import {Analytics} from "../utils/Analytics"; import {LieuCliqueCustomEvent} from "../components/vmd-appointment-card.component"; +import {setDebouncedInterval} from "../utils/Schedulers"; +import {ArrayBuilder} from "../utils/Arrays"; const MAX_DISTANCE_CENTRE_IN_KM = 100; @@ -52,8 +54,10 @@ export abstract class AbstractVmdRdvView extends LitElement { @property({type: Array, attribute: false}) lieuxParDepartementAffiches: LieuxAvecDistanceParDepartement | undefined = undefined; @property({type: Boolean, attribute: false}) searchInProgress: boolean = false; + @property({type: Boolean, attribute: false}) miseAJourDisponible: boolean = false; protected derniereCommuneSelectionnee: Commune|undefined = undefined; + protected lieuBackgroundRefreshIntervalId: number|undefined = undefined; get communeSelectionnee(): Commune|undefined { @@ -95,12 +99,12 @@ export abstract class AbstractVmdRdvView extends LitElement { return undefined; } - get totalDoses() { + get totalCreneaux() { if (!this.lieuxParDepartementAffiches) { return 0; } return this.lieuxParDepartementAffiches - .lieuxDisponibles + .lieuxAffichables .reduce((total, lieu) => total+lieu.appointment_count, 0); } @@ -156,6 +160,10 @@ export abstract class AbstractVmdRdvView extends LitElement { } render() { + const lieuxDisponibles = (this.lieuxParDepartementAffiches && this.lieuxParDepartementAffiches.lieuxAffichables)? + this.lieuxParDepartementAffiches.lieuxAffichables.filter(l => l.disponible) + :[]; + return html`
    @@ -188,58 +196,53 @@ export abstract class AbstractVmdRdvView extends LitElement {
    `:html`

    - ${this.totalDoses.toLocaleString()} dose${Strings.plural(this.totalDoses)} de vaccination Covid trouvée${Strings.plural(this.totalDoses)} + ${this.totalCreneaux.toLocaleString()} créneau${Strings.plural(this.totalCreneaux, "x")} de vaccination trouvé${Strings.plural(this.totalCreneaux)} ${this.libelleLieuSelectionne()}
    - ${(this.lieuxParDepartementAffiches && this.lieuxParDepartementAffiches.derniereMiseAJour) ? html`Dernière mise à jour : il y a ${Dates.formatDurationFromNow(this.lieuxParDepartementAffiches!.derniereMiseAJour)}` : html``} + ${(this.lieuxParDepartementAffiches && this.lieuxParDepartementAffiches.derniereMiseAJour) ? + html` + + Dernière mise à jour : il y a + ${Dates.formatDurationFromNow(this.lieuxParDepartementAffiches!.derniereMiseAJour)} + ${this.miseAJourDisponible?html` + + `:html``} + ` + : html``}

    - ${(this.lieuxParDepartementAffiches && this.lieuxParDepartementAffiches.lieuxDisponibles.length) ? html` + ${lieuxDisponibles.length ? html`

    - ${this.lieuxParDepartementAffiches.lieuxDisponibles.length} Lieu${Strings.plural(this.lieuxParDepartementAffiches.lieuxDisponibles.length, 'x')} de vaccination Covid avec des disponibilités + ${lieuxDisponibles.length} Lieu${Strings.plural(lieuxDisponibles.length, 'x')} de vaccination avec des disponibilités

    ` : html` -

    Aucun créneau de vaccination trouvé

    -

    Nous n’avons pas trouvé de rendez-vous de vaccination Covid sur ces centres, nous vous recommandons toutefois de vérifier manuellement les rendez-vous de vaccination auprès des sites qui gèrent la réservation de créneau de vaccination. Pour ce faire, cliquez sur le bouton “vérifier le centre de vaccination”.

    +

    + + + Aucun créneau de vaccination trouvé + +

    +
    + Nous n’avons pas trouvé de rendez-vous de vaccination Covid-19 + sur les plateformes de réservation. Nous vous recommandons toutefois de vérifier manuellement + les rendez-vous de vaccination auprès des sites qui gèrent la réservation de créneau de vaccination. + Pour ce faire, cliquez sur le bouton “vérifier le centre de vaccination”. +
    `} - -
    - ${repeat(this.lieuxParDepartementAffiches?this.lieuxParDepartementAffiches.lieuxDisponibles:[], (c => `${c.departement}||${c.nom}||${c.plateforme}}`), (lieu, index) => { - return html` `${c.departement}||${c.nom}||${c.plateforme}}`), (lieu, index) => { + return html``; })} - - ${(this.lieuxParDepartementAffiches && this.lieuxParDepartementAffiches.lieuxIndisponibles.length) ? html` -
    - -
    - - - Autres centres sans créneaux de vaccination détectés - -
    - - ${repeat(this.lieuxParDepartementAffiches.lieuxIndisponibles || [], (c => `${c.departement}||${c.nom}||${c.plateforme}`), (lieu, index) => { - return html``; - })} - ` : html``}

    Les critères d'éligibilité sont vérifiés lors de la prise de rendez-vous

    @@ -248,11 +251,11 @@ export abstract class AbstractVmdRdvView extends LitElement { `; } - onCommuneAutocompleteLoaded(autocompletes: string[]): Promise { + onCommuneAutocompleteLoaded(autocompletes: Set): Promise { return Promise.resolve(); } - onceStartupPromiseResolved() { + async onceStartupPromiseResolved() { // to be overriden } @@ -260,17 +263,28 @@ export abstract class AbstractVmdRdvView extends LitElement { super.connectedCallback(); await Promise.all([ - State.current.departementsDisponibles().then(departementsDisponibles => { - this.departementsDisponibles = departementsDisponibles; - }), - State.current.communeAutocompleteTriggers(Router.basePath).then(async (autocompletes) => { - await this.onCommuneAutocompleteLoaded(autocompletes); - this.communesAutocomplete = new Set(autocompletes); - }) - ]) + State.current.departementsDisponibles(), + State.current.communeAutocompleteTriggers(Router.basePath) + ]).then(async ([departementsDisponibles, autocompletes]: [Departement[], string[]]) => { + this.departementsDisponibles = departementsDisponibles; + + this.communesAutocomplete = new Set(autocompletes); + await this.onCommuneAutocompleteLoaded(this.communesAutocomplete); + }); await this.onceStartupPromiseResolved(); await this.refreshLieux(); + + this.lieuBackgroundRefreshIntervalId = setDebouncedInterval(async () => { + if(this.codeDepartementSelectionne) { + const derniereMiseAJour = this.lieuxParDepartementAffiches?this.lieuxParDepartementAffiches.derniereMiseAJour:undefined; + const lieuxAJourPourDepartement = await State.current.lieuxPour(this.codeDepartementSelectionne, true) + this.miseAJourDisponible = (derniereMiseAJour !== lieuxAJourPourDepartement.derniereMiseAJour); + + // Used only to refresh derniereMiseAJour's displayed relative time + await this.requestUpdate(); + } + }, 45000); } preventRafraichissementLieux(): boolean { @@ -358,6 +372,37 @@ export abstract class AbstractVmdRdvView extends LitElement { return html``; } + protected extraireFormuleDeTri(lieu: LieuAffichableAvecDistance, tri: CodeTriCentre) { + if(tri === 'date') { + let firstLevelSort; + if(lieu.appointment_by_phone_only && lieu.metadata.phone_number) { + firstLevelSort = 2; + } else if(lieu.url) { + firstLevelSort = lieu.appointment_count !== 0 ? (lieu.prochain_rdv!==null? 0:1):3; + } else { + firstLevelSort = 4; + } + return `${firstLevelSort}__${Strings.padLeft(Date.parse(lieu.prochain_rdv!) || 0, 15, '0')}`; + } else if(tri === 'distance') { + let firstLevelSort; + + // Considering only 2 kind of sorting sections : + // - the one with (potentially) available appointments (with url, or appointment by phone only) + // - the one with unavailable appointments (without url, or with 0 available appointments) + if(lieu.appointment_by_phone_only && lieu.metadata.phone_number) { + firstLevelSort = 0; + } else if(lieu.url) { + firstLevelSort = lieu.appointment_count !== 0 ? 0:1; + } else { + firstLevelSort = 1; + } + + return `${firstLevelSort}__${Strings.padLeft(Math.round(lieu.distance!*1000), 8, '0')}`; + } else { + throw new Error(`Unsupported tri : ${tri}`); + } + } + abstract libelleLieuSelectionne(): TemplateResult; abstract afficherLieuxParDepartement(lieuxParDepartement: LieuxParDepartement): LieuxAvecDistanceParDepartement; } @@ -405,14 +450,14 @@ export class VmdRdvParCommuneView extends AbstractVmdRdvView { ` } - async onCommuneAutocompleteLoaded(autocompletes: string[]): Promise { + async onCommuneAutocompleteLoaded(autocompletes: Set): Promise { if(this.codePostalSelectionne && this.codeCommuneSelectionne) { let codePostalSelectionne = this.codePostalSelectionne; await this.refreshBasedOnCodePostalSelectionne(autocompletes, codePostalSelectionne); } } - private async refreshBasedOnCodePostalSelectionne(autocompletes: string[], codePostalSelectionne: string) { + private async refreshBasedOnCodePostalSelectionne(autocompletes: Set, codePostalSelectionne: string) { const autoCompleteCodePostal = this.getAutoCompleteCodePostal(autocompletes, codePostalSelectionne); if (!autoCompleteCodePostal) { console.error(`Can't find autocomplete matching codepostal ${codePostalSelectionne}`); @@ -430,11 +475,10 @@ export class VmdRdvParCommuneView extends AbstractVmdRdvView { return autocompletes; } - private getAutoCompleteCodePostal(autocompletes: string[], codePostalSelectionne: string) { - const autocompletesSet = new Set(autocompletes); + private getAutoCompleteCodePostal(autocompletes: Set, codePostalSelectionne: string) { return codePostalSelectionne.split('') .map((_, index) => codePostalSelectionne!.substring(0, index + 1)) - .find(autoCompleteAttempt => autocompletesSet.has(autoCompleteAttempt)); + .find(autoCompleteAttempt => autocompletes.has(autoCompleteAttempt)); } private async updateCommunesDisponiblesBasedOnAutocomplete(autoCompleteCodePostal: string) { @@ -469,33 +513,19 @@ export class VmdRdvParCommuneView extends AbstractVmdRdvView { :(lieu: Lieu) => undefined; const { lieuxDisponibles, lieuxIndisponibles } = { - lieuxDisponibles: lieuxParDepartement?lieuxParDepartement.lieuxDisponibles.map(l => ({ - ...l, distance: distanceAvec(l) - })).filter(l => !l.distance || l.distance < MAX_DISTANCE_CENTRE_IN_KM):[], - lieuxIndisponibles: lieuxParDepartement?lieuxParDepartement.lieuxIndisponibles.map(l => ({ - ...l, distance: distanceAvec(l) - })).filter(l => !l.distance || l.distance < MAX_DISTANCE_CENTRE_IN_KM):[], + lieuxDisponibles: lieuxParDepartement?lieuxParDepartement.lieuxDisponibles:[], + lieuxIndisponibles: lieuxParDepartement?lieuxParDepartement.lieuxIndisponibles:[], }; - if(this.critèreDeTri==='date') { - return { - ...lieuxParDepartement, - lieuxDisponibles: [...lieuxDisponibles] - .sort((a, b) => Date.parse(a.prochain_rdv!) - Date.parse(b.prochain_rdv!)), - lieuxIndisponibles: [...lieuxIndisponibles] - .sort((a, b) => Date.parse(a.prochain_rdv!) - Date.parse(b.prochain_rdv!)), - }; - } else if(this.critèreDeTri==='distance') { - return { - ...lieuxParDepartement!, - lieuxDisponibles: [...lieuxDisponibles] - .sort((a, b) => a.distance! - b.distance!), - lieuxIndisponibles: [...lieuxIndisponibles] - .sort((a, b) => a.distance! - b.distance!), - }; - } else { - throw new Error("No critereDeTri defined !"); - } + return { + ...lieuxParDepartement, + lieuxAffichables: ArrayBuilder.from([...lieuxDisponibles].map(l => ({...l, disponible: true}))) + .concat([...lieuxIndisponibles].map(l => ({...l, disponible: false}))) + .map(l => ({...l, distance: distanceAvec(l) })) + .filter(l => !l.distance || l.distance < MAX_DISTANCE_CENTRE_IN_KM) + .sortBy(l => this.extraireFormuleDeTri(l, this.critèreDeTri)) + .build() + }; } critereTriUpdated(triCentre: CodeTriCentre) { @@ -549,20 +579,17 @@ export class VmdRdvParDepartementView extends AbstractVmdRdvView { afficherLieuxParDepartement(lieuxParDepartement: LieuxParDepartement): LieuxAvecDistanceParDepartement { const { lieuxDisponibles, lieuxIndisponibles } = { - lieuxDisponibles: lieuxParDepartement?lieuxParDepartement.lieuxDisponibles.map(l => ({ - ...l, distance: undefined - })):[], - lieuxIndisponibles: lieuxParDepartement?lieuxParDepartement.lieuxIndisponibles.map(l => ({ - ...l, distance: undefined - })):[], + lieuxDisponibles: lieuxParDepartement?lieuxParDepartement.lieuxDisponibles:[], + lieuxIndisponibles: lieuxParDepartement?lieuxParDepartement.lieuxIndisponibles:[], }; return { ...lieuxParDepartement, - lieuxDisponibles: [...lieuxDisponibles] - .sort((a, b) => Date.parse(a.prochain_rdv!) - Date.parse(b.prochain_rdv!)), - lieuxIndisponibles: [...lieuxIndisponibles] - .sort((a, b) => Date.parse(a.prochain_rdv!) - Date.parse(b.prochain_rdv!)), + lieuxAffichables: ArrayBuilder.from([...lieuxDisponibles].map(l => ({...l, disponible: true}))) + .concat([...lieuxIndisponibles].map(l => ({...l, disponible: false}))) + .map(l => ({...l, distance: undefined })) + .sortBy(l => this.extraireFormuleDeTri(l, 'date')) + .build() }; } } diff --git a/src/vmd-app.component.ts b/src/vmd-app.component.ts index 69b3b6386..4cc7696f3 100644 --- a/src/vmd-app.component.ts +++ b/src/vmd-app.component.ts @@ -29,18 +29,20 @@ export class VmdAppComponent extends LitElement { render() { return html` -
    -
    - - -
    -
    -
    -
    - À propos -
    -
    - CovidTracker  +
    +
    +
    + + +
    +
    +