Améliore les statistiques

- Tous les simulateurs sont désormais présents dans la page stat
- Refacto du code, et séparation en plusieurs fichiers
- Ajout des stats pour l'assistant au choix du statut
pull/2757/head
Johan Girod 2023-08-03 13:14:45 +02:00
parent 1952a15784
commit 8d635bdbbb
11 changed files with 413 additions and 47535 deletions

View File

@ -55,119 +55,12 @@ const buildSimulateursQuery = (period, granularity) => ({
granularity,
top: {
'page-num': 1,
'max-results': 500,
'max-results': 5000,
sort: ['-m_visits'],
filter: {
property: {
page_chapter1: {
$in: ['gerer', 'simulateurs'],
},
},
},
},
},
options: {
ignore_null_properties: true,
},
})
const buildCreerQuery = (period, granularity) => ({
columns: [
'page',
'page_chapter1',
'page_chapter2',
'page_chapter3',
'm_visits',
],
space: {
s: [617190, 617189],
},
period: {
p1: [period],
},
evo: {
granularity,
top: {
'page-num': 1,
'max-results': 500,
sort: ['-m_visits'],
filter: {
property: {
$AND: [
{
page: {
$eq: 'accueil',
},
},
{
page_chapter1: {
$eq: 'creer',
},
},
],
},
},
},
},
options: {
ignore_null_properties: true,
},
})
const buildCreerSegmentQuery = (period, granularity) => ({
columns: [
'page',
'page_chapter1',
'page_chapter2',
'page_chapter3',
'm_visits',
],
segment: {
section: {
scope: 'visit_id',
category: 'property',
coverage: 'at_least_one_visit',
content: {
and: [
{
condition: {
filter: {
page_chapter1: {
$eq: 'creer',
},
},
},
},
{
condition: {
filter: {
page: {
$eq: 'accueil',
},
},
},
},
],
},
mode: 'include',
},
},
space: {
s: [617190],
},
period: {
p1: [period],
},
evo: {
granularity,
top: {
'page-num': 1,
'max-results': 500,
sort: ['-m_visits'],
filter: {
property: {
page_chapter1: {
$eq: 'creer',
$in: ['assistant', 'simulateurs', 'gérer'],
},
},
},
@ -196,14 +89,14 @@ const buildSatisfactionQuery = () => ({
granularity: 'M',
top: {
'page-num': 1,
'max-results': 500,
'max-results': 5000,
sort: ['-m_events'],
filter: {
property: {
$AND: [
{
page_chapter1: {
$in: ['creer', 'gerer', 'simulateurs'],
$in: ['assistant', 'gerer', 'simulateurs'],
},
},
{
@ -233,7 +126,7 @@ const buildSiteQuery = (period, granularity) => ({
granularity,
top: {
'page-num': 1,
'max-results': 500,
'max-results': 5000,
sort: ['-m_visits'],
},
},
@ -286,40 +179,32 @@ const uniformiseData = (data) =>
const flattenPage = (list) =>
list
.filter(
(p) => p && (p.page_chapter2 !== 'N/A' || p.page_chapter1 === 'creer')
) // Remove simulateur landing page
.filter((p) => p && p.page_chapter2 !== 'N/A') // Remove landing pages
.map(({ Rows, ...page }) => Rows.map((r) => ({ ...page, ...r })))
.flat()
async function fetchDailyVisits() {
const pages = uniformiseData([
...flattenPage(await fetchApi(buildSimulateursQuery(last60days, 'D'))),
...flattenPage(await fetchApi(buildCreerQuery(last60days, 'D'))),
])
const pages = uniformiseData(
flattenPage(await fetchApi(buildSimulateursQuery(last60days, 'D')))
)
const site = uniformiseData(
(await fetchApi(buildSiteQuery(last60days, 'D')))[0].Rows
)
const creer = uniformiseData(
flattenPage(await fetchApi(buildCreerSegmentQuery(last60days, 'D')))
)
const { start, end } = last60days
return {
pages,
site,
creer,
api: await apiStats(start, end, 'date'),
}
}
async function fetchMonthlyVisits() {
const pages = uniformiseData([
...flattenPage(await fetchApi(buildSimulateursQuery(last12Months, 'M'))),
...flattenPage(await fetchApi(buildCreerQuery(last12Months, 'M'))),
])
const pages = uniformiseData(
flattenPage(await fetchApi(buildSimulateursQuery(last12Months, 'M')))
)
const site = [
...matomoSiteVisitsHistory.map(({ date, visites }) => ({
@ -331,16 +216,11 @@ async function fetchMonthlyVisits() {
),
]
const creer = uniformiseData(
flattenPage(await fetchApi(buildCreerSegmentQuery(last12Months, 'M')))
)
const { start, end } = last12Months
return {
pages,
site,
creer,
api: await apiStats(start, end, 'month'),
}
}

View File

@ -1250,32 +1250,23 @@ dirigeant . exonérations . ACRE:
titre.en: '[automatic] Default ACRE'
titre.fr: ACRE par défaut
description.en: >-
[automatic] The aid for creating or taking over a business
(Acre) consists of a partial exemption from social security charges, known
as the start-up exemption, for 12 months.
[automatic] Assistance for setting up or taking over a business
(Acre) consists of a partial exemption from social security contributions,
known as the "start-up exemption", for a period of 12 months.
It is **automatic** for **companies and sole proprietorships** (under certain conditions, such as not having benefited from it in the last three years).
It is **automatic** for **companies and sole proprietorships** (subject to certain conditions, such as not having benefited from it in the last three years).
For **self-employed** entrepreneurs, however, it must be requested and is reserved for the following beneficiaries
For **self-employed** entrepreneurs, on the other hand, it must be applied for, and is reserved for the following beneficiaries:
- Jobseekers (whether or not they receive compensation but have been registered with the Pôle Emploi for at least 6 months during the last 18 months).
- Jobseekers (with or without benefits, but registered with Pôle Emploi for at least 6 months in the last 18 months).
- Recipients of social assistance (RSA, ASS, ATA)
- Recipients of social assistance (RSA, ASS, ATA).
- Young people between 18 and 25 years old (up to 29 years old for people recognized as disabled)
- Young people aged between 18 and 25 (up to 29 for people recognized as disabled).
- People creating a micro-enterprise in a priority district of the city (QPPV)
> *Historical*:
- For auto-businesses created from January 1, 2020, the exemption is again subject to conditions.
- For businesses created between January 1, 2019 and December 31, 2019, the reduction is generalized to all creators, unless you have already obtained the ACCRE in the previous three years
- For companies created before January 1, 2019, the exemption of contribution called ACCRE was subject to conditions and was not automatic: you had to apply.
- People setting up a micro-business in a priority urban district (QPPV).
description.fr: >-
L'aide à la création ou à la reprise d'une entreprise (Acre)
consiste en une exonération partielle de charges sociales, dite exonération
@ -1294,15 +1285,6 @@ dirigeant . exonérations . ACRE:
- Les jeunes entre 18 et 25 ans (jusqu'à 29 ans pour les personnes reconnues en situation de handicap)
- Les personnes créant une micro-entreprise dans un quartier prioritaire de la ville (QPPV)
> *Historique*:
- Pour les auto-entreprise créées à partir du 1er janvier 2020, l'exonération est de nouveau soumise à condition.
- Pour les entreprises créées entre le 1er janvier 2019 et le 31 décembre 2019, la réduction est généralisée à tous les créateurs, sauf si vous avez déjà obtenu l'ACCRE dans les trois années précédentes
- Pour les entreprises créées avant le 1er janvier 2019, la l'exonération de cotisation s'appelait ACCRE était soumise à conditions et n'était pas automatique : il fallait en faire la demande.
question.en: '[automatic] Did you benefit from the ACRE?'
question.fr: Bénéficiez-vous de l'ACRE ?
titre.en: '[automatic] ACRE'

View File

@ -479,10 +479,10 @@ choix-statut:
question3:
help:
title: Choosing between a sole proprietorship and a company
label: Would you prefer to run your business solely as a company?<1><0>Sole
label: Do you want to run your business as a company only?<1><0>Sole
proprietorship</0><1><0>You are <2>responsible for all your business
assets</2> (equipment, premises, tools, etc.). </0><1>Your <2>personal
assets may be seized</2> if you fail to meet your tax and social
assets can be seized</2> if you fail to meet your tax and social
security obligations.</1><2>You can <2>waive the separation of your
assets</2>, for example to secure a bank loan.</2><3>The formalities
involved in setting up and running a company are <2>simpler and less

View File

@ -504,14 +504,14 @@ choix-statut:
question3:
help:
title: Choisir entre une entreprise individuelle et une société
label: Préférez-vous exercer votre activité sous la forme d'une société
uniquement ?<1><0>Entreprise individuelle</0><1><0>Vous êtes
<2>responsable sur l'ensemble de vos biens utiles à votre activité</2>
(matériel, locaux, outils, etc.). </0><1>Votre <2>patrimoine personnel
peut être saisi</2> en cas de manquements à vos obligations fiscales et
sociales.</1><2>Vous pouvez <2>renoncer à la séparation de ses
patrimoines</2>, par exemple pour garantir un crédit bancaire.</2><3>Les
formalités de création et de gestion sont <2>plus simples et moins
label: Voulez-vous exercer votre activité sous la forme d'une société uniquement
?<1><0>Entreprise individuelle</0><1><0>Vous êtes <2>responsable sur
l'ensemble de vos biens utiles à votre activité</2> (matériel, locaux,
outils, etc.). </0><1>Votre <2>patrimoine personnel peut être saisi</2>
en cas de manquements à vos obligations fiscales et sociales.</1><2>Vous
pouvez <2>renoncer à la séparation de ses patrimoines</2>, par exemple
pour garantir un crédit bancaire.</2><3>Les formalités de création et de
gestion sont <2>plus simples et moins
coûteuses.</2></3></1><2>Société</2><3><0>Vous êtes uniquement
<2>responsable sur le montant de votre apport au capital social</2> et
des biens détenus par la société.</0><1>Les formalités de création et de

View File

@ -121,8 +121,8 @@ export default function Associés() {
<Message type="secondary" border={false}>
<H4 as="h3" id="question3">
<Trans i18nKey="choix-statut.associés.question3.label">
Préférez-vous exercer votre activité sous la forme d'une
société uniquement ?
Voulez-vous exercer votre activité sous la forme d'une société
uniquement ?
<HelpButtonWithPopover
title={t(
'choix-statut.associés.question3.help.title',

View File

@ -0,0 +1,71 @@
import { Item } from '@/design-system'
import { Emoji } from '@/design-system/emoji'
import { Select } from '@/design-system/field/Select'
import useSimulatorsData from '@/hooks/useSimulatorsData'
import { SimulateurCard } from '../../components/SimulateurCard'
import { getFilter } from './StatsDetail'
import { Filter } from './useStatistiques'
export function SelectedSimulator(props: { filter: Filter | '' }) {
const simulateur = Object.values(useSimulatorsData()).find(
(s) => JSON.stringify(getFilter(s)) === JSON.stringify(props.filter)
)
if (!simulateur) {
return null
}
return <SimulateurCard small {...simulateur} />
}
export function SimulateursChoice(props: {
onChange: (ch: Filter | '') => void
value: Filter | ''
}) {
const simulateurs = useSimulatorsData()
const choices = Object.values(simulateurs)
.filter((s) => getFilter(s))
.sort((a, b) => (a.shortName < b.shortName ? -1 : 1))
return (
<Select
onSelectionChange={(val) => {
if (val === '' || val === 'api-rest') {
return props.onChange(val)
}
if (!(val in simulateurs)) {
return
}
props.onChange(getFilter(simulateurs[val as keyof typeof simulateurs]))
}}
defaultSelectedKey={
typeof props.value === 'string'
? props.value
: JSON.stringify(props.value)
}
label={'Voir les statistiques pour :'}
id="simulator-choice-input"
>
{[
<Item key="" textValue="Tout le site">
<Emoji emoji="🌍" />
&nbsp;Tout le site
</Item>,
<Item key="api-rest" textValue="API REST">
<Emoji emoji="👩‍💻" />
&nbsp;API REST
</Item>,
...choices.map((s) => (
<Item key={s.id} textValue={s.shortName}>
{s.icône && (
<>
<Emoji emoji={s.icône} />
&nbsp;
</>
)}
{s.shortName}
</Item>
)),
]}
</Select>
)
}

View File

@ -1,5 +1,5 @@
import { formatValue } from 'publicodes'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
import { BrushProps } from 'recharts'
@ -8,28 +8,25 @@ import styled from 'styled-components'
import { toAtString } from '@/components/ATInternetTracking'
import PagesChart from '@/components/charts/PagesCharts'
import { useScrollToHash } from '@/components/utils/markdown'
import { Item, Message, Radio, ToggleGroup } from '@/design-system'
import { Emoji } from '@/design-system/emoji'
import { Select } from '@/design-system/field/Select'
import { Message, Radio, ToggleGroup } from '@/design-system'
import InfoBulle from '@/design-system/InfoBulle'
import { Grid, Spacing } from '@/design-system/layout'
import { H2, H3 } from '@/design-system/typography/heading'
import { Body } from '@/design-system/typography/paragraphs'
import useSimulatorsData, { SimulatorData } from '@/hooks/useSimulatorsData'
import { debounce, groupBy } from '@/utils'
import { debounce } from '@/utils'
import { SimulateurCard } from '../../components/SimulateurCard'
import { AccessibleTable } from './AccessibleTable'
import Chart, { Data, formatLegend, isDataStacked } from './Chart'
import Chart, { formatLegend } from './Chart'
import SatisfactionChart from './SatisfactionChart'
import { SelectedSimulator, SimulateursChoice } from './SimulateursChoice'
import { BigIndicator } from './StatsGlobal'
import { Page, PageChapter2, PageSatisfaction, StatsStruct } from './types'
import { PageChapter2, StatsStruct } from './types'
import { Filter, useStatistiques } from './useStatistiques'
import { useTotals } from './useTotals'
import { formatDay, formatMonth } from './utils'
type Period = 'mois' | 'jours'
type Chapter2 = PageChapter2 | 'PAM' | 'api-rest'
type Pageish = Page | PageSatisfaction
interface StatsDetailProps {
stats: StatsStruct
@ -37,66 +34,15 @@ interface StatsDetailProps {
}
export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
const defaultPeriod = 'mois'
const [searchParams, setSearchParams] = useSearchParams()
useScrollToHash()
const [period, setPeriod] = useState<Period>(
(searchParams.get('periode') as Period) ?? defaultPeriod
)
const [chapter2, setChapter2] = useState<Chapter2 | ''>(
(searchParams.get('module') as Chapter2) ?? ''
)
const [{ period, filter }, { setPeriod, setFilter }] = useStatState()
const { t } = useTranslation()
useEffect(() => {
const paramsEntries = [
['periode', period !== defaultPeriod ? period : ''],
['module', chapter2],
].filter(([, val]) => val !== '') as [string, string][]
setSearchParams(paramsEntries, { replace: true })
}, [period, chapter2, setSearchParams])
const visites = useMemo(() => {
const rawData = period === 'jours' ? stats.visitesJours : stats.visitesMois
if (!chapter2) {
return rawData.site
}
if (chapter2 === 'api-rest') {
return (rawData.api ?? []).map(({ date, ...nombre }) => ({
date,
nombre,
info:
(period === 'jours' ? '2023-06-16' : '2023-06-01') === date ? (
<ChangeJune2023 />
) : null,
}))
}
if (chapter2 === 'guide') {
const pages = rawData.pages as Pageish[]
const creer = rawData.creer as Pageish[]
return statsCreer(pages, creer)
}
return filterByChapter2(rawData.pages as Pageish[], chapter2)
}, [period, chapter2])
const repartition = useMemo(() => {
const rawData = stats.visitesMois
return groupByDate(rawData.pages as Pageish[])
}, [])
const satisfaction = useMemo(() => {
return filterByChapter2(stats.satisfaction as Pageish[], chapter2)
}, [chapter2]) as Array<{
date: string
nombre: Record<string, number>
percent: Record<string, number>
}>
const { visites, repartition, satisfaction } = useStatistiques({
period,
stats,
filter,
})
const [[startDateIndex, endDateIndex], setDateIndex] = useState<
[startIndex: number, endIndex: number]
@ -111,6 +57,8 @@ export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
setSlicedVisits(visites)
}, [visites])
const totals = useTotals(slicedVisits)
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleDateChange = useCallback(
debounce(1000, ({ startIndex, endIndex }) => {
@ -122,22 +70,12 @@ export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
[visites]
)
const totals: number | Record<string, number> = useMemo(
() => computeTotals(slicedVisits),
[slicedVisits]
)
const chapters2: Chapter2[] = [
...new Set(stats.visitesMois?.pages.map((p) => p.page_chapter2)),
'PAM',
]
type ApiData = {
date: string
nombre: { evaluate: number; rules: number; rule: number }
}
const apiCumul =
chapter2 === 'api-rest' &&
filter === 'api-rest' &&
slicedVisits.length > 0 &&
typeof slicedVisits[0]?.nombre === 'object' &&
(slicedVisits as ApiData[]).reduce(
@ -159,14 +97,10 @@ export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
flex-basis: 50%;
`}
>
<SimulateursChoice
onChange={setChapter2}
value={chapter2}
possibleValues={chapters2}
/>
<SimulateursChoice onChange={setFilter} value={filter} />
<Spacing sm />
<Grid container columns={4}>
{chapter2 && <SelectedSimulator chapter2={chapter2} />}
{filter && <SelectedSimulator filter={filter} />}
</Grid>
</div>
<div>
@ -294,14 +228,14 @@ export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
<>
<H3>Satisfaction</H3>
<SatisfactionChart
key={chapter2}
key={JSON.stringify(filter)}
data={satisfaction}
accessibleMode={accessibleMode}
/>
</>
)}
{chapter2 === '' && period === 'mois' && (
{filter === '' && period === 'mois' && (
<>
<H2>Simulateurs principaux</H2>
<PagesChart data={repartition} accessibleMode={accessibleMode} />
@ -311,247 +245,29 @@ export const StatsDetail = ({ stats, accessibleMode }: StatsDetailProps) => {
)
}
const ChangeJune2023 = () => (
<Body style={{ maxWidth: '350px' }}>
<Trans i18nKey="stats.change_june_2023">
Ajout d'un cache sur l'API pour améliorer les performances et réduire le
nombre de requêtes.
</Trans>
</Body>
)
const isPAM = (name: string | undefined) =>
name &&
[
'medecin',
'chirurgien_dentiste',
'auxiliaire_medical',
'sage_femme',
].includes(name)
const filterByChapter2 = (pages: Pageish[], chapter2: Chapter2 | '') => {
return Object.entries(
groupBy(
pages.filter(
(p) =>
!chapter2 ||
((!('page' in p) || p.page !== 'accueil_pamc') &&
(p.page_chapter2 === chapter2 ||
(chapter2 === 'PAM' && isPAM(p.page_chapter3))))
),
(p) => ('date' in p ? p.date : p.month)
)
).map(([date, values]) => ({
date,
nombre: Object.fromEntries(
Object.entries(
groupBy(values, (x) => ('page' in x ? x.page : x.click))
).map(([key, values]) => [
key,
values.reduce((sum, value) => sum + value.nombre, 0),
])
),
}))
}
const statsCreer = (pages: Pageish[], creer: Pageish[]) => {
const accueil = groupBy(
pages.filter(
(p) =>
'page' in p &&
p.page === 'accueil' &&
p.page_chapter1 === 'creer' &&
true
),
(p) => ('date' in p ? p.date : p.month)
)
const commencee = groupBy(
creer.filter(
(p) =>
'page' in p &&
p.page === 'accueil' &&
p.page_chapter1 === 'creer' &&
true
),
(p) => ('date' in p ? p.date : p.month)
)
const terminee = groupBy(
creer.filter(
(p) =>
'page' in p &&
p.page !== 'liste' &&
p.page_chapter1 === 'creer' &&
(p.page_chapter2 as string) === 'statut' &&
true
),
(p) => ('date' in p ? p.date : p.month)
)
return Object.entries(commencee).map(([date, values]) => ({
date,
nombre: {
accueil: accueil[date]?.reduce((acc, p) => acc + p.nombre, 0),
simulation_commencee: values.reduce((acc, p) => acc + p.nombre, 0),
simulation_terminee: terminee[date]?.reduce(
(acc, p) => acc + p.nombre,
0
),
},
}))
}
function groupByDate(data: Pageish[]) {
const topTenPageByMonth = Object.entries(
groupBy(
data.filter((d) => 'page' in d && d.page === 'accueil'),
(p) => ('date' in p ? p.date : p.month)
)
).map(([date, values]) => ({
date,
nombre: Object.fromEntries(
Object.entries(
groupBy(values, (x) => x.page_chapter1 + ' / ' + x.page_chapter2)
).map(
([k, v]) =>
[k, v.map((v) => v.nombre).reduce((a, b) => a + b, 0)] as const
)
),
}))
const topPagesOfAllTime = Object.entries(
topTenPageByMonth.reduce((acc, { nombre }) => {
Object.entries(nombre).forEach(([page, visits]) => {
acc[page] ??= 0
acc[page] += visits
})
return acc
}, {} as Record<string, number>)
)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([page]) => page)
return topTenPageByMonth.map(({ date, nombre }) => ({
date,
nombre: Object.fromEntries(
Object.entries(nombre).filter(([page]) =>
topPagesOfAllTime.includes(page)
)
),
}))
}
const computeTotals = (
data: Data<number> | Data<Record<string, number>>
): number | Record<string, number> => {
return isDataStacked(data)
? data
.map((d) => d.nombre)
.reduce(
(acc, record) =>
[...Object.entries(acc), ...Object.entries(record)].reduce(
(merge, [key, value]) => {
return { ...merge, [key]: (acc[key] ?? 0) + value }
},
{}
),
{}
)
: data.map((d) => d.nombre).reduce((a, b) => a + b, 0)
}
function getChapter2(s: SimulatorData[keyof SimulatorData]): Chapter2 | '' {
export function getFilter(s: SimulatorData[keyof SimulatorData]): Filter | '' {
if ('iframePath' in s && s.iframePath === 'pamc') {
return 'PAM'
}
if (!s.tracking) {
return ''
}
const tracking = s.tracking as { chapter2?: Chapter2 }
const chapter2 =
typeof tracking === 'string' ? tracking : tracking.chapter2 ?? ''
const tracking = s.tracking as
| string
| { chapter2?: PageChapter2; chapter3?: string }
return toAtString(chapter2) as typeof chapter2
}
function SelectedSimulator(props: { chapter2: Chapter2 | '' }) {
const simulateur = Object.values(useSimulatorsData()).find(
(s) =>
getChapter2(s) === props.chapter2 &&
!(
typeof s.tracking === 'object' &&
'chapter3' in s.tracking &&
s.tracking.chapter3
)
)
if (!simulateur) {
return null
const filter =
typeof tracking === 'string' ? { chapter2: tracking } : tracking ?? ''
if (!filter.chapter2) {
return ''
}
return <SimulateurCard small {...simulateur} />
}
function SimulateursChoice(props: {
onChange: (ch: Chapter2 | '') => void
value: Chapter2 | ''
possibleValues: Array<Chapter2>
}) {
const simulateurs = Object.values(useSimulatorsData())
.filter((s) => {
const chapter2 = getChapter2(s)
return (
chapter2 &&
props.possibleValues.includes(chapter2) &&
!(
typeof s.tracking === 'object' &&
'chapter3' in s.tracking &&
s.tracking.chapter3
)
)
})
.sort((a, b) => (a.shortName < b.shortName ? -1 : 1))
return (
<Select
onSelectionChange={(val) => {
props.onChange(
typeof val === 'string' && val.length && !isNaN(parseInt(val))
? getChapter2(simulateurs[parseInt(val)])
: val === 'api-rest'
? val
: ''
)
}}
defaultSelectedKey={props.value || 'general-stats'}
label={'Voir les statistiques pour :'}
id="simulator-choice-input"
>
{[
<Item key="general-stats" textValue="Tout le site">
<Emoji emoji="🌍" />
&nbsp;Tout le site
</Item>,
<Item key="api-rest" textValue="API REST">
<Emoji emoji="👩‍💻" />
&nbsp;API REST
</Item>,
...simulateurs.map((s, i) => (
<Item key={i} textValue={s.shortName}>
{s.icône && (
<>
<Emoji emoji={s.icône} />
&nbsp;
</>
)}
{s.shortName}
</Item>
)),
]}
</Select>
)
return {
chapter2: toAtString(filter.chapter2),
...('chapter3' in filter && filter.chapter3
? { chapter3: toAtString(filter.chapter3) }
: {}),
} as Filter
}
const Indicators = styled.div`
@ -569,3 +285,47 @@ const Indicators = styled.div`
const StyledBody = styled(Body)`
margin-bottom: 0.25rem;
`
const DEFAULT_PERIOD = 'mois'
function useStatState() {
const [searchParams, setSearchParams] = useSearchParams()
const [period, setPeriod] = useState<Period>(
(searchParams.get('periode') as Period) ?? DEFAULT_PERIOD
)
const simulators = useSimulatorsData()
const URLFilter: string = searchParams.get('module') ?? ''
const [filter, setFilter] = useState<Filter | ''>(
URLFilter in simulators
? getFilter(simulators[URLFilter as keyof typeof simulators])
: ['PAMC', 'api-rest'].includes(URLFilter)
? (URLFilter as Filter)
: ''
)
useEffect(() => {
const module =
Object.values(simulators).find(
(s) =>
!!filter && JSON.stringify(getFilter(s)) === JSON.stringify(filter)
)?.id ?? filter
const paramsEntries = [
['periode', period !== DEFAULT_PERIOD ? period : ''],
['module', module],
].filter(([, val]) => val !== '') as [string, string][]
setSearchParams(paramsEntries, { replace: true })
}, [period, filter, simulators, setSearchParams])
return [
{
period,
filter,
},
{
setPeriod,
setFilter,
},
] as const
}

View File

@ -71,4 +71,5 @@ export enum PageChapter2 {
Independant = 'independant',
ProfessionLiberale = 'profession_liberale',
Salarie = 'salarie',
ChoixDuStatut = 'choix_du_statut',
}

View File

@ -0,0 +1,200 @@
import { useMemo } from 'react'
import { Trans } from 'react-i18next'
import { Body } from '@/design-system/typography/paragraphs'
import { groupBy } from '@/utils'
import { Page, PageChapter2, PageSatisfaction, StatsStruct } from './types'
type Pageish = Page | PageSatisfaction
export type Filter =
| { chapter2: PageChapter2; chapter3?: string }
| 'PAM'
| 'api-rest'
export function useStatistiques({
period,
stats,
filter,
}: {
period: 'mois' | 'jours'
stats: StatsStruct
filter: Filter | ''
}) {
const visites = useMemo(() => {
const rawData = period === 'jours' ? stats.visitesJours : stats.visitesMois
if (!filter) {
return rawData.site
}
if (filter === 'api-rest') {
return (rawData.api ?? []).map(({ date, ...nombre }) => ({
date,
nombre,
info:
(period === 'jours' ? '2023-06-16' : '2023-06-01') === date ? (
<ChangeJune2023 />
) : null,
}))
}
if (typeof filter !== 'string' && filter.chapter2 === 'choix_du_statut') {
const pages = rawData.pages as Pageish[]
return statsChoixStatut(pages)
}
return filterPage(rawData.pages as Pageish[], filter)
}, [period, filter])
const repartition = useMemo(() => {
const rawData = stats.visitesMois
return groupByDate(rawData.pages as Pageish[])
}, [])
const satisfaction = useMemo(() => {
if (filter === 'api-rest') {
return []
}
return filterPage(stats.satisfaction as Pageish[], filter)
}, [filter]) as Array<{
date: string
nombre: Record<string, number>
percent: Record<string, number>
}>
return {
visites,
repartition,
satisfaction,
}
}
const ChangeJune2023 = () => (
<Body style={{ maxWidth: '350px' }}>
<Trans i18nKey="stats.change_june_2023">
Ajout d'un cache sur l'API pour améliorer les performances et réduire le
nombre de requêtes.
</Trans>
</Body>
)
const isPAM = (name: string | undefined) =>
name &&
[
'medecin',
'chirurgien_dentiste',
'auxiliaire_medical',
'sage_femme',
].includes(name)
function filterPage(
pages: Pageish[],
filter: Exclude<Filter, 'api-rest'> | ''
) {
return Object.entries(
groupBy(
pages.filter(
(p) =>
(!('page' in p) || p.page !== 'accueil_pamc') &&
(!filter
? true
: filter === 'PAM'
? isPAM(p.page_chapter3)
: filter.chapter2 === p.page_chapter2 &&
(!filter.chapter3 || filter.chapter3 === p.page_chapter3))
),
(p) => ('date' in p ? p.date : p.month)
)
).map(([date, values]) => ({
date,
nombre: Object.fromEntries(
Object.entries(
groupBy(values, (x) => ('page' in x ? x.page : x.click))
).map(([key, values]) => [
key,
values.reduce((sum, value) => sum + value.nombre, 0),
])
),
}))
}
const statsChoixStatut = (pages: Pageish[]) => {
const choixStatutPage = pages.filter(
(p) => p.page_chapter2 === 'choix_du_statut'
)
const accueil = groupBy(
choixStatutPage.filter((p) => 'page' in p && p.page === 'accueil'),
(p) => ('date' in p ? p.date : p.month)
)
const commencee = groupBy(
pages.filter(
(p) =>
p.page_chapter3 === 'pas_a_pas' &&
'page' in p &&
p.page === 'recherche_activite'
),
(p) => ('date' in p ? p.date : p.month)
)
const terminee = groupBy(
pages.filter((p) => p.page_chapter3 === 'resultat'),
(p) => ('date' in p ? p.date : p.month)
)
return Object.entries(commencee).map(([date, values]) => ({
date,
nombre: {
accueil: accueil[date]?.reduce((acc, p) => acc + p.nombre, 0),
simulation_commencee: values.reduce((acc, p) => acc + p.nombre, 0),
simulation_terminee: terminee[date]?.reduce(
(acc, p) => acc + p.nombre,
0
),
},
}))
}
export type Visites = ReturnType<typeof useStatistiques>['visites']
function groupByDate(data: Pageish[]) {
const topTenPageByMonth = Object.entries(
groupBy(
data.filter((d) => 'page' in d && d.page === 'accueil'),
(p) => ('date' in p ? p.date : p.month)
)
).map(([date, values]) => ({
date,
nombre: Object.fromEntries(
Object.entries(
groupBy(values, (x) => x.page_chapter1 + ' / ' + x.page_chapter2)
).map(
([k, v]) =>
[k, v.map((v) => v.nombre).reduce((a, b) => a + b, 0)] as const
)
),
}))
const topPagesOfAllTime = Object.entries(
topTenPageByMonth.reduce((acc, { nombre }) => {
Object.entries(nombre).forEach(([page, visits]) => {
acc[page] ??= 0
acc[page] += visits
})
return acc
}, {} as Record<string, number>)
)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([page]) => page)
return topTenPageByMonth.map(({ date, nombre }) => ({
date,
nombre: Object.fromEntries(
Object.entries(nombre).filter(([page]) =>
topPagesOfAllTime.includes(page)
)
),
}))
}

View File

@ -0,0 +1,27 @@
import { useMemo } from 'react'
import { Data, isDataStacked } from './Chart'
import { Visites } from './useStatistiques'
export function useTotals(visits: Visites): number | Record<string, number> {
return useMemo(() => computeTotals(visits), [visits])
}
const computeTotals = (
data: Data<number> | Data<Record<string, number>>
): number | Record<string, number> => {
return isDataStacked(data)
? data
.map((d) => d.nombre)
.reduce(
(acc, record) =>
[...Object.entries(acc), ...Object.entries(record)].reduce(
(merge, [key, value]) => {
return { ...merge, [key]: (acc[key] ?? 0) + value }
},
{}
),
{}
)
: data.map((d) => d.nombre).reduce((a, b) => a + b, 0)
}

File diff suppressed because it is too large Load Diff