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
variantprop 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 |
|
![]() |
![]() |
![]() |
![]() |
vue_profile_activity_calendar FF enabled, fluid layout |
new: always set to fixed |
![]() |
![]() |
![]() |
![]() |
How to set up and validate locally
- Enable FF https://gdk.test:3000/rails/features/vue_profile_activity_calendar
- Go to https://gdk.test:3000/root
Test a full activity calendar/feed
- Create tmp file
touch tmp/seed_calendar_user.rb - Copy & paste content from below
- Run
bin/rails runner tmp/seed_calendar_user.rb 2>&1 | tail -30 - 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







