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}
+
+ {: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)}
+
+
+
+
+
+
+
+
+
+
+
+ {/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}
+
+ {#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}
+
+ {/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}
+
+
+
+
+
+ {: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
+
+
+
+ per
+
+
+
+
+
+ {#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