Make dashboard into partial (#127)

* Initial work to break out dashboard into own partial

* Fix chart initialization on filter change

* Factor out multiselect css & js

* Clean up js & css in filterable dashboard

* Refactor filter searching

* Fix projects filter

* Prevent race condition in dashboard async loading

* Move dashboard css / js out of user/show

* Move filterable dashboards over to homepage

* Clean up unused user controller routes
This commit is contained in:
Max Wofford
2025-03-26 01:54:00 -04:00
committed by GitHub
parent 80bf84f623
commit c4c8e330ab
12 changed files with 1195 additions and 1358 deletions

View File

@@ -0,0 +1,517 @@
.filter .options-container {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: rgba(30, 33, 37, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
margin-top: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 1000;
padding: 0;
}
.filter .search-input {
width: 100%;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px;
margin: 0;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
border-radius: 0;
height: auto;
}
.filter .search-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.filter .search-input:focus {
outline: none;
box-shadow: none;
border-color: rgba(255, 255, 255, 0.2);
}
.filter .options-list {
max-height: 200px;
overflow-y: auto;
padding: 0;
margin: 0;
}
.filter .option {
display: flex;
align-items: center;
padding: 6px 8px;
cursor: pointer;
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
background: transparent;
}
.filter .option:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.filter .option input[type="checkbox"] {
margin-right: 8px;
margin-bottom: 0;
height: 14px;
width: 14px;
min-width: 14px;
appearance: none;
-webkit-appearance: none;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(255, 255, 255, 0.1);
position: relative;
cursor: pointer;
padding: 0;
}
.filter .option input[type="checkbox"]:checked {
background: #3291ff;
border-color: #3291ff;
}
.filter .option input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter .option input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.5);
}
.filter .select-header-container {
display: flex;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.05);
margin: 0;
padding: 0;
}
.filter .select-header {
flex: 1;
padding: 6px 8px;
font-size: 13px;
cursor: pointer;
user-select: none;
color: rgba(255, 255, 255, 0.7);
margin: 0;
background: transparent;
}
.filter .clear-button {
padding: 6px 8px;
font-size: 16px;
line-height: 1;
color: rgba(255, 255, 255, 0.4);
background: none;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
margin: 0;
}
.filter .clear-button:hover {
color: #ff4444;
background-color: rgba(255, 255, 255, 0.05);
}
.filter .option.hidden {
display: none;
}
@media (prefers-color-scheme: light) {
.filter .options-container {
background: white;
border: 1px solid #ddd;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter .search-input {
border-bottom: 1px solid #eee;
color: var(--text-color);
}
.filter .search-input::placeholder {
color: #999;
}
.filter .option {
color: var(--text-color);
}
.filter .option:hover {
background-color: #f5f5f5;
}
.filter .select-header-container {
border: 1px solid #ddd;
background-color: white;
}
.filter .select-header {
color: #666;
}
.filter .clear-button {
color: #666;
border-left: 1px solid #ddd;
}
.filter .clear-button:hover {
color: #ff4444;
background-color: rgba(0, 0, 0, 0.05);
}
.filter .option input[type="checkbox"] {
border: 1px solid rgba(0, 0, 0, 0.2);
background: rgba(0, 0, 0, 0.05);
}
.filter .option input[type="checkbox"]:checked {
background: #3291ff;
border-color: #3291ff;
}
.filter .option input[type="checkbox"]:hover {
border-color: rgba(0, 0, 0, 0.4);
}
}
.dashboard-wrapper {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.dashboard-wrapper .stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.dashboard-wrapper .dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.dashboard-wrapper .stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.2s ease;
}
.dashboard-wrapper .stat-label {
color: rgba(255, 255, 255, 0.5);
font-size: 0.5rem;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.dashboard-wrapper .stat-value {
font-size: 1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.dashboard-wrapper .card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.dashboard-wrapper .card h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.dashboard-wrapper .chart-container {
flex: 1;
position: relative;
height: 140px;
}
.dashboard-wrapper .chart-container canvas {
max-height: 100%;
width: 100%;
}
.dashboard-wrapper .bar-graph {
margin-top: 1rem;
}
.dashboard-wrapper .bar-row {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.dashboard-wrapper .bar-label {
width: 150px;
text-align: right;
padding-right: 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-wrapper .bar-container {
flex: 1;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.dashboard-wrapper .bar {
height: 100%;
background: #85e394;
border-radius: 4px;
position: relative;
transition: width 0.3s ease;
}
.dashboard-wrapper .bar-value {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.8);
}
.dashboard-wrapper .no-data {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
@media (prefers-color-scheme: light) {
.dashboard-wrapper .stat-card {
background: var(--smoke);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.dashboard-wrapper .stat-label {
color: #666;
}
.dashboard-wrapper .stat-value {
color: var(--text-color);
}
.dashboard-wrapper .card {
background: white;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dashboard-wrapper .card h2 {
color: var(--text-color);
}
.dashboard-wrapper .bar-label {
color: var(--text-color);
}
.dashboard-wrapper .bar-container {
background: var(--smoke);
}
.dashboard-wrapper .bar-value {
color: var(--text-inverse);
}
.dashboard-wrapper .no-data {
color: #666;
}
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.content {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: bold;
margin-bottom: 1.5rem;
}
.filters-section {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters {
margin-bottom: 2rem;
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
}
.filter-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.filter {
flex: 1;
min-width: 150px;
position: relative;
}
.filter-label {
display: block;
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: #666;
}
.custom-select {
position: relative;
width: 100%;
}
.select-header-container {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
.select-header {
flex: 1;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
user-select: none;
}
.clear-button {
padding: 0.4rem 0.5rem;
font-size: 1rem;
line-height: 1;
color: #666;
background: none;
border: none;
border-left: 1px solid #ddd;
cursor: pointer;
display: none;
}
.clear-button:hover {
color: #ff4444;
background-color: rgba(0, 0, 0, 0.05);
}
.options-container {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.custom-select.active .options-container {
display: block;
}
.option {
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.8rem;
}
.option:hover {
background-color: #f5f5f5;
}
.option input[type="checkbox"] {
margin-right: 0.5rem;
transform: scale(0.9);
}
.time-filter {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.calendar-icon {
font-size: 1.1rem;
}
.dropdown-arrow {
font-size: 0.8rem;
color: #666;
}
#dashboard-content {
min-height: 200px;
width: 100%;
}
#dashboard-content.loading .dashboard-wrapper {
filter: grayscale(1) opacity(0.7);
pointer-events: none;
transition: filter 0.2s ease;
}

View File

@@ -48,6 +48,11 @@ class StaticPagesController < ApplicationController
@todays_languages = language_counts.map(&:first)
@todays_editors = editor_counts.map(&:first)
@show_logged_time_sentence = @todays_languages.any? || @todays_editors.any?
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
else
@social_proof ||= begin
# Only run queries as needed, starting with the smallest time range
@@ -145,6 +150,24 @@ class StaticPagesController < ApplicationController
render partial: "currently_hacking", locals: locals
end
def filterable_dashboard
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
render partial: "filterable_dashboard"
end
def filterable_dashboard_content
cached_data = filterable_dashboard_data
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
render partial: "filterable_dashboard_content"
end
def 🃏
redirect_to root_path unless current_user && current_user.slack_uid.present?
@@ -220,4 +243,95 @@ class StaticPagesController < ApplicationController
"#{count_unique.to_s + ' Hack Clubber'.pluralize(count_unique)} set up Hackatime #{humanized_time_period}"
end
def filterable_dashboard_data
filters = %i[project language operating_system editor]
# Cache key based on user and filter parameters
cache_key = []
cache_key << current_user
filters.each do |filter|
cache_key << params[filter]
end
filtered_heartbeats = current_user.heartbeats
# Load filter options and apply filters with caching
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
result = {}
# Load filter options
filters.each do |filter|
group_by_time = current_user.heartbeats.group(filter).duration_seconds
result[filter] = group_by_time.sort_by { |k, v| v }
.reverse.map(&:first)
.compact_blank
if params[filter].present?
filter_arr = params[filter].split(",")
filtered_heartbeats = filtered_heartbeats.where(filter => filter_arr)
result["singular_#{filter}"] = filter_arr.length == 1
end
end
result[:filtered_heartbeats] = filtered_heartbeats
# Calculate stats for filtered data
result[:total_time] = filtered_heartbeats.duration_seconds
result[:total_heartbeats] = filtered_heartbeats.count
filters.each do |filter|
result["top_#{filter}"] = filtered_heartbeats.group(filter)
.duration_seconds
.max_by { |_, v| v }
&.first
end
# Prepare project durations data
result[:project_durations] = filtered_heartbeats
.group(:project)
.duration_seconds
.sort_by { |_, duration| -duration }
.first(10)
.to_h unless result["singular_project"]
# Prepare pie chart data
result[:language_stats] = filtered_heartbeats
.group(:language)
.duration_seconds
.sort_by { |_, duration| -duration }
.first(10)
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h unless result["singular_language"]
result[:editor_stats] = filtered_heartbeats
.group(:editor)
.duration_seconds
.sort_by { |_, duration| -duration }
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h unless result["singular_editor"]
result[:operating_system_stats] = filtered_heartbeats
.group(:operating_system)
.duration_seconds
.sort_by { |_, duration| -duration }
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h unless result["singular_operating_system"]
# Calculate weekly project stats for the last 6 months
result[:weekly_project_stats] = {}
(0..25).each do |week_offset| # 26 weeks = 6 months
week_start = week_offset.weeks.ago.beginning_of_week
week_end = week_offset.weeks.ago.end_of_week
week_stats = filtered_heartbeats
.where(time: week_start.to_f..week_end.to_f)
.group(:project)
.duration_seconds
result[:weekly_project_stats][week_start.to_date.iso8601] = week_stats
end
result
end
end
end

View File

@@ -57,134 +57,6 @@ class UsersController < ApplicationController
].sample
end
def show
# Use current_user for /my/home route, otherwise find by id
@user = if params[:id].present?
User.find(params[:id])
else
current_user
end
# Cache key based on user and filter parameters
cache_key = [
@user,
params[:projects],
params[:language],
params[:os],
params[:editor]
]
# Load filter options and apply filters with caching
cached_data = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
result = {}
# Load filter options
result[:projects] = @user.heartbeats.select(:project).distinct.order(:project).pluck(:project)
result[:languages] = @user.heartbeats.select(:language).distinct.order(:language).pluck(:language)
result[:operating_systems] = @user.heartbeats.select(:operating_system).distinct.order(:operating_system).pluck(:operating_system)
result[:editors] = @user.heartbeats.select(:editor).distinct.order(:editor).pluck(:editor)
# Apply filters to heartbeats
filtered_heartbeats = @user.heartbeats
filtered_heartbeats = filtered_heartbeats.where(project: params[:projects].split(",")) if params[:projects].present?
filtered_heartbeats = filtered_heartbeats.where(language: params[:language].split(",")) if params[:language].present?
filtered_heartbeats = filtered_heartbeats.where(operating_system: params[:os].split(",")) if params[:os].present?
filtered_heartbeats = filtered_heartbeats.where(editor: params[:editor].split(",")) if params[:editor].present?
result[:filtered_heartbeats] = filtered_heartbeats
# Calculate stats for filtered data
result[:total_time] = filtered_heartbeats.duration_seconds
result[:total_heartbeats] = filtered_heartbeats.count
result[:top_project] = filtered_heartbeats.group(:project).duration_seconds.max_by { |_, v| v }&.first
result[:top_language] = filtered_heartbeats.group(:language).duration_seconds.max_by { |_, v| v }&.first
result[:top_os] = filtered_heartbeats.group(:operating_system).duration_seconds.max_by { |_, v| v }&.first
result[:top_editor] = filtered_heartbeats.group(:editor).duration_seconds.max_by { |_, v| v }&.first
# Prepare project durations data
result[:project_durations] = filtered_heartbeats
.group(:project)
.duration_seconds
.sort_by { |_, duration| -duration }
.first(10)
.to_h
# Prepare pie chart data
result[:language_stats] = filtered_heartbeats
.group(:language)
.duration_seconds
.sort_by { |_, duration| -duration }
.first(10)
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h
result[:editor_stats] = filtered_heartbeats
.group(:editor)
.duration_seconds
.sort_by { |_, duration| -duration }
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h
result[:os_stats] = filtered_heartbeats
.group(:operating_system)
.duration_seconds
.sort_by { |_, duration| -duration }
.map { |k, v| [ k.presence || "Unknown", v ] }
.to_h
# Calculate weekly project stats for the last 6 months
result[:weekly_project_stats] = {}
(0..25).each do |week_offset| # 26 weeks = 6 months
week_start = week_offset.weeks.ago.beginning_of_week
week_end = week_offset.weeks.ago.end_of_week
week_stats = filtered_heartbeats
.where(time: week_start.to_f..week_end.to_f)
.group(:project)
.duration_seconds
result[:weekly_project_stats][week_start.to_date.iso8601] = week_stats
end
result
end
cached_data.entries.each do |key, value|
instance_variable_set("@#{key}", value)
end
respond_to do |format|
format.html do
if request.xhr?
render partial: "filterable_dashboard_content"
end
end
format.json do
render json: {
stats: {
total_time: ApplicationController.helpers.short_time_simple(@total_time),
total_heartbeats: number_with_delimiter(@total_heartbeats),
top_project: @top_project || "None",
top_language: @top_language || "Unknown",
top_os: @top_os || "Unknown",
top_editor: @top_editor || "Unknown"
},
project_durations: @project_durations.transform_values { |v|
{
seconds: v,
formatted: ApplicationController.helpers.short_time_simple(v)
}
},
language_stats: @language_stats,
editor_stats: @editor_stats,
os_stats: @os_stats,
weekly_project_stats: @weekly_project_stats
}
end
end
end
private
def require_admin

View File

@@ -1,6 +1,6 @@
<div class="filter">
<label class="filter-label">▼ <%= label %></label>
<div class="custom-select" data-param="<%= param %>">
<div class="custom-select" id="<%= param %>-select" data-param="<%= param %>">
<div class="select-header-container">
<div class="select-header">
Filter by <%= label.downcase %>...
@@ -23,298 +23,4 @@
</div>
</div>
</div>
</div>
<style>
.filter .options-container {
position: absolute !important;
top: 100% !important;
left: 0 !important;
right: 0 !important;
background: rgba(30, 33, 37, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 4px !important;
margin-top: 4px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
z-index: 1000 !important;
padding: 0 !important;
}
.filter .search-input {
width: 100% !important;
border: none !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;
padding: 8px !important;
margin: 0 !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.9) !important;
font-size: 13px !important;
border-radius: 0 !important;
height: auto !important;
}
.filter .search-input::placeholder {
color: rgba(255, 255, 255, 0.4) !important;
}
.filter .search-input:focus {
outline: none !important;
box-shadow: none !important;
border-color: rgba(255, 255, 255, 0.2) !important;
}
.filter .options-list {
max-height: 200px !important;
overflow-y: auto !important;
padding: 0 !important;
margin: 0 !important;
}
.filter .option {
display: flex !important;
align-items: center !important;
padding: 6px 8px !important;
cursor: pointer !important;
font-size: 13px !important;
color: rgba(255, 255, 255, 0.9) !important;
margin: 0 !important;
background: transparent !important;
}
.filter .option:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.filter .option input[type="checkbox"] {
margin-right: 8px !important;
margin-bottom: 0 !important;
height: 14px !important;
width: 14px !important;
min-width: 14px !important;
appearance: none !important;
-webkit-appearance: none !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
border-radius: 3px !important;
background: rgba(255, 255, 255, 0.1) !important;
position: relative !important;
cursor: pointer !important;
padding: 0 !important;
}
.filter .option input[type="checkbox"]:checked {
background: #3291ff !important;
border-color: #3291ff !important;
}
.filter .option input[type="checkbox"]:checked::after {
content: '' !important;
position: absolute !important;
left: 4px !important;
top: 1px !important;
width: 4px !important;
height: 8px !important;
border: solid white !important;
border-width: 0 2px 2px 0 !important;
transform: rotate(45deg) !important;
}
.filter .option input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.5) !important;
}
.filter .select-header-container {
display: flex !important;
align-items: center !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 4px !important;
background-color: rgba(255, 255, 255, 0.05) !important;
margin: 0 !important;
padding: 0 !important;
}
.filter .select-header {
flex: 1 !important;
padding: 6px 8px !important;
font-size: 13px !important;
cursor: pointer !important;
user-select: none !important;
color: rgba(255, 255, 255, 0.7) !important;
margin: 0 !important;
background: transparent !important;
}
.filter .clear-button {
padding: 6px 8px !important;
font-size: 16px !important;
line-height: 1 !important;
color: rgba(255, 255, 255, 0.4) !important;
background: none !important;
border: none !important;
border-left: 1px solid rgba(255, 255, 255, 0.1) !important;
cursor: pointer !important;
margin: 0 !important;
}
.filter .clear-button:hover {
color: #ff4444 !important;
background-color: rgba(255, 255, 255, 0.05) !important;
}
.filter .option.hidden {
display: none !important;
}
@media (prefers-color-scheme: light) {
.filter .options-container {
background: white !important;
border: 1px solid #ddd !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
}
.filter .search-input {
border-bottom: 1px solid #eee !important;
color: var(--text-color) !important;
}
.filter .search-input::placeholder {
color: #999 !important;
}
.filter .option {
color: var(--text-color) !important;
}
.filter .option:hover {
background-color: #f5f5f5 !important;
}
.filter .select-header-container {
border: 1px solid #ddd !important;
background-color: white !important;
}
.filter .select-header {
color: #666 !important;
}
.filter .clear-button {
color: #666 !important;
border-left: 1px solid #ddd !important;
}
.filter .clear-button:hover {
color: #ff4444 !important;
background-color: rgba(0, 0, 0, 0.05) !important;
}
.filter .option input[type="checkbox"] {
border: 1px solid rgba(0, 0, 0, 0.2) !important;
background: rgba(0, 0, 0, 0.05) !important;
}
.filter .option input[type="checkbox"]:checked {
background: #3291ff !important;
border-color: #3291ff !important;
}
.filter .option input[type="checkbox"]:hover {
border-color: rgba(0, 0, 0, 0.4) !important;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const customSelects = document.querySelectorAll('.custom-select');
customSelects.forEach(select => {
const searchInput = select.querySelector('.search-input');
const optionsList = select.querySelector('.options-list');
const options = select.querySelectorAll('.option');
const optionsContainer = select.querySelector('.options-container');
const header = select.querySelector('.select-header');
const clearButton = select.querySelector('.clear-button');
// Function to update clear button visibility
function updateClearButton() {
const checkedBoxes = select.querySelectorAll('input[type="checkbox"]:checked');
clearButton.style.display = checkedBoxes.length > 0 ? 'block' : 'none';
}
// Initialize clear button state
updateClearButton();
// Handle checkbox changes
options.forEach(option => {
const checkbox = option.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', () => {
updateClearButton();
});
});
// Handle clear button click
clearButton.addEventListener('click', (e) => {
e.stopPropagation();
options.forEach(option => {
const checkbox = option.querySelector('input[type="checkbox"]');
checkbox.checked = false;
});
updateClearButton();
// Trigger change event for any external listeners
const event = new Event('change');
select.dispatchEvent(event);
});
// Improved search function with debugging
function filterOptions(searchTerm) {
const normalizedSearch = searchTerm.toLowerCase().trim();
let matchCount = 0;
options.forEach(option => {
const text = option.querySelector('span').textContent.toLowerCase().trim();
const shouldShow = normalizedSearch === '' || text.includes(normalizedSearch);
// Use classList instead of style.display
if (shouldShow) {
option.classList.remove('hidden');
matchCount++;
} else {
option.classList.add('hidden');
}
});
}
// Search input handler
searchInput.addEventListener('input', function(e) {
filterOptions(e.target.value);
});
// Prevent dropdown from closing when clicking search input
searchInput.addEventListener('click', function(e) {
e.stopPropagation();
});
// Toggle dropdown visibility
header.addEventListener('click', function() {
const isVisible = optionsContainer.style.display === 'block';
const newDisplay = isVisible ? 'none' : 'block';
optionsContainer.style.display = newDisplay;
if (!isVisible) {
searchInput.focus();
searchInput.value = '';
filterOptions('');
}
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!select.contains(e.target)) {
optionsContainer.style.display = 'none';
searchInput.value = '';
filterOptions('');
}
});
});
});
</script>
</div>

View File

@@ -50,11 +50,6 @@
Settings
<% end %>
</li>
<li>
<%= link_to my_home_path, class: "nav-item #{current_page?(my_home_path) ? 'active' : ''}", data: { turbo: false } do %>
Dashboard
<% end %>
</li>
<li>
<%= link_to signout_path, class: "nav-item", data: { turbo_method: :delete } do %>
Logout

View File

@@ -0,0 +1,405 @@
<%= turbo_frame_tag "filterable_dashboard" do %>
<div class="container">
<div class="content">
<div class="filters-section">
<%= render partial: 'shared/multi_select', locals: {
label: 'Project',
param: 'project',
values: @project,
selected: params[:project]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'Language',
param: 'language',
values: @language,
selected: params[:language]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'OS',
param: 'operating_system',
values: @operating_system,
selected: params[:operating_system]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'Editor',
param: 'editor',
values: @editor,
selected: params[:editor]
} %>
</div>
</div>
</div>
<div id="filterable_dashboard_content">
<%= render partial: 'filterable_dashboard_content' %>
</div>
<script>
// Global initialization functions for each multi-select type
window.initializeMultiSelect = window.initializeMultiSelect || function(selectId) {
const select = document.getElementById(selectId);
if (!select || select.dataset.initialized) return;
select.dataset.initialized = 'true';
const header = select.querySelector('.select-header');
const container = select.querySelector('.options-container');
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const clearButton = select.querySelector('.clear-button');
const searchInput = select.querySelector('.search-input');
// Initialize clear button visibility
const checkedBoxes = Array.from(checkboxes).filter(cb => cb.checked);
if (checkedBoxes.length > 0 && clearButton) {
clearButton.style.display = 'block';
if (checkedBoxes.length === 1) {
header.textContent = checkedBoxes[0].value;
} else {
header.textContent = `${checkedBoxes.length} selected`;
}
}
// Toggle dropdown
header.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = container.style.display === 'block';
// Close all other dropdowns
document.querySelectorAll('.options-container').forEach(c => {
if (c !== container) c.style.display = 'none';
});
// Toggle current dropdown
container.style.display = isVisible ? 'none' : 'block';
// Focus search input when opening
if (!isVisible && searchInput) {
searchInput.focus();
}
});
// Clear filter when clicking the clear button
if (clearButton) {
clearButton.addEventListener('click', function(e) {
e.stopPropagation();
checkboxes.forEach(cb => cb.checked = false);
updateSelect(select);
});
}
// Handle search input
if (searchInput) {
searchInput.addEventListener('input', function(e) {
console.log('searchInput.addEventListener', e.target.value);
const searchTerm = e.target.value.toLowerCase().trim();
const options = select.querySelectorAll('.option');
options.forEach(option => {
const text = option.querySelector('span').textContent.toLowerCase().trim();
option.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
// Prevent dropdown from closing when clicking search
searchInput.addEventListener('click', function(e) {
e.stopPropagation();
});
}
// Update header text and URL when checkboxes change
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelect(select);
});
});
};
// Global function to update select and fetch new data
window.updateSelect = window.updateSelect || function(select) {
const header = select.querySelector('.select-header');
const clearButton = select.querySelector('.clear-button');
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const param = select.dataset.param;
const frame = document.querySelector('#filterable_dashboard_content');
frame.classList.add('loading');
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
// Update header text
if (selected.length === 0) {
header.textContent = `Filter by ${header.closest('.filter').querySelector('.filter-label').textContent.slice(2).toLowerCase()}...`;
if (clearButton) clearButton.style.display = 'none';
} else if (selected.length === 1) {
header.textContent = selected[0];
if (clearButton) clearButton.style.display = 'block';
} else {
header.textContent = `${selected.length} selected`;
if (clearButton) clearButton.style.display = 'block';
}
// Update URL parameters without triggering navigation
const rootUrl = new URL(window.location);
if (selected.length > 0) {
rootUrl.searchParams.set(param, selected.join(','));
} else {
rootUrl.searchParams.delete(param);
}
window.history.pushState({}, '', rootUrl);
// update content-frame url
const contentUrl = new URL(window.location);
contentUrl.pathname = "<%= filterable_dashboard_content_static_pages_path %>";
contentUrl.searchParams.set(param, selected.join(','));
// Let Turbo handle the content update
frame.src = contentUrl.toString();
// Track this request with a timestamp
const requestTimestamp = Date.now();
window.lastRequestTimestamp = requestTimestamp;
fetch(contentUrl.toString(), {
headers: {
'Accept': 'text/html'
}
}).then(response => response.text()).then(html => {
// Only update if this is still the most recent request
if (requestTimestamp === window.lastRequestTimestamp) {
frame.innerHTML = html;
frame.classList.remove('loading');
window.hackatimeCharts?.initializeCharts();
}
});
};
// Initialize multi-selects when the frame loads
document.addEventListener('turbo:frame-load', function(event) {
if (event.target.id === 'filterable_dashboard') {
// Initialize each multi-select
['project', 'language', 'editor', 'operating_system'].forEach(type => {
window.initializeMultiSelect(`${type}-select`);
});
// Close all dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.custom-select')) {
document.querySelectorAll('.options-container').forEach(container => {
container.style.display = 'none';
});
}
});
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" data-turbo-track="reload"></script>
<script>
window.chartInstances = window.chartInstances || {};
if (!window.hackatimeCharts) {
window.hackatimeCharts = {
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
},
createPieChart(elementId) {
const canvas = document.getElementById(elementId);
if (!canvas) return;
const stats = JSON.parse(canvas.dataset.stats);
const labels = Object.keys(stats);
const data = Object.values(stats);
if (window.chartInstances[elementId]) {
window.chartInstances[elementId].destroy();
}
const ctx = canvas.getContext('2d');
window.chartInstances[elementId] = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.2,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const duration = window.hackatimeCharts.formatDuration(value);
const percentage = ((value / data.reduce((a, b) => a + b, 0)) * 100).toFixed(1);
return `${label}: ${duration} (${percentage}%)`;
}
}
},
legend: {
position: 'right',
align: 'center',
labels: {
boxWidth: 10,
padding: 8,
font: {
size: 10
}
}
}
}
}
});
},
createProjectTimelineChart() {
const canvas = document.getElementById('projectTimelineChart');
if (!canvas) return;
const weeklyStats = JSON.parse(canvas.dataset.stats);
const allProjects = new Set();
Object.values(weeklyStats).forEach(weekData => {
Object.keys(weekData).forEach(project => allProjects.add(project));
});
const sortedWeeks = Object.keys(weeklyStats).sort();
const datasets = Array.from(allProjects).map((project, index) => {
return {
label: project,
data: sortedWeeks.map(week => {
const value = weeklyStats[week][project] || 0;
return value;
}),
stack: 'stack0',
};
});
datasets.sort((a, b) => {
const sumA = a.data.reduce((acc, val) => acc + val, 0);
const sumB = b.data.reduce((acc, val) => acc + val, 0);
return sumB - sumA; // Sort in descending order
});
if (window.chartInstances['projectTimelineChart']) {
window.chartInstances['projectTimelineChart'].destroy();
}
const ctx = canvas.getContext('2d');
window.chartInstances['projectTimelineChart'] = new Chart(ctx, {
type: 'bar',
data: {
labels: sortedWeeks.map(week => {
const date = new Date(week);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false
}
},
y: {
stacked: true,
type: 'linear',
grid: {
color: (context) => {
if (context.tick.value === 0) return 'transparent';
return context.tick.value % 1 === 0 ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.05)';
}
},
ticks: {
callback: function(value) {
if (value === 0) return '0s';
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) {
return `${hours}h`;
}
return `${minutes}m`;
}
}
}
},
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) {
return `${context.dataset.label}: ${hours}h ${minutes}m`;
}
return `${context.dataset.label}: ${minutes}m`;
}
}
}
}
}
});
},
initializeCharts() {
this.createPieChart('languageChart');
this.createPieChart('editorChart');
this.createPieChart('operatingSystemChart');
this.createProjectTimelineChart();
}
};
}
if (!window.chartListenersInitialized) {
window.chartListenersInitialized = true;
document.addEventListener('turbo:frame-load', () => {
if (typeof Chart === 'undefined') {
const checkChart = setInterval(() => {
if (typeof Chart !== 'undefined') {
clearInterval(checkChart);
window.hackatimeCharts.initializeCharts();
}
}, 50);
setTimeout(() => clearInterval(checkChart), 5000);
} else {
window.hackatimeCharts.initializeCharts();
}
});
}
if (typeof Chart !== 'undefined') {
window.hackatimeCharts.initializeCharts();
}
</script>
<% end %>

