Draft: Rewrite activity calendar with accessible Vue component

What does this MR do and why?

Rewrite activity calendar with accessible Vue component

Replace D3.js/SVG-based activity calendar with a modern, accessible Vue component using semantic HTML and CSS Grid.

tl;dr: What's new:

  • Introduces a new FF :vue_profile_activity_calendar
  • Makes calendar accessible
  • Allows for full keyboard navigation
  • Adds ability to select previous years
  • Shows empty calendar and loads data async
  • Uses skeleton loader for events
  • Shows multiple years (at least 3, depending on the data retention policy)

Changes:

  • Remove legacy activity_calendar.js (D3.js/SVG implementation)
  • Rewrite activity_calendar.vue with accessible HTML markup
  • Use single CSS Grid layout for better performance and maintainability
  • Update user_tabs.js to mount Vue component instead of legacy class
  • Remove obsolete test file for legacy implementation
  • Adds variant prop to contribution event variants

Improvements:

  • Accessible: semantic HTML with ARIA labels and keyboard navigation
  • Responsive: cells scale with viewport (16px minimum)
  • Better performance: single grid vs nested SVG elements
  • Maintainable: pure Vue without D3.js dependency

Keyboard navigation:

  • Arrow keys (←↑→↓) navigate between calendar cells
  • Home/End jump to first/last day of current week
  • PageUp/PageDown move 4 weeks (approximately 1 month)
  • Arrow up/down wrap to previous/next week at row edges
  • Tab key management: only focused cell is tabbable (tabindex=0)
  • Focus indicator tracks current keyboard position

The calendar respects user's first day of week preference and supports all contribution levels (0-4) with proper color coding.

Screenshots or screen recordings

Before After
:vue_profile_activity_calendar FF enabled, fixed layout
before after
before2 after2
vue_profile_activity_calendar FF enabled, fluid layout new: always set to fixed
fluid_before after_fluid
fluid_before2 after_fluid2

How to set up and validate locally

  1. Enable FF https://gdk.test:3000/rails/features/vue_profile_activity_calendar
  2. Go to https://gdk.test:3000/root

Test a full activity calendar/feed

  1. Create tmp file touch tmp/seed_calendar_user.rb
  2. Copy & paste content from below
  3. Run bin/rails runner tmp/seed_calendar_user.rb 2>&1 | tail -30
  4. View profile: https://gdk.test:3000/calendar_tester
Generate user with activity for testing
# frozen_string_literal: true

# Seeds a user with contribution activity spread across several years so the
# activity-calendar year switcher can be exercised.
#
# Run with: bin/rails runner tmp/seed_calendar_user.rb

USERNAME = 'calendar_tester'
YEARS_BACK = 6

org = Organizations::Organization.default_organization

user = User.find_by(username: USERNAME)
unless user
  Users::CreateService.new(
    nil,
    username: USERNAME,
    name: 'Calendar Tester',
    email: "#{USERNAME}@example.com",
    password: 'Password123!',
    skip_confirmation: true,
    organization_id: org&.id
  ).execute
  user = User.find_by(username: USERNAME)
end

raise 'Could not create user' unless user

# Backdate the account so the year switcher offers YEARS_BACK+1 years.
user.update_column(:created_at, YEARS_BACK.years.ago.beginning_of_year)

project =
  user.namespace.projects.find_by(path: 'activity-playground') ||
  Projects::CreateService.new(
    user,
    name: 'Activity Playground',
    path: 'activity-playground',
    namespace_id: user.namespace_id,
    visibility_level: Gitlab::VisibilityLevel::PUBLIC
  ).execute

raise "Project not created: #{project.errors.full_messages.join(', ')}" unless project.persisted?

issue = project.issues.first
unless issue
  Issues::CreateService.new(
    container: project,
    current_user: user,
    params: { title: 'Seed issue' },
    perform_spam_check: false
  ).execute
  issue = project.issues.first
end

raise 'Could not create issue target' unless issue

current_year = Date.current.year
created = 0

(current_year - YEARS_BACK..current_year).each do |year|
  active_days = rand(40..120)

  active_days.times do
    date = Time.zone.local(year, rand(1..12), rand(1..28), rand(0..23))
    next if date.future?

    rand(1..6).times do
      Event.create!(
        project: project,
        author: user,
        action: :created,
        target: issue,
        created_at: date
      )
      created += 1
    end
  end
end

puts '---'
puts "User:     #{user.username} (id=#{user.id})"
puts "Login:    #{USERNAME} / Password123!"
puts "Profile:  /#{user.username}"
puts "Joined:   #{user.created_at.to_date}"
puts "Events:   #{user.events.count} (#{created} created this run)"
puts "Years:    #{(user.created_at.year..current_year).to_a.reverse.inspect}"
puts '---'

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Sascha Eggenberger

Merge request reports

Loading