From 335cc426541b6d5e4f6565d3649fa06466e3411d Mon Sep 17 00:00:00 2001 From: Echo Date: Sun, 21 Dec 2025 00:54:04 -0500 Subject: [PATCH] Profiles (#719) * new username logic * lint * change up settings for username * user profiles --- app/controllers/profiles_controller.rb | 76 +++++++++++++++ app/models/user.rb | 3 +- app/views/profiles/_project_cards.html.erb | 33 +++++++ app/views/profiles/not_found.html.erb | 7 ++ app/views/profiles/show.html.erb | 103 +++++++++++++++++++++ app/views/static_pages/index.html.erb | 2 +- app/views/users/edit.html.erb | 7 +- app/views/users/wakatime_setup.html.erb | 2 +- config/routes.rb | 2 + 9 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 app/controllers/profiles_controller.rb create mode 100644 app/views/profiles/_project_cards.html.erb create mode 100644 app/views/profiles/not_found.html.erb create mode 100644 app/views/profiles/show.html.erb diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 0000000..2545af0 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,76 @@ +class ProfilesController < ApplicationController + def show + @user = find(params[:username]) + + if @user.nil? + render :not_found, status: :not_found + return + end + + @is_own_profile = current_user && current_user.id == @user.id + @profile_visible = @user.allow_public_stats_lookup || @is_own_profile + + return unless @profile_visible + + load + end + + private + + def find(username) + User.find_by(username: username) + end + + def load + Time.use_zone(@user.timezone) do + @total_time_today = @user.heartbeats.today.duration_seconds + @total_time_week = @user.heartbeats.this_week.duration_seconds + @total_time_all = @user.heartbeats.duration_seconds + + @top_languages = @user.heartbeats + .where.not(language: [ nil, "" ]) + .group(:language) + .duration_seconds + .sort_by { |_, v| -v } + .first(5) + .to_h + + @top_projects = @user.heartbeats + .group(:project) + .duration_seconds + .sort_by { |_, v| -v } + .first(5) + .to_h + + project_repo_mappings = @user.project_repo_mappings.index_by(&:project_name) + + @top_projects_month = @user.heartbeats + .where("time >= ?", 1.month.ago.to_f) + .group(:project) + .duration_seconds + .sort_by { |_, v| -v } + .first(6) + .map do |project, duration| + mapping = project_repo_mappings[project] + { project: project, duration: duration, repo_url: mapping&.repo_url } + end + + @top_editors = @user.heartbeats + .where.not(editor: [ nil, "" ]) + .group(:editor) + .duration_seconds + .each_with_object(Hash.new(0)) do |(editor, duration), acc| + normalized = ApplicationController.helpers.display_editor_name(editor) + acc[normalized] += duration + end + .sort_by { |_, v| -v } + .first(3) + .to_h + + @daily_durations = @user.heartbeats.daily_durations(user_timezone: @user.timezone).to_h + + @streak_days = @user.streak_days + @cool = @user.trust_level == 2 + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 33547c3..d3a5191 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord validates :username, length: { maximum: USERNAME_MAX_LENGTH }, format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" }, + uniqueness: { case_sensitive: false, message: "has already been taken" }, allow_nil: true validate :username_must_be_visible @@ -566,7 +567,7 @@ class User < ApplicationRecord end def display_name - name = username || slack_username || github_username + name = slack_username || github_username return name if name.present? # "zach@hackclub.com" -> "zach (email sign-up)" diff --git a/app/views/profiles/_project_cards.html.erb b/app/views/profiles/_project_cards.html.erb new file mode 100644 index 0000000..952d1f7 --- /dev/null +++ b/app/views/profiles/_project_cards.html.erb @@ -0,0 +1,33 @@ +<% if projects.present? && projects.size > 0 %> + <% + max = projects.map { |p| p[:duration] }.max || 1 + %> +
+ <% projects.each do |project| %> +
+
+

+ <%= h(project[:project].presence || "Unknown") %> +

+ <% if project[:repo_url].present? %> + <%= link_to project[:repo_url], target: "_blank", title: "View repository", class: "p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 shrink-0" do %> + + + + <% end %> + <% end %> +
+ +
+ <%= ApplicationController.helpers.short_time_simple(project[:duration]) %> +
+ +
+
+
+
+ <% end %> +
+<% end %> diff --git a/app/views/profiles/not_found.html.erb b/app/views/profiles/not_found.html.erb new file mode 100644 index 0000000..f5d2340 --- /dev/null +++ b/app/views/profiles/not_found.html.erb @@ -0,0 +1,7 @@ +
+
+

User not found

+

We couldn't find a user with that username... Usernames are case-sensitive if that helps.

+ <%= link_to "Go back home", root_path, class: "inline-block px-6 py-3 bg-primary text-white rounded font-bold hover:bg-primary/80 transition-colors" %> +
+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 0000000..5a13eb8 --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,103 @@ +
+
+ <%= @user.display_name %> +
+
+

<%= @user.display_name %>

+ <% if @user.trust_level == "green" %> + + <% end %> + <% if @profile_visible && @streak_days && @streak_days > 0 %> + <%= render "static_pages/streak", user: @user, streak_count: @streak_days, turbo_frame: false, icon_size: 20, show_text: true %> + <% end %> +
+ <% if @user.username.present? %> +

@<%= @user.username %>

+ <% end %> + <% if @user.github_username.present? %> + + + <%= @user.github_username %> + + <% end %> +
+
+ + <% if @profile_visible %> + +
+
+
Today
+
<%= ApplicationController.helpers.short_time_simple(@total_time_today) %>
+
+
+
This Week
+
<%= ApplicationController.helpers.short_time_simple(@total_time_week) %>
+
+
+
All Time
+
<%= ApplicationController.helpers.short_time_simple(@total_time_all) %>
+
+
+ + <% if @top_projects_month.present? && @top_projects_month.size > 0 %> +
+

Top Projects (Past Month)

+ <%= render "profiles/project_cards", projects: @top_projects_month %> +
+ <% end %> + +
+ <% if @top_languages.present? && @top_languages.size > 0 %> +
+

Top Languages

+
+ <% max_duration = @top_languages.values.max %> + <% @top_languages.each do |language, duration| %> +
+
<%= ApplicationController.helpers.display_language_name(language) || "Unknown" %>
+
+
+ <%= ApplicationController.helpers.short_time_simple(duration) %> +
+
+
+ <% end %> +
+
+ <% end %> + + <% if @top_editors.present? && @top_editors.size > 0 %> +
+

Favorite Editors

+
+ <% max_duration = @top_editors.values.max %> + <% @top_editors.each do |editor, duration| %> +
+
<%= editor %>
+
+
+ <%= ApplicationController.helpers.short_time_simple(duration) %> +
+
+
+ <% end %> +
+
+ <% end %> +
+ + <% if @daily_durations.present? %> +
+

Activity

+ <%= render "static_pages/activity_graph", daily_durations: @daily_durations, length_of_busiest_day: 8.hours.to_i %> +
+ <% end %> + <% else %> +
+
🔒
+

This user has their stats private

+

They've chosen not to share their coding statistics publicly.

+
+ <% end %> +
diff --git a/app/views/static_pages/index.html.erb b/app/views/static_pages/index.html.erb index 11f03ee..d45d22d 100644 --- a/app/views/static_pages/index.html.erb +++ b/app/views/static_pages/index.html.erb @@ -45,7 +45,7 @@ <% end %> <%= link_to slack_auth_path, class: "flex items-center justify-center px-4 py-3 rounded text-white cursor-pointer border border-darkless bg-dark hover:bg-gray-600 transition-colors w-1/4 gap-2" do %> - + <% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 3736aa5..e8a414d 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -73,14 +73,14 @@
🪪
-

Display Name

+

Username

<%= form_with model: @user, url: @is_own_settings ? my_settings_path : settings_user_path(@user), method: :patch, local: false, class: "space-y-4" do |f| %>
- <%= f.label :username, "Custom display name", class: "block text-sm font-medium text-gray-200 mb-2" %> + <%= f.label :username, "Custom username", class: "block text-sm font-medium text-gray-200 mb-2" %> <%= f.text_field :username, class: "w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white focus:border-primary focus:ring-1 focus:ring-primary", placeholder: "HackClubber", @@ -90,7 +90,7 @@ <% end %>

- Choose a name to use in Hackatime. This will take priority over Slack or GitHub names when possible. Letters, numbers, "-" and "_" only, max <%= User::USERNAME_MAX_LENGTH %> chars. + Choose a name to use in Hackatime. This will take priority over Slack or GitHub names when possible. You will be able to use your username in your profile URL, hackatime.hackclub.com/@username. Letters, numbers, "-" and "_" only, max <%= User::USERNAME_MAX_LENGTH %> chars.

<%= f.submit "Save Settings", class: "w-full px-4 py-2 bg-primary text-white font-medium rounded transition-colors duration-200 cursor-pointer" %> <% end %> @@ -628,7 +628,6 @@ custom: '
' %> - <% if @user.can_request_deletion? %>
<%= render "shared/modal", diff --git a/app/views/users/wakatime_setup.html.erb b/app/views/users/wakatime_setup.html.erb index 18d760f..b9a6468 100644 --- a/app/views/users/wakatime_setup.html.erb +++ b/app/views/users/wakatime_setup.html.erb @@ -178,7 +178,7 @@ const now = new Date(); const secondsAgo = (now - heartbeatTime) / 1000; const recentThreshold = 30; // 30 seconds - + if (secondsAgo <= recentThreshold) { window.location.href = '<%= my_wakatime_setup_step_2_path %>'; return; diff --git a/config/routes.rb b/config/routes.rb index 439221d..fcde602 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -216,6 +216,8 @@ Rails.application.routes.draw do end end + get "/@:username", to: "profiles#show", as: :profile, constraints: { username: /[A-Za-z0-9_-]+/ } + # SEO routes get "/sitemap.xml", to: "sitemap#sitemap", defaults: { format: "xml" }