Make users on calendar selectable

This commit is contained in:
Zach Latta 2025-05-14 19:00:05 -04:00
parent 6efdd05be9
commit 9d35bd3ad3
6 changed files with 513 additions and 27 deletions

View file

@ -12,29 +12,31 @@ class Admin::TimelineController < Admin::BaseController
@next_date = @date + 1.day
@prev_date = @date - 1.day
# Step 1: Consolidate User Loading
# Note: current_user in an admin controller is the admin user.
# The original list of user_ids_to_fetch includes some hardcoded IDs.
# This logic is preserved but might need review for an admin-specific timeline.
user_ids_to_fetch = [
current_user&.id, # Admin's own data (if they are also a tracked user)
1, # Example: User.find(1) if it's relevant
10, 1792, 69, 1476, 805, 2003, 2011 # Original hardcoded IDs
].compact.uniq
# User selection logic
raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(',').map(&:to_i).uniq : []
# Always include current_user (admin)
@selected_user_ids = [current_user.id] + raw_user_ids
@selected_user_ids.uniq!
user_ids_to_fetch = @selected_user_ids
# Fetch all valid users in one go
users_by_id = User.where(id: user_ids_to_fetch).index_by(&:id)
# Ensure we only use IDs of users that actually exist
valid_user_ids_to_fetch = users_by_id.keys
mappings_by_user_project = ProjectRepoMapping.where(user_id: users_by_id.keys)
mappings_by_user_project = ProjectRepoMapping.where(user_id: valid_user_ids_to_fetch)
.group_by(&:user_id)
.transform_values { |mappings| mappings.index_by(&:project_name) }
users_to_process = user_ids_to_fetch.map { |id| users_by_id[id] }.compact
users_to_process = valid_user_ids_to_fetch.map { |id| users_by_id[id] }.compact
start_of_day_timestamp = @date.beginning_of_day.to_f
end_of_day_timestamp = @date.end_of_day.to_f
all_heartbeats = Heartbeat
.where(user_id: user_ids_to_fetch, deleted_at: nil)
.where(user_id: valid_user_ids_to_fetch, deleted_at: nil)
.where('time >= ? AND time <= ?', start_of_day_timestamp, end_of_day_timestamp)
.select(:id, :user_id, :time, :entity, :project, :editor, :language)
.order(:user_id, :time)
@ -42,7 +44,7 @@ class Admin::TimelineController < Admin::BaseController
heartbeats_by_user_id = all_heartbeats.group_by(&:user_id)
@users_with_timeline_data = []
@users_with_timeline_data_unordered = []
users_to_process.each do |user|
user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil)
@ -96,17 +98,85 @@ class Admin::TimelineController < Admin::BaseController
end
end
if calculated_spans_with_details.any? || total_coded_time_seconds > 0
@users_with_timeline_data << {
user: user,
spans: calculated_spans_with_details,
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
}
end
# Add user data, even if no spans/time, if they were explicitly selected
@users_with_timeline_data_unordered << {
user: user,
spans: calculated_spans_with_details,
total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day
}
end
# Order @users_with_timeline_data according to @selected_user_ids
# This ensures that if a user was explicitly selected they appear in the timeline
# even if they have no heartbeats for the day.
data_map_for_ordering = @users_with_timeline_data_unordered.index_by { |data| data[:user].id }
@users_with_timeline_data = @selected_user_ids.map do |id|
data_map_for_ordering[id] || (users_by_id[id] ? { user: users_by_id[id], spans: [], total_coded_time: 0 } : nil)
end.compact
@primary_user = users_to_process.first || current_user # current_user is the admin
# For Stimulus: provide initial selected users with details
@initial_selected_user_objects = User.where(id: @selected_user_ids)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
.map { |u| { id: u.id, display_name: u.display_name, avatar_url: u.avatar_url } }
.sort_by { |u_obj| @selected_user_ids.index(u_obj[:id]) || Float::INFINITY } # Preserve order
@primary_user = @users_with_timeline_data.first&.[](:user) || current_user
render :show # Renders app/views/admin/timeline/show.html.erb
end
def search_users
query_term = params[:query].to_s.downcase
users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%")
.order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match
.limit(20)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
results = users.map do |user|
{
id: user.id,
display_name: user.display_name,
avatar_url: user.avatar_url
}
end
render json: results
end
def leaderboard_users
period = params[:period]
limit = 25
leaderboard_period_type = (period == 'last_7_days') ? :last_7_days : :daily
start_date = Date.current # Leaderboard job for :last_7_days uses Date.current as start_date
leaderboard = Leaderboard.where.not(finished_generating_at: nil)
.find_by(start_date: start_date, period_type: leaderboard_period_type, deleted_at: nil)
user_ids_from_leaderboard = leaderboard ? leaderboard.entries.order(total_seconds: :desc).limit(limit).pluck(:user_id) : []
all_ids_to_fetch = user_ids_from_leaderboard.dup
all_ids_to_fetch.unshift(current_user.id).uniq!
users_data = User.where(id: all_ids_to_fetch)
.select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url)
.index_by(&:id)
final_user_objects = []
# Add admin first
if admin_data = users_data[current_user.id]
final_user_objects << { id: admin_data.id, display_name: admin_data.display_name, avatar_url: admin_data.avatar_url }
end
# Add leaderboard users, ensuring no duplicates and respecting limit
user_ids_from_leaderboard.each do |uid|
break if final_user_objects.size >= limit
next if uid == current_user.id
if user_data = users_data[uid]
final_user_objects << { id: user_data.id, display_name: user_data.display_name, avatar_url: user_data.avatar_url }
end
end
render json: { users: final_user_objects }
end
end

