From 3cf9ce54989e008e86558bb3feaa9914369f5fc6 Mon Sep 17 00:00:00 2001 From: Alexandre Fauquette <45398769+alexfauquette@users.noreply.github.com> Date: Sat, 23 Nov 2024 23:19:30 +0100 Subject: [PATCH] feat: Add stat cards (#61) * feat: Add stat cards * add-charts * updates * work --- .../debat/[compteRenduRef]/DebateSummary.tsx | 4 +- app/depute/[slug]/InfoPersonelles.tsx | 80 +- app/depute/[slug]/WeeklyActivity.tsx | 203 +++ .../amendements/AmendementsStatistics.tsx | 3 - app/depute/[slug]/amendements/page.tsx | 2 + app/depute/[slug]/getWeekIndex.ts | 37 + app/depute/[slug]/page.tsx | 192 ++- app/depute/[slug]/qag/QuestionCard.tsx | 18 +- app/deputes/DeputesView.tsx | 6 +- app/dossiers/api/route.ts | 2 +- app/layout.tsx | 29 +- {components/ThemeRegistry => app}/theme.tsx | 12 +- components/FilterContainer.tsx | 4 +- components/ThemeRegistry/EmotionCache.tsx | 94 -- components/ThemeRegistry/ThemeRegistry.tsx | 18 - components/folderHomePage/DossierList.tsx | 9 +- components/folders/DeputeCard.tsx | 14 +- components/folders/HeroSection.tsx | 8 +- components/home/FloatingIcons.tsx | 2 +- components/home/SearchBar.tsx | 2 +- package-lock.json | 1443 +++++++++++++---- package.json | 8 +- prisma/models/acteur.prisma | 98 +- prisma/models/agenda.prisma | 5 +- prisma/models/document.prisma | 66 +- prisma/models/dossier.prisma | 42 +- prisma/models/organe.prisma | 100 +- prisma/models/question.prisma | 2 +- prisma/models/scrutin.prisma | 8 - prisma/models/stats.prisma | 6 + prisma/models/texte_loi.prisma | 152 -- prisma/swagger/json-schema.json | 550 +++---- 32 files changed, 2087 insertions(+), 1132 deletions(-) create mode 100644 app/depute/[slug]/WeeklyActivity.tsx create mode 100644 app/depute/[slug]/getWeekIndex.ts rename {components/ThemeRegistry => app}/theme.tsx (95%) delete mode 100644 components/ThemeRegistry/EmotionCache.tsx delete mode 100644 components/ThemeRegistry/ThemeRegistry.tsx delete mode 100644 prisma/models/texte_loi.prisma diff --git a/app/[legislature]/dossier/[id]/debat/[compteRenduRef]/DebateSummary.tsx b/app/[legislature]/dossier/[id]/debat/[compteRenduRef]/DebateSummary.tsx index aab2589..d2a8448 100644 --- a/app/[legislature]/dossier/[id]/debat/[compteRenduRef]/DebateSummary.tsx +++ b/app/[legislature]/dossier/[id]/debat/[compteRenduRef]/DebateSummary.tsx @@ -173,7 +173,7 @@ export const DebateSummary = (props: DebateSummaryProps) => { }} > { diff --git a/app/depute/[slug]/InfoPersonelles.tsx b/app/depute/[slug]/InfoPersonelles.tsx index 65597f0..ad886c6 100644 --- a/app/depute/[slug]/InfoPersonelles.tsx +++ b/app/depute/[slug]/InfoPersonelles.tsx @@ -15,15 +15,15 @@ export default function InfoPersonelles({ // .filter((mandat) => mandat.legislature === "16") Partis politique est `null` .sort((a, b) => (a.dateDebut < b.dateDebut ? 1 : -1)); - const dernerMandatDepute = sortedMandats.filter( + const dernierMandatDepute = sortedMandats.filter( (mandat) => mandat.typeOrgane === "ASSEMBLEE" )[0]; - const dernergroupeParlementaire = sortedMandats.filter( + const derniergroupeParlementaire = sortedMandats.filter( (mandat) => mandat.typeOrgane === "GP" )[0]; - const dernerPartisPolitique = sortedMandats.filter( + const dernierPartisPolitique = sortedMandats.filter( (mandat) => mandat.typeOrgane === "PARPOL" )[0]; @@ -37,43 +37,57 @@ export default function InfoPersonelles({ return ( - Infomrations personelles + Informations personelles -
- - Debut de mandat - - - Le{" "} - {new Date(dernerMandatDepute.dateDebut).toLocaleDateString( - "fr-FR", - { day: "numeric", month: "long", year: "numeric" } - )} - -
- -
- - Fin de mandat - - - {dernerMandatDepute.dateFin !== null - ? `Le ${new Date(dernerMandatDepute.dateFin).toLocaleDateString( + {dernierMandatDepute === undefined ? ( +
+ + Debut de mandat + + N'est pas député·e·s +
+ ) : ( + +
+ + Debut de mandat + + + Le{" "} + {new Date(dernierMandatDepute?.dateDebut).toLocaleDateString( "fr-FR", { day: "numeric", month: "long", year: "numeric" } - )}` - : "en cours"} - -
+ )} +
+
+ +
+ + Fin de mandat + + + {dernierMandatDepute?.dateFin !== null + ? `Le ${new Date( + dernierMandatDepute?.dateFin + ).toLocaleDateString("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + })}` + : "en cours"} + +
+ + )}
Group politique - {dernergroupeParlementaire && - dernergroupeParlementaire.dateFin === null - ? dernergroupeParlementaire.libelle + {derniergroupeParlementaire && + derniergroupeParlementaire.dateFin === null + ? derniergroupeParlementaire.libelle : "-"}
@@ -83,8 +97,8 @@ export default function InfoPersonelles({ Partis politique
- {dernerPartisPolitique && dernerPartisPolitique.dateFin === null - ? dernerPartisPolitique.libelle + {dernierPartisPolitique && dernierPartisPolitique.dateFin === null + ? dernierPartisPolitique.libelle : "-"} diff --git a/app/depute/[slug]/WeeklyActivity.tsx b/app/depute/[slug]/WeeklyActivity.tsx new file mode 100644 index 0000000..182d359 --- /dev/null +++ b/app/depute/[slug]/WeeklyActivity.tsx @@ -0,0 +1,203 @@ +"use client"; +import * as React from "react"; +import { StateHebdoType } from "@prisma/client"; +import { getMondayDate, getWeekIndex } from "./getWeekIndex"; +import { + BarPlot, + ChartsAxis, + ChartsAxisHighlight, + ChartsTooltip, + LinePlot, + ChartContainer, + useDrawingArea, + useXScale, +} from "@mui/x-charts"; + +const POINTS_NUMBER = 50; + +export type WeeklyStatsProps = { + deputeWeeklyActivity: { + semaineIndex: number; + valeur: number; + type: StateHebdoType; + acteurUid: string; + }[]; + statsOnWeeklyActivity: { + semaineIndex: number; + valeur: number; + type: StateHebdoType; + acteurUid: string; + }[]; +}; + +type WeekActivity = { + [key in StateHebdoType]: { + depute?: number; + max?: number; + median?: number; + }; +}; + +export default function WeeklyStats(props: WeeklyStatsProps) { + const groupedPerWeek: Record = {}; + + for (const activity of [ + ...props.deputeWeeklyActivity, + ...props.statsOnWeeklyActivity, + ]) { + if (groupedPerWeek[activity.semaineIndex] === undefined) { + groupedPerWeek[activity.semaineIndex] = {} as WeekActivity; + } + + if (groupedPerWeek[activity.semaineIndex][activity.type] === undefined) { + groupedPerWeek[activity.semaineIndex][activity.type] = {}; + } + + switch (activity.acteurUid) { + case "median": + groupedPerWeek[activity.semaineIndex][activity.type].median = + activity.valeur; + break; + case "max": + groupedPerWeek[activity.semaineIndex][activity.type].max = + activity.valeur; + break; + default: + groupedPerWeek[activity.semaineIndex][activity.type].depute = + activity.valeur; + break; + } + } + + const currentWeekIndex = getWeekIndex(17, new Date()); + + const displayedWeekIndex = [...Array(POINTS_NUMBER)].map( + (_, index) => currentWeekIndex - POINTS_NUMBER + index + ); + + const presenceDetecteeDataset = displayedWeekIndex.map((semaineIndex) => ({ + date: getMondayDate(17, semaineIndex), + debat: groupedPerWeek[semaineIndex]?.presenceDetectee?.depute ?? 0, + debatMedian: groupedPerWeek[semaineIndex]?.presenceDetectee?.median ?? 0, + debatMax: groupedPerWeek[semaineIndex]?.presenceDetectee?.max ?? 0, + + presenceCommission: + groupedPerWeek[semaineIndex]?.presenceCommision?.depute ?? 0, + presenceCommissionMedian: + groupedPerWeek[semaineIndex]?.presenceCommision?.median ?? 0, + presenceCommissionMax: + groupedPerWeek[semaineIndex]?.presenceCommision?.max ?? 0, + })); + + const vacances = presenceDetecteeDataset + .reduce<{ start: number; end: number }[]>((acc, week, index) => { + if (week.debatMax !== 0 || week.presenceCommissionMax !== 0) { + return acc; + } + if (acc.length === 0) { + return [{ start: index, end: index + 1 }]; + } + if (index === acc[acc.length - 1].end) { + return [ + ...acc.slice(0, acc.length - 1), + { ...acc[acc.length - 1], end: index + 1 }, + ]; + } + + return [...acc, { start: index, end: index + 1 }]; + }, []) + .map(({ start, end }) => ({ + start: presenceDetecteeDataset[start].date, + end: presenceDetecteeDataset[end - 1].date, + })); + + return ( + { + if (ctx.location === "tick") { + return date.toLocaleDateString("fr-FR", { + month: "long", + year: "2-digit", + }); + } + return `Semaine du ${(date as Date).toLocaleDateString("fr-FR", { + month: "short", + day: "2-digit", + })}`; + }, + tickInterval: (_, index) => index % 10 === 5, + // @ts-ignore + categoryGapRatio: 0, + }, + ]} + series={[ + { + dataKey: "presenceCommissionMedian", + type: "line", + stack: "mediane", + color: "red", + curve: "step", + label: "mediane réunion commissions", + }, + { + dataKey: "presenceCommission", + type: "bar", + stack: "depute", + color: "orange", + label: "réunion commissions", + }, + + { + dataKey: "debat", + type: "bar", + stack: "depute", + color: "blue", + + label: "présence hémicicle", + }, + { + dataKey: "debatMedian", + type: "line", + stack: "mediane", + color: "darkblue", + curve: "step", + label: "mediane présence hémicicle", + }, + ]} + > + + + + + + + + ); +} + +type VacanceParlementaireProps = { + vacances: { start: Date; end: Date }[]; +}; + +function VacanceParlementaire({ vacances }: VacanceParlementaireProps) { + const scale = useXScale<"band">(); + const drawingArea = useDrawingArea(); + + return vacances.map(({ start, end }) => ( + + )); +} diff --git a/app/depute/[slug]/amendements/AmendementsStatistics.tsx b/app/depute/[slug]/amendements/AmendementsStatistics.tsx index 57ea4ea..a122c2a 100644 --- a/app/depute/[slug]/amendements/AmendementsStatistics.tsx +++ b/app/depute/[slug]/amendements/AmendementsStatistics.tsx @@ -1,7 +1,4 @@ import React from "react"; - -import Stack from "@mui/material/Stack"; - import { prisma } from "@/prisma"; async function getDeputeAmendementStatsUnCached(uid: string) { diff --git a/app/depute/[slug]/amendements/page.tsx b/app/depute/[slug]/amendements/page.tsx index 01ee7e8..5afe888 100644 --- a/app/depute/[slug]/amendements/page.tsx +++ b/app/depute/[slug]/amendements/page.tsx @@ -37,7 +37,9 @@ export default async function Amendements({ return (

Amendements

+ + {amendements && amendements .sort((a, b) => diff --git a/app/depute/[slug]/getWeekIndex.ts b/app/depute/[slug]/getWeekIndex.ts new file mode 100644 index 0000000..0ade595 --- /dev/null +++ b/app/depute/[slug]/getWeekIndex.ts @@ -0,0 +1,37 @@ +/** + * Date du lundi de la premiere sceance de la legislature. + */ +export const legistature_begining: Record = { + 14: new Date("2012-06-18T00:00:00Z").getTime(), + 15: new Date("2017-06-26T00:00:00Z").getTime(), + 16: new Date("2022-06-27T00:00:00Z").getTime(), + 17: new Date("2024-07-15T00:00:00Z").getTime(), + 18: undefined, + 0: undefined, +}; + +const MILLISECONDS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; + +export function getWeekIndex(legislature: number, date: Date) { + if (legistature_begining[legislature] === undefined) { + throw new Error( + `La legislature ${legislature} n'as pas de date de départ.` + ); + } + + return Math.floor( + (date.getTime() - legistature_begining[legislature]) / MILLISECONDS_PER_WEEK + ); +} + +export function getMondayDate(legislature: number, weekIndex: number) { + if (legistature_begining[legislature] === undefined) { + throw new Error( + `La legislature ${legislature} n'as pas de date de départ.` + ); + } + + return new Date( + legistature_begining[legislature] + weekIndex * MILLISECONDS_PER_WEEK + ); +} diff --git a/app/depute/[slug]/page.tsx b/app/depute/[slug]/page.tsx index 2fb3a93..d6060fa 100644 --- a/app/depute/[slug]/page.tsx +++ b/app/depute/[slug]/page.tsx @@ -1,5 +1,193 @@ import React from "react"; -export default function Page({ params }: { params: { slug: string } }) { - return

Activités

; +import Stack from "@mui/material/Stack"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import CardHeader from "@mui/material/CardHeader"; + +import { prisma } from "@/prisma"; +import { Box, Typography } from "@mui/material"; +import WeeklyStats from "./WeeklyActivity"; + +async function getDeputeStatsUnCached(slug: string) { + try { + return await prisma.acteur.findFirst({ + where: { slug }, + select: { + uid: true, + nombreAmendements: true, + nombreInterventions: true, + nombreQuestions: true, + statistiquesHebdomadaire: true, + }, + }); + } catch (error) { + console.error(`Error fetching stats from depute ${slug}:`, error); + throw error; + } +} + +async function getStatsOnWeeklyActivityUnCached(slug: string) { + try { + return await prisma.statistiqueHebdomadaire.findMany({ + where: { OR: [{ acteurUid: "median" }, { acteurUid: "max" }] }, + select: { + type: true, + semaineIndex: true, + valeur: true, + acteurUid: true, + }, + }); + } catch (error) { + console.error(`Error fetching stats from depute ${slug}:`, error); + throw error; + } +} + +async function getBaselineStatsUnCached() { + try { + return await prisma.stats.findMany({ + select: { + type: true, + id: true, + minimum: true, + maximum: true, + q20: true, + q40: true, + q60: true, + q80: true, + }, + }); + } catch (error) { + console.error(`Error fetching stat baseline:`, error); + throw error; + } +} + +const getDeputeStats = React.cache(getDeputeStatsUnCached); +const getBaselineStats = React.cache(getBaselineStatsUnCached); +const getStatsOnWeeklyActivity = React.cache(getStatsOnWeeklyActivityUnCached); + +const baselineTypeToDeputeKey: Record< + string, + "nombreAmendements" | "nombreInterventions" | "nombreQuestions" +> = { + questions: "nombreQuestions", + interventions: "nombreInterventions", + amendements: "nombreAmendements", +}; + +const baselineTypeToTitle: Record = { + questions: "Nombre de questions", + interventions: "Nombre d'interventions", + amendements: "Nombre d'amendements", +}; + +const quantilesSentences = [ + "Dans les 20% moins actifs", + "Dans les 40% moins actifs", + "Dans les 60% moins actifs", + "Dans les 40% plus actifs", + "Dans les 20% plus actifs", +]; + +export default async function Page({ params }: { params: { slug: string } }) { + const deputeStatsData = getDeputeStats(params.slug); + const statsOnWeeklyActivityData = getStatsOnWeeklyActivity(params.slug); + const baselineStatsData = getBaselineStats(); + + // Initiate both requests in parallel + const [deputeStats, statsOnWeeklyActivity, baselineStats] = await Promise.all( + [deputeStatsData, statsOnWeeklyActivityData, baselineStatsData] + ); + + return ( +
+ + + {baselineStats.map(({ q20, q40, q60, q80, maximum, type, id }) => { + if (!baselineTypeToDeputeKey[type as string] || !deputeStats) { + return null; + } + + if (!id.includes("-AN-")) { + // Enleve les stats liées au senat + return null; + } + + const value = deputeStats[baselineTypeToDeputeKey[type]]; + + const quantiles = [q20, q40, q60, q80, maximum]; + + // On equality we are kind and put then in the next one. Except for the last one because there is no next one. + const quantileIndex = quantiles.findLastIndex( + (q, index) => index === 0 || value >= q + ); + + return ( + + + + {value} + + + {baselineTypeToTitle[type]} + + + {quantiles.map((q, index) => { + return ( + div": { + bgColor: + quantileIndex === index ? "black" : "#A4A4A7", + }, + }, + position: "relative", + flexGrow: 1, + }} + > + + + ); + })} + + + {quantilesSentences[quantileIndex]} + + + + ); + })} +
+ ); } diff --git a/app/depute/[slug]/qag/QuestionCard.tsx b/app/depute/[slug]/qag/QuestionCard.tsx index 7bddbbf..dcc7692 100644 --- a/app/depute/[slug]/qag/QuestionCard.tsx +++ b/app/depute/[slug]/qag/QuestionCard.tsx @@ -23,7 +23,7 @@ export default function QuestionCard(props: QuestionCardProps) { type, numero, dateDepot, - dateCloture, + // dateCloture, titre, rubrique, texteQuestion, @@ -57,7 +57,7 @@ export default function QuestionCard(props: QuestionCardProps) { spacing={1} sx={{ width: "100%", mr: 2 }} > - {titre && {titre}} + {titre && {titre}} {rubrique && } {type && }
@@ -74,9 +74,8 @@ export default function QuestionCard(props: QuestionCardProps) { flexBasis={0} component="div" sx={{ bgcolor: "grey.50", p: 1 }} - > - {texteQuestion} -
+ dangerouslySetInnerHTML={{ __html: texteQuestion }} + /> )} {texteReponse && ( - {texteReponse} - + dangerouslySetInnerHTML={{ __html: texteReponse }} + /> )} {erratumQuestion && ( - + {/* Date de cloture:  {dateCloture && dateCloture.toLocaleDateString("fr-FR")} - + */} diff --git a/app/deputes/DeputesView.tsx b/app/deputes/DeputesView.tsx index b80fe43..b042df1 100644 --- a/app/deputes/DeputesView.tsx +++ b/app/deputes/DeputesView.tsx @@ -131,11 +131,7 @@ function Deputes({ } : undefined } - component={Link} - href={`/depute/${slug}`} - sx={{ - "&:hover": { bgcolor: "grey.50" }, - }} + isFullCardLink /> ); })} diff --git a/app/dossiers/api/route.ts b/app/dossiers/api/route.ts index f872ce6..207142d 100644 --- a/app/dossiers/api/route.ts +++ b/app/dossiers/api/route.ts @@ -15,7 +15,7 @@ function parseNumber(value: string | null, defaultValue: number) { export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - const legislature = searchParams.get("legislature") ?? "16"; + const legislature = searchParams.get("legislature") ?? "17"; const theme = searchParams.get("theme"); const page = parseNumber(searchParams.get("page"), 0); const pageSize = parseNumber(searchParams.get("pageSize"), 20); diff --git a/app/layout.tsx b/app/layout.tsx index f63e316..fec9023 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,18 @@ import type { Metadata } from "next"; import { Raleway } from "next/font/google"; import "./globals.css"; -import ThemeRegistry from "@/components/ThemeRegistry/ThemeRegistry"; +import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; import { NavBar, NavigationItem } from "@/components/NavBar"; +import theme from "./theme"; +import { ThemeProvider } from "@mui/material/styles"; +import { CssBaseline } from "@mui/material"; -const inter = Raleway({ subsets: ["latin"] }); +const raleway = Raleway({ + weight: ["400", "600", "700"], + subsets: ["latin"], + display: "swap", + variable: "--font-raleway", +}); export const metadata: Metadata = { metadataBase: new URL("https://www.nosdeputes.fr"), @@ -50,13 +58,16 @@ export default function RootLayout({ }) { return ( - - -
- - {children} -
-
+ + + + +
+ + {children} +
+
+
); diff --git a/components/ThemeRegistry/theme.tsx b/app/theme.tsx similarity index 95% rename from components/ThemeRegistry/theme.tsx rename to app/theme.tsx index 24d3481..8f1d1c4 100644 --- a/components/ThemeRegistry/theme.tsx +++ b/app/theme.tsx @@ -1,3 +1,4 @@ +"use client"; import { Raleway } from "next/font/google"; import { SvgIconProps } from "@mui/material/SvgIcon"; import { createTheme } from "@mui/material/styles"; @@ -12,13 +13,8 @@ const AccordionIcon = (props: SvgIconProps) => ( ); -const raleway = Raleway({ - weight: ["400", "600", "700"], - subsets: ["latin"], - display: "swap", -}); - const theme = createTheme({ + cssVariables: true, palette: { mode: "light", success: { @@ -39,7 +35,7 @@ const theme = createTheme({ }, primary: { main: "#171B1E", - contrastText: "white", + contrastText: "#fff", }, grey: { 900: "#171B1E", @@ -56,7 +52,7 @@ const theme = createTheme({ }, spacing: 8, typography: { - fontFamily: raleway.style.fontFamily, + fontFamily: "var(--font-raleway)", fontWeightBold: 700, fontWeightRegular: 600, fontWeightLight: 400, diff --git a/components/FilterContainer.tsx b/components/FilterContainer.tsx index 259ee97..4f607d5 100644 --- a/components/FilterContainer.tsx +++ b/components/FilterContainer.tsx @@ -35,7 +35,7 @@ export const FilterContainer = ({ {children} @@ -56,7 +56,7 @@ export const FilterContainer = ({ {children} diff --git a/components/ThemeRegistry/EmotionCache.tsx b/components/ThemeRegistry/EmotionCache.tsx deleted file mode 100644 index d01a55c..0000000 --- a/components/ThemeRegistry/EmotionCache.tsx +++ /dev/null @@ -1,94 +0,0 @@ -'use client'; -import * as React from 'react'; -import createCache from '@emotion/cache'; -import { useServerInsertedHTML } from 'next/navigation'; -import { CacheProvider as DefaultCacheProvider } from '@emotion/react'; -import type { EmotionCache, Options as OptionsOfCreateCache } from '@emotion/cache'; - -export type NextAppDirEmotionCacheProviderProps = { - /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */ - options: Omit; - /** By default from 'import { CacheProvider } from "@emotion/react"' */ - CacheProvider?: (props: { - value: EmotionCache; - children: React.ReactNode; - }) => React.JSX.Element | null; - children: React.ReactNode; -}; - -// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx -export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps) { - const { options, CacheProvider = DefaultCacheProvider, children } = props; - - const [registry] = React.useState(() => { - const cache = createCache(options); - cache.compat = true; - const prevInsert = cache.insert; - let inserted: { name: string; isGlobal: boolean }[] = []; - cache.insert = (...args) => { - const [selector, serialized] = args; - if (cache.inserted[serialized.name] === undefined) { - inserted.push({ - name: serialized.name, - isGlobal: !selector, - }); - } - return prevInsert(...args); - }; - const flush = () => { - const prevInserted = inserted; - inserted = []; - return prevInserted; - }; - return { cache, flush }; - }); - - useServerInsertedHTML(() => { - const inserted = registry.flush(); - if (inserted.length === 0) { - return null; - } - let styles = ''; - let dataEmotionAttribute = registry.cache.key; - - const globals: { - name: string; - style: string; - }[] = []; - - inserted.forEach(({ name, isGlobal }) => { - const style = registry.cache.inserted[name]; - - if (typeof style !== 'boolean') { - if (isGlobal) { - globals.push({ name, style }); - } else { - styles += style; - dataEmotionAttribute += ` ${name}`; - } - } - }); - - return ( - - {globals.map(({ name, style }) => ( -