View File

@@ -0,0 +1,140 @@
<div class="dashboard-wrapper">
<div class="stats-section">
<div class="stat-card">
<div class="stat-label">TOTAL TIME</div>
<div class="stat-value" data-stat="total_time"><%= ApplicationController.helpers.short_time_simple(@total_time) %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOTAL HEARTBEATS</div>
<div class="stat-value" data-stat="total_heartbeats">
<%= number_with_delimiter(@total_heartbeats) %>
</div>
</div>
<div class="stat-card">
<div class="stat-label">TOP PROJECT</div>
<div class="stat-value" data-stat="top_project">
<%= @top_project || "None" %>
<% if @singular_project %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-label">TOP LANGUAGE</div>
<div class="stat-value" data-stat="top_language">
<%= @top_language || "Unknown" %>
<% if @singular_language %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-label">TOP OS</div>
<div class="stat-value" data-stat="top_operating_system">
<%= @top_operating_system || "Unknown" %>
<% if @singular_operating_system %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="stat-card">
<div class="stat-label">TOP EDITOR</div>
<div class="stat-value" data-stat="top_editor">
<%= @top_editor || "Unknown" %>
<% if @singular_editor %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
</div>
<div class="dashboard-grid">
<% if @project_durations&.size&.> 1 %>
<div class="card">
<h2>Project Durations</h2>
<div class="bar-graph">
<%
max_duration = @project_durations.values.max
min_duration = @project_durations.values.min
# Use logarithmic scale for better visibility of smaller values
# Add 1 to avoid log(0), scale to 15-100 range
def log_scale(value, max_val)
return 0 if value == 0
min_percent = 5 # Minimum bar width percentage
max_percent = 100 # Maximum bar width percentage
# Mix linear and logarithmic scaling
# 80% linear, 20% logarithmic
linear_ratio = value.to_f / max_val
log_ratio = Math.log(value + 1) / Math.log(max_val + 1)
linear_weight = 0.8
log_weight = 0.2
scaled = min_percent + (linear_weight * linear_ratio + log_weight * log_ratio) * (max_percent - min_percent)
[scaled, max_percent].min.round
end
%>
<% @project_durations.each do |project, duration| %>
<div class="bar-row">
<div class="bar-label"><%= project %></div>
<div class="bar-container">
<div class="bar" style="width: <%= log_scale(duration, max_duration) %>%">
<span class="bar-value"><%= ApplicationController.helpers.short_time_simple(duration) %></span>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Language distribution %>
<% if @language_stats.present? %>
<div class="card">
<h2>Languages</h2>
<div class="chart-container">
<canvas id="languageChart" data-stats="<%= @language_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<%# Editor distribution %>
<% if @editor_stats.present? %>
<div class="card">
<h2>Editors</h2>
<div class="chart-container">
<canvas id="editorChart" data-stats="<%= @editor_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<%# OS distribution %>
<% if @operating_system_stats.present? %>
<div class="card">
<h2>Operating Systems</h2>
<div class="chart-container">
<canvas id="operatingSystemChart" data-stats="<%= @operating_system_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Project Timeline</h3>
</div>
<div class="card-body">
<canvas id="projectTimelineChart" data-stats="<%= @weekly_project_stats.to_json %>"></canvas>
</div>
</div>
</div>
</div>
</div>

