pull out picocss and redo admin timeline

This commit is contained in:
Echo
2025-06-24 16:23:59 -04:00
parent c45e0fc043
commit e986c2debe
4 changed files with 294 additions and 619 deletions

View File

@@ -1,248 +0,0 @@
.admin-timeline-view-wrapper {
overflow-x: auto; /* Enables horizontal scrolling */
width: 100%; /* Takes full width of its parent in the main layout */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
flex-grow: 1; /* Allow it to take available vertical space in flex layouts */
display: flex; /* Needed for flex-grow to work properly with its child */
flex-direction: column;
}
.admin-timeline-content-sizer {
/* min-width is set inline dynamically via ERB based on number of users and min_column_width_px */
display: block; /* Or inline-block; block should be fine to ensure it respects min-width */
/* This element will be wider than its parent if content requires it, triggering scroll on admin-timeline-view-wrapper */
}
.admin-timeline-sticky-header {
/* Changed structure to use simple absolute positioning, no more flex layout */
position: sticky; /* Sticky to the top of admin-timeline-view-wrapper */
top: 0;
z-index: 20; /* Above other content */
background-color: #1F2937; /* Match page background to avoid transparency issues */
padding-bottom: 0.5rem;
padding-top: 0.25rem;
box-sizing: border-box;
/* No padding left/right as we use absolute positioning for the headers */
}
/* Removed header spacer and container styles as they're no longer used */
.admin-timeline-user-header-cell {
position: absolute; /* Positioned absolutely within the sticky header container */
/* left is set inline per instance - using the EXACT same calculation as activity spans */
top: 0; /* All headers start at the top of the container */
min-width: 186px; /* Minimum width for each header cell */
width: 186px; /* Fixed width set inline to match span columns exactly */
box-sizing: border-box; /* Include padding and border in width/height */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-weight: 500;
color: #E5E7EB;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.1rem 0.25rem; /* Padding for content */
}
#timeline-grid-scroll-container { /* Original ID, holds hour rows and activity spans */
overflow-y: auto; /* Retains vertical scroll for hours */
position: relative; /* For absolute positioning of spans and current time line */
flex-grow: 1; /* Takes remaining vertical space within admin-timeline-content-sizer */
/* Width is implicitly 100% of admin-timeline-content-sizer, which can be very wide */
}
/* Span items representing user activity */
.admin-timeline-span-item {
position: absolute;
/* background-color is set by --span-color CSS variable, applied inline */
color: #FFFFFF;
border-radius: 0.25rem;
font-size: 0.75rem;
line-height: 1.1;
padding: 2px 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
z-index: 10; /* Above grid lines */
overflow: hidden; /* Clip content that overflows */
box-sizing: border-box;
min-width: 186px; /* Minimum width for each span "column" */
width: 186px; /* Fixed width set inline to match exactly with headers */
}
.admin-timeline-span-item a { /* Ensure links inside spans are visible and styled */
color: inherit; /* Inherit color from parent span */
text-decoration: underline;
}
.admin-timeline-span-item a:hover {
color: #FACC15; /* Example: yellow hover for links */
}
/* Styling for the hour rows that form the timeline background grid */
.admin-timeline-hour-row {
display: flex; /* To layout hour label container and gridline container */
align-items: center;
border-top: 1px solid #374151; /* Horizontal line for the hour */
position: relative; /* For the absolutely positioned detailed grid line */
box-sizing: border-box;
/* height for each hour row is set inline via ERB (pixels_per_hour) */
}
.admin-timeline-hour-label-container { /* Container for the hour text, e.g., "9:00 AM" */
/* width for this container is set inline via ERB (line_left_rem) */
flex-shrink: 0; /* Prevent shrinking */
font-size: 0.75rem;
line-height: 1rem;
color: #6B7280;
padding-right: 0.5rem;
text-align: right;
box-sizing: border-box;
height: 100%; /* Match parent row height */
display: flex; /* To vertically center text if needed, though align-items on parent might suffice */
align-items: flex-start; /* Vertically center text */
}
.admin-timeline-hour-gridline-container { /* Container that stretches across for the faint mid-hour line */
flex-grow: 1; /* Take remaining width */
height: 100%; /* Fill the row height */
position: relative; /* For the absolutely positioned line itself */
}
.admin-timeline-hour-gridline { /* The faint horizontal line in the middle of an hour block */
position: absolute;
/* left: 0; right: (calculated from line_right_rem) are applied inline */
top: 50%;
border-bottom: 1px solid #374151; /* Color of the grid line */
transform: translateY(-50%);
z-index: 1; /* Behind activity spans */
}
/* Current time indicator line */
.admin-timeline-now-marker {
position: absolute;
/* left, right, top positions are set inline via ERB */
height: 2px;
background-color: #F87171; /* Prominent color for "now" line */
z-index: 15; /* Above grid lines, potentially above spans if desired (adjust z-index accordingly) */
}
.admin-timeline-now-marker-text { /* For the "NOW" text label */
position: absolute;
left: -2.5rem; /* Adjust to position text to the left of the line_left_rem area */
top: -0.4rem; /* Adjust to vertically center with the line */
font-size: 0.65rem;
color: #F87171; /* Match line color */
background-color: #1F2937; /* Match page background to make it appear to "cut through" */
padding: 0 0.2rem;
white-space: nowrap;
}
.user-trust-red {
background-color: rgba(239, 68, 68, 0.15) !important;
border-left: 3px solid rgb(239, 68, 68) !important;
}
.user-trust-green {
background-color: rgba(16, 185, 129, 0.15) !important;
border-left: 3px solid rgb(16, 185, 129) !important;
}
.user-trust-yellow {
background-color: rgba(245, 158, 11, 0.15) !important;
border-left: 3px solid rgb(245, 158, 11) !important;
}
.user-trust-blue {
background-color: rgba(59, 130, 246, 0.1) !important;
border-left: 3px solid rgb(59, 130, 246) !important;
}
.user-trust-indicator {
display: inline-block;
margin-left: 4px;
font-size: 0.875rem;
}
.conviction-hammer {
cursor: pointer;
margin-left: 6px;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s;
}
.conviction-hammer:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.cm {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cm-content {
background-color: #1F2937;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 500px;
}
.cm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #374151;
}
.cm-header h3 {
margin: 0;
color: #F3F4F6;
font-size: 1.25rem;
}
.cm-close {
background: none;
border: none;
color: #9CA3AF;
font-size: 1.5rem;
cursor: pointer;
}
.cm-body {
padding: 16px;
color: #D1D5DB;
}
.cos {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 16px;
}
.co {
background-color: #2D3748;
border: 1px solid #4B5563;
border-radius: 6px;
padding: 12px;
color: #E5E7EB;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
text-align: center;
}
.co:hover {
background-color: #374151;
}