View file

@ -0,0 +1,304 @@
import { Controller } from "@hotwired/stimulus"
import { FetchRequest } from '@rails/request.js'
// Helper for debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
export default class extends Controller {
static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput"]
static values = {
currentUserJson: Object,
initialSelectedUsersJson: Array,
searchUrl: String,
leaderboardUsersUrl: String
}
connect() {
this.selectedUsers = new Map(); // Stores selected users {id: userObject}
this._debouncedSearchImpl = debounce(this.search.bind(this), 300);
try {
console.log("Initializing timeline user selector");
// Parse the JSON if it's a string
let adminUser = this.currentUserJsonValue;
if (typeof adminUser === 'string') {
adminUser = JSON.parse(adminUser);
console.log("Parsed admin user:", adminUser);
}
if (adminUser && adminUser.id) {
// Ensure ID is a number
adminUser.id = parseInt(adminUser.id, 10);
this.addUserToSelection(adminUser, true); // true = isPillForAdmin
}
// Parse the JSON if it's a string
let initialUsers = this.initialSelectedUsersJsonValue;
if (typeof initialUsers === 'string') {
initialUsers = JSON.parse(initialUsers);
console.log("Parsed initial users:", initialUsers);
}
// Make sure it's an array
if (Array.isArray(initialUsers)) {
initialUsers.forEach(user => {
// Ensure ID is a number
if (user && user.id) {
user.id = parseInt(user.id, 10);
if (!adminUser || user.id !== adminUser.id) {
this.addUserToSelection(user, false);
}
}
});
}
this.updateHiddenInput();
this.updateDateLinks();
} catch (error) {
console.error("Error initializing user selector:", error);
}
}
async search() {
const query = this.searchInputTarget.value;
if (query.length < 2) {
this.searchResultsTarget.innerHTML = "";
this.searchResultsTarget.classList.remove('active');
return;
}
const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}`, { responseKind: 'json' })
const response = await request.perform()
if (response.ok) {
const users = await response.json;
console.log(`Found ${users.length} users in search results`);
this.renderSearchResults(users);
} else {
this.searchResultsTarget.innerHTML = "<li class='list-group-item list-group-item-light disabled'>Error searching users</li>";
this.searchResultsTarget.classList.add('active');
}
}
renderSearchResults(users) {
if (users.length === 0) {
this.searchResultsTarget.innerHTML = "<li class='list-group-item list-group-item-light disabled'>No users found</li>";
} else {
this.searchResultsTarget.innerHTML = users.map(user => `
<li class="list-group-item list-group-item-action list-group-item-dark"
data-action="click->${this.identifier}#selectUser"
data-${this.identifier}-user-id-value="${user.id}"
data-${this.identifier}-user-display-name-value="${this.escapeHTML(user.display_name)}"
data-${this.identifier}-user-avatar-url-value="${user.avatar_url || ''}">
<img src="${user.avatar_url || 'https://via.placeholder.com/20'}" alt="${this.escapeHTML(user.display_name)}" class="avatar-xs me-2 rounded-circle">${this.escapeHTML(user.display_name)}
</li>
`).join("");
}
// Make sure the result list is shown
this.searchResultsTarget.style.display = 'block';
this.searchResultsTarget.classList.add('active');
console.log("Search results rendered and active class added");
}
selectUser(event) {
event.preventDefault();
// Get values directly from data attributes
const element = event.currentTarget;
const userId = parseInt(element.getAttribute(`data-${this.identifier}-user-id-value`), 10);
const displayName = element.getAttribute(`data-${this.identifier}-user-display-name-value`);
const avatarUrl = element.getAttribute(`data-${this.identifier}-user-avatar-url-value`);
console.log("Selected user data:", { userId, displayName, avatarUrl });
if (isNaN(userId)) {
console.error("Invalid user ID");
this.clearSearch();
return;
}
if (this.selectedUsers.has(userId)) {
this.clearSearch();
return;
}
const user = {
id: userId,
display_name: displayName,
avatar_url: avatarUrl
};
this.addUserToSelection(user, false);
this.clearSearch();
this.updateHiddenInput();
this.updateDateLinks();
}
addUserToSelection(user, isPillForAdmin = false) {
// Make sure we have a valid user ID
if (!user || !user.id || isNaN(parseInt(user.id, 10))) {
console.error("Invalid user object or ID:", user);
return;
}
// Convert ID to number just to be safe
const userId = parseInt(user.id, 10);
user.id = userId;
if (this.selectedUsers.has(userId)) return;
console.log("Adding user to selection:", user);
this.selectedUsers.set(userId, user);
const pill = document.createElement("span");
// Using Pico.css friendly classes if available, or simple badges
pill.classList.add("badge", "secondary", "me-1", "mb-1", "d-inline-flex", "align-items-center", "user-pill");
pill.style.backgroundColor = isPillForAdmin ? 'var(--pico-primary-focus)' : 'var(--pico-muted-border-color)';
pill.style.color = isPillForAdmin ? 'var(--pico-primary-inverse)' : 'var(--pico-color)';
pill.style.padding = '0.3em 0.6em';
pill.style.borderRadius = 'var(--pico-border-radius)';
pill.dataset.userId = userId;
let avatarImg = '';
if (user.avatar_url) {
avatarImg = `<img src="${user.avatar_url}" alt="${this.escapeHTML(user.display_name)}" class="avatar-xxs me-1 rounded-circle">`;
}
pill.innerHTML = `${avatarImg} ${this.escapeHTML(user.display_name)}`;
if (!isPillForAdmin) {
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.classList.add("btn-close-custom", "ms-1"); // Custom class for styling
removeButton.innerHTML = "&times;"; // Simple 'x'
removeButton.dataset.action = `click->${this.identifier}#removeUser`;
removeButton.setAttribute("aria-label", "Remove");
pill.appendChild(removeButton);
}
this.selectedUsersContainerTarget.appendChild(pill);
}
removeUser(event) {
const pill = event.target.closest(".user-pill");
const userId = parseInt(pill.dataset.userId, 10);
console.log("Removing user with ID:", userId);
// Don't allow removing if it's the current user or an invalid ID
if (isNaN(userId) || (this.currentUserJsonValue && userId === this.currentUserJsonValue.id)) return;
this.selectedUsers.delete(userId);
pill.remove();
this.updateHiddenInput();
this.updateDateLinks();
}
async applyPreset(event) {
event.preventDefault();
const period = event.currentTarget.dataset.period;
const request = new FetchRequest('get', `${this.leaderboardUsersUrlValue}?period=${period}`, { responseKind: 'json' })
const response = await request.perform()
if (response.ok) {
const data = await response.json;
const presetUsers = data.users;
this.selectedUsersContainerTarget.querySelectorAll('.user-pill').forEach(pill => {
const uid = parseInt(pill.dataset.userId);
if (uid !== this.currentUserJsonValue.id) {
pill.remove();
this.selectedUsers.delete(uid);
}
});
presetUsers.forEach(user => {
if (user.id !== this.currentUserJsonValue.id) {
this.addUserToSelection(user, false);
}
});
this.updateHiddenInput();
this.updateDateLinks();
} else {
console.error("Failed to load preset users");
alert("Could not load preset users. Please try again.");
}
}
updateHiddenInput() {
this.userIdsInputTarget.value = Array.from(this.selectedUsers.keys()).join(',');
}
updateDateLinks() {
const selectedIds = Array.from(this.selectedUsers.keys()).join(',');
document.querySelectorAll('a[data-date-nav-link="true"]').forEach(link => {
const url = new URL(link.href, window.location.origin);
if (selectedIds) {
url.searchParams.set('user_ids', selectedIds);
} else {
url.searchParams.delete('user_ids');
}
link.href = url.toString();
});
}
submitForm() {
const form = this.element.querySelector('form#timeline-filter-form');
if (form) {
if(this.hasDateInputTarget && form.elements.date) {
form.elements.date.value = this.dateInputTarget.value;
}
form.requestSubmit();
}
}
handleKeydown(event) {
if (event.key === "Escape") {
this.clearSearch();
}
}
clearSearch() {
this.searchInputTarget.value = "";
this.searchResultsTarget.innerHTML = "";
this.searchResultsTarget.classList.remove('active');
this.searchResultsTarget.style.display = 'none';
}
hideResultsDelayed() {
setTimeout(() => {
if (!this.searchResultsTarget.matches(':hover')) {
this.clearSearch();
}
}, 200);
}
escapeHTML(str) {
if (str === null || str === undefined) return '';
return str.toString().replace(/[&<>"']/g, function (match) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[match];
});
}
// This is just a wrapper to satisfy Stimulus data-action format
// The actual debounced search implementation is stored in _debouncedSearchImpl
debouncedSearch() {
this._debouncedSearchImpl();
}
}