View File

@@ -105,6 +105,10 @@
Loading project durations...
</div>
<% end %>
<%= turbo_frame_tag "filterable_dashboard", src: filterable_dashboard_static_pages_path do %>
<span>Loading...</span>
<% end %>
<% else %>
<% if @leaderboard %>
<h3>Today's Top Hack Clubbers</h3>

View File

@@ -1,495 +0,0 @@
<%# This partial will be loaded asynchronously when filters change %>
<div class="dashboard-wrapper">
<%# Stats cards %>
<div class="stats-section">
<div class="stat-card">
<div class="stat-label">TOTAL TIME</div>
<div class="stat-value" data-stat="total_time"><%= ApplicationController.helpers.short_time_simple(@total_time) %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOTAL HEARTBEATS</div>
<div class="stat-value" data-stat="total_heartbeats"><%= number_with_delimiter(@total_heartbeats) %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOP PROJECT</div>
<div class="stat-value" data-stat="top_project"><%= @top_project || "None" %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOP LANGUAGE</div>
<div class="stat-value" data-stat="top_language"><%= @top_language || "Unknown" %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOP OS</div>
<div class="stat-value" data-stat="top_os"><%= @top_os || "Unknown" %></div>
</div>
<div class="stat-card">
<div class="stat-label">TOP EDITOR</div>
<div class="stat-value" data-stat="top_editor"><%= @top_editor || "Unknown" %></div>
</div>
</div>
<div class="dashboard-grid">
<% if @project_durations.size > 1 %>
<div class="card">
<h2>Project Durations</h2>
<div class="bar-graph">
<%
max_duration = @project_durations.values.max
min_duration = @project_durations.values.min
# Use logarithmic scale for better visibility of smaller values
# Add 1 to avoid log(0), scale to 15-100 range
def log_scale(value, max_val)
return 0 if value == 0
min_percent = 5 # Minimum bar width percentage
max_percent = 100 # Maximum bar width percentage
# Mix linear and logarithmic scaling
# 80% linear, 20% logarithmic
linear_ratio = value.to_f / max_val
log_ratio = Math.log(value + 1) / Math.log(max_val + 1)
linear_weight = 0.8
log_weight = 0.2
scaled = min_percent + (linear_weight * linear_ratio + log_weight * log_ratio) * (max_percent - min_percent)
[scaled, max_percent].min.round
end
%>
<% @project_durations.each do |project, duration| %>
<div class="bar-row">
<div class="bar-label"><%= project %></div>
<div class="bar-container">
<div class="bar" style="width: <%= log_scale(duration, max_duration) %>%">
<span class="bar-value"><%= ApplicationController.helpers.short_time_simple(duration) %></span>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Language distribution %>
<div class="card">
<h2>Languages</h2>
<div class="chart-container">
<canvas id="languageChart" data-stats="<%= @language_stats.to_json %>"></canvas>
</div>
</div>
<%# Editor distribution %>
<div class="card">
<h2>Editors</h2>
<div class="chart-container">
<canvas id="editorChart" data-stats="<%= @editor_stats.to_json %>"></canvas>
</div>
</div>
<%# OS distribution %>
<div class="card">
<h2>Operating Systems</h2>
<div class="chart-container">
<canvas id="osChart" data-stats="<%= @os_stats.to_json %>"></canvas>
</div>
</div>
<div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Project Timeline</h3>
</div>
<div class="card-body">
<canvas id="projectTimelineChart" data-stats="<%= @weekly_project_stats.to_json %>"></canvas>
</div>
</div>
</div>
</div>
</div>
<style>
.dashboard-wrapper {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.stats-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
width: 100%;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.2s ease;
}
.stat-label {
color: rgba(255, 255, 255, 0.5);
font-size: 0.5rem;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.stat-value {
font-size: 1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.card h2 {
margin-bottom: 1rem;
font-size: 1.1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.chart-container {
flex: 1;
position: relative;
height: 140px;
}
.chart-container canvas {
max-height: 100%;
width: 100%;
}
.bar-graph {
margin-top: 1rem;
}
.bar-row {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.bar-label {
width: 150px;
text-align: right;
padding-right: 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-container {
flex: 1;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.bar {
height: 100%;
background: #85e394;
border-radius: 4px;
position: relative;
transition: width 0.3s ease;
}
.bar-value {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.8);
}
.no-data {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
@media (prefers-color-scheme: light) {
.stat-card {
background: var(--smoke);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.stat-label {
color: #666;
}
.stat-value {
color: var(--text-color);
}
.card {
background: white;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card h2 {
color: var(--text-color);
}
.bar-label {
color: var(--text-color);
}
.bar-container {
background: var(--smoke);
}
.bar-value {
color: var(--text-inverse);
}
.no-data {
color: #666;
}
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" data-turbo-track="reload"></script>
<script>
// Initialize global chart instances if not already done
window.chartInstances = window.chartInstances || {};
// Only define functions if they haven't been defined yet
if (!window.hackatimeCharts) {
window.hackatimeCharts = {
// Helper function to format duration
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
},
// Helper function to create pie charts
createPieChart(elementId) {
const canvas = document.getElementById(elementId);
if (!canvas) return;
// Get stats from data attribute
const stats = JSON.parse(canvas.dataset.stats);
const labels = Object.keys(stats);
const data = Object.values(stats);
// Destroy existing chart if it exists
if (window.chartInstances[elementId]) {
window.chartInstances[elementId].destroy();
}
const ctx = canvas.getContext('2d');
window.chartInstances[elementId] = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.2,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const duration = window.hackatimeCharts.formatDuration(value);
const percentage = ((value / data.reduce((a, b) => a + b, 0)) * 100).toFixed(1);
return `${label}: ${duration} (${percentage}%)`;
}
}
},
legend: {
position: 'right',
align: 'center',
labels: {
boxWidth: 10,
padding: 8,
font: {
size: 10
}
}
}
}
}
});
},
createProjectTimelineChart() {
const canvas = document.getElementById('projectTimelineChart');
if (!canvas) return;
const weeklyStats = JSON.parse(canvas.dataset.stats);
const allProjects = new Set();
Object.values(weeklyStats).forEach(weekData => {
Object.keys(weekData).forEach(project => allProjects.add(project));
});
const sortedWeeks = Object.keys(weeklyStats).sort();
const datasets = Array.from(allProjects).map((project, index) => {
return {
label: project,
data: sortedWeeks.map(week => {
const value = weeklyStats[week][project] || 0;
return value;
}),
stack: 'stack0',
};
});
datasets.sort((a, b) => {
const sumA = a.data.reduce((acc, val) => acc + val, 0);
const sumB = b.data.reduce((acc, val) => acc + val, 0);
return sumB - sumA; // Sort in descending order
});
if (window.chartInstances['projectTimelineChart']) {
window.chartInstances['projectTimelineChart'].destroy();
}
const ctx = canvas.getContext('2d');
window.chartInstances['projectTimelineChart'] = new Chart(ctx, {
type: 'bar',
data: {
labels: sortedWeeks.map(week => {
const date = new Date(week);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false
}
},
y: {
stacked: true,
type: 'linear',
grid: {
color: (context) => {
if (context.tick.value === 0) return 'transparent';
return context.tick.value % 1 === 0 ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.05)';
}
},
ticks: {
callback: function(value) {
if (value === 0) return '0s';
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) {
return `${hours}h`;
}
return `${minutes}m`;
}
}
}
},
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
padding: 15
}
},
tooltip: {
callbacks: {
label: function(context) {
const value = context.raw;
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) {
return `${context.dataset.label}: ${hours}h ${minutes}m`;
}
return `${context.dataset.label}: ${minutes}m`;
}
}
}
}
}
});
},
// Initialize or reinitialize charts
initializeCharts() {
this.createPieChart('languageChart');
this.createPieChart('editorChart');
this.createPieChart('osChart');
this.createProjectTimelineChart();
}
};
}
// Function to set up event listeners (only needs to run once)
if (!window.chartListenersInitialized) {
window.chartListenersInitialized = true;
document.addEventListener('turbo:render', () => {
// Wait for Chart.js to load
if (typeof Chart === 'undefined') {
const checkChart = setInterval(() => {
if (typeof Chart !== 'undefined') {
clearInterval(checkChart);
window.hackatimeCharts.initializeCharts();
}
}, 50);
// Set a timeout to stop checking after 5 seconds
setTimeout(() => clearInterval(checkChart), 5000);
} else {
window.hackatimeCharts.initializeCharts();
}
});
}
// Initialize charts immediately if Chart.js is already loaded
if (typeof Chart !== 'undefined') {
window.hackatimeCharts.initializeCharts();
}
</script>

