diff --git a/README.md b/README.md index 1a0a26c46..dd290fb6f 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,17 @@ postgres:16 "docker-entrypoint.s…" 11 seconds ago Up 10 seconds Access the web application by browser: http://localhost:3000 and enjoy the ride! +## Skill snapshot for departments +The core competence of this feature is to track how many people in a department have a given skill. This also includes tracking the level of the skill. +It works by running a monthly DelayedJob that creates these 'Snapshots' for each department. +You can then take a look at these Snapshots using `chart.js` to see how the amount people with certain skills have changed over the span of a year. +This also features a soft-delete for skills since we want to be able to access skills that have been a thing in the past. +For local development dynamically generated, extensive seeds are available for each department and skill. + +**Make sure to start the delayed job worker**, otherwise the job won't be executed. You can find help on how to do this in +[the delayed job documentation](https://github.com/collectiveidea/delayed_job?tab=readme-ov-file#running-jobs) +or just simply run `rails jobs:work` to start working off queued delayed jobs. + ## Debugging To interact with `pry` inside a controller, you have to attach to the container first using `docker attach rails`. This will show you any **new** logs, and if you encounter a `pry` prompt, you can interact with it. diff --git a/app/controllers/department_skill_snapshots_controller.rb b/app/controllers/department_skill_snapshots_controller.rb new file mode 100644 index 000000000..6abde50c5 --- /dev/null +++ b/app/controllers/department_skill_snapshots_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +include DateHelper + +class DepartmentSkillSnapshotsController < CrudController + def index + @data = chart_data.to_json + super + end + + private + + def chart_data + { + labels: months.compact, + datasets: dataset_values.map.with_index(1) do |label, level| + build_dataset(label, level) + end.compact + } + end + + def dataset_values + %w[Azubi Junior Senior Professional Expert] + end + + # level corresponds to 1-5 (Azubi = 1, ..., Expert = 5) + def build_dataset(label, level) + return unless params[:department_id].present? && + params[:skill_id].present? && + params[:year].present? + + { + label: label, + data: get_data_for_each_level(level), + fill: false, + tension: 0.1 + } + end + + def get_data_for_each_level(level) + monthly_data = Array.new(12, 0) + skill_id = params[:skill_id].to_s + + find_snapshots_by_department_id_and_year.each do |snapshot| + month_index = snapshot.created_at.month - 1 + levels = snapshot.department_skill_levels[skill_id] || [] + monthly_data[month_index] += levels.count(level) + end + + monthly_data + end + + def find_snapshots_by_department_id_and_year + year = params[:year].to_i + start_date = Date.new(year, 1, 1) + end_date = start_date.end_of_year + + DepartmentSkillSnapshot.where( + department_id: params[:department_id], + created_at: start_date..end_date + ) + end +end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 23ecd85a2..a03db27e9 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -12,15 +12,18 @@ def person_tabs(person) ] end + # rubocop:disable Metrics/LineLength def global_tabs [ { title: ti('navbar.profile'), path: people_path, admin_only: false }, { title: ti('navbar.skill_search'), path: people_skills_path, admin_only: false }, { title: ti('navbar.cv_search'), path: cv_search_index_path, admin_only: false }, { title: ti('navbar.skillset'), path: skills_path, admin_only: false }, - { title: ti('navbar.certificates'), path: certificates_path, admin_only: true } + { title: ti('navbar.certificates'), path: certificates_path, admin_only: true }, + { title: ti('navbar.skills_tracking'), path: department_skill_snapshots_path, admin_only: false } ] end + # rubocop:enable Metrics/LineLength def extract_path(regex) request.path.match(regex)&.captures&.join diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js new file mode 100644 index 000000000..e3f486198 --- /dev/null +++ b/app/javascript/controllers/auto_submit_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + requestAnimationFrame(() => { + this.element.requestSubmit() + }) + } +} diff --git a/app/javascript/controllers/chart_controller.js b/app/javascript/controllers/chart_controller.js new file mode 100644 index 000000000..2e3595b5f --- /dev/null +++ b/app/javascript/controllers/chart_controller.js @@ -0,0 +1,65 @@ +import { Controller } from "@hotwired/stimulus" +import Chart from "chart.js/auto" +import { Colors } from 'chart.js'; +import annotationPlugin from 'chartjs-plugin-annotation'; + +export default class extends Controller { + static targets = ["canvas"] + static values = { + dataset: String, + emptychart: String, + charttype: String + } + + connect() { + Chart.register(annotationPlugin); + Chart.register(Colors); + + const ctx = this.canvasTarget.getContext("2d"); + const chartData = JSON.parse(this.datasetValue); + + const isEmpty = !chartData?.datasets?.length || chartData.datasets.every(ds => !ds.data?.length); + + const options = { + responsive: true, + scales: { + y: { + beginAtZero: true + } + } + }; + + if (isEmpty) { + options.plugins = { + annotation: { + annotations: { + noData: { + type: 'label', + content: [ this.emptychartValue ], + position: { + x: '50%', + y: '50%' + }, + font: { + size: 32, + weight: 'bold' + }, + color: 'gray', + textAlign: 'center' + } + } + } + }; + } + + this.chart = new Chart(ctx, { + type: this.charttypeValue.toLowerCase(), + data: chartData, + options: options + }); + } + + disconnect() { + this.chart?.destroy() + } +} diff --git a/app/views/department_skill_snapshots/index.html.haml b/app/views/department_skill_snapshots/index.html.haml new file mode 100644 index 000000000..99b2fdc51 --- /dev/null +++ b/app/views/department_skill_snapshots/index.html.haml @@ -0,0 +1,31 @@ += form_with url: department_skill_snapshots_path, method: :get, data: { turbo_frame: "team-skill-chart", controller: "auto-submit" } do |f| + .d-flex.mt-4.mb-4.gap-4 + %div + = f.label :department_id, t('activerecord.models.department.one'), class: "text-secondary" + = f.select :department_id, + options_from_collection_for_select(Department.all, :id, :name, params[:department_id]), + { prompt: ta(:please_select)}, + { onchange: "this.form.requestSubmit()", class: "form-select"} + %div + = f.label :skill_id, t('activerecord.models.skill.one'), class: "text-secondary" + = f.select :skill_id, + options_from_collection_for_select(Skill.all, :id, :title, params[:skill_id]), + { prompt: ta(:please_select)}, + { onchange: "this.form.requestSubmit()", class: "form-select" } + %div + = f.label :year, t('global.date.year'), class: "text-secondary" + = f.select :year, + options_for_select((2025..Date.today.year).to_a.reverse, params[:year] || Date.today.year), + { prompt: ta(:please_select)}, + { onchange: "this.form.requestSubmit()", class: "form-select"} + + %div + = f.label :chart_type, ti('skills_tracking.chart_type'), class: "text-secondary" + = f.select :chart_type, + options_for_select([['Line Chart', 'line'], ['Bar Chart', 'bar']].map { |label, value| [ti("skills_tracking.#{value}-chart"), value] }, params[:chart_type] || 'line'), + { prompt: ta(:please_select)}, + { onchange: "this.form.requestSubmit()", class: "form-select"} + +%turbo-frame#team-skill-chart + %div.chart-container{data: {controller: "chart", "chart-dataset-value": @data, "chart-emptychart-value": t('errors.messages.chart_data_empty'), "chart-charttype-value": params[:chart_type]}} + %canvas{ "data-chart-target": "canvas", width: "3", height: "1" } diff --git a/config/locales/de-CH.yml b/config/locales/de-CH.yml index 34137ef38..a209abbdd 100644 --- a/config/locales/de-CH.yml +++ b/config/locales/de-CH.yml @@ -189,6 +189,7 @@ de-CH: messages: authorization_error: Du hesch ke Berächtigung, diä Funktion uszfüährä. cannot_remove: darf nid glöscht wärdä. + chart_data_empty: Wähl e Abteilig, e Skill und es Jahr us um d date ds zeige. invalid_date_range: muess vorem Änddatum si. max_size_10MB: darf nid grösser aus 10MB si. max_size_error: darf nid grösser aus 10MB si. @@ -233,6 +234,7 @@ de-CH: cv_search: CV Suechi profile: Profiu skill_search: Skill Suechi + skills_tracking: Skills tracking skillset: Skillset new: Neu people_skills: @@ -260,6 +262,10 @@ de-CH: no_results: Kei Resultatä no_skill: Füegä Sie ä Skill zur Suechi hinzu. search_results: Suechergäbnissä + skills_tracking: + bar-chart: Bauke + chart_type: Diagramm Typ + line-chart: Liniä 'true': Ja helpers: cancel: Abbrächä diff --git a/config/locales/de.yml b/config/locales/de.yml index b5ddde3dc..5af45aaf7 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -174,6 +174,7 @@ de: messages: authorization_error: Du hast keine Berechtigung, diese Funktion auszuführen. cannot_remove: darf nicht gelöscht werden. + chart_data_empty: Wähle eine Abteilung, einen Skill und ein Jahr aus um Daten anzuzeigen. invalid_date_range: muss vor dem Enddatum sein. max_size_10MB: darf nicht grösser als 10MB sein. max_size_error: darf nicht grösser als 10MB sein. @@ -218,6 +219,7 @@ de: cv_search: CV Suche profile: Profil skill_search: Skill Suche + skills_tracking: Skills Entwicklung skillset: Skillset new: Neu people_skills: @@ -245,6 +247,10 @@ de: no_results: Keine Resultate gefunden no_skill: Füge einen Skill zur Suche hinzu. search_results: Suchergebnisse + skills_tracking: + bar-chart: Balken + chart_type: Diagramm Typ + line-chart: Linien 'true': Ja helpers: cancel: Abbrechen diff --git a/config/locales/en.yml b/config/locales/en.yml index af08bb24a..584b31201 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -170,6 +170,7 @@ en: messages: authorization_error: You do not have permission to execute this function. cannot_remove: must not be deleted. + chart_data_empty: Select a department, skill and a year to display data invalid_date_range: must be before the end date. max_size_10MB: must not be larger than 10MB. max_size_error: must not be larger than 10MB. @@ -214,6 +215,7 @@ en: cv_search: CV search profile: Profile skill_search: Skill search + skills_tracking: Skills tracking skillset: Skillset new: New people_skills: @@ -241,6 +243,10 @@ en: no_results: No results found no_skill: No results found, please add a skill to the search. search_results: Search results + skills_tracking: + bar-chart: Bar + chart_type: Chart type + line-chart: Line 'true': 'Yes' helpers: cancel: Cancel diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 80c276198..97d6a7698 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -170,6 +170,7 @@ fr: messages: authorization_error: Vous n'avez pas les droits pour exécuter cette fonction. cannot_remove: ne doit pas être supprimé. + chart_data_empty: Sélectionnez un département, une compétence et une année pour afficher les données. invalid_date_range: doit être antérieure à la date de fin. max_size_10MB: ne doit pas dépasser 10 Mo. max_size_error: ne doit pas dépasser 10 Mo. @@ -214,6 +215,7 @@ fr: cv_search: Recherche de CV profile: Profil skill_search: Recherche de compétences + skills_tracking: Suivi des compétences skillset: Kit de compétences new: Nouveau people_skills: @@ -241,6 +243,10 @@ fr: no_results: Aucun résultat trouvé no_skill: Aucun résultat trouvé, veuillez ajouter une compétence à la recherche. search_results: Résultats de la recherche + skills_tracking: + bar-chart: Barre + chart_type: Type de graphique + line-chart: Ligne 'true': Oui helpers: cancel: Annuler diff --git a/config/locales/it.yml b/config/locales/it.yml index 5625fdb59..c77649c62 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -170,6 +170,7 @@ it: messages: authorization_error: Non hai i diritti per eseguire questa funzione. cannot_remove: non deve essere cancellato. + chart_data_empty: Selezionare un unità organizzativa, una competenza e un anno per visualizzare i dati. invalid_date_range: deve essere precedente alla data di scadenza. max_size_10MB: non deve essere più grande di 10 MB. max_size_error: non deve essere più grande di 10 MB. @@ -214,6 +215,7 @@ it: cv_search: Ricerca di CV profile: Profilo skill_search: Ricerca di competenze + skills_tracking: Tracciamento delle competenze skillset: Competenze new: Nuovo people_skills: @@ -241,6 +243,10 @@ it: no_results: Nessun risultato trovato no_skill: Nessun risultato trovato, aggiungi una competenza alla ricerca. search_results: Risultati della ricerca + skills_tracking: + bar-chart: Bar + chart_type: Tipo di grafico + line-chart: Linea 'true': Sì helpers: cancel: Annullamento diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 16743b1bd..136b1eea3 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -170,6 +170,7 @@ ja: messages: authorization_error: この機能を実行する権限がありません。 cannot_remove: 削除できません。 + chart_data_empty: データを表示するには、部署、スキル、年を選択してください invalid_date_range: 終了日より前の日付でなければなりません。 max_size_10MB: 10MBを超えてはいけません。 max_size_error: 10MBを超えてはいけません。 @@ -214,6 +215,7 @@ ja: cv_search: 履歴書検索 profile: プロフィール skill_search: スキル検索 + skills_tracking: スキルトラッキング skillset: スキルセット new: 新規 people_skills: @@ -241,6 +243,10 @@ ja: no_results: 結果が見つかりませんでした no_skill: 検索にヒットするスキルがありません。スキルを追加してください。 search_results: 検索結果 + skills_tracking: + bar-chart: 棒グラフ + chart_type: チャートの種類 + line-chart: 折れ線グラフ 'true': はい helpers: cancel: キャンセル diff --git a/config/routes.rb b/config/routes.rb index 5d4fc05d5..7d61e4255 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,6 +66,8 @@ end resources :certificates + + resources :department_skill_snapshots, only: [:index] end diff --git a/db/seeds/development/05_department_skill_snapshots.rb b/db/seeds/development/05_department_skill_snapshots.rb new file mode 100644 index 000000000..cf343cf43 --- /dev/null +++ b/db/seeds/development/05_department_skill_snapshots.rb @@ -0,0 +1,5 @@ +# encoding: utf-8 + +require Rails.root.join('db', 'seeds', 'support', 'department_skill_snapshot_seeder.rb') + +DepartmentskillSnapshotSeeder.new.seed_department_skill_snapshots \ No newline at end of file diff --git a/db/seeds/support/department_skill_snapshot_seeder.rb b/db/seeds/support/department_skill_snapshot_seeder.rb new file mode 100644 index 000000000..d23d96692 --- /dev/null +++ b/db/seeds/support/department_skill_snapshot_seeder.rb @@ -0,0 +1,51 @@ +# encoding: utf-8 +class DepartmentskillSnapshotSeeder + def initialize + @previous_skill_levels = {} + end + + def seed_department_skill_snapshots + Department.ids.each do |department_id| + (1..12).each do |month| + seed_snapshot_for_month(department_id, DateTime.new(2025, month, 1)) + end + end + end + + def seed_snapshot_for_month(department_id, date) + DepartmentSkillSnapshot.seed do |snp| + snp.department_id = department_id + snp.department_skill_levels = seed_department_skill_levels(department_id) + snp.created_at = date + snp.updated_at = date + end + end + + def seed_department_skill_levels(department_id) + skill_levels = {} + + Skill.all.each do |skill| + previous = @previous_skill_levels.dig(department_id, skill.id) + + if previous.nil? + new_values = Array.new(rand(8..15)) { rand(1..5) } + else + new_values = previous.dup + indices_to_change = new_values.size.times.to_a.sample(rand(1..3)) + + indices_to_change.each do |i| + current_value = new_values[i] + delta = [-2, -1, 1, 2].sample + new_value = current_value + delta + new_values[i] = new_value.clamp(1, 5) + end + end + + skill_levels[skill.id.to_s] = new_values + @previous_skill_levels[department_id] ||= {} + @previous_skill_levels[department_id][skill.id] = new_values + end + + skill_levels + end +end \ No newline at end of file diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index e9825961f..bed440327 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,6 +1,7 @@ # Work in progress ### Features +- **Team skill tracking** The Skills application now automatically keeps track of the level and amount of certain skills inside a department. - **Language localization** Added translations for Japanese and Swiss-German to the application. - **Different template for CV export** A Red Hat template has been added as an option when exporting the CV. The foundation has been laid to add more templates if requested. - **Unifying of skills** Duplicates of skills can now be unified using an option in the admin view. diff --git a/package.json b/package.json index 57ad35f53..a2cc557db 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "autoprefixer": "^10.4.17", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.3", + "chart.js": "^4.4.9", + "chartjs-plugin-annotation": "^3.1.0", "esbuild": "^0.25.0", "esbuild-rails": "^1.0.7", "nodemon": "^3.0.3", diff --git a/spec/controllers/department_skill_snapshots_controller_spec.rb b/spec/controllers/department_skill_snapshots_controller_spec.rb new file mode 100644 index 000000000..47fc5e0c1 --- /dev/null +++ b/spec/controllers/department_skill_snapshots_controller_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +describe DepartmentSkillSnapshotsController do + + before(:each) do + sign_in auth_users(:admin), scope: :auth_user + end + + it "Should return data JSON in correct format" do + get :index, params: { + department_id: 457905166, + skill_id: "31989848", + year: 2025 + } + + expect(response).to be_successful + + data = assigns(:data) + json = JSON.parse(data) + + expect(json["labels"]).to eq(%w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember]) + + expect(json["datasets"].find { |ds| ds["label"] == "Azubi" }["data"].length).to eq(12) + expect(json["datasets"].find { |ds| ds["label"] == "Junior" }["data"].length).to eq(12) + expect(json["datasets"].find { |ds| ds["label"] == "Senior" }["data"].length).to eq(12) + expect(json["datasets"].find { |ds| ds["label"] == "Professional" }["data"].length).to eq(12) + expect(json["datasets"].find { |ds| ds["label"] == "Expert" }["data"].length).to eq(12) + + # Index 4 equals the month May here + expect(json["datasets"].find { |ds| ds["label"] == "Junior" }["data"][4]).to eq(2) + expect(json["datasets"].find { |ds| ds["label"] == "Expert" }["data"][4]).to eq(1) + end + + it "Should only return data JSON with data from the correct company and year" do + get :index, params: { + department_id: 457905166, + skill_id: "31989848", + year: 2024 + } + + expect(response).to be_successful + + data = assigns(:data) + json = JSON.parse(data) + + expect(json["datasets"].find { |ds| ds["label"] == "Azubi" }["data"][0]).to eq(2) + expect(json["datasets"].find { |ds| ds["label"] == "Senior" }["data"][0]).to eq(1) + + expect(json["datasets"].find { |ds| ds["label"] == "Azubi" }["data"].drop(2).all?(&:zero?)).to eql(true) + expect(json["datasets"].find { |ds| ds["label"] == "Junior" }["data"].all?(&:zero?)).to eql(true) + expect(json["datasets"].find { |ds| ds["label"] == "Senior" }["data"].drop(1).all?(&:zero?)).to eql(true) + expect(json["datasets"].find { |ds| ds["label"] == "Professional" }["data"].all?(&:zero?)).to eql(true) + expect(json["datasets"].find { |ds| ds["label"] == "Expert" }["data"].all?(&:zero?)).to eql(true) + end +end \ No newline at end of file diff --git a/spec/domain/department_skills_snapshot_spec.rb b/spec/domain/department_skills_snapshot_spec.rb index bd0f2722c..b7ad41e22 100644 --- a/spec/domain/department_skills_snapshot_spec.rb +++ b/spec/domain/department_skills_snapshot_spec.rb @@ -2,6 +2,8 @@ describe PeopleSearch do it 'should create snapshot of all departments that have members' do + DepartmentSkillSnapshot.delete_all + DepartmentSkillsSnapshotBuilder.new.snapshot_all_departments snapshots = DepartmentSkillSnapshot.all diff --git a/spec/features/department_skill_snapshots_spec.rb b/spec/features/department_skill_snapshots_spec.rb new file mode 100644 index 000000000..77b7b1c52 --- /dev/null +++ b/spec/features/department_skill_snapshots_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe 'Department Skill Snapshots', type: :feature, js: true do + before(:each) do + admin = auth_users(:admin) + login_as(admin, scope: :auth_user) + visit department_skill_snapshots_path + end + + it 'Should display all selects with the corresponding labels and the the canvas chart' do + expect(page).to have_content('Organisationseinheit') + expect(page).to have_content('Skill') + expect(page).to have_content('Jahr') + + expect(page).to have_select('department_id') + expect(page).to have_select('skill_id') + expect(page).to have_select('year') + expect(page).to have_select('chart_type') + + expect(page).to have_selector("canvas") + end +end diff --git a/spec/features/edit_people_skills_spec.rb b/spec/features/edit_people_skills_spec.rb index 70c4175a9..f09969bf5 100644 --- a/spec/features/edit_people_skills_spec.rb +++ b/spec/features/edit_people_skills_spec.rb @@ -12,7 +12,7 @@ bob = people(:bob) visit person_path(bob) - expect(page).to have_css('.nav-link', text: 'Skills', count: 2) + expect(page).to have_css('.nav-link', text: 'Skills', count: 3) page.all('.nav-link', text: 'Skills')[1].click end diff --git a/spec/features/tabbar_spec.rb b/spec/features/tabbar_spec.rb index 2266ba211..8f0a8d29a 100644 --- a/spec/features/tabbar_spec.rb +++ b/spec/features/tabbar_spec.rb @@ -9,7 +9,8 @@ { title: 'global.navbar.skill_search', path_helper: "people_skills_path", admin_only: false }, { title: 'global.navbar.cv_search', path_helper: "cv_search_index_path", admin_only: false }, { title: 'global.navbar.skillset', path_helper: "skills_path", admin_only: false }, - { title: 'global.navbar.certificates', path_helper: "certificates_path", admin_only: true } + { title: 'global.navbar.certificates', path_helper: "certificates_path", admin_only: true }, + { title: 'global.navbar.skills_tracking', path_helper: "department_skill_snapshots_path", admin_only: false } ] PERSON_TABS = @@ -27,7 +28,7 @@ end after(:each) do - expect(current_path).to start_with("/#{locale}") + expect(current_path).to start_with("/#{locale}/") end describe 'Global' do diff --git a/spec/fixtures/department_skill_snapshots.yml b/spec/fixtures/department_skill_snapshots.yml new file mode 100644 index 000000000..d7b6a1e32 --- /dev/null +++ b/spec/fixtures/department_skill_snapshots.yml @@ -0,0 +1,27 @@ +# == Schema Information +# +# Table name: department_skill_snapshots +# + # id :bigint(8) not null, primary key + # department_id :bigint(8) + # department_skill_levels :text + # created_at :datetime not null + # updated_at :datetime not null + +dev-one-2024: + department_id: 457905166 + department_skill_levels: "{\"31989848\":[1,1,3],\"677333953\":[4,5,2]}" + created_at: 2024-01-01 12:00:00 + updated_at: 2024-01-01 12:00:00 + +dev-one-2025: + department_id: 457905166 + department_skill_levels: "{\"31989848\":[5,2,2],\"677333953\":[3,3,3]}" + created_at: 2025-05-01 12:00:00 + updated_at: 2025-05-01 12:00:00 + +dev-two-2025: + department_id: 820844698 + department_skill_levels: "{\"31989848\":[5,2,2],\"677333953\":[3,3,3]}" + created_at: 2025-05-01 12:00:00 + updated_at: 2025-05-01 12:00:00 diff --git a/yarn.lock b/yarn.lock index c9622ee1b..39846c595 100644 --- a/yarn.lock +++ b/yarn.lock @@ -145,6 +145,11 @@ resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.12.tgz#50aa8345d7f62402680c6d2d9814660761837001" integrity sha512-l3BiQRkD7qrnQv6ms6sqPLczvwbQpXt5iAVwjDvX0iumrz6yEonQkNAzNjeDX25/OJMFDTxpHjkJZHGpM9ikWw== +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -263,6 +268,18 @@ caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001688: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz#a102cf330d153bf8c92bfb5be3cd44c0a89c8c12" integrity sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w== +chart.js@^4.4.9: + version "4.4.9" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.4.9.tgz#602e2fc2462f0f7bb7b255eaa1b51f56a43a1362" + integrity sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg== + dependencies: + "@kurkle/color" "^0.3.0" + +chartjs-plugin-annotation@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz#0b3910862bde232344bbb6cf998633f71db7b093" + integrity sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ== + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.3.0, chokidar@^3.5.2: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"