archive projects (#770)

This commit is contained in:
Echo 2026-01-06 09:28:39 -05:00 committed by GitHub
parent 9cc68b881a
commit 7ebb1b2085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 226 additions and 20 deletions

View file

@ -75,4 +75,32 @@
.nav-mobile-slide.open {
@apply translate-x-0;
}
.project-toggle-group {
@apply flex items-center gap-2 rounded-lg p-1;
background-color: var(--color-darkless);
}
.project-toggle-btn {
@apply px-3 py-1 rounded transition-all duration-200 font-medium cursor-pointer;
color: var(--color-secondary);
}
.project-toggle-btn:hover {
color: var(--color-cyan);
}
.project-toggle-btn.active {
background-color: var(--color-primary);
color: white;
}
.project-toggle-btn.active:hover {
background-color: var(--color-primary);
opacity: 0.9;
}
.project-toggle-btn.inactive {
color: var(--color-secondary);
}
}

View file

@ -287,7 +287,8 @@ module Api
last_heartbeat: stat.last_heartbeat,
languages: stat.languages || [],
repo: repo_mapping&.repo_url,
repo_mapping_id: repo_mapping&.id
repo_mapping_id: repo_mapping&.id,
archived: repo_mapping&.archived? || false
}
end

View file

@ -4,19 +4,31 @@ module Api
class ProjectsController < ApplicationController
def index
projects = time_per_project.map { |project, _|
archived = archived_project_names.include?(project)
next if archived && !include_archived?
{
name: project,
total_seconds: time_per_project[project] || 0,
most_recent_heartbeat: most_recent_heartbeat_per_project[project] ? Time.at(most_recent_heartbeat_per_project[project]).strftime("%Y-%m-%dT%H:%M:%SZ") : nil,
languages: languages_per_project[project] || []
languages: languages_per_project[project] || [],
archived: archived
}
}
}.compact
render json: { projects: projects }
end
private
def include_archived?
params[:include_archived] == "true"
end
def archived_project_names
@archived_project_names ||= current_user.project_repo_mappings.archived.pluck(:project_name)
end
def time_per_project
@time_per_project ||= current_user.heartbeats
.with_valid_timestamps

View file

@ -307,6 +307,7 @@ class Api::V1::StatsController < ApplicationController
.pluck(:language)
repo = @user.project_repo_mappings.find_by(project_name: name)
next if repo&.archived?
data << {
name: name,

View file

@ -1,10 +1,11 @@
class My::ProjectRepoMappingsController < ApplicationController
before_action :ensure_current_user
before_action :require_github_oauth, only: [ :edit, :update ]
before_action :set_project_repo_mapping, only: [ :edit, :update ]
before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ]
before_action :set_project_repo_mapping, only: [ :archive, :unarchive ]
def index
@project_repo_mappings = current_user.project_repo_mappings || []
@project_repo_mappings = current_user.project_repo_mappings.active
@interval = params[:interval] || "daily"
@from = params[:from]
@to = params[:to]
@ -26,6 +27,16 @@ class My::ProjectRepoMappingsController < ApplicationController
end
end
def archive
@project_repo_mapping.archive!
redirect_to my_projects_path, notice: "Away it goes!"
end
def unarchive
@project_repo_mapping.unarchive!
redirect_to my_projects_path(show_archived: true), notice: "Back from the dead!"
end
private
def ensure_current_user
@ -39,13 +50,20 @@ class My::ProjectRepoMappingsController < ApplicationController
end
end
def set_project_repo_mapping
def set_project_repo_mapping_for_edit
decoded_project_name = CGI.unescape(params[:project_name])
@project_repo_mapping = current_user.project_repo_mappings.find_or_initialize_by(
project_name: decoded_project_name
)
end
def set_project_repo_mapping
decoded_project_name = CGI.unescape(params[:project_name])
@project_repo_mapping = current_user.project_repo_mappings.find_by!(
project_name: decoded_project_name
)
end
def project_repo_mapping_params
params.require(:project_repo_mapping).permit(:repo_url)
end

View file

@ -42,7 +42,7 @@ class ProfilesController < ApplicationController
.first(5)
.to_h
project_repo_mappings = @user.project_repo_mappings.index_by(&:project_name)
project_repo_mappings = @user.project_repo_mappings.active.index_by(&:project_name)
@top_projects_month = @user.heartbeats
.where("time >= ?", 1.month.ago.to_f)

View file