View File

@@ -5,439 +5,9 @@
<div class="container">
<div class="content">
<h1>Welcome, <%= @user.display_name %></h1>
<div class="filters-section">
<%= render partial: 'shared/multi_select', locals: {
label: 'Project',
param: 'projects',
values: @projects,
selected: params[:projects]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'Language',
param: 'language',
values: @languages,
selected: params[:language]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'OS',
param: 'os',
values: @operating_systems,
selected: params[:os]
} %>
<%= render partial: 'shared/multi_select', locals: {
label: 'Editor',
param: 'editor',
values: @editors,
selected: params[:editor]
} %>
</div>
</div>
</div>
<%# Async dashboard content %>
<div id="dashboard-content">
<%= render partial: 'filterable_dashboard_content' %>
</div>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.content {
max-width: 1200px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: bold;
margin-bottom: 1.5rem;
}
.filters-section {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters {
margin-bottom: 2rem;
background: #f5f5f5;
padding: 1rem;
border-radius: 4px;
}
.filter-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.filter {
flex: 1;
min-width: 150px;
position: relative;
}
.filter-label {
display: block;
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: #666;
}
.custom-select {
position: relative;
width: 100%;
}
.select-header-container {
display: flex;
align-items: center;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
.select-header {
flex: 1;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
user-select: none;
}
.clear-button {
padding: 0.4rem 0.5rem;
font-size: 1rem;
line-height: 1;
color: #666;
background: none;
border: none;
border-left: 1px solid #ddd;
cursor: pointer;
display: none;
}
.clear-button:hover {
color: #ff4444;
background-color: rgba(0, 0, 0, 0.05);
}
.options-container {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.custom-select.active .options-container {
display: block;
}
.option {
display: flex;
align-items: center;
padding: 0.35rem 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.8rem;
}
.option:hover {
background-color: #f5f5f5;
}
.option input[type="checkbox"] {
margin-right: 0.5rem;
transform: scale(0.9);
}
.time-filter {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.calendar-icon {
font-size: 1.1rem;
}
.dropdown-arrow {
font-size: 0.8rem;
color: #666;
}
#dashboard-content {
min-height: 200px;
width: 100%;
}
#dashboard-content.loading .dashboard-wrapper {
filter: grayscale(1) opacity(0.7);
pointer-events: none;
transition: filter 0.2s ease;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const customSelects = document.querySelectorAll('.custom-select');
const dashboardContent = document.getElementById('dashboard-content');
// Disable Turbo drive for this section
if (typeof Turbo !== 'undefined') {
Turbo.session.drive = false;
}
// Close all dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.custom-select')) {
customSelects.forEach(select => {
const container = select.querySelector('.options-container');
if (container) container.style.display = 'none';
});
}
});
customSelects.forEach(select => {
const header = select.querySelector('.select-header');
const container = select.querySelector('.options-container');
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const clearButton = select.querySelector('.clear-button');
const searchInput = select.querySelector('.search-input');
// Initialize clear button visibility
const checkedBoxes = Array.from(checkboxes).filter(cb => cb.checked);
if (checkedBoxes.length > 0 && clearButton) {
clearButton.style.display = 'block';
if (checkedBoxes.length === 1) {
header.textContent = checkedBoxes[0].value;
} else {
header.textContent = `${checkedBoxes.length} selected`;
}
}
// Toggle dropdown
header.addEventListener('click', function(e) {
e.stopPropagation();
const isVisible = container.style.display === 'block';
// Close all other dropdowns
customSelects.forEach(s => {
if (s !== select) {
const c = s.querySelector('.options-container');
if (c) c.style.display = 'none';
}
});
// Toggle current dropdown
container.style.display = isVisible ? 'none' : 'block';
// Focus search input when opening
if (!isVisible && searchInput) {
searchInput.focus();
}
});
// Clear filter when clicking the clear button
if (clearButton) {
clearButton.addEventListener('click', function(e) {
e.stopPropagation();
checkboxes.forEach(cb => cb.checked = false);
updateSelect(select);
});
}
// Handle search input
if (searchInput) {
searchInput.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const options = select.querySelectorAll('.option');
options.forEach(option => {
const text = option.textContent.toLowerCase();
option.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
// Prevent dropdown from closing when clicking search
searchInput.addEventListener('click', function(e) {
e.stopPropagation();
});
}
// Update header text and URL when checkboxes change
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelect(select);
});
});
});
// Function to update header text and URL for a select
function updateSelect(select) {
const header = select.querySelector('.select-header');
const clearButton = select.querySelector('.clear-button');
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const param = select.dataset.param;
const dashboardContent = document.getElementById('dashboard-content');
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
// Update header text
if (selected.length === 0) {
header.textContent = `Filter by ${header.closest('.filter').querySelector('.filter-label').textContent.slice(2).toLowerCase()}...`;
if (clearButton) clearButton.style.display = 'none';
} else if (selected.length === 1) {
header.textContent = selected[0];
if (clearButton) clearButton.style.display = 'block';
} else {
header.textContent = `${selected.length} selected`;
if (clearButton) clearButton.style.display = 'block';
}
// Update URL
const url = new URL(window.location);
if (selected.length > 0) {
url.searchParams.set(param, selected.join(','));
} else {
url.searchParams.delete(param);
}
window.history.pushState({}, '', url);
// Add loading state before fetch
dashboardContent.classList.add('loading');
// First time load uses HTML, subsequent updates use JSON
if (!window.dashboardInitialized) {
// Initial HTML load
fetch(url.toString(), {
headers: {
'Accept': 'text/html',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.text())
.then(html => {
dashboardContent.innerHTML = html;
dashboardContent.classList.remove('loading');
window.dashboardInitialized = true;
if (typeof window.hackatimeCharts !== 'undefined') {
window.hackatimeCharts.initializeCharts();
}
})
.catch(error => {
console.error('Error updating dashboard:', error);
dashboardContent.classList.remove('loading');
});
} else {
// Subsequent JSON updates
fetch(url.toString(), {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
// Update stats
if (data.stats) {
Object.entries(data.stats).forEach(([key, value]) => {
const statValue = document.querySelector(`[data-stat="${key}"]`);
if (statValue) statValue.textContent = value;
});
}
// Update project durations
const barGraph = document.querySelector('.bar-graph');
if (barGraph && data.project_durations) {
const durations = Object.values(data.project_durations);
if (durations.length > 0) {
const maxDuration = Math.max(...durations.map(d => d.seconds));
barGraph.innerHTML = Object.entries(data.project_durations)
.map(([project, { seconds, formatted }]) => `
<div class="bar-row">
<div class="bar-label">${project}</div>
<div class="bar-container">
<div class="bar" style="width: ${logScale(seconds, maxDuration)}%">
<span class="bar-value">${formatted}</span>
</div>
</div>
</div>
`).join('');
} else {
barGraph.innerHTML = '<div class="no-data">No project data available</div>';
}
}
// Update chart data
['language', 'editor', 'os'].forEach(type => {
const canvas = document.getElementById(`${type}Chart`);
if (canvas && data[`${type}_stats`]) {
canvas.dataset.stats = JSON.stringify(data[`${type}_stats`]);
}
});
const projectTimelineCanvas = document.getElementById('projectTimelineChart');
if (projectTimelineCanvas && data.weekly_project_stats) {
projectTimelineCanvas.dataset.stats = JSON.stringify(data.weekly_project_stats);
}
// Reinitialize charts with new data
if (typeof window.hackatimeCharts !== 'undefined') {
window.hackatimeCharts.initializeCharts();
}
dashboardContent.classList.remove('loading');
})
.catch(error => {
console.error('Error updating dashboard:', error);
dashboardContent.classList.remove('loading');
});
}
}
// Helper function for log scale calculation (copied from ERB)
function logScale(value, maxVal) {
if (value === 0) return 0;
const minPercent = 5;
const maxPercent = 100;
const linearRatio = value / maxVal;
const logRatio = Math.log(value + 1) / Math.log(maxVal + 1);
const linearWeight = 0.8;
const logWeight = 0.2;
const scaled = minPercent + (linearWeight * linearRatio + logWeight * logRatio) * (maxPercent - minPercent);
return Math.min(scaled, maxPercent).toFixed(0);
}
});
</script>
<%= turbo_frame_tag "filterable_dashboard", src: filterable_dashboard_static_pages_path do %>
<span>Loading...</span>
<% end %>

View File

@@ -35,6 +35,8 @@ Rails.application.routes.draw do
get :project_durations
get :activity_graph
get :currently_hacking
get :filterable_dashboard_content
get :filterable_dashboard
get "🃏", to: "static_pages#🃏", as: :wildcard
end
end
@@ -56,7 +58,6 @@ Rails.application.routes.draw do
end
# Namespace for current user actions
get "my/home", to: "users#show", as: :my_home
get "my/settings", to: "users#edit", as: :my_settings
patch "my/settings", to: "users#update"
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats

View File

@@ -166,6 +166,14 @@ class FlavorText
]
end
def self.obvious
[
"obv.",
"duh",
"clearly"
]
end
def self.motto
[
"track your time before it tracks you!",