From 1e913a3a30b3354e08e20b4d82d3947d99cb50c9 Mon Sep 17 00:00:00 2001 From: Alexandre Hajjar Date: Sat, 15 May 2021 12:52:45 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88=20Stats=20page=20-=20indicateurs?= =?UTF-8?q?=20globaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ajout des indicateurs globaux (depuis le début et 30 derniers jours) en haut de page * Améliore typage page Stats * Refactor SatisfactionChart fix #1473 --- CONTRIBUTING.md | 6 + .../source/components/LangSwitcher.tsx | 5 +- .../source/pages/Stats/GlobalStats.tsx | 192 ++++++++++++++++++ .../source/pages/Stats/SatisfactionChart.tsx | 55 ++--- mon-entreprise/source/pages/Stats/Stats.tsx | 128 +++++------- mon-entreprise/source/pages/Stats/types.ts | 64 ++++++ mon-entreprise/source/pages/Stats/utils.tsx | 66 ++++++ 7 files changed, 410 insertions(+), 106 deletions(-) create mode 100644 mon-entreprise/source/pages/Stats/GlobalStats.tsx create mode 100644 mon-entreprise/source/pages/Stats/types.ts create mode 100644 mon-entreprise/source/pages/Stats/utils.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f0f241e8..f2f0b4c2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,9 @@ Nous utilisons : ### Démarrage +Tout d'abord assurez-vous d'avoir toutes les clés d'API nécessaires dans votre fichier `mon-entreprise/.env`. +Demandez les détails à vos collègues (ces informations n'étant pas publiques). + Si l'historique des commits est trop volumineux, vous pouvez utiliser le paramètre `depth` de git pour ne télécharger que les derniers commits. ``` @@ -39,6 +42,9 @@ git clone --depth 100 git@github.com:betagouv/mon-entreprise.git && cd mon-entre # Install the Javascript dependencies through Yarn yarn install +# Download some data +yarn prepare + # Watch changes in publicodes and run the server for mon-entreprise yarn start ``` diff --git a/mon-entreprise/source/components/LangSwitcher.tsx b/mon-entreprise/source/components/LangSwitcher.tsx index c090901c5..e8c6a04b1 100644 --- a/mon-entreprise/source/components/LangSwitcher.tsx +++ b/mon-entreprise/source/components/LangSwitcher.tsx @@ -1,3 +1,4 @@ +import { AvailableLangs } from 'locales/i18n' import emoji from 'react-easy-emoji' import { useTranslation } from 'react-i18next' @@ -9,7 +10,7 @@ const languageCodeToEmoji = { export default function LangSwitcher({ className }: { className: string }) { const { i18n } = useTranslation() const languageCode = i18n.language - const unusedLanguageCode = + const unusedLanguageCode: AvailableLangs = !languageCode || languageCode === 'fr' ? 'en' : 'fr' const changeLanguage = () => { i18n.changeLanguage(unusedLanguageCode) @@ -19,7 +20,7 @@ export default function LangSwitcher({ className }: { className: string }) { className={className ?? 'ui__ link-button'} onClick={changeLanguage} > - {emoji(languageCodeToEmoji[languageCode as 'fr' | 'en'])}{' '} + {emoji(languageCodeToEmoji[languageCode as AvailableLangs])}{' '} {languageCode.toUpperCase()} ) diff --git a/mon-entreprise/source/pages/Stats/GlobalStats.tsx b/mon-entreprise/source/pages/Stats/GlobalStats.tsx new file mode 100644 index 000000000..62cfaf73a --- /dev/null +++ b/mon-entreprise/source/pages/Stats/GlobalStats.tsx @@ -0,0 +1,192 @@ +import emoji from 'react-easy-emoji' +import { Indicators, Indicator } from './utils' +import { SatisfactionLevel, StatsStruct } from './types' +import { useTranslation } from 'react-i18next' +import { SatisfactionStyle } from './SatisfactionChart' + +const add = (a: number, b: number) => a + b +const lastCompare = (startDate: Date, dateStr: string) => + startDate < new Date(dateStr) + +const BigIndicator: typeof Indicator = ({ main, subTitle, footnote }) => ( + + {main} + + } + subTitle={subTitle} + footnote={footnote} + /> +) + +const RetoursAsProgress = ({ + percentages, +}: { + percentages: Record +}) => ( +
+ {' '} + {SatisfactionStyle.map(([level, { emoji: emojiStr, color }]) => ( +
+ {emoji(emojiStr)} +
+ {Math.round(percentages[level])}% +
+
+ ))} +
+) +export default function GlobalStats({ stats }: { stats: StatsStruct }) { + const { i18n } = useTranslation() + const formatNumber = Intl.NumberFormat(i18n.language).format.bind(null) + + const totalVisits = formatNumber( + stats.visitesMois.site.map(({ nombre }) => nombre).reduce(add, 0) + ) + const totalCommenceATI = stats.visitesMois.pages + .filter(({ page }) => page === 'simulation_commencee') + .map(({ nombre }) => nombre) + .reduce(add, 0) + // Hardcoded stuff from https://github.com/betagouv/mon-entreprise/pull/1563#discussion_r635893624 + const totalCommenceMatomo = Object.values({ + 2019: Math.floor((1262601 * 45) / 100), + 2020: 1373536, + 2021: 273731, + }).reduce(add, 0) + const totalCommence = formatNumber(totalCommenceMatomo + totalCommenceATI) + + const day30before = new Date(new Date().setDate(new Date().getDate() - 30)) + + const last30dVisitsNum = stats.visitesJours.site + .filter(({ date }) => lastCompare(day30before, date)) + .map(({ nombre }) => nombre) + .reduce(add, 0) + const last30dVisits = formatNumber(last30dVisitsNum) + const last30dCommenceNum = stats.visitesJours.pages + .filter( + ({ date, page }) => + lastCompare(day30before, date) && page === 'simulation_commencee' + ) + .map(({ nombre }) => nombre) + .reduce(add, 0) + const last30dCommence = formatNumber(last30dCommenceNum) + const last30dConv = Math.round((100 * last30dCommenceNum) / last30dVisitsNum) + + const last30dSatisfactions = stats.satisfaction + .filter(({ date }) => lastCompare(day30before, date)) + .reduce( + (acc, { click: satisfactionLevel, nombre }) => ({ + ...acc, + [satisfactionLevel]: acc[satisfactionLevel] + nombre, + }), + { + [SatisfactionLevel.Mauvais]: 0, + [SatisfactionLevel.Moyen]: 0, + [SatisfactionLevel.Bien]: 0, + [SatisfactionLevel.TrèsBien]: 0, + } + ) + const last30dSatisfactionTotal = Object.values(last30dSatisfactions).reduce( + (a, b) => a + b + ) + const last30dSatisfactionPercentages = Object.fromEntries( + Object.entries(last30dSatisfactions).map(([level, count]) => [ + level, + (100 * count) / last30dSatisfactionTotal, + ]) + ) as Record + + return ( + <> + {' '} + + + + + + + + +
+ + Taux de conversion vers une simulation :{' '} + {last30dConv}% + +
+ + + {' '} + + + } + footnote={`${last30dSatisfactionTotal} avis sur les 30 derniers jours`} + width="75%" + /> + + + ) +} diff --git a/mon-entreprise/source/pages/Stats/SatisfactionChart.tsx b/mon-entreprise/source/pages/Stats/SatisfactionChart.tsx index 8e1dbe2a6..fc20a7cde 100644 --- a/mon-entreprise/source/pages/Stats/SatisfactionChart.tsx +++ b/mon-entreprise/source/pages/Stats/SatisfactionChart.tsx @@ -1,6 +1,4 @@ -import { ThemeColorsContext } from 'Components/utils/colors' import { add, mapObjIndexed } from 'ramda' -import React, { useContext } from 'react' import emoji from 'react-easy-emoji' import { Bar, @@ -10,6 +8,22 @@ import { Tooltip, XAxis, } from 'recharts' +import { SatisfactionLevel } from './types' + +export const SatisfactionStyle: [ + SatisfactionLevel, + { emoji: string; color: string } +][] = [ + [SatisfactionLevel.Mauvais, { emoji: '🙁', color: '#ff5959' }], + [SatisfactionLevel.Moyen, { emoji: '😐', color: '#fff339' }], + [SatisfactionLevel.Bien, { emoji: '🙂', color: '#90e789' }], + [SatisfactionLevel.TrèsBien, { emoji: '😀', color: '#0fc700' }], +] + +function toPercentage(data: Record): Record { + const total = Object.values(data).reduce(add) + return { ...mapObjIndexed((value) => (100 * value) / total, data), total } +} type SatisfactionChartProps = { data: Array<{ @@ -17,13 +31,7 @@ type SatisfactionChartProps = { nombre: Record }> } - -function toPercentage(data: Record): Record { - const total = Object.values(data).reduce(add) - return { ...mapObjIndexed((value) => (100 * value) / total, data), total } -} export default function SatisfactionChart({ data }: SatisfactionChartProps) { - const { color, lightColor, lighterColor } = useContext(ThemeColorsContext) if (!data.length) { return null } @@ -34,22 +42,21 @@ export default function SatisfactionChart({ data }: SatisfactionChartProps) { } /> - - '🙁'} position="left" /> - - - '😐'} position="left" /> - - - '🙂'} position="left" /> - - - '😀'} - position="left" - /> - + {SatisfactionStyle.map(([level, { emoji, color }]) => ( + + emoji} + position="left" + /> + + ))} diff --git a/mon-entreprise/source/pages/Stats/Stats.tsx b/mon-entreprise/source/pages/Stats/Stats.tsx index ae8f71001..07145d260 100644 --- a/mon-entreprise/source/pages/Stats/Stats.tsx +++ b/mon-entreprise/source/pages/Stats/Stats.tsx @@ -10,18 +10,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import emoji from 'react-easy-emoji' import { Trans } from 'react-i18next' import { useHistory, useLocation } from 'react-router-dom' -import styled from 'styled-components' import { TrackPage } from '../../ATInternetTracking' -import stats from '../../data/stats.json' +import statsJson from '../../data/stats.json' import { debounce } from '../../utils' import { SimulateurCard } from '../Simulateurs/Home' import useSimulatorsData, { SimulatorData } from '../Simulateurs/metadata' import Chart from './Chart' import DemandeUtilisateurs from './DemandesUtilisateurs' +import GlobalStats from './GlobalStats' +import { formatDay, formatMonth, Indicators, Indicator } from './utils' import SatisfactionChart from './SatisfactionChart' +import { StatsStruct, PageChapter2, Page, PageSatisfaction } from './types' + +const stats = (statsJson as unknown) as StatsStruct type Period = 'mois' | 'jours' -type Chapter2 = typeof stats.visitesJours.pages[number]['page_chapter2'] | 'PAM' +type Chapter2 = PageChapter2 | 'PAM' + const chapters2: Chapter2[] = [ ...new Set(stats.visitesMois.pages.map((p) => p.page_chapter2)), 'PAM', @@ -31,6 +36,8 @@ type Data = | Array<{ date: string; nombre: number }> | Array<{ date: string; nombre: Record }> +type Pageish = Page & PageSatisfaction + const isPAM = (name: string | undefined) => name && [ @@ -39,23 +46,15 @@ const isPAM = (name: string | undefined) => 'auxiliaire_medical', 'sage_femme', ].includes(name) -type RawData = Array<{ - date: string - page_chapter1?: string - page_chapter2: string - page_chapter3?: string - page?: string - click?: string - nombre: number -}> + const filterByChapter2 = ( - data: RawData, - chapter2: Chapter2 + pages: Pageish[], + chapter2: Chapter2 | '' ): Array<{ date: string; nombre: Record }> => { return toPairs( groupBy( (p) => p.date, - data.filter( + pages.filter( (p) => !chapter2 || (p.page !== 'accueil_pamc' && @@ -72,7 +71,7 @@ const filterByChapter2 = ( })) } -function groupByDate(data: RawData) { +function groupByDate(data: Pageish[]) { return toPairs( groupBy( (p) => p.date, @@ -102,7 +101,7 @@ const computeTotals = (data: Data): number | Record => { .reduce(mergeWith(add), {}) } -export default function Stats() { +const StatsDetail = () => { const defaultPeriod = 'mois' const history = useHistory() const location = useLocation() @@ -113,7 +112,7 @@ export default function Stats() { (urlParams.get('periode') as Period) ?? defaultPeriod ) const [chapter2, setChapter2] = useState( - urlParams.get('module') ?? '' + (urlParams.get('module') as Chapter2) ?? '' ) // The logic to persist some state in query parameters in the URL could be @@ -134,16 +133,16 @@ export default function Stats() { if (!chapter2) { return rawData.site } - return filterByChapter2(rawData.pages, chapter2) + return filterByChapter2(rawData.pages as Pageish[], chapter2) }, [period, chapter2]) const repartition = useMemo(() => { const rawData = stats.visitesMois - return groupByDate(rawData.pages) + return groupByDate(rawData.pages as Pageish[]) }, []) const satisfaction = useMemo(() => { - return filterByChapter2(stats.satisfaction, chapter2) + return filterByChapter2(stats.satisfaction as Pageish[], chapter2) }, [chapter2]) const [[startDateIndex, endDateIndex], setDateIndex] = useState< @@ -173,21 +172,10 @@ export default function Stats() { () => computeTotals(slicedVisits), [slicedVisits] ) + return ( <> - - - -

- Statistiques <>{emoji('📊')} -

-

- Découvrez nos statistiques d'utilisation mises à jour quotidiennement. -
- Les données recueillies sont anonymisées.{' '} - -

- +

Statistiques détaillées

1. Sélectionner la fonctionnalité :

@@ -315,57 +303,34 @@ export default function Stats() { /> - - ) } -const Indicators = styled.div` - display: flex; - flex-direction: row; - justify-content: space-around; - margin: 2rem 0; -` - -type IndicatorProps = { - main?: string - subTitle?: React.ReactNode -} - -function Indicator({ main, subTitle }: IndicatorProps) { +export default function Stats() { return ( -
- {subTitle} -
- {main} -
+ <> + + + +

+ Statistiques <>{emoji('📊')} +

+

+ Découvrez nos statistiques d'utilisation mises à jour quotidiennement. +
+ Les données recueillies sont anonymisées.{' '} + +

+ + + + + + ) } -function formatDay(date: string | Date) { - return new Date(date).toLocaleString('default', { - weekday: 'long', - day: 'numeric', - month: 'long', - }) -} - -function formatMonth(date: string | Date) { - return new Date(date).toLocaleString('default', { - month: 'long', - year: 'numeric', - }) -} - function getChapter2(s: SimulatorData[keyof SimulatorData]): Chapter2 | '' { if (s.iframePath === 'pamc') { return 'PAM' @@ -373,9 +338,10 @@ function getChapter2(s: SimulatorData[keyof SimulatorData]): Chapter2 | '' { if (!s.tracking) { return '' } - return typeof s.tracking === 'string' ? s.tracking : s.tracking.chapter2 ?? '' + const tracking = s.tracking as { chapter2?: Chapter2 } + return typeof tracking === 'string' ? tracking : tracking.chapter2 ?? '' } -function SelectedSimulator(props: { chapter2: Chapter2 }) { +function SelectedSimulator(props: { chapter2: Chapter2 | '' }) { const simulateur = Object.values(useSimulatorsData()).find( (s) => getChapter2(s) === props.chapter2 && !(s.tracking as any).chapter3 ) @@ -440,7 +406,9 @@ function SimulateursChoice(props: { type="radio" name="simulateur" value={getChapter2(s)} - onChange={(evt) => props.onChange(evt.target.value)} + onChange={(evt) => + props.onChange(evt.target.value as Chapter2 | '') + } checked={getChapter2(s) === props.value} /> diff --git a/mon-entreprise/source/pages/Stats/types.ts b/mon-entreprise/source/pages/Stats/types.ts new file mode 100644 index 000000000..bf418a24f --- /dev/null +++ b/mon-entreprise/source/pages/Stats/types.ts @@ -0,0 +1,64 @@ +import statsJson from '../../data/stats.json' + +// Generated using app.quicktype.io + +export interface StatsStruct { + visitesJours: Visites + visitesMois: Visites + satisfaction: PageSatisfaction[] + retoursUtilisateurs: RetoursUtilisateurs +} + +export interface RetoursUtilisateurs { + open: Closed[] + closed: Closed[] +} + +export interface Closed { + title: string + closedAt: string | null + number: number + count: number +} + +export interface BasePage { + date: string + nombre: number + page_chapter1: string + page_chapter2: PageChapter2 + page_chapter3: string +} +export type Page = BasePage & { page: string } +export type PageSatisfaction = BasePage & { click: SatisfactionLevel } + +export enum SatisfactionLevel { + Bien = 'bien', + Mauvais = 'mauvais', + Moyen = 'moyen', + TrèsBien = 'très bien', +} + +export interface Visites { + pages: Page[] + site: Site[] +} + +export interface Site { + date: string + nombre: number +} + +export enum PageChapter2 { + AideDeclarationIndependant = 'aide_declaration_independant', + ArtisteAuteur = 'artiste_auteur', + AutoEntrepreneur = 'auto_entrepreneur', + ChomagePartiel = 'chomage_partiel', + ComparaisonStatut = 'comparaison_statut', + DirigeantSasu = 'dirigeant_sasu', + EconomieCollaborative = 'economie_collaborative', + Guide = 'guide', + ImpotSociete = 'impot_societe', + Independant = 'independant', + ProfessionLiberale = 'profession_liberale', + Salarie = 'salarie', +} diff --git a/mon-entreprise/source/pages/Stats/utils.tsx b/mon-entreprise/source/pages/Stats/utils.tsx new file mode 100644 index 000000000..b36cd0c5d --- /dev/null +++ b/mon-entreprise/source/pages/Stats/utils.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import styled from 'styled-components' + +export const Indicators = styled.div` + display: flex; + flex-direction: row; + justify-content: space-around; + margin: 2rem 0; +` +type IndicatorProps = { + main?: React.ReactNode + subTitle?: React.ReactNode + footnote?: string + width?: string +} +export function Indicator({ main, subTitle, footnote, width }: IndicatorProps) { + return ( +
+ + {subTitle} + + + {main} + + {footnote && ( + + {footnote} + + )} +
+ ) +} +export function formatDay(date: string | Date) { + return new Date(date).toLocaleString('default', { + weekday: 'long', + day: 'numeric', + month: 'long', + }) +} +export function formatMonth(date: string | Date) { + return new Date(date).toLocaleString('default', { + month: 'long', + year: 'numeric', + }) +}