@ -111,9 +111,18 @@ class StaticPagesController < ApplicationController
def project_durations
return unless current_user
@project_repo_mappings = current_user.project_repo_mappings.includes(:repository)
cache_key = "user_#{current_user.id}_project_durations_#{params[:interval]}"
show_archived = params[:show_archived] == "true"
if show_archived
@project_repo_mappings = current_user.project_repo_mappings.archived.includes(:repository)
else
@project_repo_mappings = current_user.project_repo_mappings.active.includes(:repository)
end
archived_projects = current_user.project_repo_mappings.archived.pluck(:project_name)
cache_key = "user_#{current_user.id}_project_durations_#{params[:interval]}_v2"
cache_key += "_#{params[:from]}_#{params[:to]}" if params[:interval] == "custom"
cache_key += "_archived" if show_archived
project_durations = Rails.cache.fetch(cache_key, expires_in: 1.minute) do
heartbeats = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
@ -123,13 +132,22 @@ class StaticPagesController < ApplicationController
mapping = @project_repo_mappings.find { |p| p.project_name == project }
{
project: project_labels.find { |p| p.project_key == project }&.label || project || "Unknown",
project_key: project,
repo_url: mapping&.repo_url,
repository: mapping&.repository,
has_mapping: mapping.present?,
duration: duration
}
end.filter { |p| p[:duration].positive? }.sort_by { |p| p[:duration] }.reverse
end
render partial: "project_durations", locals: { project_durations: project_durations }
if show_archived
project_durations = project_durations.select { |p| archived_projects.include?(p[:project_key]) }
else
project_durations = project_durations.reject { |p| archived_projects.include?(p[:project_key]) }
end
render partial: "project_durations", locals: { project_durations: project_durations, show_archived: show_archived }
end
def activity_graph
@ -264,10 +282,12 @@ class StaticPagesController < ApplicationController
# Load filter options and apply filters with caching
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
result = {}
archived_projects = current_user.project_repo_mappings.archived.pluck(:project_name)
# Load filter options
Time.use_zone(current_user.timezone) do
filters.each do |filter|
group_by_time = current_user.heartbeats.group(filter).duration_seconds
group_by_time = group_by_time.reject { |name, _| archived_projects.include?(name) } if filter == :project
result[filter] = group_by_time.sort_by { |k, v| v }
.reverse.map(&:first)
.compact_blank
@ -309,10 +329,9 @@ class StaticPagesController < ApplicationController
result[:total_heartbeats] = filtered_heartbeats.count
filters.each do |filter|
result["top_#{filter}"] = filtered_heartbeats.group(filter)
.duration_seconds
.max_by { |_, v| v }
&.first
stats = filtered_heartbeats.group(filter).duration_seconds
stats = stats.reject { |name, _| archived_projects.include?(name) } if filter == :project
result["top_#{filter}"] = stats.max_by { |_, v| v }&.first
end
# Transform top editor, OS, and language names
@ -324,6 +343,7 @@ class StaticPagesController < ApplicationController
result[:project_durations] = filtered_heartbeats
.group(:project)
.duration_seconds
.reject { |project, _| archived_projects.include?(project) }
.sort_by { |_, duration| -duration }
.first(10)
.to_h unless result["singular_project"]
@ -395,6 +415,7 @@ class StaticPagesController < ApplicationController
.where(time: week_start.to_f..week_end.to_f)
.group(:project)
.duration_seconds
.reject { |project, _| archived_projects.include?(project) }
result[:weekly_project_stats][week_start.to_date.iso8601] = week_stats
end

View file

@ -9,7 +9,8 @@ class Cache::ActiveProjectsJob < Cache::ActivityJob
def calculate
# Get recent heartbeats with matching project_repo_mappings in a single SQL query
ProjectRepoMapping.joins("INNER JOIN heartbeats ON heartbeats.project = project_repo_mappings.project_name AND heartbeats.user_id = project_repo_mappings.user_id")
ProjectRepoMapping.active
.joins("INNER JOIN heartbeats ON heartbeats.project = project_repo_mappings.project_name AND heartbeats.user_id = project_repo_mappings.user_id")
.joins("INNER JOIN users ON users.id = heartbeats.user_id")
.where("heartbeats.source_type = ?", Heartbeat.source_types[:direct_entry])
.where("heartbeats.time > ?", 5.minutes.ago.to_f)

View file