View File

@@ -52,6 +52,9 @@
.bg-dark {
background-color: var(--color-dark);
}
.bg-darkless {
background-color: var(--color-darkless);
}
.bg-sheet {
background-color: var(--color-sheet);
}

View File

@@ -28,8 +28,7 @@ export default class extends Controller {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')
.content,
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ trust_level: trustLevel }),
});
@@ -40,10 +39,7 @@ export default class extends Controller {
);
}
// Update the current trust level in the dataset
event.target.dataset.currentTrustLevel = trustLevel;
// Update the leaderboard entry's omitted class
const leaderboardEntry = event.target.closest(".leaderboard-entry");
if (leaderboardEntry) {
if (trustLevel === "red") {

View File

@@ -13,35 +13,10 @@
num_users = users_data_array.count
num_users = 1 if num_users == 0 # Ensure num_users is at least 1 for calculations
# Fixed REM values for layout (assuming 1rem = 16px for px calculations)
line_left_rem = 4.0
line_right_rem = 0.5
activity_col_area_start_rem = line_left_rem # Header spacer aligns with hour labels
activity_col_area_end_rem = line_right_rem # Header spacer aligns with right padding of grid
gutter_rem = 0.25
pixels_per_hour = 128 # Controls vertical scale
pixels_per_minute = pixels_per_hour / 60.0
min_column_width_px = 186 # Minimum width for each user column
gutter_px = gutter_rem * 16 # Gutter in pixels
# Calculate the total minimum width required for all user columns + gutters + fixed label/padding areas
# This width will be applied to the admin-timeline-content-sizer div
user_columns_total_min_width_px = (num_users * min_column_width_px)
gutters_total_width_px = (num_users > 1 ? (num_users - 1) * gutter_px : 0)
# Total min width for the content that scrolls (headers part)
min_header_content_width_px = user_columns_total_min_width_px + gutters_total_width_px
# Total min width for the grid content part (timeline-grid-scroll-container's content)
# This area includes the hour labels on the left, then the user activity columns, then right padding
min_grid_content_width_px = (line_left_rem * 16) + min_header_content_width_px + (line_right_rem * 16)
# The sizer div needs to be at least as wide as the widest of its direct children (header row or grid row)
# The header row's actual content part (user headers) spans min_header_content_width_px.
# 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)
gutter_px = 4
# Current admin user and selected users for Stimulus
current_admin_user = {
@@ -55,189 +30,179 @@
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;">
<!-- User Selector UI -->
<div class=" text-white flex flex-col font-sans min-h-screen">
<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"
class="mb-4 p-3 bg-dark rounded-md flex-shrink-0"
>
<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/id..."
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 class="flex items-center gap-3">
<div class="flex-1 relative">
<input type="text"
id="user-search-input"
placeholder="Add user by name/email/id..."
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"
class="w-full px-3 py-2 bg-darker rounded-md text-white placeholder-gray-300 focus:outline-none focus:border-transparent text-sm">
<ul class="absolute top-full left-0 w-full bg-dark border border-gray-600 rounded-b-md z-50 max-h-48 overflow-y-auto hidden"
data-admin-timeline-user-selector-target="searchResults">
<%# Search results will appear here %>
</ul>
</div>
<button type="button"
class="px-3 py-2 bg-darker rounded-md text-sm text-white transition-colors"
data-action="admin-timeline-user-selector#applyPreset"
data-period="today">
Top 15 Today
</button>
<button type="button"
class="px-3 py-2 bg-darker rounded-md text-sm text-white transition-colors"
data-action="admin-timeline-user-selector#applyPreset"
data-period="last_7_days">
Top 15 Week
</button>
<button type="submit"
class="px-4 py-2 bg-green-600 rounded-md text-sm text-white transition-colors font-medium">
View
</button>
</div>
<div class="mt-2" data-admin-timeline-user-selector-target="selectedUsersContainer" style="margin-top: 0.5rem; min-height: 28px;">
<div class="mt-2 min-h-7" data-admin-timeline-user-selector-target="selectedUsersContainer">
<%# 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;">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<div class="text-lg font-semibold">
<%= @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", 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 class="flex gap-2">
<%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
<%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
<%= link_to "Next →", admin_timeline_path(date: @next_date.to_s),
class: "px-3 py-1 bg-darker rounded text-sm transition-colors",
data: { "date-nav-link": "true" } %>
</div>
</div>
<!-- Horizontal Scroll Wrapper -->
<div class="admin-timeline-view-wrapper">
<!-- Inner Content Sizer: This div gets the calculated min-width -->
<div class="admin-timeline-content-sizer" style="min-width: <%= total_min_scroll_width_px %>px;">
<div class="flex-1 overflow-x-auto overflow-y-auto">
<% if users_data_array.any? %>
<%
hour_label_width = 80
grid_width = hour_label_width + (users_data_array.count * (min_column_width_px + gutter_px))
grid_height = 120 + (24 * pixels_per_hour)
%>
<!-- User Headers Section (Sticky) - Using position: absolute for exact alignment -->
<% if users_data_array.any? %>
<div class="admin-timeline-sticky-header" style="padding-left: 0; padding-right: 0;">
<div style="position: relative; width: 100%; height: 5rem;">
<% users_data_array.each_with_index do |data, index| %>
<% user = data[:user] %>
<% total_coded_time_seconds = data[:total_coded_time] %>
<%
# Calculate the base offset for this column (without the hour label padding)
# This ensures evenly spaced columns considering gutters
base_offset_px = index * min_column_width_px + (index > 0 ? index * gutter_px : 0)
# Add the hour label padding to get the absolute left position - EXACT SAME CALCULATION AS SPANS
header_left_px = (line_left_rem * 16) + base_offset_px
%>
<%
trust_level_class = ""
if user.respond_to?(:trust_level)
case user.trust_level
when "red"
trust_level_class = "user-trust-red"
when "green"
trust_level_class = "user-trust-green"
when "yellow"
trust_level_class = "user-trust-yellow"
end
end
%>
<div class="admin-timeline-user-header-cell <%= trust_level_class %>"
data-user-id="<%= user.id %>"
data-controller="user-conviction"
style="width: <%= min_column_width_px %>px; position: absolute; left: <%= header_left_px %>px;"
title="User ID: <%= user.id %> - <%= user.respond_to?(:username) && user.username.present? ? user.username : user.email_addresses.first&.email %> | Total Coded: <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? short_time_detailed(total_coded_time_seconds) : '0m' %> | TZ: <%= user.timezone %>">
<% github_url = user.respond_to?(:github_profile_url) ? user.github_profile_url : nil %>
<%= render "shared/user_mention", user: user %>
<div style="margin-top: 0.25rem; display: flex; gap: 0.5rem;">
<% if current_user && user != current_user && user.slack_uid.present? %>
<%= link_to "Slack", "slack://user?team=T0266FRGM&id=#{user.slack_uid}", target: "_blank", style: "font-size: 0.7rem; color: #9CA3AF; text-decoration: underline;" %>
<% end %>
<% if github_url.present? %>
<%= link_to "Git", github_url, target: "_blank", style: "font-size: 0.7rem; color: #9CA3AF; text-decoration: underline;" %>
<% end %>
<% if current_user && current_user.admin? && user != current_user %>
<a href="#"
data-action="click->user-conviction#convictUser"
data-user-id="<%= user.id %>"
class="conviction-hammer"
style="font-size: 0.7rem; color: #9CA3AF; text-decoration: none;"
title="Set trust level">🔨</a>
<% end %>
<% if user.respond_to?(:trust_level) %>
<span class="user-trust-indicator" data-user-conviction-target="trustIndicator" title="<%= user.trust_level.capitalize %>">
<% case user.trust_level %>
<% when "red" %>
🔴
<% when "green" %>
🟢
<% when "yellow" %>
<20>
<% when "blue" %>
🔵
<% end %>
</span>
<% else %>
<span class="user-trust-indicator" data-user-conviction-target="trustIndicator"></span>
<% end %>
</div>
<% if total_coded_time_seconds && total_coded_time_seconds > 0 %>
<div style="font-size: 0.7rem; color: #cbd5e0; margin-top: 0.1rem; line-height: 1;">
<%= short_time_simple(total_coded_time_seconds) %> coded
</div>
<% else %>
<div style="font-size: 0.7rem; color: #718096; margin-top: 0.1rem; line-height: 1;">
No time coded
</div>
<% end %>
<div style="font-size: 0.75rem; color: #9CA3AF; margin-top: 0.125rem; line-height: 1.1;"><%= user.timezone %></div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Timeline Grid and Spans Section -->
<div id="timeline-grid-scroll-container" style="overflow: hidden;">
<%# Hour markers and lines (background grid) %>
<% (timeline_start_hour..timeline_end_hour).each do |hour| %>
<div class="admin-timeline-hour-row" style="height: <%= pixels_per_hour %>px;">
<div class="admin-timeline-hour-label-container" style="width: <%= line_left_rem %>rem;">
<%= Time.utc(2000,1,1, hour).strftime("%-l:00 %p") %>
</div>
<div class="admin-timeline-hour-gridline-container">
<%# The actual line is styled to span the container using absolute positioning from CSS %>
<div class="admin-timeline-hour-gridline" style="left:0; right: <%= line_right_rem %>rem;"></div>
<div class="relative" style="width: <%= grid_width %>px; height: <%= grid_height %>px;">
<% (0..23).each do |hour| %>
<%
hour_top = 120 + (hour * pixels_per_hour)
formatted_hour = Time.utc(2000,1,1, hour).strftime("%-l:00 %p")
%>
<div class="absolute left-0 w-full border-t border-gray-600"
style="top: <%= hour_top %>px; height: <%= pixels_per_hour %>px;">
<div class="absolute left-2 top-2 text-xs text-gray-300 font-mono px-1">
<%= formatted_hour %>
</div>
</div>
<% end %>
<%# Current Time Line Indicator %>
<% users_data_array.each_with_index do |data, index| %>
<%
user = data[:user]
total_coded_time_seconds = data[:total_coded_time]
column_left = hour_label_width + (index * (min_column_width_px + gutter_px))
trust_level_emoji = case user.respond_to?(:trust_level) ? user.trust_level : nil
when "red" then "🔴"
when "green" then "🟢"
when "yellow" then "🟡"
else "🔵"
end
trust_level_bg = case user.respond_to?(:trust_level) ? user.trust_level : nil
when "red" then "bg-red-500/20"
when "green" then "bg-green-500/20"
when "yellow" then "bg-yellow-500/20"
when "blue" then "bg-blue-500/20"
else "bg-gray-500/20"
end
%>
<div class="absolute border-r border-gray-700"
style="left: <%= column_left %>px; width: <%= min_column_width_px %>px; top: 120px; bottom: 0;">
</div>
<div class="absolute top-0 p-3 rounded-lg shadow-lg <%= trust_level_bg %>"
data-user-id="<%= user.id %>"
style="left: <%= column_left + 2 %>px; width: <%= min_column_width_px - 4 %>px;"
title="User ID: <%= user.id %> - <%= user.respond_to?(:username) && user.username.present? ? user.username : user.email_addresses.first&.email %> | Total Coded: <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? short_time_detailed(total_coded_time_seconds) : '0m' %> | TZ: <%= user.timezone %>">
<div class="flex items-center space-x-1 mb-1">
<%= render "shared/user_mention", user: user %>
</div>
<div class="flex justify-center items-center gap-4 mb-1 text-center">
<% if current_user && user != current_user && user.slack_uid.present? %>
<div>
<%= link_to "Slack", "slack://user?team=T0266FRGM&id=#{user.slack_uid}",
target: "_blank",
class: "text-xs text-blue-400 hover:text-blue-300 underline" %>
</div>
<% end %>
<% if user.respond_to?(:github_profile_url) && user.github_profile_url.present? %>
<div>
<%= link_to "Git", user.github_profile_url,
target: "_blank",
class: "text-xs text-green-400 hover:text-green-300 underline" %>
</div>
<% end %>
<div>
<span class="text-sm"><%= trust_level_emoji %></span>
</div>
<% if current_user && current_user.admin? && user != current_user %>
<div>
<button data-action="openModal"
data-user-id="<%= user.id %>"
class="text-xs text-gray-300 hover:text-white"
title="Set trust level">🔨</button>
</div>
<% end %>
</div>
<div class="text-sm font-medium mb-1 <%= total_coded_time_seconds && total_coded_time_seconds > 0 ? 'text-green-300' : 'text-gray-400' %>">
<%= total_coded_time_seconds && total_coded_time_seconds > 0 ? "#{short_time_simple(total_coded_time_seconds)} coded" : "No time coded" %>
</div>
<div class="text-xs text-gray-300">
<%= user.timezone %>
</div>
</div>
<% end %>
<%
current_time_in_zone = Time.current.in_time_zone(primary_user_tz)
is_today = @date == Time.current.in_time_zone(primary_user_tz).to_date
show_current_time_line = is_today && current_time_in_zone.hour >= timeline_start_hour && current_time_in_zone.hour < (timeline_end_hour + 1)
if show_current_time_line
minutes_from_timeline_display_start_for_now = (current_time_in_zone.hour - timeline_start_hour) * 60 + current_time_in_zone.min
current_time_line_top_px = (minutes_from_timeline_display_start_for_now * pixels_per_minute)
minutes_from_timeline_start = (current_time_in_zone.hour - timeline_start_hour) * 60 + current_time_in_zone.min
current_time_line_top_px = 120 + (minutes_from_timeline_start * pixels_per_minute)
end
%>
<% if show_current_time_line %>
<div class="admin-timeline-now-marker" style="left: <%= line_left_rem %>rem; right: <%= line_right_rem %>rem; top: <%= current_time_line_top_px %>px;">
<div class="admin-timeline-now-marker-text">NOW</div>
<div class="absolute w-full h-0.5 bg-red-500 z-300 flex items-center"
style="left: <%= hour_label_width %>px; right: 0; top: <%= current_time_line_top_px %>px;">
<div class="bg-red-500 text-white text-xs px-2 py-1 rounded -ml-16">NOW</div>
</div>
<% end %>
<%# Logic for calculating span properties (remains mostly the same) %>
<%
calculate_span_properties = lambda do |span_data, span_user_tz|
return nil unless span_data && span_data[:start_time] && span_data[:duration]
@@ -254,7 +219,7 @@
duration_seconds_in_view = effective_end_time - effective_start_time
height_px = (duration_seconds_in_view / 60.0) * pixels_per_minute
return nil if height_px <= 0.5
final_top_px = (minutes_from_view_start * pixels_per_minute).round
final_top_px = 120 + (minutes_from_view_start * pixels_per_minute).round
title_parts = []
title_parts << "Languages: #{span_data[:languages].join(', ')}" if span_data[:languages]&.any?
project_title_segments = (span_data[:projects_edited_details] || []).map do |proj_detail|
@@ -282,37 +247,31 @@
}
end
%>
<%# Activity Spans %>
<% users_data_array.each_with_index do |data, index| %>
<%
user = data[:user]
user_spans = data[:spans]
block_color = user_colors[index % user_colors.length]
# Calculate the base offset for this column (without the hour label padding)
# This ensures evenly spaced columns considering gutters
base_offset_px = index * min_column_width_px + (index > 0 ? index * gutter_px : 0)
# Add the hour label padding to get the absolute left position
current_span_column_left_px = (line_left_rem * 16) + base_offset_px
column_left = hour_label_width + (index * (min_column_width_px + gutter_px))
%>
<% Array(user_spans).each do |span_data| %>
<% props = calculate_span_properties.call(span_data, user.timezone || primary_user_tz) %>
<% next unless props %>
<div class="admin-timeline-span-item"
style="--span-color: <%= block_color %>;
background-color: var(--span-color);
left: <%= current_span_column_left_px %>px;
width: <%= min_column_width_px %>px;
<div class="absolute rounded-md p-2 text-white text-xs overflow-hidden z-10"
style="background-color: <%= block_color %>;
left: <%= column_left + 2 %>px;
width: <%= min_column_width_px - 4 %>px;
top: <%= props[:final_top_px] %>px;
height: <%= props[:height_px] %>px;"
title="<%= props[:title] %>">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<div class="font-medium truncate">
<% if props[:projects_to_display]&.any? %>
<% props[:projects_to_display].each_with_index do |project_detail, p_idx| %>
<% if project_detail[:repo_url].present? %>
<a href="<%= project_detail[:repo_url] %>" target="_blank" rel="noopener noreferrer" title="Open <%= project_detail[:name] %> on GitHub"><%= project_detail[:name].truncate(20) %></a>
<a href="<%= project_detail[:repo_url] %>" target="_blank" rel="noopener noreferrer"
class="text-white hover:text-gray-200 underline"
title="Open <%= project_detail[:name] %> on GitHub"><%= project_detail[:name].truncate(20) %></a>
<% else %>
<span title="<%= project_detail[:name] %> - No GitHub Repo Mapped"><%= project_detail[:name].truncate(20) %> 🚫</span>
<% end %>
@@ -324,16 +283,15 @@
<span>Coding Activity</span>
<% end %>
</div>
<div style="font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<div class="truncate opacity-90">
<%= props[:display_text_line2] %>
</div>
<div style="font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB;">
<div class="truncate opacity-75">
<%= props[:display_text_line3] %>
</div>
</div>
<% end %>
<% end %>
<% @timeline_commit_markers.each do |commit| %>
<% user_index = users_data_array.find_index { |data| data[:user].id == commit[:user_id] } %>
<% next unless user_index %>
@@ -341,187 +299,153 @@
<% user_tz = user&.timezone || primary_user_tz %>
<% today_start_of_day_for_user = @date.in_time_zone(user_tz).beginning_of_day %>
<% view_start_datetime = today_start_of_day_for_user.advance(hours: timeline_start_hour) %>
<% base_offset_px = user_index * min_column_width_px + (user_index > 0 ? user_index * gutter_px : 0) %>
<% pill_left_px = (line_left_rem * 16) + base_offset_px + min_column_width_px - 60 %> <!-- right align, adjust as needed -->
<% column_left = hour_label_width + (user_index * (min_column_width_px + gutter_px)) %>
<% pill_left_px = column_left + 93 %>
<% commit_minutes_from_view_start = ((Time.at(commit[:timestamp]).in_time_zone(user_tz) - view_start_datetime) / 60.0).to_f %>
<% commit_top_px = (commit_minutes_from_view_start * pixels_per_minute).round %>
<% commit_top_px = 120 + (commit_minutes_from_view_start * pixels_per_minute).round %>
<a href="<%= commit[:github_url] %>" target="_blank" rel="noopener noreferrer"
title="<%= commit[:message] %>"
class="timeline-commit-pill"
style="position: absolute;
left: <%= pill_left_px %>px;
top: <%= commit_top_px %>px;
z-index: 20;
background: #222;
color: #fff;
border-radius: 12px;
padding: 0px 7px;
font-size: 0.72em;
border: 1px solid rgba(255,255,255,0.1);
text-decoration: none;
box-shadow: 0 1px 4px rgba(0,0,0,0.10);">
<span style="<%= commit[:additions].to_i > 0 ? 'color: #22c55e; font-weight: 600;' : '' %>">+<%= commit[:additions] %></span>
class="absolute z-20 bg-darker text-white rounded-full px-2 py-1 text-xs hover:bg-gray-800 transition-colors"
style="left: <%= pill_left_px %>px; top: <%= commit_top_px %>px; transform: translateX(-50%);">
<span class="<%= commit[:additions].to_i > 0 ? 'text-green-400 font-semibold' : '' %>">+<%= commit[:additions] %></span>
/
<span style="<%= commit[:deletions].to_i > 0 ? 'color: #ef4444; font-weight: 600;' : '' %>">-<%= commit[:deletions] %></span>
<span class="<%= commit[:deletions].to_i > 0 ? 'text-red-400 font-semibold' : '' %>">-<%= commit[:deletions] %></span>
</a>
<% end %>
</div> <!-- End #timeline-grid-scroll-container -->
</div> <!-- End .admin-timeline-content-sizer -->
</div> <!-- End .admin-timeline-view-wrapper -->
</div> <!-- End Main Page Container -->
</div>
<% else %>
<div class="flex items-center justify-center h-64 text-gray-400">
<div class="text-center">
<div class="text-lg mb-2">No users selected</div>
<div class="text-sm">Use the search bar above to add users to the timeline</div>
</div>
</div>
<% end %>
</div>
<div id="trust-level-modal" class="fixed inset-0 items-center justify-center hidden z-50">
<div class="bg-darker rounded-lg shadow-xl max-w-sm w-full mx-4">
<div class="flex justify-between items-center p-4 border-b border-gray-800">
<h3 class="text-base font-semibold text-gray-100">Set Trust Level</h3>
<button id="close-modal" class="text-gray-400 hover:text-white text-xl">×</button>
</div>
<div class="p-5">
<div id="trust-level-status" class="hidden mb-4 p-3 rounded text-sm"></div>
<ul class="space-y-2">
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="green">
🟢 Green - Trusted
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="yellow">
🟡 Yellow - Suspected
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="red">
🔴 Red - Convicted (banned)
</button>
</li>
<li>
<button class="trust-option w-full text-left px-3 py-2 rounded bg-darkless text-gray-100" data-level="blue">
🔵 Blue - Unscored
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('trust-level-modal');
const closeModal = document.getElementById('close-modal');
const sdiv = document.getElementById('trust-level-status');
let currentUserId = null;
function report(message, type = 'i') {
sdiv.className = `mb-4 p-3 rounded-lg text-sm ${type === 's' ? 'bg-green-500/20 border border-green-500 text-green-300' :
type === 'e' ? 'bg-red-500/20 border border-red-500 text-red-300' :
'bg-blue-500/20 border border-blue-500 text-blue-300'}`;
sdiv.textContent = message;
sdiv.classList.remove('hidden');
}
function hide() {
sdiv.classList.add('hidden');
}
function pause() {
document.querySelectorAll('.trust-option').forEach(btn => btn.disabled = true);
}
function resume() {
document.querySelectorAll('.trust-option').forEach(btn => btn.disabled = false);
}
document.addEventListener('click', function(e) {
if (e.target.closest('[data-action="openModal"]')) {
e.preventDefault();
currentUserId = e.target.dataset.userId;
hide();
resume();
modal.classList.remove('hidden');
modal.style.display = 'flex';
}
});
function close() {
modal.classList.add('hidden');
modal.style.display = 'none';
hide();
resume();
}
closeModal.addEventListener('click', close);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
close();
}
});
document.querySelectorAll('.trust-option').forEach(button => {
button.addEventListener('click', async function() {
const level = this.dataset.level;
if (!currentUserId || !level) return;
pause();
report('Updating trust level...', 'i');
try {
console.log("set trust", currentUserId, "to:", level);
const url = new URL(
`/users/${currentUserId}/update_trust_level`,
window.location.origin
);
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ trust_level: level })
});
<% 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;
}
.user-trust-red {
background-color: rgba(220, 38, 38, 0.15);
border-left: 3px solid #DC2626;
}
.user-trust-green {
background-color: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10B981;
}
.user-trust-yellow {
background-color: rgba(245, 158, 11, 0.08);
border-left: 3px solid #F59E0B;
}
.user-trust-indicator {
font-size: 0.75rem;
margin-left: 0.25rem;
}
.cm {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.cm-content {
background-color: #2D3748;
border-radius: 0.5rem;
width: 95%;
max-width: 500px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
color: white;
}
.cm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #4A5568;
}
.cm-header h3 {
margin: 0;
font-size: 1.25rem;
}
.cm-close {
background: none;
border: none;
font-size: 1.5rem;
color: #CBD5E0;
cursor: pointer;
}
.cm-body {
padding: 1.5rem;
}
.cos {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.co {
background-color: #4A5568;
border: none;
border-radius: 0.375rem;
padding: 0.75rem;
color: white;
text-align: left;
cursor: pointer;
transition: background-color 0.2s;
}
.tln {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #38A169;
color: white;
padding: 1rem;
border-radius: 0.375rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
animation: f 3s ease-in-out;
}
@keyframes f {
0% { opacity: 0; transform: translateY(20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
</style>
<% end %>
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}`
);
}
report('done, reloading...', 's');
close();
location.reload();
} catch (error) {
console.error('you done did it', error);
report('check console', 'e');
resume();
}
});
});
});
</script>