From e3456be1874d7a4dbbf52eb7c14c7910da065aea Mon Sep 17 00:00:00 2001 From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:47:01 +0000 Subject: [PATCH] goaaaal! (#985) * Add goals * Fix up some migrations * Formatting * Simplify migration * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Fix svelte-check issues, make CI less janky on dev * svelte-check/fix tests * Fix N+1s * Formatting! * More tests, fix anonymization and N+1 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 26 +- AGENTS.md | 39 +- Dockerfile.dev | 5 +- app/controllers/profiles_controller.rb | 101 ++++- app/controllers/settings/base_controller.rb | 40 +- app/controllers/settings/goals_controller.rb | 65 +++ app/controllers/static_pages_controller.rb | 17 +- app/javascript/components/Modal.svelte | 2 +- .../components/MultiSelectCombobox.svelte | 150 +++++++ app/javascript/components/Select.svelte | 2 +- app/javascript/layouts/AppLayout.svelte | 21 +- app/javascript/pages/Home/SignedIn.svelte | 15 + .../pages/Home/signedIn/Dashboard.svelte | 15 + .../Home/signedIn/GoalsProgressCard.svelte | 201 +++++++++ .../pages/OAuthApplications/Form.svelte | 12 +- .../pages/OAuthAuthorize/Error.svelte | 2 +- .../pages/OAuthAuthorize/New.svelte | 2 +- .../pages/OAuthAuthorize/Show.svelte | 2 +- app/javascript/pages/Profiles/Show.svelte | 270 ++++++++++++ .../pages/Users/Settings/Goals.svelte | 403 ++++++++++++++++++ .../pages/Users/Settings/Integrations.svelte | 6 +- .../pages/Users/Settings/Profile.svelte | 9 +- app/javascript/pages/Users/Settings/types.ts | 44 ++ app/models/goal.rb | 86 ++++ app/models/user.rb | 1 + app/services/anonymize_user_service.rb | 12 +- .../programming_goals_progress_service.rb | 78 ++++ config/database.yml | 21 +- config/routes.rb | 4 + ...5517_add_public_profile_fields_to_users.rb | 12 + db/migrate/20260219153152_create_goals.rb | 16 + db/schema.rb | 23 +- docker-compose.yml | 1 + test/controllers/profiles_controller_test.rb | 62 +++ .../settings_goals_controller_test.rb | 124 ++++++ test/models/goal_test.rb | 118 +++++ test/services/anonymize_user_service_test.rb | 40 ++ ...programming_goals_progress_service_test.rb | 141 ++++++ 38 files changed, 2106 insertions(+), 82 deletions(-) create mode 100644 app/controllers/settings/goals_controller.rb create mode 100644 app/javascript/components/MultiSelectCombobox.svelte create mode 100644 app/javascript/pages/Home/signedIn/GoalsProgressCard.svelte create mode 100644 app/javascript/pages/Profiles/Show.svelte create mode 100644 app/javascript/pages/Users/Settings/Goals.svelte create mode 100644 app/models/goal.rb create mode 100644 app/services/programming_goals_progress_service.rb create mode 100644 db/migrate/20260219125517_add_public_profile_fields_to_users.rb create mode 100644 db/migrate/20260219153152_create_goals.rb create mode 100644 test/controllers/profiles_controller_test.rb create mode 100644 test/controllers/settings_goals_controller_test.rb create mode 100644 test/models/goal_test.rb create mode 100644 test/services/anonymize_user_service_test.rb create mode 100644 test/services/programming_goals_progress_service_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5195230..7d9540e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: test_db + POSTGRES_DB: app_test steps: - name: Checkout code @@ -139,39 +139,19 @@ jobs: - name: Run tests env: RAILS_ENV: test - PARALLEL_WORKERS: 4 - TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test PGHOST: localhost PGUSER: postgres PGPASSWORD: postgres run: | bin/rails db:create RAILS_ENV=test bin/rails db:schema:load RAILS_ENV=test - # Create additional test databases used by multi-db models - 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 env: RAILS_ENV: test - TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db + TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test PGHOST: localhost PGUSER: postgres PGPASSWORD: postgres diff --git a/AGENTS.md b/AGENTS.md index a603420..9d0d2fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,31 @@ # AGENT.md - Rails Hackatime/Harbor Project -We do development using docker-compose. Run `docker-compose ps` to see if the dev server is running. If it is, then you can restart the dev server with `touch tmp/restart.txt`. If not, then bring the dev server up with `docker-compose up`. +We do development using docker-compose. Run `docker compose ps` to see if the dev server is running. If it is, then you can restart the dev server with `touch tmp/restart.txt`. If not, bring the containers up first with `docker compose up -d`. + +**IMPORTANT**: Always use `docker compose exec` (not `run`) to execute commands in the existing container. `run` creates a new container each time; `exec` reuses the running one. ## Commands (via Docker Compose) -- **Tests**: `docker compose run web rails test` (all), `docker compose run web rails test test/models/user_test.rb` (single file), `docker compose run web rails test test/models/user_test.rb -n test_method_name` (single test) - Note: Limited test coverage -- **Lint**: `docker compose run web bundle exec rubocop` (check), `docker compose run web bundle exec rubocop -A` (auto-fix) -- **Console**: `docker compose run web rails c` (interactive console) -- **Server**: `docker compose run --service-ports web rails s -b 0.0.0.0` (development server) -- **Database**: `docker compose run web rails db:migrate`, `docker compose run web rails db:create`, `docker compose run web rails db:schema:load`, `docker compose run web rails db:seed` -- **Security**: `docker compose run web bundle exec brakeman` (security audit) -- **JS Security**: `docker compose run web bin/importmap audit` (JS dependency scan) -- **Zeitwerk**: `docker compose run web bin/rails zeitwerk:check` (autoloader check) -- **Swagger**: `docker compose run web bin/rails rswag:specs:swaggerize` (generate API docs) +- **Tests**: `docker compose exec web rails test` (all), `docker compose exec web rails test test/models/user_test.rb` (single file), `docker compose exec web rails test test/models/user_test.rb -n test_method_name` (single test) - Note: Limited test coverage +- **Lint**: `docker compose exec web bundle exec rubocop` (check), `docker compose exec web bundle exec rubocop -A` (auto-fix) +- **Console**: `docker compose exec web rails c` (interactive console) +- **Server**: `docker compose exec web rails s -b 0.0.0.0` (development server) +- **Database**: `docker compose exec web rails db:migrate`, `docker compose exec web rails db:create`, `docker compose exec web rails db:schema:load`, `docker compose exec web rails db:seed` +- **Security**: `docker compose exec web bundle exec brakeman` (security audit) +- **JS Security**: `docker compose exec web bin/importmap audit` (JS dependency scan) +- **Zeitwerk**: `docker compose exec web bin/rails zeitwerk:check` (autoloader check) +- **Swagger**: `docker compose exec web bin/rails rswag:specs:swaggerize` (generate API docs) ## CI/Testing Requirements **Before marking any task complete, run ALL CI checks locally:** -1. `docker compose run web bundle exec rubocop` (lint check) -2. `docker compose run web bundle exec brakeman` (security scan) -3. `docker compose run web bin/importmap audit` (JS security) -4. `docker compose run web bin/rails zeitwerk:check` (autoloader) -5. `docker compose run web rails test` (full test suite) -6. `docker compose run web bin/rails rswag:specs:swaggerize` (ensure docs are up to date) +1. `docker compose exec web bundle exec rubocop` (lint check) +2. `docker compose exec web bundle exec brakeman` (security scan) +3. `docker compose exec web bin/importmap audit` (JS security) +4. `docker compose exec web bin/rails zeitwerk:check` (autoloader) +5. `docker compose exec web rails test` (full test suite) +6. `docker compose exec web bin/rails rswag:specs:swaggerize` (ensure docs are up to date) ## API Documentation @@ -33,8 +35,9 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de ## Docker Development -- **Interactive shell**: `docker compose run --service-ports web /bin/bash` -- **Initial setup**: `docker compose run web bin/rails db:create db:schema:load db:seed` +- **Start containers**: `docker compose up -d` (must be running before using `exec`) +- **Interactive shell**: `docker compose exec web /bin/bash` +- **Initial setup**: `docker compose exec web bin/rails db:create db:schema:load db:seed` - **Cleanup**: Run commands with the `--remove-orphans` flag to remove unused containers and images ## Git Practices diff --git a/Dockerfile.dev b/Dockerfile.dev index 7665e16..44f40ac 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -25,6 +25,9 @@ WORKDIR /app # Install npm dependencies for Vite COPY package.json bun.lock ./ +# Local file deps in package.json need to exist before bun install. +COPY vendor/inertia/packages/core/package.json ./vendor/inertia/packages/core/package.json +COPY vendor/inertia/packages/svelte/package.json ./vendor/inertia/packages/svelte/package.json RUN bun install # Install application dependencies @@ -44,4 +47,4 @@ EXPOSE 3000 EXPOSE 3036 # Start the main process -CMD ["rails", "server", "-b", "0.0.0.0"] \ No newline at end of file +CMD ["rails", "server", "-b", "0.0.0.0"] diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index c8cf7f9..6e1ff07 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -1,16 +1,23 @@ -class ProfilesController < ApplicationController +class ProfilesController < InertiaController + layout "inertia" + before_action :find_user before_action :check_profile_visibility, only: %i[time_stats projects languages editors activity] def show if @user.nil? - render :not_found, status: :not_found, formats: [ :html ] + render inertia: "Errors/NotFound", props: { + status_code: 404, + title: "Page Not Found", + message: "The profile you were looking for doesn't exist." + }, status: :not_found return end - @is_own_profile = current_user && current_user.id == @user.id + @is_own_profile = current_user.present? && current_user.id == @user.id @profile_visible = @user.allow_public_stats_lookup || @is_own_profile - @streak_days = @user.streak_days if @profile_visible + + render inertia: "Profiles/Show", props: profile_props end def time_stats @@ -58,6 +65,92 @@ class ProfilesController < ApplicationController @user = User.find_by(username: params[:username]) end + def profile_props + { + page_title: profile_page_title, + profile_visible: @profile_visible, + is_own_profile: @is_own_profile, + edit_profile_path: (@is_own_profile ? my_settings_profile_path : nil), + profile: profile_summary_payload, + stats: (@profile_visible ? profile_stats_payload : nil) + } + end + + def profile_page_title + username = @user.username.present? ? "@#{@user.username}" : @user.display_name + "#{username} | Hackatime" + end + + def profile_summary_payload + { + display_name: @user.display_name_override.presence || @user.display_name, + username: (@user.username || ""), + avatar_url: @user.avatar_url, + trust_level: @user.trust_level, + bio: @user.profile_bio, + social_links: profile_social_links, + github_profile_url: @user.github_profile_url, + github_username: @user.github_username, + streak_days: (@profile_visible ? @user.streak_days : nil) + } + end + + def profile_social_links + links = [] + + links << { key: "github", label: "GitHub", url: @user.profile_github_url } if @user.profile_github_url.present? + links << { key: "twitter", label: "Twitter", url: @user.profile_twitter_url } if @user.profile_twitter_url.present? + links << { key: "bluesky", label: "Bluesky", url: @user.profile_bluesky_url } if @user.profile_bluesky_url.present? + links << { key: "linkedin", label: "LinkedIn", url: @user.profile_linkedin_url } if @user.profile_linkedin_url.present? + links << { key: "discord", label: "Discord", url: @user.profile_discord_url } if @user.profile_discord_url.present? + links << { key: "website", label: "Website", url: @user.profile_website_url } if @user.profile_website_url.present? + + links + end + + def profile_stats_payload + h = ApplicationController.helpers + timezone = @user.timezone + stats = ProfileStatsService.new(@user).stats + + durations = Rails.cache.fetch("user_#{@user.id}_daily_durations_#{timezone}", expires_in: 1.minute) do + Time.use_zone(timezone) { @user.heartbeats.daily_durations(user_timezone: timezone).to_h } + end + + { + totals: { + today_seconds: stats[:total_time_today], + week_seconds: stats[:total_time_week], + all_seconds: stats[:total_time_all], + today_label: h.short_time_simple(stats[:total_time_today]), + week_label: h.short_time_simple(stats[:total_time_week]), + all_label: h.short_time_simple(stats[:total_time_all]) + }, + top_projects_month: stats[:top_projects_month].map { |project| + { + project: project[:project], + duration_seconds: project[:duration], + duration_label: h.short_time_simple(project[:duration]), + repo_url: project[:repo_url] + } + }, + top_languages: stats[:top_languages].map { |language, duration| + [ h.display_language_name(language), duration ] + }, + top_editors: stats[:top_editors].map { |editor, duration| + [ h.display_editor_name(editor), duration ] + }, + activity_graph: { + start_date: 365.days.ago.to_date.iso8601, + end_date: Time.current.to_date.iso8601, + duration_by_date: durations.transform_keys { |date| date.to_date.iso8601 }.transform_values(&:to_i), + busiest_day_seconds: 8.hours.to_i, + timezone_label: ActiveSupport::TimeZone[timezone].to_s, + timezone_settings_path: "/my/settings#user_timezone" + } + } + end + def check_profile_visibility return if @user.nil? diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index a245122..0d2a296 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -22,6 +22,7 @@ class Settings::BaseController < InertiaController "profile" => "Users/Settings/Profile", "integrations" => "Users/Settings/Integrations", "access" => "Users/Settings/Access", + "goals" => "Users/Settings/Goals", "badges" => "Users/Settings/Badges", "data" => "Users/Settings/Data", "admin" => "Users/Settings/Admin" @@ -40,6 +41,18 @@ class Settings::BaseController < InertiaController @heartbeats_migration_jobs = @user.data_migration_jobs @projects = @user.project_repo_mappings.distinct.pluck(:project_name) + heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project) + goal_languages = [] + goal_projects = @projects.dup + + heartbeat_language_and_projects.each do |language, project| + categorized_language = language&.categorize_language + goal_languages << categorized_language if categorized_language.present? + goal_projects << project if project.present? + end + + @goal_selectable_languages = goal_languages.uniq.sort + @goal_selectable_projects = goal_projects.uniq.sort @work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil @work_time_stats_url = if @work_time_stats_base_url.present? "#{@work_time_stats_base_url}#{@projects.first || 'example'}" @@ -67,14 +80,16 @@ class Settings::BaseController < InertiaController profile: my_settings_profile_path, integrations: my_settings_integrations_path, access: my_settings_access_path, + goals: my_settings_goals_path, badges: my_settings_badges_path, data: my_settings_data_path, admin: my_settings_admin_path }, page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"), heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"), - subheading: "Manage your profile, integrations, API access, and data tools.", + subheading: "Manage your profile, integrations, access, goals, and data tools.", settings_update_path: settings_update_path, + create_goal_path: my_settings_goals_create_path, username_max_length: User::USERNAME_MAX_LENGTH, user: { id: @user.id, @@ -90,7 +105,13 @@ class Settings::BaseController < InertiaController can_request_deletion: @user.can_request_deletion?, github_uid: @user.github_uid, github_username: @user.github_username, - slack_uid: @user.slack_uid + slack_uid: @user.slack_uid, + programming_goals: @user.goals.order(:created_at).map { |goal| + goal.as_programming_goal_payload.merge( + update_path: my_settings_goal_update_path(goal), + destroy_path: my_settings_goal_destroy_path(goal) + ) + } }, paths: { settings_path: settings_update_path, @@ -125,7 +146,20 @@ class Settings::BaseController < InertiaController } }, themes: User.theme_options, - badge_themes: GithubReadmeStats.themes + badge_themes: GithubReadmeStats.themes, + goals: { + periods: Goal::PERIODS.map { |period| + { + label: period.humanize, + value: period + } + }, + preset_target_seconds: Goal::PRESET_TARGET_SECONDS, + selectable_languages: @goal_selectable_languages + .map { |language| { label: language, value: language } }, + selectable_projects: @goal_selectable_projects + .map { |project| { label: project, value: project } } + } }, slack: { can_enable_status: @can_enable_slack_status, diff --git a/app/controllers/settings/goals_controller.rb b/app/controllers/settings/goals_controller.rb new file mode 100644 index 0000000..3850fcd --- /dev/null +++ b/app/controllers/settings/goals_controller.rb @@ -0,0 +1,65 @@ +class Settings::GoalsController < Settings::BaseController + def show + render_goals + end + + def create + @goal = @user.goals.build(goal_params) + + if @goal.save + PosthogService.capture(@user, "settings_updated", { fields: [ "programming_goals" ] }) + redirect_to my_settings_goals_path, notice: "Goal created." + else + flash.now[:error] = @goal.errors.full_messages.to_sentence + render_goals(status: :unprocessable_entity, goal_form: goal_form_props(@goal, "create")) + end + end + + def update + @goal = @user.goals.find(params[:goal_id]) + + if @goal.update(goal_params) + PosthogService.capture(@user, "settings_updated", { fields: [ "programming_goals" ] }) + redirect_to my_settings_goals_path, notice: "Goal updated." + else + flash.now[:error] = @goal.errors.full_messages.to_sentence + render_goals(status: :unprocessable_entity, goal_form: goal_form_props(@goal, "edit")) + end + end + + def destroy + @goal = @user.goals.find(params[:goal_id]) + @goal.destroy! + PosthogService.capture(@user, "settings_updated", { fields: [ "programming_goals" ] }) + redirect_to my_settings_goals_path, notice: "Goal deleted." + end + + private + + def render_goals(status: :ok, goal_form: nil) + extra_props = {} + extra_props[:goal_form] = goal_form if goal_form + + render inertia: settings_component_for("goals"), props: settings_page_props( + active_section: "goals", + settings_update_path: my_settings_goals_path + ).merge(extra_props), status: status + end + + def goal_params + params.require(:goal).permit(:period, :target_seconds, languages: [], projects: []) + end + + def goal_form_props(goal, mode) + { + open: true, + mode: mode, + goal_id: goal.id&.to_s, + period: goal.period, + target_seconds: goal.target_seconds, + languages: goal.languages, + projects: goal.projects, + errors: goal.errors.full_messages + } + end +end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 4ebdbe9..835d12c 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -153,7 +153,8 @@ class StaticPagesController < InertiaController { filterable_dashboard_data: filterable_dashboard_data, activity_graph: activity_graph_data, - today_stats: today_stats_data + today_stats: today_stats_data, + programming_goals_progress: programming_goals_progress_data } end @@ -170,4 +171,18 @@ class StaticPagesController < InertiaController home_stats: @home_stats || {} } end + + def programming_goals_progress_data + goals = current_user.goals.order(:id) + return [] if goals.blank? + + goals_hash = ActiveSupport::Digest.hexdigest( + goals.pluck(:id, :period, :target_seconds, :languages, :projects).to_json + ) + cache_key = "user_#{current_user.id}_programming_goals_progress_#{current_user.timezone}_#{goals_hash}" + + Rails.cache.fetch(cache_key, expires_in: 1.minute) do + ProgrammingGoalsProgressService.new(user: current_user, goals: goals).call + end + end end diff --git a/app/javascript/components/Modal.svelte b/app/javascript/components/Modal.svelte index d035a54..8742d59 100644 --- a/app/javascript/components/Modal.svelte +++ b/app/javascript/components/Modal.svelte @@ -44,7 +44,7 @@
-
+
{#if hasIcon}
+ import { Popover } from "bits-ui"; + import Button from "./Button.svelte"; + + type Option = { + label: string; + value: string; + }; + + let { + label, + placeholder = "Select...", + emptyText = "No results", + options = [], + selected = $bindable([]), + }: { + label: string; + placeholder?: string; + emptyText?: string; + options: Option[]; + selected?: string[]; + } = $props(); + + let open = $state(false); + let search = $state(""); + + const filtered = $derived( + options.filter((option) => + option.label.toLowerCase().includes(search.trim().toLowerCase()), + ), + ); + + function toggle(value: string) { + if (selected.includes(value)) { + selected = selected.filter((entry) => entry !== value); + return; + } + + selected = [...selected, value]; + } + + function remove(value: string, event: MouseEvent) { + event.stopPropagation(); + selected = selected.filter((entry) => entry !== value); + } + + $effect(() => { + if (!open) { + search = ""; + } + }); + + +
+

{label}

+ + + + {#snippet child({ props })} +
+ {#if selected.length === 0} +
+

{placeholder}

+ + + +
+ {:else} +
+ {#each selected as value} + + {options.find((option) => option.value === value)?.label || + value} + + + {/each} +
+ {/if} +
+ {/snippet} +
+ + + + + +
+ {#if filtered.length > 0} + {#each filtered as option} + {@const isSelected = selected.includes(option.value)} + + {/each} + {:else} +

{emptyText}

+ {/if} +
+
+
+
+
diff --git a/app/javascript/components/Select.svelte b/app/javascript/components/Select.svelte index 79e15e9..1e44513 100644 --- a/app/javascript/components/Select.svelte +++ b/app/javascript/components/Select.svelte @@ -65,7 +65,7 @@ 0); + let flashVisible = $state(false); let flashHiding = $state(false); const flashHideDelay = 6000; const flashExitDuration = 250; + const currentlyHackingPollInterval = () => + layout.currently_hacking?.interval || 30000; const toggleNav = () => (navOpen = !navOpen); const closeNav = () => (navOpen = false); const openLogout = () => (logoutOpen = true); const closeLogout = () => (logoutOpen = false); - usePoll(layout.currently_hacking?.interval || 30000, { + usePoll(currentlyHackingPollInterval(), { only: ["currently_hacking"], }); @@ -307,7 +309,13 @@ /> - +
+ +
+ import { secondsToDisplay } from "./utils"; + + let { + goals, + }: { + goals: { + id: string; + period: "day" | "week" | "month"; + target_seconds: number; + tracked_seconds: number; + completion_percent: number; + complete: boolean; + languages: string[]; + projects: string[]; + period_end: string; + }[]; + } = $props(); + + const percentWidth = (percent: number) => + `${Math.max(0, Math.min(percent || 0, 100))}%`; + + const periodLabel = (period: "day" | "week" | "month") => { + if (period === "day") return "Daily goal"; + if (period === "week") return "Weekly goal"; + return "Monthly goal"; + }; + + const scopeSubtitle = (goal: { languages: string[]; projects: string[] }) => { + const languageScope = + goal.languages.length > 0 + ? `Languages: ${goal.languages.join(", ")}` + : ""; + const projectScope = + goal.projects.length > 0 ? `Projects: ${goal.projects.join(", ")}` : ""; + + if (languageScope && projectScope) { + return `${languageScope} AND ${projectScope}`; + } + + return languageScope || projectScope || "All programming activity"; + }; + + function lastItemSpanClass(index: number, total: number): string { + if (index !== total - 1) return ""; + const parts: string[] = []; + + // 2-column grid (sm): last item fills the row if total is odd + if (total % 2 === 1) parts.push("sm:col-span-2"); + + // 3-column grid (lg): last item fills remaining columns + const lgRemainder = total % 3; + if (lgRemainder === 1) parts.push("lg:col-span-3"); + else if (lgRemainder === 2) parts.push("lg:col-span-2"); + else parts.push("lg:col-span-1"); // reset sm:col-span-2 + + return parts.join(" "); + } + + // Arc progress ring + const RADIUS = 20; + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + const strokeDashoffset = (percent: number) => { + const clamped = Math.max(0, Math.min(percent || 0, 100)); + return CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE; + }; + + const periodTimeLeft = (goal: { period_end: string; complete: boolean }) => { + if (goal.complete) return "Done!"; + const now = new Date(); + const end = new Date(goal.period_end); + const diffMs = end.getTime() - now.getTime(); + if (diffMs <= 0) return "Period ended"; + + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + + if (diffHours < 1) { + const mins = Math.ceil(diffMs / (1000 * 60)); + return `${mins}m left today`; + } + if (diffHours < 24) { + return `${Math.ceil(diffHours)}h left today`; + } + return `${diffDays} day${diffDays === 1 ? "" : "s"} left`; + }; + + +{#if goals.length > 0} +
+ {#each goals as goal, i} +
+
+ +
+

+ {secondsToDisplay(goal.tracked_seconds)} + / {secondsToDisplay(goal.target_seconds)} +

+

{scopeSubtitle(goal)}

+
+ + +
+
+

+ {periodLabel(goal.period)} +

+

+ {periodTimeLeft(goal)} +

+
+ + + + + + + + + + {#if goal.complete} + + {:else} + {Math.round(goal.completion_percent)}% + {/if} + +
+
+ + +
+
+
+
+ {/each} +
+{/if} diff --git a/app/javascript/pages/OAuthApplications/Form.svelte b/app/javascript/pages/OAuthApplications/Form.svelte index 1851df0..5dac1c9 100644 --- a/app/javascript/pages/OAuthApplications/Form.svelte +++ b/app/javascript/pages/OAuthApplications/Form.svelte @@ -21,9 +21,15 @@ .querySelector("meta[name='csrf-token']") ?.getAttribute("content") || ""; - let selectedScopes = $state([...(application.selected_scopes || [])]); - let confidential = $state(Boolean(application.confidential)); - let redirectUri = $state(application.redirect_uri); + let selectedScopes = $state([]); + let confidential = $state(false); + let redirectUri = $state(""); + + $effect(() => { + selectedScopes = [...(application.selected_scopes || [])]; + confidential = Boolean(application.confidential); + redirectUri = application.redirect_uri; + }); const nameLocked = $derived(application.persisted && application.verified); diff --git a/app/javascript/pages/OAuthAuthorize/Error.svelte b/app/javascript/pages/OAuthAuthorize/Error.svelte index b332baa..b213869 100644 --- a/app/javascript/pages/OAuthAuthorize/Error.svelte +++ b/app/javascript/pages/OAuthAuthorize/Error.svelte @@ -1,4 +1,4 @@ - diff --git a/app/javascript/pages/OAuthAuthorize/New.svelte b/app/javascript/pages/OAuthAuthorize/New.svelte index c766a12..284a460 100644 --- a/app/javascript/pages/OAuthAuthorize/New.svelte +++ b/app/javascript/pages/OAuthAuthorize/New.svelte @@ -1,4 +1,4 @@ - diff --git a/app/javascript/pages/OAuthAuthorize/Show.svelte b/app/javascript/pages/OAuthAuthorize/Show.svelte index 374c61e..ad9c2d5 100644 --- a/app/javascript/pages/OAuthAuthorize/Show.svelte +++ b/app/javascript/pages/OAuthAuthorize/Show.svelte @@ -1,4 +1,4 @@ - diff --git a/app/javascript/pages/Profiles/Show.svelte b/app/javascript/pages/Profiles/Show.svelte new file mode 100644 index 0000000..dd63d93 --- /dev/null +++ b/app/javascript/pages/Profiles/Show.svelte @@ -0,0 +1,270 @@ + + + + {page_title} + + +
+
+
+
+ {profile.display_name} +
+
+

+ {profile.display_name} +

+ {#if profile.trust_level === "green"} + + Verified + + {/if} + {#if profile.streak_days && profile.streak_days > 0} + + Streak: {profile.streak_days} days + + {/if} +
+ +

@{profile.username}

+ + {#if profile.bio} +

+ {profile.bio} +

+ {/if} +
+
+ + {#if is_own_profile && edit_profile_path} +
+ +
+ {/if} +
+ + {#if profile.social_links.length > 0} +
+ {#each profile.social_links as link} + + {link.label} + + {/each} +
+ {/if} +
+ + {#if profile_visible} + {#if hasStats && stats} +
+
+
Today
+
+ {stats.totals.today_label} +
+
+
+
+ This Week +
+
+ {stats.totals.week_label} +
+
+
+
All Time
+
+ {stats.totals.all_label} +
+
+
+ +
+
+

+ Top Projects +

+

Past month

+
+ + {#if stats.top_projects_month.length > 0} +
+ {#each stats.top_projects_month as project} +
+
+

+ {project.project || "Unknown"} +

+ {project.duration_label} +
+ + {#if project.repo_url} + + Open repository + + {/if} +
+ {/each} +
+ {:else} +

+ No project activity in the past month. +

+ {/if} +
+ +
+ + + +
+ +
+

Activity

+ +
+ {:else} +
+

Loading profile stats...

+
+ {/if} + {:else} +
+

+ Stats are private +

+

+ This user chose not to share coding stats publicly. +

+ {#if is_own_profile && edit_profile_path} +
+ +
+ {/if} +
+ {/if} + +
+ Explore leaderboards +
+
diff --git a/app/javascript/pages/Users/Settings/Goals.svelte b/app/javascript/pages/Users/Settings/Goals.svelte new file mode 100644 index 0000000..f7ed8d3 --- /dev/null +++ b/app/javascript/pages/Users/Settings/Goals.svelte @@ -0,0 +1,403 @@ + + + +
+
+
+
+

+ Programming Goals +

+

Set up to {MAX_GOALS} goals.

+
+ +
+

+ {activeGoalSummary} +

+ +
+
+ + {#if goals.length === 0} +
+

+ Set a goal to track your coding consistency. +

+ +
+ {:else} +
+ {#each goals as goal (goal.id)} +
+
+

+ {formatPeriod(goal.period)}: {formatDuration( + goal.target_seconds, + )} +

+

+ {scopeSubtitle(goal)} +

+
+ +
+ + +
+
+ {/each} +
+ {/if} +
+
+
+ + + {#snippet body()} +
+
+ I want to code for + + +
+
+ +
+ {#each QUICK_TARGETS as quickTarget} + {@const isActive = quickTarget.seconds === currentTargetSeconds} + + {/each} +
+ +
+ + + +
+ + {#if modalErrors.length > 0} +

+ {modalErrors.join(", ")} +

+ {/if} +
+ {/snippet} + + {#snippet actions()} +
+ + +
+ {/snippet} + diff --git a/app/javascript/pages/Users/Settings/Integrations.svelte b/app/javascript/pages/Users/Settings/Integrations.svelte index aec1d47..ef53a03 100644 --- a/app/javascript/pages/Users/Settings/Integrations.svelte +++ b/app/javascript/pages/Users/Settings/Integrations.svelte @@ -23,9 +23,13 @@ }: IntegrationsPageProps = $props(); let csrfToken = $state(""); - let usesSlackStatus = $state(user.uses_slack_status); + let usesSlackStatus = $state(false); let unlinkGithubModalOpen = $state(false); + $effect(() => { + usesSlackStatus = user.uses_slack_status; + }); + onMount(() => { csrfToken = document diff --git a/app/javascript/pages/Users/Settings/Profile.svelte b/app/javascript/pages/Users/Settings/Profile.svelte index 3f4ec97..d7bf27c 100644 --- a/app/javascript/pages/Users/Settings/Profile.svelte +++ b/app/javascript/pages/Users/Settings/Profile.svelte @@ -22,8 +22,13 @@ }: ProfilePageProps = $props(); let csrfToken = $state(""); - let selectedTheme = $state(user.theme || "gruvbox_dark"); - let allowPublicStatsLookup = $state(user.allow_public_stats_lookup); + let selectedTheme = $state("gruvbox_dark"); + let allowPublicStatsLookup = $state(false); + + $effect(() => { + selectedTheme = user.theme || "gruvbox_dark"; + allowPublicStatsLookup = user.allow_public_stats_lookup; + }); onMount(() => { csrfToken = diff --git a/app/javascript/pages/Users/Settings/types.ts b/app/javascript/pages/Users/Settings/types.ts index 619a40a..e832ae3 100644 --- a/app/javascript/pages/Users/Settings/types.ts +++ b/app/javascript/pages/Users/Settings/types.ts @@ -2,6 +2,7 @@ export type SectionId = | "profile" | "integrations" | "access" + | "goals" | "badges" | "data" | "admin"; @@ -31,6 +32,27 @@ export type ThemeOption = { }; }; +export type ProgrammingGoal = { + id: string; + period: "day" | "week" | "month"; + target_seconds: number; + languages: string[]; + projects: string[]; + update_path: string; + destroy_path: string; +}; + +export type GoalForm = { + open: boolean; + mode: "create" | "edit"; + goal_id: string | null; + period: string; + target_seconds: number; + languages: string[]; + projects: string[]; + errors: string[]; +}; + export type UserProps = { id: number; display_name: string; @@ -46,6 +68,7 @@ export type UserProps = { github_uid?: string | null; github_username?: string | null; slack_uid?: string | null; + programming_goals: ProgrammingGoal[]; }; export type PathsProps = { @@ -71,6 +94,12 @@ export type OptionsProps = { extension_text_types: Option[]; themes: ThemeOption[]; badge_themes: string[]; + goals: { + periods: Option[]; + preset_target_seconds: number[]; + selectable_languages: Option[]; + selectable_projects: Option[]; + }; }; export type SlackProps = { @@ -201,6 +230,14 @@ export type AccessPageProps = SettingsCommonProps & { config_file: ConfigFileProps; }; +export type GoalsPageProps = SettingsCommonProps & { + settings_update_path: string; + create_goal_path: string; + user: UserProps; + options: OptionsProps; + goal_form?: GoalForm | null; +}; + export type BadgesPageProps = SettingsCommonProps & { options: OptionsProps; badges: BadgesProps; @@ -240,6 +277,12 @@ export const buildSections = (sectionPaths: SectionPaths, adminVisible: boolean) blurb: "Time tracking setup, extension options, and API key access.", path: sectionPaths.access, }, + { + id: "goals" as SectionId, + label: "Goals", + blurb: "Set daily, weekly, or monthly programming targets.", + path: sectionPaths.goals, + }, { id: "badges" as SectionId, label: "Badges", @@ -275,6 +318,7 @@ const hashSectionMap: Record = { user_hackatime_extension: "access", user_api_key: "access", user_config_file: "access", + user_programming_goals: "goals", user_slack_status: "integrations", user_slack_notifications: "integrations", user_github_account: "integrations", diff --git a/app/models/goal.rb b/app/models/goal.rb new file mode 100644 index 0000000..c550871 --- /dev/null +++ b/app/models/goal.rb @@ -0,0 +1,86 @@ +class Goal < ApplicationRecord + PERIODS = %w[day week month].freeze + PRESET_TARGET_SECONDS = [ + 30.minutes.to_i, + 1.hour.to_i, + 2.hours.to_i, + 4.hours.to_i + ].freeze + MAX_TARGET_SECONDS_BY_PERIOD = { + "day" => 24.hours.to_i, + "week" => 7.days.to_i, + "month" => 31.days.to_i + }.freeze + MAX_GOALS = 5 + + belongs_to :user + + before_validation :normalize_fields + + validates :period, inclusion: { in: PERIODS } + validates :target_seconds, numericality: { only_integer: true, greater_than: 0 } + validate :languages_must_be_string_array + validate :projects_must_be_string_array + validate :target_must_fit_within_period + validate :max_goals_per_user + validate :no_duplicate_goal_for_user + + def as_programming_goal_payload + { + id: id.to_s, + period: period, + target_seconds: target_seconds, + languages: languages, + projects: projects + } + end + + private + + def normalize_fields + self.period = period.to_s + self.languages = Array(languages).map(&:to_s).compact_blank.uniq.sort + self.projects = Array(projects).map(&:to_s).compact_blank.uniq.sort + end + + def languages_must_be_string_array + return if languages.is_a?(Array) && languages.all? { |language| language.is_a?(String) } + + errors.add(:languages, "must be an array of strings") + end + + def projects_must_be_string_array + return if projects.is_a?(Array) && projects.all? { |project| project.is_a?(String) } + + errors.add(:projects, "must be an array of strings") + end + + def target_must_fit_within_period + max_seconds = MAX_TARGET_SECONDS_BY_PERIOD[period] + return if max_seconds.blank? + return if target_seconds.to_i <= max_seconds + + errors.add(:target_seconds, "cannot exceed #{max_seconds / 3600} hours for a #{period} goal") + end + + def max_goals_per_user + return if user.blank? + + current_goal_count = user.goals.where.not(id: id).count + return if current_goal_count < MAX_GOALS + + errors.add(:base, "cannot have more than #{MAX_GOALS} goals") + end + + def no_duplicate_goal_for_user + return if user.blank? + + duplicate_exists = user.goals + .where.not(id: id) + .exists?(period: period, target_seconds: target_seconds, languages: languages, projects: projects) + + return unless duplicate_exists + + errors.add(:base, "duplicate goal") + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4c42a0a..5eb8e70 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -279,6 +279,7 @@ class User < ApplicationRecord # ex: .set_trust(:green) or set_trust(1) setting it to red has_many :heartbeats + has_many :goals, dependent: :destroy has_many :email_addresses, dependent: :destroy has_many :email_verification_requests, dependent: :destroy has_many :sign_in_tokens, dependent: :destroy diff --git a/app/services/anonymize_user_service.rb b/app/services/anonymize_user_service.rb index 5f9360d..f86abfd 100644 --- a/app/services/anonymize_user_service.rb +++ b/app/services/anonymize_user_service.rb @@ -47,8 +47,15 @@ class AnonymizeUserService username: "deleted_user_#{user.id}", uses_slack_status: false, country_code: nil, - - deprecated_name: nil + deprecated_name: nil, + display_name_override: nil, + profile_bio: nil, + profile_github_url: nil, + profile_twitter_url: nil, + profile_bluesky_url: nil, + profile_linkedin_url: nil, + profile_discord_url: nil, + profile_website_url: nil ) end @@ -59,6 +66,7 @@ class AnonymizeUserService user.email_verification_requests.destroy_all user.wakatime_mirrors.destroy_all user.project_repo_mappings.destroy_all + user.goals.destroy_all # tombstone Heartbeat.unscoped.where(user_id: user.id, deleted_at: nil).update_all(deleted_at: Time.current) diff --git a/app/services/programming_goals_progress_service.rb b/app/services/programming_goals_progress_service.rb new file mode 100644 index 0000000..f88f32b --- /dev/null +++ b/app/services/programming_goals_progress_service.rb @@ -0,0 +1,78 @@ +class ProgrammingGoalsProgressService + def initialize(user:, goals: nil) + @user = user + @goals = goals || user.goals.order(:created_at) + end + + def call + return [] if goals.blank? + + Time.use_zone(user.timezone.presence || "UTC") do + now = Time.zone.now + goals.map { |goal| build_progress(goal, now: now) } + end + end + + private + + attr_reader :user, :goals + + def build_progress(goal, now:) + tracked_seconds = tracked_seconds_for_goal(goal, now: now) + completion_percent = [ ((tracked_seconds.to_f / goal.target_seconds) * 100).round, 100 ].min + time_window = time_window_for(goal.period, now: now) + + { + id: goal.id.to_s, + period: goal.period, + target_seconds: goal.target_seconds, + tracked_seconds: tracked_seconds, + completion_percent: completion_percent, + complete: tracked_seconds >= goal.target_seconds, + languages: goal.languages, + projects: goal.projects, + period_end: time_window.end.iso8601 + } + end + + def tracked_seconds_for_goal(goal, now:) + time_window = time_window_for(goal.period, now: now) + scope = user.heartbeats.coding_only.where(time: time_window.begin.to_i..time_window.end.to_i) + scope = scope.where(project: goal.projects) if goal.projects.any? + + if goal.languages.any? + grouped_languages = languages_grouped_by_category(scope.distinct.pluck(:language)) + matching_languages = goal.languages.flat_map { |language| grouped_languages[language] }.compact_blank.uniq + + return 0 if matching_languages.empty? + + scope = scope.where(language: matching_languages) + end + + scope.duration_seconds.to_i + end + + def languages_grouped_by_category(languages) + languages.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |language, grouped| + next if language.blank? + + categorized_language = language.categorize_language + next if categorized_language.blank? + + grouped[categorized_language] << language + end + end + + def time_window_for(period, now:) + case period + when "day" + now.beginning_of_day..now.end_of_day + when "week" + now.beginning_of_week(:monday)..now.end_of_week(:monday) + when "month" + now.beginning_of_month..now.end_of_month + else + now.beginning_of_day..now.end_of_day + end + end +end diff --git a/config/database.yml b/config/database.yml index 199e392..34eced2 100644 --- a/config/database.yml +++ b/config/database.yml @@ -38,27 +38,22 @@ test: primary: <<: *default adapter: postgresql - database: test_db - username: postgres - password: postgres - # url: <%= ENV['TEST_DATABASE_URL'] %> + database: app_test + url: <%= ENV['TEST_DATABASE_URL'] %> wakatime: adapter: postgresql - database: test_wakatime - username: postgres - password: postgres + database: app_test + url: <%= ENV['TEST_DATABASE_URL'] %> replica: true sailors_log: adapter: postgresql - database: test_sailors_log - username: postgres - password: postgres + database: app_test + url: <%= ENV['TEST_DATABASE_URL'] %> replica: true warehouse: adapter: postgresql - database: test_warehouse - username: postgres - password: postgres + database: app_test + url: <%= ENV['TEST_DATABASE_URL'] %> replica: true # Store production database in the storage/ directory, which by default diff --git a/config/routes.rb b/config/routes.rb index d297e91..ef7fec8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -142,6 +142,10 @@ Rails.application.routes.draw do patch "my/settings/integrations", to: "settings/integrations#update" get "my/settings/access", to: "settings/access#show", as: :my_settings_access patch "my/settings/access", to: "settings/access#update" + get "my/settings/goals", to: "settings/goals#show", as: :my_settings_goals + post "my/settings/goals", to: "settings/goals#create", as: :my_settings_goals_create + patch "my/settings/goals/:goal_id", to: "settings/goals#update", as: :my_settings_goal_update + delete "my/settings/goals/:goal_id", to: "settings/goals#destroy", as: :my_settings_goal_destroy get "my/settings/badges", to: "settings/badges#show", as: :my_settings_badges get "my/settings/data", to: "settings/data#show", as: :my_settings_data get "my/settings/admin", to: "settings/admin#show", as: :my_settings_admin diff --git a/db/migrate/20260219125517_add_public_profile_fields_to_users.rb b/db/migrate/20260219125517_add_public_profile_fields_to_users.rb new file mode 100644 index 0000000..fbd19b2 --- /dev/null +++ b/db/migrate/20260219125517_add_public_profile_fields_to_users.rb @@ -0,0 +1,12 @@ +class AddPublicProfileFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :display_name_override, :string + add_column :users, :profile_bio, :text + add_column :users, :profile_github_url, :string + add_column :users, :profile_twitter_url, :string + add_column :users, :profile_bluesky_url, :string + add_column :users, :profile_linkedin_url, :string + add_column :users, :profile_discord_url, :string + add_column :users, :profile_website_url, :string + end +end diff --git a/db/migrate/20260219153152_create_goals.rb b/db/migrate/20260219153152_create_goals.rb new file mode 100644 index 0000000..a743854 --- /dev/null +++ b/db/migrate/20260219153152_create_goals.rb @@ -0,0 +1,16 @@ +class CreateGoals < ActiveRecord::Migration[8.1] + def change + create_table :goals do |t| + t.references :user, null: false, foreign_key: true + t.string :period, null: false + t.integer :target_seconds, null: false + t.string :languages, array: true, default: [], null: false + t.string :projects, array: true, default: [], null: false + t.timestamps + end + + add_index :goals, + [ :user_id, :period, :target_seconds, :languages, :projects ], + name: "index_goals_on_user_and_scope" + end +end diff --git a/db/schema.rb b/db/schema.rb index 724c49b..a4c2c97 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_02_15_220822) do +ActiveRecord::Schema[8.1].define(version: 2026_02_19_153152) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -104,6 +104,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_15_220822) do t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true end + create_table "goals", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "languages", default: [], null: false, array: true + t.string "period", null: false + t.string "projects", default: [], null: false, array: true + t.integer "target_seconds", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["user_id", "period", "target_seconds", "languages", "projects"], name: "index_goals_on_user_and_scope", unique: true + t.index ["user_id"], name: "index_goals_on_user_id" + end + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.integer "callback_priority" t.text "callback_queue_name" @@ -559,6 +571,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_15_220822) do t.datetime "created_at", null: false t.boolean "default_timezone_leaderboard", default: true, null: false t.string "deprecated_name" + t.string "display_name_override" t.text "github_access_token" t.string "github_avatar_url" t.string "github_uid" @@ -567,6 +580,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_15_220822) do t.string "hca_access_token" t.string "hca_id" t.string "hca_scopes", default: [], array: true + t.text "profile_bio" + t.string "profile_bluesky_url" + t.string "profile_discord_url" + t.string "profile_github_url" + t.string "profile_linkedin_url" + t.string "profile_twitter_url" + t.string "profile_website_url" t.text "slack_access_token" t.string "slack_avatar_url" t.string "slack_scopes", default: [], array: true @@ -617,6 +637,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_15_220822) do add_foreign_key "deletion_requests", "users", column: "admin_approved_by_id" add_foreign_key "email_addresses", "users" add_foreign_key "email_verification_requests", "users" + add_foreign_key "goals", "users" add_foreign_key "heartbeat_branches", "users" add_foreign_key "heartbeat_machines", "users" add_foreign_key "heartbeat_projects", "users" diff --git a/docker-compose.yml b/docker-compose.yml index cfbc60f..d21461b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - POSTGRES_HOST=db - POSTGRES_USER=postgres - POSTGRES_PASSWORD=secureorpheus123 + - TEST_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_test depends_on: - db command: ["sleep", "infinity"] diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb new file mode 100644 index 0000000..53ca03b --- /dev/null +++ b/test/controllers/profiles_controller_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class ProfilesControllerTest < ActionDispatch::IntegrationTest + test "shows inertia profile page for existing user" do + user = User.create!(username: "profile_user_#{SecureRandom.hex(4)}", profile_bio: "I like building tools") + + get profile_path(user.username) + + assert_response :success + assert_inertia_component "Profiles/Show" + assert_inertia_prop "profile_visible", true + assert_equal "I like building tools", inertia_page.dig("props", "profile", "bio") + end + + test "returns inertia not found for unknown profile" do + get profile_path("missing_#{SecureRandom.hex(4)}") + + assert_response :not_found + assert_inertia_component "Errors/NotFound" + end + + test "shows bio and socials while hiding stats for private profiles" do + user = User.create!( + username: "priv_#{SecureRandom.hex(3)}", + allow_public_stats_lookup: false, + profile_bio: "Private stats, public profile.", + profile_github_url: "https://github.com/hackclub" + ) + + get profile_path(user.username) + + assert_response :success + assert_inertia_component "Profiles/Show" + assert_inertia_prop "profile_visible", false + assert_equal "Private stats, public profile.", inertia_page.dig("props", "profile", "bio") + assert_equal "GitHub", inertia_page.dig("props", "profile", "social_links", 0, "label") + assert_nil inertia_page.dig("props", "stats") + end + + test "shows stats to owner even when profile is private" do + user = User.create!( + username: "own_#{SecureRandom.hex(3)}", + allow_public_stats_lookup: false + ) + sign_in_as(user) + + get profile_path(user.username) + + assert_response :success + assert_inertia_component "Profiles/Show" + assert_inertia_prop "profile_visible", true + assert_inertia_prop "is_own_profile", true + end + + private + + def sign_in_as(user) + token = user.sign_in_tokens.create!(auth_type: :email) + get auth_token_path(token: token.token) + assert_equal user.id, session[:user_id] + end +end diff --git a/test/controllers/settings_goals_controller_test.rb b/test/controllers/settings_goals_controller_test.rb new file mode 100644 index 0000000..fe96313 --- /dev/null +++ b/test/controllers/settings_goals_controller_test.rb @@ -0,0 +1,124 @@ +require "test_helper" + +class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest + test "show renders goals settings page" do + user = User.create! + sign_in_as(user) + + get my_settings_goals_path + + assert_response :success + assert_inertia_component "Users/Settings/Goals" + + page = inertia_page + assert_equal my_settings_goals_path, page.dig("props", "section_paths", "goals") + assert_equal [], page.dig("props", "user", "programming_goals") + end + + test "create saves valid goal" do + user = User.create! + sign_in_as(user) + + post my_settings_goals_create_path, params: { + goal: { + period: "day", + target_seconds: 3600, + languages: [ "Ruby" ], + projects: [ "hackatime" ] + } + } + + assert_response :redirect + assert_redirected_to my_settings_goals_path + + saved_goal = user.reload.goals.first + assert_equal "day", saved_goal.period + assert_equal [ "Ruby" ], saved_goal.languages + assert_equal [ "hackatime" ], saved_goal.projects + end + + test "rejects sixth goal when limit reached" do + user = User.create! + sign_in_as(user) + + 5.times do |index| + user.goals.create!( + period: "day", + target_seconds: 1800 + index, + languages: [], + projects: [] + ) + end + + post my_settings_goals_create_path, params: { + goal: { + period: "day", + target_seconds: 9999, + languages: [], + projects: [] + } + } + + assert_response :unprocessable_entity + assert_equal 5, user.reload.goals.count + end + + test "create rejects invalid goal period" do + user = User.create! + sign_in_as(user) + + post my_settings_goals_create_path, params: { + goal: { + period: "year", + target_seconds: 1800, + languages: [], + projects: [] + } + } + + assert_response :unprocessable_entity + assert_equal 0, user.reload.goals.count + end + + test "create rejects nonpositive goal target" do + user = User.create! + sign_in_as(user) + + post my_settings_goals_create_path, params: { + goal: { + period: "day", + target_seconds: 0, + languages: [], + projects: [] + } + } + + assert_response :unprocessable_entity + assert_equal 0, user.reload.goals.count + end + + test "create rejects impossible day target" do + user = User.create! + sign_in_as(user) + + post my_settings_goals_create_path, params: { + goal: { + period: "day", + target_seconds: 25.hours.to_i, + languages: [], + projects: [] + } + } + + assert_response :unprocessable_entity + assert_equal 0, user.reload.goals.count + end + + private + + def sign_in_as(user) + token = user.sign_in_tokens.create!(auth_type: :email) + get auth_token_path(token: token.token) + assert_equal user.id, session[:user_id] + end +end diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb new file mode 100644 index 0000000..e99ac40 --- /dev/null +++ b/test/models/goal_test.rb @@ -0,0 +1,118 @@ +require "test_helper" + +class GoalTest < ActiveSupport::TestCase + test "normalizes language and project arrays" do + user = User.create! + goal = user.goals.create!( + period: "day", + target_seconds: 1800, + languages: [ "Ruby", "Ruby", "", nil ], + projects: [ "alpha", "", "alpha" ] + ) + + assert_equal [ "Ruby" ], goal.languages + assert_equal [ "alpha" ], goal.projects + end + + test "requires supported period" do + user = User.create! + goal = user.goals.build(period: "year", target_seconds: 1800) + + assert_not goal.valid? + assert goal.errors[:period].any? + end + + test "requires positive target seconds" do + user = User.create! + goal = user.goals.build(period: "day", target_seconds: 0) + + assert_not goal.valid? + assert goal.errors[:target_seconds].any? + end + + test "rejects targets longer than possible day" do + user = User.create! + goal = user.goals.build(period: "day", target_seconds: 25.hours.to_i) + + assert_not goal.valid? + assert_includes goal.errors[:target_seconds], "cannot exceed 24 hours for a day goal" + end + + test "rejects targets longer than possible week" do + user = User.create! + goal = user.goals.build(period: "week", target_seconds: (7.days + 1.hour).to_i) + + assert_not goal.valid? + assert_includes goal.errors[:target_seconds], "cannot exceed 168 hours for a week goal" + end + + test "rejects targets longer than possible month" do + user = User.create! + goal = user.goals.build(period: "month", target_seconds: (31.days + 1.hour).to_i) + + assert_not goal.valid? + assert_includes goal.errors[:target_seconds], "cannot exceed 744 hours for a month goal" + end + + test "rejects exact duplicate goals for user" do + user = User.create! + + user.goals.create!( + period: "week", + target_seconds: 3600, + languages: [ "Ruby" ], + projects: [ "alpha" ] + ) + + duplicate = user.goals.build( + period: "week", + target_seconds: 3600, + languages: [ "Ruby" ], + projects: [ "alpha" ] + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:base], "duplicate goal" + end + + test "rejects duplicate goals when languages and projects are in different order" do + user = User.create! + + user.goals.create!( + period: "week", + target_seconds: 3600, + languages: [ "Ruby", "Python" ], + projects: [ "beta", "alpha" ] + ) + + duplicate = user.goals.build( + period: "week", + target_seconds: 3600, + languages: [ "Python", "Ruby" ], + projects: [ "alpha", "beta" ] + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:base], "duplicate goal" + assert_equal [ "Python", "Ruby" ], duplicate.languages + assert_equal [ "alpha", "beta" ], duplicate.projects + end + + test "limits users to five goals" do + user = User.create! + + 5.times do |index| + user.goals.create!( + period: "day", + target_seconds: 1800 + index, + languages: [], + projects: [] + ) + end + + extra_goal = user.goals.build(period: "month", target_seconds: 9999) + + assert_not extra_goal.valid? + assert_includes extra_goal.errors[:base], "cannot have more than 5 goals" + end +end diff --git a/test/services/anonymize_user_service_test.rb b/test/services/anonymize_user_service_test.rb new file mode 100644 index 0000000..d7045cd --- /dev/null +++ b/test/services/anonymize_user_service_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class AnonymizeUserServiceTest < ActiveSupport::TestCase + test "anonymization clears profile identity fields" do + user = User.create!( + username: "anon_user_#{SecureRandom.hex(4)}", + display_name_override: "Custom Name", + profile_bio: "Bio", + profile_github_url: "https://github.com/hackclub", + profile_twitter_url: "https://x.com/hackclub", + profile_bluesky_url: "https://bsky.app/profile/hackclub.com", + profile_linkedin_url: "https://linkedin.com/in/hackclub", + profile_discord_url: "https://discord.gg/hackclub", + profile_website_url: "https://hackclub.com" + ) + + AnonymizeUserService.call(user) + + user.reload + assert_nil user.display_name_override + assert_nil user.profile_bio + assert_nil user.profile_github_url + assert_nil user.profile_twitter_url + assert_nil user.profile_bluesky_url + assert_nil user.profile_linkedin_url + assert_nil user.profile_discord_url + assert_nil user.profile_website_url + end + + test "anonymization destroys goals" do + user = User.create!(username: "ag_#{SecureRandom.hex(4)}") + user.goals.create!(period: "day", target_seconds: 600, languages: [ "Ruby" ], projects: [ "alpha" ]) + + assert_equal 1, user.goals.count + + AnonymizeUserService.call(user) + + assert_equal 0, user.goals.count + end +end diff --git a/test/services/programming_goals_progress_service_test.rb b/test/services/programming_goals_progress_service_test.rb new file mode 100644 index 0000000..8f315e0 --- /dev/null +++ b/test/services/programming_goals_progress_service_test.rb @@ -0,0 +1,141 @@ +require "test_helper" + +class ProgrammingGoalsProgressServiceTest < ActiveSupport::TestCase + setup do + @original_timeout = Heartbeat.heartbeat_timeout_duration + Heartbeat.heartbeat_timeout_duration(1.second) + end + + teardown do + Heartbeat.heartbeat_timeout_duration(@original_timeout) + end + + test "day goal uses current day in user timezone" do + user = User.create!(timezone: "America/New_York") + user.goals.create!(period: "day", target_seconds: 10) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00") + create_heartbeat_pair(user, "2026-01-13 09:00:00") + + progress = ProgrammingGoalsProgressService.new(user: user).call + + assert_equal 1, progress.first[:tracked_seconds] + end + end + + test "week goal starts on monday" do + user = User.create!(timezone: "America/New_York") + user.goals.create!(period: "week", target_seconds: 10) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + timezone = ActiveSupport::TimeZone[user.timezone] + monday = timezone.now.beginning_of_week(:monday) + + create_heartbeat_pair(user, monday.change(hour: 9, min: 0, sec: 0)) + create_heartbeat_pair(user, (monday - 1.day).change(hour: 9, min: 0, sec: 0)) + + progress = ProgrammingGoalsProgressService.new(user: user).call + + assert_equal 1, progress.first[:tracked_seconds] + end + end + + test "month goal uses current calendar month" do + user = User.create!(timezone: "America/New_York") + user.goals.create!(period: "month", target_seconds: 10) + + travel_to Time.utc(2026, 2, 15, 17, 0, 0) do + create_heartbeat_pair(user, "2026-02-01 08:00:00") + create_heartbeat_pair(user, "2026-01-31 08:00:00") + + progress = ProgrammingGoalsProgressService.new(user: user).call + + assert_equal 1, progress.first[:tracked_seconds] + end + end + + test "language and project filters apply with and behavior" do + user = User.create!(timezone: "America/New_York") + + language_goal = user.goals.create!( + period: "day", + target_seconds: 10, + languages: [ "Ruby" ], + projects: [] + ) + project_goal = user.goals.create!( + period: "day", + target_seconds: 10, + languages: [], + projects: [ "alpha" ] + ) + and_goal = user.goals.create!( + period: "day", + target_seconds: 10, + languages: [ "Ruby" ], + projects: [ "alpha" ] + ) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00", language: "rb", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:10:00", language: "python", project: "alpha") + create_heartbeat_pair(user, "2026-01-14 09:20:00", language: "rb", project: "beta") + + progress = ProgrammingGoalsProgressService.new(user: user).call.index_by { |goal| goal[:id] } + + assert_equal 3, progress[language_goal.id.to_s][:tracked_seconds] + assert_equal 3, progress[project_goal.id.to_s][:tracked_seconds] + assert_equal 1, progress[and_goal.id.to_s][:tracked_seconds] + end + end + + test "completion percent is capped at one hundred" do + user = User.create!(timezone: "America/New_York") + user.goals.create!(period: "day", target_seconds: 1) + + travel_to Time.utc(2026, 1, 14, 16, 0, 0) do + create_heartbeat_pair(user, "2026-01-14 09:00:00") + create_heartbeat_pair(user, "2026-01-14 09:05:00") + + progress = ProgrammingGoalsProgressService.new(user: user).call.first + + assert_equal 100, progress[:completion_percent] + assert_equal true, progress[:complete] + end + end + + private + + def create_heartbeat_pair(user, start_time, language: "Ruby", project: "alpha") + start_at = to_time_in_zone(user.timezone, start_time) + + Heartbeat.create!( + user: user, + time: start_at.to_i, + language: language, + project: project, + category: "coding", + source_type: :test_entry + ) + + Heartbeat.create!( + user: user, + time: (start_at + 1.second).to_i, + language: language, + project: project, + category: "coding", + source_type: :test_entry + ) + end + + def to_time_in_zone(timezone_name, value) + timezone = ActiveSupport::TimeZone[timezone_name] + + if value.is_a?(String) + timezone.parse(value) + else + value.in_time_zone(timezone) + end + end +end