@ -23,7 +23,8 @@ class Cache::CurrentlyHackingJob < Cache::ActivityJob
active_projects = {}
users.each do |user|
recent_heartbeat = recent_heartbeats[user.id]
active_projects[user.id] = user.project_repo_mappings.find { |p| p.project_name == recent_heartbeat&.project }
mapping = user.project_repo_mappings.find { |p| p.project_name == recent_heartbeat&.project }
active_projects[user.id] = mapping&.archived? ? nil : mapping
end
users = users.sort_by do |user|

View file

@ -22,9 +22,25 @@ class ProjectRepoMapping < ApplicationRecord
"<<LAST PROJECT>>"
]
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :all_statuses, -> { unscoped.where(nil) }
after_create :create_repository_and_sync
after_update :sync_repository_if_url_changed
def archive!
update!(archived_at: Time.current)
end
def unarchive!
update!(archived_at: nil)
end
def archived?
archived_at.present?
end
private
def repo_host_supported

View file

@ -0,0 +1,25 @@
<%
modal_id = "archive-project-modal-#{project[:project]&.parameterize || 'unknown'}"
project_name = project[:project]
%>
<%= render "shared/modal",
modal_id: modal_id,
title: "Archive #{project_name}?",
description: "This project will be hidden from most stats and listings, however it will still be visible to you and any time logged will still count towards it. You can restore it anytime from the archived projects page.",
icon_svg: '<path fill="currentColor" d="m20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27m-8.89 11.92L6.5 12H10v-2h4v2h3.5l-5.15 5.15c-.19.19-.51.19-.7 0M5.12 5l.81-1h12l.94 1z"/>',
max_width: "max-w-md",
buttons: [
{
text: "Cancel",
class: "border border-gray-600 text-gray-300 hover:bg-darkless",
action: "click->modal#close"
},
{
text: "Archive",
class: "bg-primary text-white hover:bg-red font-medium",
form: true,
url: archive_my_project_repo_mapping_path(CGI.escape(project_name)),
method: "patch"
}
] %>

View file

@ -0,0 +1,24 @@
<%
modal_id = "unarchive-project-modal-#{project[:project]&.parameterize || 'unknown'}"
%>
<%= render "shared/modal",
modal_id: modal_id,
title: "Restore #{project[:project]}?",
description: "This will restore the project to your main projects list and it will appear in stats again. This will not affect any time logged on this project.",
icon_svg: '<path fill="currentColor" d="m20.55 5.22l-1.39-1.68A1.51 1.51 0 0 0 18 3H6c-.47 0-.88.21-1.15.55L3.46 5.22C3.17 5.57 3 6.01 3 6.5V19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V6.5c0-.49-.17-.93-.45-1.28m-8.2 4.63L17.5 15H14v2h-4v-2H6.5l5.15-5.15c.19-.19.51-.19.7 0M5.12 5l.82-1h12l.93 1z"/>',
max_width: "max-w-md",
buttons: [
{
text: "Cancel",
class: "border border-gray-600 text-gray-300 hover:bg-darkless",
action: "click->modal#close"
},
{
text: "Restore",
class: "bg-primary text-white hover:bg-red font-medium",
form: true,
url: unarchive_my_project_repo_mapping_path(CGI.escape(project[:project_key] || project[:project])),
method: "patch"
}
] %>

View file

@ -1,4 +1,17 @@
<h1 class="text-3xl font-bold text-white mb-4">My Projects</h1>
<div class="flex items-center gap-4 mb-4">
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-white">My Projects</h1>
<% archived_count = current_user.project_repo_mappings.archived.count %>
<% if archived_count > 0 %>
<div class="project-toggle-group">
<%= link_to "Active", my_projects_path(show_archived: false),
class: "project-toggle-btn #{params[:show_archived] != 'true' ? 'active' : 'inactive'}" %>
<%= link_to "Archived", my_projects_path(show_archived: true),
class: "project-toggle-btn #{params[:show_archived] == 'true' ? 'active' : 'inactive'}" %>
</div>
<% end %>
</div>
</div>
<% if current_user.github_uid.blank? %>
<div class="notice">
@ -9,6 +22,6 @@
<%= render "shared/interval_selector" %>
<%= turbo_frame_tag "project_durations", src: project_durations_static_pages_path(interval: params[:interval], from: params[:from], to: params[:to]), target: "_top" do %>
<%= turbo_frame_tag "project_durations", src: project_durations_static_pages_path(interval: params[:interval], from: params[:from], to: params[:to], show_archived: params[:show_archived]), target: "_top" do %>
<div class="loading-indicator">Loading projects...</div>
<% end %>

