From a673aea6fbaf0f1a06a80586d21232354a8a7b9f Mon Sep 17 00:00:00 2001 From: Julien Furet Date: Thu, 12 Feb 2026 10:25:19 +0100 Subject: [PATCH 1/3] [REFACTO] Centralize colors in constants.ts --- pwa/app/components/tabs/temporal.tsx | 24 +----------------------- pwa/app/constants.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pwa/app/components/tabs/temporal.tsx b/pwa/app/components/tabs/temporal.tsx index 7cdcb97..ab904c1 100644 --- a/pwa/app/components/tabs/temporal.tsx +++ b/pwa/app/components/tabs/temporal.tsx @@ -3,30 +3,8 @@ import { useEffect, useState } from 'react' import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts' import { getRegions, getTimeSeries, type TimeSeriesData } from '../../services/temporal.services' -import { TAX_TYPES, YEARS } from '../../constants' +import { TAX_TYPES, YEARS, COLORS } from '../../constants' import ErrorDiv from '../molecules/ErrorDiv' -const COLORS = [ - '#8884d8', - '#82ca9d', - '#ffc658', - '#ff7300', - '#00C49F', - '#0088FE', - '#FF8042', - '#A4DE6C', - '#D0ED57', - '#FFBB28', - '#FF4444', - '#44AAFF', - '#AA44FF', - '#E91E63', - '#4CAF50', - '#9C27B0', - '#00BCD4', - '#FF5722', - '#607D8B', - '#795548', -] export default function Temporal() { const [taxType, setTaxType] = useState('tfpb') diff --git a/pwa/app/constants.ts b/pwa/app/constants.ts index f1f007c..95a8f58 100644 --- a/pwa/app/constants.ts +++ b/pwa/app/constants.ts @@ -1,2 +1,24 @@ export const TAX_TYPES = ['tfpnb', 'tfpb', 'th', 'cfe'] as const export const YEARS = [2019, 2020, 2021, 2022] as const +export const COLORS = [ + '#8884d8', + '#82ca9d', + '#ffc658', + '#ff7300', + '#00C49F', + '#0088FE', + '#FF8042', + '#A4DE6C', + '#D0ED57', + '#FFBB28', + '#FF4444', + '#44AAFF', + '#AA44FF', + '#E91E63', + '#4CAF50', + '#9C27B0', + '#00BCD4', + '#FF5722', + '#607D8B', + '#795548', +] as const -- GitLab From ddbf18e4b0707d4492dfcbe1af4651c1d5d8d5df Mon Sep 17 00:00:00 2001 From: Julien Furet Date: Thu, 12 Feb 2026 11:31:20 +0100 Subject: [PATCH 2/3] [FEAT] - add regional tax distribution donut chart --- pwa/app/components/tabs/diagram.jsx | 189 +++++++++++++++++++++++++- pwa/app/services/diagram.services.tsx | 56 ++++++++ pwa/package-lock.json | 28 ++-- pwa/package.json | 1 + 4 files changed, 257 insertions(+), 17 deletions(-) diff --git a/pwa/app/components/tabs/diagram.jsx b/pwa/app/components/tabs/diagram.jsx index 9159d24..774b694 100644 --- a/pwa/app/components/tabs/diagram.jsx +++ b/pwa/app/components/tabs/diagram.jsx @@ -1,3 +1,190 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Doughnut } from 'react-chartjs-2' +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js' +import ChartDataLabels from 'chartjs-plugin-datalabels' +import { TAX_TYPES, YEARS, COLORS } from '../../constants' +import { getRegionDistribution, prepareChartData } from '../../services/diagram.services' +import ErrorDiv from '../molecules/ErrorDiv' + +ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels) + export default function Diagram() { - return
Diagram
+ const [taxType, setTaxType] = useState('th') + const [year, setYear] = useState(2019) + const [topCount, setTopCount] = useState(9) + const [data, setData] = useState([]) + const [maxRegions, setMaxRegions] = useState(20) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Charger le nombre de régions au montage + useEffect(() => { + const loadMaxRegions = async () => { + try { + const result = await getRegionDistribution(taxType, year) + setMaxRegions(result.length) + } catch (e) { + // Garder la valeur par défaut en cas d'erreur + } + } + loadMaxRegions() + }, [taxType, year]) + + const handleSubmit = async () => { + setLoading(true) + setError(null) + try { + const result = await getRegionDistribution(taxType, year) + setMaxRegions(result.length) + const chartData = prepareChartData(result, topCount) + setData(chartData) + } catch (e) { + setData([]) + setError(e instanceof Error ? e.message : 'Une erreur est survenue') + } finally { + setLoading(false) + } + } + + const chartConfig = { + labels: data.map(d => d.region), + datasets: [ + { + data: data.map(d => d.total_amount), + backgroundColor: COLORS, + borderColor: '#1a1d21', + borderWidth: 2, + }, + ], + } + + const chartOptions = { + responsive: true, + maintainAspectRatio: true, + aspectRatio: 1.5, + plugins: { + legend: { + position: 'right', + labels: { + color: '#b0afaf', + padding: 25, + font: { size: 18 }, + boxWidth: 30, + boxHeight: 30, + }, + }, + tooltip: { + backgroundColor: '#212529', + borderColor: '#3a3f44', + borderWidth: 1, + titleFont: { size: 18 }, + bodyFont: { size: 16 }, + padding: 12, + callbacks: { + label: ctx => { + const region = data[ctx.dataIndex] + if (!region) return '' + const amount = region.total_amount ? (region.total_amount / 1_000_000).toFixed(2) : '0.00' + const percentage = region.percentage ? region.percentage.toFixed(2) : '0.00' + + const lines = [`${region.region}`, `Volume: ${amount} M€`, `Part: ${percentage}%`] + + // Si c'est "Autres", ajouter la liste des régions + if (region.region === 'Autres' && region.otherRegions) { + lines.push('', 'Régions incluses:') + region.otherRegions.forEach(r => lines.push(` • ${r}`)) + } + + return lines + }, + }, + }, + datalabels: { + color: '#fff', + font: { + weight: 'bold', + size: 24, + }, + formatter: (value, ctx) => { + const region = data[ctx.dataIndex] + if (!region || !region.percentage) return '' + // N'afficher le pourcentage que s'il est supérieur à 3% + return region.percentage >= 3 ? `${region.percentage.toFixed(1)}%` : '' + }, + }, + }, + } + + return ( +
+

Répartition des Volumes Collectés par Région

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + {error && } + + {!error && data.length > 0 && ( +
+
+ +
+
+ )} +
+ ) } diff --git a/pwa/app/services/diagram.services.tsx b/pwa/app/services/diagram.services.tsx index e69de29..13f3b85 100644 --- a/pwa/app/services/diagram.services.tsx +++ b/pwa/app/services/diagram.services.tsx @@ -0,0 +1,56 @@ +export interface RegionDistribution { + region: string + total_amount: number + percentage: number + otherRegions?: string[] +} + +export async function getRegionDistribution(taxType: string, year: number): Promise { + const params = new URLSearchParams({ + tax_type: taxType, + year: String(year), + }) + const res = await fetch(`/api/regions/distribution?${params}`) + if (!res.ok) throw new Error('Erreur lors du chargement de la distribution régionale') + const data = await res.json() + // S'assurer que les montants sont des nombres + const normalized = data.data.map((item: any) => ({ + region: item.region, + total_amount: parseFloat(item.total_amount) || 0, + percentage: parseFloat(item.percentage) || 0, + })) + return normalized +} + +export function prepareChartData(data: RegionDistribution[], topCount: number = 9): RegionDistribution[] { + // Trier par montant décroissant + const sorted = [...data].sort((a, b) => b.total_amount - a.total_amount) + + // Calculer le total pour les pourcentages + const totalAmount = sorted.reduce((sum, item) => sum + item.total_amount, 0) + + // Garder les N premières régions et recalculer les pourcentages + const topN: RegionDistribution[] = sorted.slice(0, topCount).map(item => ({ + region: item.region, + total_amount: item.total_amount, + percentage: totalAmount > 0 ? (item.total_amount / totalAmount) * 100 : 0, + })) + + // Regrouper les autres + const others = sorted.slice(topCount) + const othersTotal = others.reduce((sum, item) => sum + item.total_amount, 0) + const othersPercentage = totalAmount > 0 ? (othersTotal / totalAmount) * 100 : 0 + + // Créer le tableau final avec "Autres" si nécessaire + const result: RegionDistribution[] = [...topN] + if (others.length > 0) { + result.push({ + region: 'Autres', + total_amount: othersTotal, + percentage: othersPercentage, + otherRegions: others.map(item => item.region), + }) + } + + return result +} diff --git a/pwa/package-lock.json b/pwa/package-lock.json index e5a388c..a92e339 100644 --- a/pwa/package-lock.json +++ b/pwa/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", "lucide-react": "^0.563.0", "next": "^15", "postcss": "^8.5.6", @@ -1367,7 +1368,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1413,7 +1413,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1618,7 +1617,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1959,7 +1957,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1967,6 +1964,15 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2529,7 +2535,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2590,7 +2595,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4493,7 +4497,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4555,7 +4558,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4606,7 +4608,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4626,7 +4627,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4638,15 +4638,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4699,8 +4697,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5413,7 +5410,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/pwa/package.json b/pwa/package.json index 329c5bc..3c5c94c 100644 --- a/pwa/package.json +++ b/pwa/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "chart.js": "^4.5.1", + "chartjs-plugin-datalabels": "^2.2.0", "lucide-react": "^0.563.0", "next": "^15", "postcss": "^8.5.6", -- GitLab From 98c7ef3abe729c5cb81680f4292eeda811e31893 Mon Sep 17 00:00:00 2001 From: Julien Furet Date: Thu, 12 Feb 2026 11:54:39 +0100 Subject: [PATCH 3/3] [FIX] Replace diagram.jsx by diagram.tsx + comments in English --- .../tabs/{diagram.jsx => diagram.tsx} | 28 +++++++++---------- pwa/app/services/diagram.services.tsx | 18 ++++++------ pwa/pnpm-lock.yaml | 12 ++++++++ 3 files changed, 35 insertions(+), 23 deletions(-) rename pwa/app/components/tabs/{diagram.jsx => diagram.tsx} (88%) diff --git a/pwa/app/components/tabs/diagram.jsx b/pwa/app/components/tabs/diagram.tsx similarity index 88% rename from pwa/app/components/tabs/diagram.jsx rename to pwa/app/components/tabs/diagram.tsx index 774b694..c9df2e0 100644 --- a/pwa/app/components/tabs/diagram.jsx +++ b/pwa/app/components/tabs/diagram.tsx @@ -2,10 +2,10 @@ import { useState, useEffect } from 'react' import { Doughnut } from 'react-chartjs-2' -import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js' +import { Chart as ChartJS, ArcElement, Tooltip, Legend, TooltipItem } from 'chart.js' import ChartDataLabels from 'chartjs-plugin-datalabels' import { TAX_TYPES, YEARS, COLORS } from '../../constants' -import { getRegionDistribution, prepareChartData } from '../../services/diagram.services' +import { getRegionDistribution, prepareChartData, RegionDistribution } from '../../services/diagram.services' import ErrorDiv from '../molecules/ErrorDiv' ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels) @@ -14,19 +14,19 @@ export default function Diagram() { const [taxType, setTaxType] = useState('th') const [year, setYear] = useState(2019) const [topCount, setTopCount] = useState(9) - const [data, setData] = useState([]) + const [data, setData] = useState([]) const [maxRegions, setMaxRegions] = useState(20) const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState(null) - // Charger le nombre de régions au montage + // Load number of regions on mount useEffect(() => { const loadMaxRegions = async () => { try { const result = await getRegionDistribution(taxType, year) setMaxRegions(result.length) - } catch (e) { - // Garder la valeur par défaut en cas d'erreur + } catch { + // Keep default value on error } } loadMaxRegions() @@ -66,7 +66,7 @@ export default function Diagram() { aspectRatio: 1.5, plugins: { legend: { - position: 'right', + position: 'right' as const, labels: { color: '#b0afaf', padding: 25, @@ -83,7 +83,7 @@ export default function Diagram() { bodyFont: { size: 16 }, padding: 12, callbacks: { - label: ctx => { + label: (ctx: TooltipItem<'doughnut'>) => { const region = data[ctx.dataIndex] if (!region) return '' const amount = region.total_amount ? (region.total_amount / 1_000_000).toFixed(2) : '0.00' @@ -91,10 +91,10 @@ export default function Diagram() { const lines = [`${region.region}`, `Volume: ${amount} M€`, `Part: ${percentage}%`] - // Si c'est "Autres", ajouter la liste des régions + // If it's "Others", add the list of regions if (region.region === 'Autres' && region.otherRegions) { lines.push('', 'Régions incluses:') - region.otherRegions.forEach(r => lines.push(` • ${r}`)) + region.otherRegions.forEach((r: string) => lines.push(` • ${r}`)) } return lines @@ -104,13 +104,13 @@ export default function Diagram() { datalabels: { color: '#fff', font: { - weight: 'bold', + weight: 'bold' as const, size: 24, }, - formatter: (value, ctx) => { + formatter: (_value: unknown, ctx: { dataIndex: number }) => { const region = data[ctx.dataIndex] if (!region || !region.percentage) return '' - // N'afficher le pourcentage que s'il est supérieur à 3% + // Only display percentage if greater than 3% return region.percentage >= 3 ? `${region.percentage.toFixed(1)}%` : '' }, }, diff --git a/pwa/app/services/diagram.services.tsx b/pwa/app/services/diagram.services.tsx index 13f3b85..841506e 100644 --- a/pwa/app/services/diagram.services.tsx +++ b/pwa/app/services/diagram.services.tsx @@ -13,35 +13,35 @@ export async function getRegionDistribution(taxType: string, year: number): Prom const res = await fetch(`/api/regions/distribution?${params}`) if (!res.ok) throw new Error('Erreur lors du chargement de la distribution régionale') const data = await res.json() - // S'assurer que les montants sont des nombres - const normalized = data.data.map((item: any) => ({ + // Ensure amounts are numbers + const normalized = data.data.map((item: { region: string; total_amount: string | number; percentage?: string | number }) => ({ region: item.region, - total_amount: parseFloat(item.total_amount) || 0, - percentage: parseFloat(item.percentage) || 0, + total_amount: parseFloat(item.total_amount?.toString() || '0') || 0, + percentage: item.percentage ? parseFloat(item.percentage.toString()) : 0, })) return normalized } export function prepareChartData(data: RegionDistribution[], topCount: number = 9): RegionDistribution[] { - // Trier par montant décroissant + // Sort by descending amount const sorted = [...data].sort((a, b) => b.total_amount - a.total_amount) - // Calculer le total pour les pourcentages + // Calculate total for percentages const totalAmount = sorted.reduce((sum, item) => sum + item.total_amount, 0) - // Garder les N premières régions et recalculer les pourcentages + // Keep top N regions and recalculate percentages const topN: RegionDistribution[] = sorted.slice(0, topCount).map(item => ({ region: item.region, total_amount: item.total_amount, percentage: totalAmount > 0 ? (item.total_amount / totalAmount) * 100 : 0, })) - // Regrouper les autres + // Group the others const others = sorted.slice(topCount) const othersTotal = others.reduce((sum, item) => sum + item.total_amount, 0) const othersPercentage = totalAmount > 0 ? (othersTotal / totalAmount) * 100 : 0 - // Créer le tableau final avec "Autres" si nécessaire + // Create final array with "Others" if needed const result: RegionDistribution[] = [...topN] if (others.length > 0) { result.push({ diff --git a/pwa/pnpm-lock.yaml b/pwa/pnpm-lock.yaml index 4b41be2..5fdf521 100644 --- a/pwa/pnpm-lock.yaml +++ b/pwa/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + chartjs-plugin-datalabels: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.5.1) lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) @@ -675,6 +678,11 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chartjs-plugin-datalabels@2.2.0: + resolution: {integrity: sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==} + peerDependencies: + chart.js: '>=3.0.0' + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2256,6 +2264,10 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-plugin-datalabels@2.2.0(chart.js@4.5.1): + dependencies: + chart.js: 4.5.1 + client-only@0.0.1: {} clsx@2.1.1: {} -- GitLab