📈 Stats page - indicateurs globaux

* 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
pull/1597/head
Alexandre Hajjar 2021-05-15 12:52:45 +02:00
parent 2250dccf4b
commit 1e913a3a30
7 changed files with 410 additions and 106 deletions

View File

@ -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
```

View File

@ -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()}
</button>
)

View File

@ -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 }) => (
<Indicator
main={
<div
css={`
font-size: 2rem;
line-height: 3rem;
`}
>
{main}
</div>
}
subTitle={subTitle}
footnote={footnote}
/>
)
const RetoursAsProgress = ({
percentages,
}: {
percentages: Record<SatisfactionLevel, number>
}) => (
<div
className="progress__container"
css={`
width: 95%;
height: 2.5rem;
margin-top: 1rem;
margin-bottom: 1.5rem;
display: flex;
font-size: 1.8rem;
`}
>
{' '}
{SatisfactionStyle.map(([level, { emoji: emojiStr, color }]) => (
<div
key={level}
css={`
width: ${percentages[level]}%;
background-color: ${color};
display: flex;
align-items: center;
justify-content: center;
`}
>
{emoji(emojiStr)}
<div
css={`
position: absolute;
margin-top: 4rem;
font-size: 0.7rem;
font-weight: lighter;
`}
>
{Math.round(percentages[level])}%
</div>
</div>
))}
</div>
)
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<SatisfactionLevel, number>
return (
<>
{' '}
<Indicators>
<BigIndicator
main={totalVisits}
subTitle="Visites"
footnote="depuis le 1ᵉ janvier 2019"
/>
<BigIndicator
main={totalCommence}
subTitle="Simulations lancées"
footnote="depuis le 1ᵉ janvier 2019"
/>
</Indicators>
<Indicators>
<BigIndicator
main={last30dVisits}
subTitle="Visites"
footnote="sur les 30 derniers jours"
/>
<BigIndicator
main={last30dCommence}
subTitle="Simulations lancées"
footnote="sur les 30 derniers jours"
/>
</Indicators>
<div
css={`
display: flex;
flex-direction: row;
justify-content: space-around;
margin: -1rem 0 0 0;
`}
>
<i>
<small>Taux de conversion vers une simulation&nbsp;:</small>{' '}
<b>{last30dConv}%</b>
</i>
</div>
<Indicators>
<Indicator
subTitle="Satisfaction utilisateurs"
main={
<div
css={`
display: flex;
flex-direction: row;
justify-content: space-around;
`}
>
{' '}
<RetoursAsProgress percentages={last30dSatisfactionPercentages} />
</div>
}
footnote={`${last30dSatisfactionTotal} avis sur les 30 derniers jours`}
width="75%"
/>
</Indicators>
</>
)
}

View File

@ -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<string, number>): Record<string, number> {
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<string, number>
}>
}
function toPercentage(data: Record<string, number>): Record<string, number> {
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) {
<BarChart data={flattenData}>
<XAxis dataKey="date" tickFormatter={formatMonth} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="mauvais" stackId="1" fill="#fd667f" maxBarSize={50}>
<LabelList dataKey="mauvais" content={() => '🙁'} position="left" />
</Bar>
<Bar dataKey="moyen" stackId="1" maxBarSize={50} fill={lighterColor}>
<LabelList dataKey="moyen" content={() => '😐'} position="left" />
</Bar>
<Bar dataKey="bien" stackId="1" maxBarSize={50} fill={lightColor}>
<LabelList dataKey="bien" content={() => '🙂'} position="left" />
</Bar>
<Bar dataKey="très bien" stackId="1" maxBarSize={50} fill={color}>
<LabelList
dataKey="très bien"
content={() => '😀'}
position="left"
/>
</Bar>
{SatisfactionStyle.map(([level, { emoji, color }]) => (
<Bar
key={level}
dataKey={level}
stackId="1"
fill={color}
maxBarSize={50}
>
<LabelList
dataKey={level}
content={() => emoji}
position="left"
/>
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</>

View File

@ -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<string, number> }>
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<string, number> }> => {
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<string, number> => {
.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<Chapter2 | ''>(
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 (
<>
<TrackPage chapter1="informations" name="stats" />
<ScrollToTop />
<h1>
Statistiques <>{emoji('📊')}</>
</h1>
<p>
Découvrez nos statistiques d'utilisation mises à jour quotidiennement.
<br />
Les données recueillies sont anonymisées.{' '}
<Privacy label="En savoir plus" />
</p>
<h2>Statistiques détaillées</h2>
<p>
<strong>1. Sélectionner la fonctionnalité : </strong>
</p>
@ -315,57 +303,34 @@ export default function Stats() {
/>
</div>
</div>
<DemandeUtilisateurs />
<MoreInfosOnUs />
</>
)
}
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 (
<div
className="ui__ card lighter-bg"
css={`
text-align: center;
padding: 1rem;
width: 210px;
font-size: 110%;
`}
>
<small>{subTitle}</small>
<br />
<strong>{main}</strong>
</div>
<>
<TrackPage chapter1="informations" name="stats" />
<ScrollToTop />
<h1>
Statistiques <>{emoji('📊')}</>
</h1>
<p>
Découvrez nos statistiques d'utilisation mises à jour quotidiennement.
<br />
Les données recueillies sont anonymisées.{' '}
<Privacy label="En savoir plus" />
</p>
<GlobalStats stats={stats} />
<StatsDetail />
<DemandeUtilisateurs />
<MoreInfosOnUs />
</>
)
}
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}
/>
<span>

View File

@ -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',
}

View File

@ -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 (
<div
className="ui__ card lighter-bg"
css={`
text-align: center;
padding: 1rem;
width: ${width || '210px'};
font-size: 110%;
`}
>
<small
css={`
display: block;
`}
>
{subTitle}
</small>
<strong
css={`
display: block;
`}
>
{main}
</strong>
{footnote && (
<span
css={`
font-size: small;
display: block;
`}
>
<i>{footnote}</i>
</span>
)}
</div>
)
}
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',
})
}