From f3350234f5ea0e6ee55f84a34b5397205afbaad8 Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Mon, 16 Feb 2026 23:11:25 +0000 Subject: [PATCH] Modals! New projects page! Better dev imports! Fix OAuth2 projects! (#958) * Modals! New projects page! * Update modal close buttons * Make progress bar better * Various fixes + tests * Formatting * Fix tests? --- .github/workflows/ci.yml | 21 + app/assets/tailwind/main.css | 95 +++ app/controllers/inertia_controller.rb | 2 +- .../my/heartbeat_imports_controller.rb | 55 ++ .../my/project_repo_mappings_controller.rb | 134 +++- app/controllers/settings/base_controller.rb | 13 +- app/helpers/application_helper.rb | 9 +- app/javascript/components/Button.svelte | 19 +- app/javascript/components/Modal.svelte | 105 ++++ app/javascript/components/RailsModal.svelte | 77 +++ app/javascript/components/Select.svelte | 90 +++ app/javascript/entrypoints/rails_modals.ts | 54 ++ app/javascript/layouts/AppLayout.svelte | 136 ++-- .../pages/Home/signedIn/IntervalSelect.svelte | 192 +++--- .../pages/Home/signedIn/MultiSelect.svelte | 184 +++--- app/javascript/pages/Projects/Index.svelte | 584 ++++++++++++++++++ .../pages/Users/Settings/Access.svelte | 60 +- .../pages/Users/Settings/Admin.svelte | 81 ++- .../pages/Users/Settings/Badges.svelte | 27 +- .../pages/Users/Settings/Data.svelte | 274 +++++++- .../pages/Users/Settings/Integrations.svelte | 81 ++- .../pages/Users/Settings/Profile.svelte | 173 +++--- app/javascript/pages/Users/Settings/types.ts | 23 +- .../pages/WakatimeSetup/Step4.svelte | 15 +- app/jobs/heartbeat_import_job.rb | 7 + app/services/heartbeat_import_runner.rb | 140 +++++ app/services/heartbeat_import_service.rb | 21 +- app/services/project_stats_query.rb | 17 +- app/views/layouts/application.html.erb | 11 +- app/views/layouts/inertia.html.erb | 4 +- app/views/shared/_modal.html.erb | 130 ++-- bun.lock | 21 + config/routes.rb | 2 + package-lock.json | 127 ++++ package.json | 1 + .../my/heartbeat_imports_controller_test.rb | 118 ++++ .../project_repo_mappings_controller_test.rb | 54 ++ test/lib/project_stats_query_test.rb | 216 +++++++ 38 files changed, 2841 insertions(+), 532 deletions(-) create mode 100644 app/controllers/my/heartbeat_imports_controller.rb create mode 100644 app/javascript/components/Modal.svelte create mode 100644 app/javascript/components/RailsModal.svelte create mode 100644 app/javascript/components/Select.svelte create mode 100644 app/javascript/entrypoints/rails_modals.ts create mode 100644 app/javascript/pages/Projects/Index.svelte create mode 100644 app/jobs/heartbeat_import_job.rb create mode 100644 app/services/heartbeat_import_runner.rb create mode 100644 test/controllers/my/heartbeat_imports_controller_test.rb create mode 100644 test/controllers/my/project_repo_mappings_controller_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5bb9c4..cbc58e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,9 +128,19 @@ jobs: ruby-version: .ruby-version bundler-cache: true + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci + - name: Run tests env: RAILS_ENV: test + PARALLEL_WORKERS: 4 TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db PGHOST: localhost PGUSER: postgres @@ -142,10 +152,21 @@ jobs: psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;" || true psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;" || true psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;" || true + # Create per-worker variants for parallelized tests (e.g., test_wakatime_0) + for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do + psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime_${worker};" || true + psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log_${worker};" || true + psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse_${worker};" || true + done # Mirror schema from primary test DB so cross-db models can query safely in tests pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse + for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do + pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime_${worker} + pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log_${worker} + pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse_${worker} + done bin/rails test - name: Ensure Swagger docs are up to date diff --git a/app/assets/tailwind/main.css b/app/assets/tailwind/main.css index 274c344..4e6febc 100644 --- a/app/assets/tailwind/main.css +++ b/app/assets/tailwind/main.css @@ -147,6 +147,101 @@ select { } } +@keyframes bits-modal-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes bits-modal-overlay-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes bits-modal-content-in { + from { + opacity: 0; + transform: translateY(14px) scale(0.94); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes bits-modal-content-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(10px) scale(0.96); + } +} + +@keyframes dashboard-select-content-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes dashboard-select-content-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.97); + } +} + +.bits-modal-overlay[data-state="open"] { + animation: bits-modal-overlay-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.bits-modal-overlay[data-state="closed"] { + animation: bits-modal-overlay-out 180ms cubic-bezier(0.22, 1, 0.5, 1) both; +} + +.bits-modal-content { + will-change: transform, opacity; +} + +.bits-modal-content[data-state="open"] { + animation: bits-modal-content-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.bits-modal-content[data-state="closed"] { + animation: bits-modal-content-out 200ms cubic-bezier(0.4, 0, 1, 1) both; +} + +.dashboard-select-popover { + transform-origin: top left; + will-change: opacity, transform; +} + +.dashboard-select-popover[data-state="open"] { + animation: dashboard-select-content-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.dashboard-select-popover[data-state="closed"] { + animation: dashboard-select-content-out 130ms cubic-bezier(0.32, 0, 0.67, 0) both; +} + .animate-spin { animation: spin 1s linear infinite; } diff --git a/app/controllers/inertia_controller.rb b/app/controllers/inertia_controller.rb index b368055..9eeed6f 100644 --- a/app/controllers/inertia_controller.rb +++ b/app/controllers/inertia_controller.rb @@ -64,7 +64,7 @@ class InertiaController < ApplicationController links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path)) if current_user - links << inertia_link("Projects", my_projects_path, active: helpers.current_page?(my_projects_path)) + links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true) links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"), inertia: true) links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path), inertia: true) links << inertia_link("Settings", my_settings_path, active: request.path.start_with?("/my/settings"), inertia: true) diff --git a/app/controllers/my/heartbeat_imports_controller.rb b/app/controllers/my/heartbeat_imports_controller.rb new file mode 100644 index 0000000..424d01f --- /dev/null +++ b/app/controllers/my/heartbeat_imports_controller.rb @@ -0,0 +1,55 @@ +class My::HeartbeatImportsController < ApplicationController + before_action :ensure_current_user + before_action :ensure_development + + def create + unless params[:heartbeat_file].present? + render json: { error: "pls select a file to import" }, status: :unprocessable_entity + return + end + + file = params[:heartbeat_file] + unless valid_json_file?(file) + render json: { error: "pls upload only json (download from the button above it)" }, status: :unprocessable_entity + return + end + + import_id = HeartbeatImportRunner.start(user: current_user, uploaded_file: file) + status = HeartbeatImportRunner.status(user: current_user, import_id: import_id) + + render json: { + import_id: import_id, + status: status + }, status: :accepted + rescue => e + Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}") + render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error + end + + def show + status = HeartbeatImportRunner.status(user: current_user, import_id: params[:id]) + if status.present? + render json: status + else + render json: { error: "Import not found" }, status: :not_found + end + end + + private + + def valid_json_file?(file) + file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json") + end + + def ensure_current_user + return if current_user + + render json: { error: "You must be logged in to view this page." }, status: :unauthorized + end + + def ensure_development + return if Rails.env.development? + + render json: { error: "Heartbeat import is only available in development." }, status: :forbidden + end +end diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index 45bc78b..04786e4 100644 --- a/app/controllers/my/project_repo_mappings_controller.rb +++ b/app/controllers/my/project_repo_mappings_controller.rb @@ -1,17 +1,29 @@ -class My::ProjectRepoMappingsController < ApplicationController +class My::ProjectRepoMappingsController < InertiaController + layout "inertia", only: [ :index ] + before_action :ensure_current_user before_action :require_github_oauth, 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.active - @interval = params[:interval] || "daily" - @from = params[:from] - @to = params[:to] + archived = show_archived? - archived = params[:show_archived] == "true" - @project_count = project_count(archived) + render inertia: "Projects/Index", props: { + page_title: "My Projects", + index_path: my_projects_path, + show_archived: archived, + archived_count: current_user.project_repo_mappings.archived.count, + github_connected: current_user.github_uid.present?, + github_auth_path: github_auth_path, + settings_path: my_settings_path(anchor: "user_github_account"), + interval: selected_interval, + from: params[:from], + to: params[:to], + interval_label: helpers.human_interval_name(selected_interval, from: params[:from], to: params[:to]), + total_projects: project_count(archived), + projects_data: InertiaRails.defer { projects_payload(archived: archived) } + } end def edit @@ -73,9 +85,115 @@ class My::ProjectRepoMappingsController < ApplicationController params.require(:project_repo_mapping).permit(:repo_url) end + def show_archived? + params[:show_archived] == "true" + end + + def selected_interval + params[:interval] + end + + def project_durations_cache_key + key = "user_#{current_user.id}_project_durations_#{selected_interval}_v3" + if selected_interval == "custom" + sanitized_from = sanitized_cache_date(params[:from]) || "none" + sanitized_to = sanitized_cache_date(params[:to]) || "none" + key += "_#{sanitized_from}_#{sanitized_to}" + end + key + end + + def sanitized_cache_date(value) + value.to_s.gsub(/[^0-9-]/, "")[0, 10].presence + end + + def projects_payload(archived:) + mappings = current_user.project_repo_mappings.includes(:repository) + scoped_mappings = archived ? mappings.archived : mappings.active + mappings_by_name = scoped_mappings.index_by(&:project_name) + archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true) + labels_by_project_key = current_user.project_labels.pluck(:project_key, :label).to_h + + cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do + hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to]) + { + durations: hb.group(:project).duration_seconds, + total_time: hb.duration_seconds + } + end + + projects = cached[:durations].filter_map do |project_key, duration| + next if duration <= 0 + next if archived_names.key?(project_key) != archived + + mapping = mappings_by_name[project_key] + display_name = labels_by_project_key[project_key].presence || project_key.presence || "Unknown" + + { + id: project_card_id(project_key), + name: display_name, + project_key: project_key, + duration_seconds: duration, + duration_label: format_duration(duration), + duration_percent: 0, + repo_url: mapping&.repo_url, + repository: repository_payload(mapping&.repository), + broken_name: broken_project_name?(project_key, display_name), + manage_enabled: current_user.github_uid.present? && project_key.present?, + edit_path: project_key.present? ? edit_my_project_repo_mapping_path(CGI.escape(project_key)) : nil, + update_path: project_key.present? ? my_project_repo_mapping_path(CGI.escape(project_key)) : nil, + archive_path: project_key.present? ? archive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil, + unarchive_path: project_key.present? ? unarchive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil + } + end.sort_by { |project| -project[:duration_seconds] } + + max_duration = projects.map { |project| project[:duration_seconds].to_f }.max || 1.0 + + projects.each do |project| + project[:duration_percent] = ((project[:duration_seconds].to_f / max_duration) * 100).round(1) + end + + total_time = cached[:total_time].to_i + + { + total_time_seconds: total_time, + total_time_label: format_duration(total_time), + has_activity: total_time.positive?, + projects: projects + } + end + + def format_duration(seconds) + helpers.short_time_detailed(seconds).presence || "0m" + end + + def project_card_id(project_key) + raw_key = project_key.nil? ? "__nil__" : "str:#{project_key}" + "project-#{raw_key.unpack1('H*')}" + end + + def broken_project_name?(project_key, display_name) + key = project_key.to_s + name = display_name.to_s + + key.blank? || name.downcase == "unknown" || key.match?(/<<.*>>/) || name.match?(/<<.*>>/) + end + + def repository_payload(repository) + return nil unless repository + + { + homepage: repository.homepage, + stars: repository.stars, + description: repository.description, + formatted_languages: repository.formatted_languages, + last_commit_ago: repository.last_commit_at ? "#{helpers.time_ago_in_words(repository.last_commit_at)} ago" : nil + } + end + def project_count(archived) archived_names = current_user.project_repo_mappings.archived.pluck(:project_name) - hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to]) + hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to]) projects = hb.select(:project).distinct.pluck(:project) projects.count { |proj| archived_names.include?(proj) == archived } end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 1c2affa..cf67de2 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -53,6 +53,13 @@ class Settings::BaseController < InertiaController def settings_page_props(active_section:, settings_update_path:) heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id) + heartbeat_import_id = nil + heartbeat_import_status = nil + + if active_section.to_s == "data" && Rails.env.development? + heartbeat_import_id = params[:heartbeat_import_id].presence + heartbeat_import_status = HeartbeatImportRunner.status(user: @user, import_id: heartbeat_import_id) if heartbeat_import_id + end { active_section: active_section, @@ -97,7 +104,7 @@ class Settings::BaseController < InertiaController migrate_heartbeats_path: my_settings_migrate_heartbeats_path, export_all_heartbeats_path: export_my_heartbeats_path(format: :json, all_data: "true"), export_range_heartbeats_path: export_my_heartbeats_path(format: :json), - import_heartbeats_path: import_my_heartbeats_path, + create_heartbeat_import_path: my_heartbeat_imports_path, create_deletion_path: create_deletion_path, user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user) }, @@ -187,6 +194,10 @@ class Settings::BaseController < InertiaController ui: { show_dev_import: Rails.env.development? }, + heartbeat_import: { + import_id: heartbeat_import_id, + status: heartbeat_import_status + }, errors: { full_messages: @user.errors.full_messages, username: @user.errors[:username] diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2b31a9a..8b374a2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -210,8 +210,13 @@ module ApplicationHelper def modal_open_button(modal_id, text, **options) button_tag text, { type: "button", - data: { action: "click->modal#open" }, - onclick: "document.getElementById('#{modal_id}').querySelector('[data-controller=\"modal\"]').dispatchEvent(new CustomEvent('modal:open', { bubbles: true }))" + onclick: "document.getElementById('#{modal_id}')?.dispatchEvent(new CustomEvent('modal:open'))" }.merge(options) end + + def safe_asset_path(asset_name, fallback: nil) + asset_path(asset_name) + rescue StandardError + fallback.present? ? asset_path(fallback) : asset_name + end end diff --git a/app/javascript/components/Button.svelte b/app/javascript/components/Button.svelte index 88bb9df..26c887a 100644 --- a/app/javascript/components/Button.svelte +++ b/app/javascript/components/Button.svelte @@ -1,5 +1,6 @@ {#if href} - - {@render children?.()} - + {#if native} + + {@render children?.()} + + {:else} + + {@render children?.()} + + {/if} {:else} - + {/if} diff --git a/app/javascript/components/Modal.svelte b/app/javascript/components/Modal.svelte new file mode 100644 index 0000000..d035a54 --- /dev/null +++ b/app/javascript/components/Modal.svelte @@ -0,0 +1,105 @@ + + + + + + + +
+ +
+
+
+ {#if hasIcon} +
+ {@render icon?.()} +
+ {/if} + + + {title} + + + {#if description} + + {description} + + {/if} +
+ + + + +
+ + {#if hasBody} +
+ {@render body?.()} +
+ {/if} + + {#if hasActions} +
{@render actions?.()}
+ {/if} +
+
+
+
diff --git a/app/javascript/components/RailsModal.svelte b/app/javascript/components/RailsModal.svelte new file mode 100644 index 0000000..a534682 --- /dev/null +++ b/app/javascript/components/RailsModal.svelte @@ -0,0 +1,77 @@ + + + + {#snippet icon()} + {@html iconHtml} + {/snippet} + + {#snippet body()} + {@html customHtml} + {/snippet} + + {#snippet actions()} + {@html actionsHtml} + {/snippet} + diff --git a/app/javascript/components/Select.svelte b/app/javascript/components/Select.svelte new file mode 100644 index 0000000..b66922e --- /dev/null +++ b/app/javascript/components/Select.svelte @@ -0,0 +1,90 @@ + + + + + {selectedLabel} + + + + + + + {#each items as item} + + {#snippet children({ selected })} + {item.label} + {#if selected} + + {/if} + {/snippet} + + {/each} + + + + diff --git a/app/javascript/entrypoints/rails_modals.ts b/app/javascript/entrypoints/rails_modals.ts new file mode 100644 index 0000000..64e5241 --- /dev/null +++ b/app/javascript/entrypoints/rails_modals.ts @@ -0,0 +1,54 @@ +import { mount, unmount } from "svelte"; +import RailsModal from "../components/RailsModal.svelte"; + +type ModalComponent = ReturnType; + +const mountedModals = new Map(); + +function templateHtml(host: HTMLElement, selector: string): string { + const template = host.querySelector(selector); + return template?.innerHTML.trim() ?? ""; +} + +function mountModal(host: HTMLElement) { + if (mountedModals.has(host)) return; + if (!host.id) return; + + const props = { + modalId: host.id, + title: host.dataset.modalTitle ?? "Confirm", + description: host.dataset.modalDescription ?? "", + maxWidth: host.dataset.modalMaxWidth ?? "max-w-md", + iconHtml: templateHtml(host, "template[data-modal-icon]"), + customHtml: templateHtml(host, "template[data-modal-custom]"), + actionsHtml: templateHtml(host, "template[data-modal-actions]"), + }; + + host.replaceChildren(); + const component = mount(RailsModal, { target: host, props }); + mountedModals.set(host, component); +} + +function pruneUnmounted() { + for (const [host, component] of mountedModals) { + if (!host.isConnected) { + unmount(component); + mountedModals.delete(host); + } + } +} + +function mountAllRailsModals() { + pruneUnmounted(); + document + .querySelectorAll("[data-bits-modal]") + .forEach((host) => mountModal(host)); +} + +["DOMContentLoaded", "turbo:load", "turbo:render", "turbo:frame-load"].forEach( + (eventName) => { + document.addEventListener(eventName, mountAllRailsModals); + }, +); + +mountAllRailsModals(); diff --git a/app/javascript/layouts/AppLayout.svelte b/app/javascript/layouts/AppLayout.svelte index 6ac54fe..b23f01f 100644 --- a/app/javascript/layouts/AppLayout.svelte +++ b/app/javascript/layouts/AppLayout.svelte @@ -1,8 +1,9 @@ -
+
Date Range -
- - - {#if !isDefault} - - {/if} -
- - {#if open} +
-
- {#each INTERVALS as interval} -
+ {displayLabel} + + + + + {/snippet} + -
- -
- - + {#if !isDefault} -
+ {/if}
- {/if} + + + +
+ + {#each INTERVALS as interval} + + {#snippet children({ checked })} + + {interval.label} + {/snippet} + + {/each} + +
+ +
+
+ + +
+ +
+
+
+
diff --git a/app/javascript/pages/Home/signedIn/MultiSelect.svelte b/app/javascript/pages/Home/signedIn/MultiSelect.svelte index 5e52053..bed8968 100644 --- a/app/javascript/pages/Home/signedIn/MultiSelect.svelte +++ b/app/javascript/pages/Home/signedIn/MultiSelect.svelte @@ -1,4 +1,5 @@ -
+
{label} -
- - - {#if selected.length > 0} - - {/if} -
- - {#if open} +
- - -
- {#each filtered as value} -
+ {#if selected.length > 0} + + {/if}
- {/if} + + + + + +
+ onchange(next as string[])} + class="flex flex-col" + > + {#each filtered as value} + + {#snippet children({ checked })} + + ✓ + + {value} + {/snippet} + + {/each} + + + {#if filtered.length === 0} +
No results
+ {/if} +
+
+
+
diff --git a/app/javascript/pages/Projects/Index.svelte b/app/javascript/pages/Projects/Index.svelte new file mode 100644 index 0000000..4424a91 --- /dev/null +++ b/app/javascript/pages/Projects/Index.svelte @@ -0,0 +1,584 @@ + + + + {page_title} + + +
+
+
+

My Projects

+ {#if archived_count > 0} +
+ + Active + + + Archived + +
+ {/if} +
+
+ + {#if !github_connected} +
+

+ Heads up! You can't link projects to GitHub until you connect your + account. +

+
+ + +
+
+ {/if} + +
+ +
+ + {#if projects_data} +
+

+ {#if projects_data.has_activity} + You've spent + {projects_data.total_time_label} + coding across {show_archived ? "archived" : "active"} projects. + {:else} + You haven't logged any time for this interval yet. + {/if} +

+ + {#if projects_data.projects.length == 0} +
+

+ {show_archived + ? "No archived projects match this filter." + : "No active projects match this filter."} +

+
+ {:else} +
+ {#each projects_data.projects as project (project.id)} +
+
+
+

+ {project.name} +

+ {#if project.repository?.stars} +

+ + {project.repository.stars} +

+ {/if} +
+ +
+ {#if project.repository?.homepage} + + + + + + {/if} + {#if project.repo_url} + + + + + + {/if} + {#if project.manage_enabled} + + {/if} + {#if show_archived && project.unarchive_path} + + {:else if !show_archived && project.archive_path} + + {/if} +
+
+ +

+ {project.duration_label} +

+ + {#if project.repository?.description} +

+ {project.repository.description} +

+ {/if} + + {#if project.repository?.formatted_languages} +

+ + {project.repository.formatted_languages} +

+ {/if} + + {#if project.repository?.last_commit_ago} +

+ + Last commit {project.repository.last_commit_ago} +

+ {/if} + + {#if project.broken_name} +
+

+ Your editor may be sending invalid project names. Time is + shown here but can't be submitted to Hack Club programs. +

+
+ {/if} + + {#if project.manage_enabled && editingProjectKey === project.project_key && project.update_path} +
+
+ + + + + +
+ + +
+
+
+ {/if} +
+ {/each} +
+ {/if} +
+ {:else} +
+
+
+ {#each Array.from({ length: skeletonCount }) as _unused, index (index)} +
+
+
+
+
+
+
+ {/each} +
+
+ {/if} +
+ + + {#snippet actions()} + {#if pendingStatusAction} +
+ +
+ + + +
+
+ {/if} + {/snippet} +
diff --git a/app/javascript/pages/Users/Settings/Access.svelte b/app/javascript/pages/Users/Settings/Access.svelte index 7d2d059..3851bbe 100644 --- a/app/javascript/pages/Users/Settings/Access.svelte +++ b/app/javascript/pages/Users/Settings/Access.svelte @@ -1,6 +1,8 @@ - - + items={[ + { value: "", label: "Select a country" }, + ...options.countries, + ]} + />
- + items={options.timezones} + />
@@ -154,13 +147,16 @@ name="user[allow_public_stats_lookup]" value="0" /> - + class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary" + > + {#snippet children({ checked })} + + {/snippet} + Allow public stats lookup @@ -177,82 +173,77 @@ -
+ {#each options.themes as theme} - {@const isSelected = selectedTheme === theme.value} - diff --git a/app/javascript/pages/Users/Settings/types.ts b/app/javascript/pages/Users/Settings/types.ts index ee8a0c4..79aeceb 100644 --- a/app/javascript/pages/Users/Settings/types.ts +++ b/app/javascript/pages/Users/Settings/types.ts @@ -60,7 +60,7 @@ export type PathsProps = { migrate_heartbeats_path: string; export_all_heartbeats_path: string; export_range_heartbeats_path: string; - import_heartbeats_path: string; + create_heartbeat_import_path: string; create_deletion_path: string; user_wakatime_mirrors_path: string; }; @@ -138,6 +138,26 @@ export type UiProps = { show_dev_import: boolean; }; +export type HeartbeatImportStatusProps = { + import_id: string; + state: string; + progress_percent: number; + processed_count: number; + total_count: number | null; + imported_count: number | null; + skipped_count: number | null; + errors_count: number; + message: string; + updated_at: string; + started_at?: string; + finished_at?: string; +}; + +export type HeartbeatImportProps = { + import_id?: string | null; + status?: HeartbeatImportStatusProps | null; +}; + export type ErrorsProps = { full_messages: string[]; username: string[]; @@ -189,6 +209,7 @@ export type DataPageProps = SettingsCommonProps & { migration: MigrationProps; data_export: DataExportProps; ui: UiProps; + heartbeat_import: HeartbeatImportProps; }; export type AdminPageProps = SettingsCommonProps & { diff --git a/app/javascript/pages/WakatimeSetup/Step4.svelte b/app/javascript/pages/WakatimeSetup/Step4.svelte index cee42be..6febe48 100644 --- a/app/javascript/pages/WakatimeSetup/Step4.svelte +++ b/app/javascript/pages/WakatimeSetup/Step4.svelte @@ -1,4 +1,5 @@ - + @@ -249,6 +250,6 @@
- <%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah hold on a sec', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %> + <%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah, hold on a sec!', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %> diff --git a/app/views/layouts/inertia.html.erb b/app/views/layouts/inertia.html.erb index c6c2025..b18f792 100644 --- a/app/views/layouts/inertia.html.erb +++ b/app/views/layouts/inertia.html.erb @@ -17,7 +17,7 @@ - + @@ -27,7 +27,7 @@ - + <%= csrf_meta_tags %> <%= csp_meta_tag %> diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb index d1e43e4..45aaaac 100644 --- a/app/views/shared/_modal.html.erb +++ b/app/views/shared/_modal.html.erb @@ -7,60 +7,88 @@ buttons ||= [] max_width ||= 'max-w-md' custom ||= nil + + custom_html = + if custom.is_a?(String) + sanitize( + custom, + tags: %w[div p span a button form input label select option textarea h1 h2 h3 h4 h5 h6 ul ol li strong em code pre hr br svg path circle rect line polyline polygon], + attributes: %w[class id href type name value placeholder data-controller data-action data-target data-token for method required disabled readonly accept maxlength action style target rel] + ) + elsif custom.present? + custom + else + nil + end + + actions_html = capture do + if buttons.any? %> - -