View file

@ -43,21 +43,77 @@
# Add fixed spacers for the header:
total_min_scroll_width_px = (activity_col_area_start_rem * 16) + min_header_content_width_px + (activity_col_area_end_rem * 16)
# No longer using flex-based width calculation for headers
# css_header_item_width = "calc((100% - #{(num_users > 1 ? (num_users - 1) * gutter_rem : 0)}rem) / #{num_users})"
# Current admin user and selected users for Stimulus
current_admin_user = {
id: current_user.id,
display_name: current_user.display_name,
avatar_url: current_user.avatar_url
}
current_admin_user_json = current_admin_user.to_json
initial_selected_users_json = @initial_selected_user_objects.to_json
current_date_for_form = @date.to_s
%>
<div style="background-color: #1F2937; color: #FFFFFF; padding: 1rem; border-radius: 0.5rem; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; display: flex; flex-direction: column; height: calc(100vh - 8rem);">
<!-- User Selector UI -->
<div
data-controller="admin-timeline-user-selector"
data-admin-timeline-user-selector-current-user-json-value='<%= current_admin_user_json %>'
data-admin-timeline-user-selector-initial-selected-users-json-value='<%= initial_selected_users_json %>'
data-admin-timeline-user-selector-search-url-value="<%= admin_timeline_search_users_path %>"
data-admin-timeline-user-selector-leaderboard-users-url-value="<%= admin_timeline_leaderboard_users_path %>"
style="margin-bottom: 1rem; padding: 0.75rem; background-color: #2D3748; border-radius: 0.375rem; flex-shrink: 0;"
class="user-selector-compact"
>
<form id="timeline-filter-form" action="<%= admin_timeline_path %>" method="get" data-turbo-frame="_top">
<input type="hidden" name="user_ids" data-admin-timeline-user-selector-target="userIdsInput">
<input type="hidden" name="date" value="<%= current_date_for_form %>" data-admin-timeline-user-selector-target="dateInput">
<div class="grid">
<div style="grid-column: span 7;">
<label for="user-search-input" class="visually-hidden">Add User</label>
<div style="position: relative;">
<input type="text"
id="user-search-input"
placeholder="Add user by name/email..."
data-admin-timeline-user-selector-target="searchInput"
data-action="input->admin-timeline-user-selector#debouncedSearch keydown->admin-timeline-user-selector#handleKeydown focus->admin-timeline-user-selector#search blur->admin-timeline-user-selector#hideResultsDelayed"
autocomplete="off"
style="font-size: 0.875rem; padding: 0.35rem 0.5rem; margin-bottom: 0; width: 100%;">
<ul class="list-group position-absolute w-100"
data-admin-timeline-user-selector-target="searchResults"
style="z-index: 1050; max-height: 200px; overflow-y: auto; list-style-type: none; padding-left: 0; margin-top: 0; width: 100%; background-color: #1A202C; border: 1px solid #4A5568; border-top: none; border-radius: 0 0 0.25rem 0.25rem; display: none;">
<%# Search results will appear here %>
</ul>
</div>
</div>
<div style="grid-column: span 5; display: flex; align-items: flex-end; justify-content: flex-end; gap: 0.5rem;">
<div class="btn-group" role="group" style="display:contents;">
<button type="button" class="secondary outline" data-action="admin-timeline-user-selector#applyPreset" data-period="today" style="font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0;">Top Today</button>
<button type="button" class="secondary outline" data-action="admin-timeline-user-selector#applyPreset" data-period="last_7_days" style="font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0;">Top 7 Days</button>
</div>
<button type="submit" class="primary" style="font-size: 0.8rem; padding: 0.35rem 0.75rem; margin-bottom: 0;">View</button>
</div>
</div>
<div class="mt-2" data-admin-timeline-user-selector-target="selectedUsersContainer" style="margin-top: 0.5rem; min-height: 28px;">
<%# Selected user pills will appear here %>
</div>
</form>
</div>
<!-- Date Navigation (remains the same) -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<div style="font-size: 1.125rem; line-height: 1.75rem; font-weight: 600;">
<%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
</div>
<div style="display: flex; gap: 0.5rem;">
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline" %>
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline" %>
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline" %>
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
</div>
</div>
@ -237,4 +293,53 @@
</div> <!-- End #timeline-grid-scroll-container -->
</div> <!-- End .admin-timeline-content-sizer -->
</div> <!-- End .admin-timeline-view-wrapper -->
</div> <!-- End Main Page Container -->
</div> <!-- End Main Page Container -->
<% content_for :head do %>
<style>
.user-selector-compact .form-label-sm { font-size: 0.75rem; margin-bottom: 0.25rem; }
.user-selector-compact input[type="text"] { font-size: 0.875rem; padding: 0.35rem 0.5rem; margin-bottom: 0; }
.user-selector-compact button { font-size: 0.8rem; padding: 0.35rem 0.5rem; margin-bottom: 0; }
.user-selector-compact .list-group-item {
font-size: 0.875rem;
padding: 0.4rem 0.75rem; /* Increased padding for easier clicking */
cursor: pointer;
background-color: #1A202C; /* Dark background for items */
color: #E2E8F0; /* Light text color */
border-bottom: 1px solid #2D3748; /* Separator */
}
.user-selector-compact .list-group.position-absolute {
top: 100%; left: 0;
border: 1px solid #4A5568; border-top: none;
border-radius: 0 0 0.25rem 0.25rem;
display: none !important; /* Hidden by default */
}
.user-selector-compact .list-group.active {
display: block !important;
}
.user-selector-compact .list-group-item:hover { background-color: #4A5568; }
.user-selector-compact .list-group-item.disabled { color: #A0AEC0; background-color: #2D3748; }
.user-selector-compact .avatar-xs { width: 20px; height: 20px; border-radius: 50%; vertical-align: middle; }
.user-selector-compact .avatar-xxs { width: 16px; height: 16px; border-radius: 50%; vertical-align: middle; }
.user-selector-compact .user-pill {
padding: 0.3em 0.6em; font-size: 0.8rem;
display: inline-flex; align-items: center;
border-radius: var(--pico-border-radius);
margin-right: 0.25rem; margin-bottom: 0.25rem; /* Ensure pills wrap nicely */
}
.user-selector-compact .user-pill .btn-close-custom {
background: none; border: none; color: inherit;
padding: 0 0.25rem; margin-left: 0.4rem;
font-size: 1rem; line-height: 1;
cursor: pointer; opacity: 0.7;
}
.user-selector-compact .user-pill .btn-close-custom:hover { opacity: 1; }
.visually-hidden {
border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px;
overflow: hidden; padding: 0; position: absolute; width: 1px;
}
</style>
<% end %>

View file

@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js"

View file

@ -19,6 +19,8 @@ Rails.application.routes.draw do
namespace :admin do
get 'timeline', to: 'timeline#show', as: :timeline
get 'timeline/search_users', to: 'timeline#search_users'
get 'timeline/leaderboard_users', to: 'timeline#leaderboard_users'
end
if Rails.env.development?

File diff suppressed because one or more lines are too long