View file

@ -6,6 +6,13 @@
<% project_durations.each do |project| %>
<% if current_user.github_uid.present? && project[:project].present? %>
<%= render "my/project_repo_mappings/edit_modal", project: project %>
<% if project[:has_mapping] %>
<% if show_archived %>
<%= render "my/project_repo_mappings/unarchive_modal", project: project %>
<% else %>
<%= render "my/project_repo_mappings/archive_modal", project: project %>
<% end %>
<% end %>
<% end %>
<div class="bg-dark border border-primary rounded-xl p-6 shadow-lg transition-all duration-300 flex flex-col gap-4 hover:border-primary/40 hover:-translate-y-1 hover:shadow-xl backdrop-blur-sm">
<div class="flex justify-between items-start gap-3">
@ -47,6 +54,29 @@
<path fill="currentColor" d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83l3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75z" />
</svg>
</button>
<% if project[:has_mapping] %>
<% if show_archived %>
<% unarchive_modal_id = "unarchive-project-modal-#{project[:project]&.parameterize || 'unknown'}" %>
<button type="button"
onclick="document.getElementById(<%= unarchive_modal_id.to_json %>).dispatchEvent(new CustomEvent('modal:open'))"
class="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 cursor-pointer"
title="Restore project">
<svg class="w-4 h-4 text-white/70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="m20.55 5.22l-1.39-1.68A1.51 1.51 0 0 0 18 3H6c-.47 0-.88.21-1.15.55L3.46 5.22C3.17 5.57 3 6.01 3 6.5V19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V6.5c0-.49-.17-.93-.45-1.28m-8.2 4.63L17.5 15H14v2h-4v-2H6.5l5.15-5.15c.19-.19.51-.19.7 0M5.12 5l.82-1h12l.93 1z"/>
</svg>
</button>
<% else %>
<% archive_modal_id = "archive-project-modal-#{project[:project]&.parameterize || 'unknown'}" %>
<button type="button"
onclick="document.getElementById(<%= archive_modal_id.to_json %>).dispatchEvent(new CustomEvent('modal:open'))"
class="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 cursor-pointer"
title="Archive project">
<svg class="w-4 h-4 text-white/70" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="m20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27m-8.89 11.92L6.5 12H10v-2h4v2h3.5l-5.15 5.15c-.19.19-.51.19-.7 0M5.12 5l.81-1h12l.94 1z"/>
</svg>
</button>
<% end %>
<% end %>
<% end %>
</div>
</div>

View file

@ -120,7 +120,12 @@ Rails.application.routes.draw do
post "my/settings/rotate_api_key", to: "users#rotate_api_key", as: :my_settings_rotate_api_key
namespace :my do
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ }
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
member do
patch :archive
patch :unarchive
end
end
# resource :mailing_address, only: [ :show, :edit ]
# get "mailroom", to: "mailroom#index"
resources :heartbeats, only: [] do
@ -131,6 +136,8 @@ Rails.application.routes.draw do
end
end
get "deletion", to: "deletion_requests#show", as: :deletion
post "deletion", to: "deletion_requests#create", as: :create_deletion
delete "deletion", to: "deletion_requests#cancel", as: :cancel_deletion

View file

@ -0,0 +1,6 @@
class AddArchivedAtToProjectRepoMappings < ActiveRecord::Migration[8.1]
def change
add_column :project_repo_mappings, :archived_at, :datetime
add_index :project_repo_mappings, [ :user_id, :archived_at ]
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_01_03_141942) do
ActiveRecord::Schema[8.1].define(version: 2026_01_05_230132) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -384,6 +384,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_03_141942) do
end
create_table "project_repo_mappings", force: :cascade do |t|
t.datetime "archived_at"
t.datetime "created_at", null: false
t.string "project_name", null: false
t.string "repo_url", null: false
@ -392,6 +393,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_03_141942) do
t.bigint "user_id", null: false
t.index ["project_name"], name: "index_project_repo_mappings_on_project_name"
t.index ["repository_id"], name: "index_project_repo_mappings_on_repository_id"
t.index ["user_id", "archived_at"], name: "index_project_repo_mappings_on_user_id_and_archived_at"
t.index ["user_id", "project_name"], name: "index_project_repo_mappings_on_user_id_and_project_name", unique: true
t.index ["user_id"], name: "index_project_repo_mappings_on_user_id"
end