* 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>
This commit is contained in:
Mahad Kalam 2026-02-19 18:47:01 +00:00 committed by GitHub
parent a5ad8bf6cb
commit e3456be187
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2106 additions and 82 deletions

View file

@ -113,7 +113,7 @@ jobs:
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db POSTGRES_DB: app_test
steps: steps:
- name: Checkout code - name: Checkout code
@ -139,39 +139,19 @@ jobs:
- name: Run tests - name: Run tests
env: env:
RAILS_ENV: test RAILS_ENV: test
PARALLEL_WORKERS: 4 TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_test
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
PGHOST: localhost PGHOST: localhost
PGUSER: postgres PGUSER: postgres
PGPASSWORD: postgres PGPASSWORD: postgres
run: | run: |
bin/rails db:create RAILS_ENV=test bin/rails db:create RAILS_ENV=test
bin/rails db:schema:load 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 bin/rails test
- name: Ensure Swagger docs are up to date - name: Ensure Swagger docs are up to date
env: env:
RAILS_ENV: test 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 PGHOST: localhost
PGUSER: postgres PGUSER: postgres
PGPASSWORD: postgres PGPASSWORD: postgres

View file

@ -1,29 +1,31 @@
# AGENT.md - Rails Hackatime/Harbor Project # 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) ## 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 - **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 run web bundle exec rubocop` (check), `docker compose run web bundle exec rubocop -A` (auto-fix) - **Lint**: `docker compose exec web bundle exec rubocop` (check), `docker compose exec web bundle exec rubocop -A` (auto-fix)
- **Console**: `docker compose run web rails c` (interactive console) - **Console**: `docker compose exec web rails c` (interactive console)
- **Server**: `docker compose run --service-ports web rails s -b 0.0.0.0` (development server) - **Server**: `docker compose exec 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` - **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 run web bundle exec brakeman` (security audit) - **Security**: `docker compose exec web bundle exec brakeman` (security audit)
- **JS Security**: `docker compose run web bin/importmap audit` (JS dependency scan) - **JS Security**: `docker compose exec web bin/importmap audit` (JS dependency scan)
- **Zeitwerk**: `docker compose run web bin/rails zeitwerk:check` (autoloader check) - **Zeitwerk**: `docker compose exec web bin/rails zeitwerk:check` (autoloader check)
- **Swagger**: `docker compose run web bin/rails rswag:specs:swaggerize` (generate API docs) - **Swagger**: `docker compose exec web bin/rails rswag:specs:swaggerize` (generate API docs)
## CI/Testing Requirements ## CI/Testing Requirements
**Before marking any task complete, run ALL CI checks locally:** **Before marking any task complete, run ALL CI checks locally:**
1. `docker compose run web bundle exec rubocop` (lint check) 1. `docker compose exec web bundle exec rubocop` (lint check)
2. `docker compose run web bundle exec brakeman` (security scan) 2. `docker compose exec web bundle exec brakeman` (security scan)
3. `docker compose run web bin/importmap audit` (JS security) 3. `docker compose exec web bin/importmap audit` (JS security)
4. `docker compose run web bin/rails zeitwerk:check` (autoloader) 4. `docker compose exec web bin/rails zeitwerk:check` (autoloader)
5. `docker compose run web rails test` (full test suite) 5. `docker compose exec web rails test` (full test suite)
6. `docker compose run web bin/rails rswag:specs:swaggerize` (ensure docs are up to date) 6. `docker compose exec web bin/rails rswag:specs:swaggerize` (ensure docs are up to date)
## API Documentation ## API Documentation
@ -33,8 +35,9 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de
## Docker Development ## Docker Development
- **Interactive shell**: `docker compose run --service-ports web /bin/bash` - **Start containers**: `docker compose up -d` (must be running before using `exec`)
- **Initial setup**: `docker compose run web bin/rails db:create db:schema:load db:seed` - **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 - **Cleanup**: Run commands with the `--remove-orphans` flag to remove unused containers and images
## Git Practices ## Git Practices

View file

@ -25,6 +25,9 @@ WORKDIR /app
# Install npm dependencies for Vite # Install npm dependencies for Vite
COPY package.json bun.lock ./ 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 RUN bun install
# Install application dependencies # Install application dependencies

View file

@ -1,16 +1,23 @@
class ProfilesController < ApplicationController class ProfilesController < InertiaController
layout "inertia"
before_action :find_user before_action :find_user
before_action :check_profile_visibility, only: %i[time_stats projects languages editors activity] before_action :check_profile_visibility, only: %i[time_stats projects languages editors activity]
def show def show
if @user.nil? 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 return
end 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 @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 end
def time_stats def time_stats
@ -58,6 +65,92 @@ class ProfilesController < ApplicationController
@user = User.find_by(username: params[:username]) @user = User.find_by(username: params[:username])
end 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 def check_profile_visibility
return if @user.nil? return if @user.nil?

View file

@ -22,6 +22,7 @@ class Settings::BaseController < InertiaController
"profile" => "Users/Settings/Profile", "profile" => "Users/Settings/Profile",
"integrations" => "Users/Settings/Integrations", "integrations" => "Users/Settings/Integrations",
"access" => "Users/Settings/Access", "access" => "Users/Settings/Access",
"goals" => "Users/Settings/Goals",
"badges" => "Users/Settings/Badges", "badges" => "Users/Settings/Badges",
"data" => "Users/Settings/Data", "data" => "Users/Settings/Data",
"admin" => "Users/Settings/Admin" "admin" => "Users/Settings/Admin"
@ -40,6 +41,18 @@ class Settings::BaseController < InertiaController
@heartbeats_migration_jobs = @user.data_migration_jobs @heartbeats_migration_jobs = @user.data_migration_jobs
@projects = @user.project_repo_mappings.distinct.pluck(:project_name) @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_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_url = if @work_time_stats_base_url.present?
"#{@work_time_stats_base_url}#{@projects.first || 'example'}" "#{@work_time_stats_base_url}#{@projects.first || 'example'}"
@ -67,14 +80,16 @@ class Settings::BaseController < InertiaController
profile: my_settings_profile_path, profile: my_settings_profile_path,
integrations: my_settings_integrations_path, integrations: my_settings_integrations_path,
access: my_settings_access_path, access: my_settings_access_path,
goals: my_settings_goals_path,
badges: my_settings_badges_path, badges: my_settings_badges_path,
data: my_settings_data_path, data: my_settings_data_path,
admin: my_settings_admin_path admin: my_settings_admin_path
}, },
page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"), page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"),
heading: (@is_own_settings ? "Settings" : "Settings for #{@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, settings_update_path: settings_update_path,
create_goal_path: my_settings_goals_create_path,
username_max_length: User::USERNAME_MAX_LENGTH, username_max_length: User::USERNAME_MAX_LENGTH,
user: { user: {
id: @user.id, id: @user.id,
@ -90,7 +105,13 @@ class Settings::BaseController < InertiaController
can_request_deletion: @user.can_request_deletion?, can_request_deletion: @user.can_request_deletion?,
github_uid: @user.github_uid, github_uid: @user.github_uid,
github_username: @user.github_username, 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: { paths: {
settings_path: settings_update_path, settings_path: settings_update_path,
@ -125,7 +146,20 @@ class Settings::BaseController < InertiaController
} }
}, },
themes: User.theme_options, 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: { slack: {
can_enable_status: @can_enable_slack_status, can_enable_status: @can_enable_slack_status,

View file

@ -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

View file

@ -153,7 +153,8 @@ class StaticPagesController < InertiaController
{ {
filterable_dashboard_data: filterable_dashboard_data, filterable_dashboard_data: filterable_dashboard_data,
activity_graph: activity_graph_data, activity_graph: activity_graph_data,
today_stats: today_stats_data today_stats: today_stats_data,
programming_goals_progress: programming_goals_progress_data
} }
end end
@ -170,4 +171,18 @@ class StaticPagesController < InertiaController
home_stats: @home_stats || {} home_stats: @home_stats || {}
} }
end 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 end

View file

@ -44,7 +44,7 @@
<div class="absolute inset-x-0 top-0 h-1 bg-primary"></div> <div class="absolute inset-x-0 top-0 h-1 bg-primary"></div>
<div class="p-6 sm:p-8"> <div class="p-6 sm:p-8">
<div class="mb-5 flex items-start justify-between gap-4"> <div class="mb-5 flex items-center justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if hasIcon} {#if hasIcon}
<div <div

View file

@ -0,0 +1,150 @@
<script lang="ts">
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<string[]>([]),
}: {
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 = "";
}
});
</script>
<div class="relative">
<p class="mb-1 text-xs font-medium text-muted">{label}</p>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<div
{...props}
class="group min-h-10 cursor-pointer rounded-md border border-surface-200 bg-darker px-3 py-2 transition-all duration-200 hover:border-surface-300 focus-within:border-primary/70 focus-within:ring-2 focus-within:ring-primary/35 focus-within:ring-offset-1 focus-within:ring-offset-surface"
>
{#if selected.length === 0}
<div class="flex items-center justify-between gap-2">
<p class="text-sm text-muted">{placeholder}</p>
<svg
class={`h-4 w-4 text-secondary/60 transition-all duration-200 group-hover:text-secondary ${open ? "rotate-180 text-primary" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{:else}
<div class="flex flex-wrap items-center gap-1.5">
{#each selected as value}
<span
class="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-1 text-xs font-medium text-surface-content"
>
{options.find((option) => option.value === value)?.label ||
value}
<Button
type="button"
unstyled
class="text-muted hover:text-surface-content"
onclick={(event: MouseEvent) => remove(value, event)}
>
×
</Button>
</span>
{/each}
</div>
{/if}
</div>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
sideOffset={8}
align="start"
class="dashboard-select-popover z-[11000] w-[min(28rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
>
<input
type="text"
bind:value={search}
{placeholder}
class="mb-2 h-10 w-full rounded-lg border border-surface-content/20 bg-dark px-3 text-sm text-surface-content placeholder:text-secondary/60 transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
/>
<div
class="max-h-52 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
>
{#if filtered.length > 0}
{#each filtered as option}
{@const isSelected = selected.includes(option.value)}
<Button
type="button"
unstyled
class={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-all duration-150 ${
isSelected
? "bg-primary/15 text-surface-content font-medium border-l-2 border-primary"
: "text-muted hover:bg-surface-100/60 hover:text-surface-content border-l-2 border-transparent"
}`}
onclick={() => toggle(option.value)}
>
<span class="truncate">{option.label}</span>
{#if isSelected}
<span
class="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
>✓</span
>
{/if}
</Button>
{/each}
{:else}
<p class="px-3 py-2 text-sm text-secondary/60">{emptyText}</p>
{/if}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>

View file

@ -65,7 +65,7 @@
<BitsSelect.Content <BitsSelect.Content
align="start" align="start"
sideOffset={6} sideOffset={6}
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm" class="dashboard-select-popover z-[11000] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
> >
<BitsSelect.Viewport <BitsSelect.Viewport
class="max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1" class="max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"

View file

@ -90,17 +90,19 @@
let navOpen = $state(false); let navOpen = $state(false);
let logoutOpen = $state(false); let logoutOpen = $state(false);
let currentlyExpanded = $state(false); let currentlyExpanded = $state(false);
let flashVisible = $state(layout.nav.flash.length > 0); let flashVisible = $state(false);
let flashHiding = $state(false); let flashHiding = $state(false);
const flashHideDelay = 6000; const flashHideDelay = 6000;
const flashExitDuration = 250; const flashExitDuration = 250;
const currentlyHackingPollInterval = () =>
layout.currently_hacking?.interval || 30000;
const toggleNav = () => (navOpen = !navOpen); const toggleNav = () => (navOpen = !navOpen);
const closeNav = () => (navOpen = false); const closeNav = () => (navOpen = false);
const openLogout = () => (logoutOpen = true); const openLogout = () => (logoutOpen = true);
const closeLogout = () => (logoutOpen = false); const closeLogout = () => (logoutOpen = false);
usePoll(layout.currently_hacking?.interval || 30000, { usePoll(currentlyHackingPollInterval(), {
only: ["currently_hacking"], only: ["currently_hacking"],
}); });
@ -307,7 +309,13 @@
/> />
</svg> </svg>
</Button> </Button>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div> <button
type="button"
class="nav-overlay"
class:open={navOpen}
onclick={closeNav}
aria-label="Close navigation menu"
></button>
<aside <aside
class="flex flex-col min-h-screen w-52 bg-dark text-surface-content px-3 py-4 rounded-r-lg overflow-y-auto lg:block" class="flex flex-col min-h-screen w-52 bg-dark text-surface-content px-3 py-4 rounded-r-lg overflow-y-auto lg:block"
@ -619,9 +627,12 @@
<div <div
class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform transition-transform duration-300 ease-out" class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform transition-transform duration-300 ease-out"
> >
<div <button
type="button"
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between" class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between"
onclick={toggleCurrentlyHacking} onclick={toggleCurrentlyHacking}
aria-expanded={currentlyExpanded}
aria-label="Toggle currently hacking list"
> >
<div class="text-surface-content text-sm font-medium"> <div class="text-surface-content text-sm font-medium">
<div class="flex items-center"> <div class="flex items-center">
@ -629,7 +640,7 @@
<span class="text-base">{countLabel()}</span> <span class="text-base">{countLabel()}</span>
</div> </div>
</div> </div>
</div> </button>
{#if currentlyExpanded} {#if currentlyExpanded}
{#if layout.currently_hacking.users.length === 0} {#if layout.currently_hacking.users.length === 0}

View file

@ -48,6 +48,18 @@
todays_editors: string[]; todays_editors: string[];
}; };
type ProgrammingGoalProgress = {
id: string;
period: "day" | "week" | "month";
target_seconds: number;
tracked_seconds: number;
completion_percent: number;
complete: boolean;
languages: string[];
projects: string[];
period_end: string;
};
let { let {
flavor_text, flavor_text,
trust_level_red, trust_level_red,
@ -73,6 +85,7 @@
filterable_dashboard_data: FilterableDashboardData; filterable_dashboard_data: FilterableDashboardData;
activity_graph: ActivityGraphData; activity_graph: ActivityGraphData;
today_stats: TodayStats; today_stats: TodayStats;
programming_goals_progress: ProgrammingGoalProgress[];
}; };
} = $props(); } = $props();
@ -149,6 +162,8 @@
{#if dashboard_stats?.filterable_dashboard_data} {#if dashboard_stats?.filterable_dashboard_data}
<Dashboard <Dashboard
data={dashboard_stats.filterable_dashboard_data} data={dashboard_stats.filterable_dashboard_data}
programmingGoalsProgress={dashboard_stats.programming_goals_progress ||
[]}
onFiltersChange={refreshDashboardData} onFiltersChange={refreshDashboardData}
/> />
{/if} {/if}

View file

@ -6,12 +6,25 @@
import ProjectTimelineChart from "./ProjectTimelineChart.svelte"; import ProjectTimelineChart from "./ProjectTimelineChart.svelte";
import IntervalSelect from "./IntervalSelect.svelte"; import IntervalSelect from "./IntervalSelect.svelte";
import MultiSelect from "./MultiSelect.svelte"; import MultiSelect from "./MultiSelect.svelte";
import GoalsProgressCard from "./GoalsProgressCard.svelte";
let { let {
data, data,
programmingGoalsProgress = [],
onFiltersChange, onFiltersChange,
}: { }: {
data: Record<string, any>; data: Record<string, any>;
programmingGoalsProgress?: {
id: string;
period: "day" | "week" | "month";
target_seconds: number;
tracked_seconds: number;
completion_percent: number;
complete: boolean;
languages: string[];
projects: string[];
period_end: string;
}[];
onFiltersChange?: (search: string) => void; onFiltersChange?: (search: string) => void;
} = $props(); } = $props();
@ -105,6 +118,8 @@
/> />
</div> </div>
<GoalsProgressCard goals={programmingGoalsProgress} />
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard <StatCard

View file

@ -0,0 +1,201 @@
<script lang="ts">
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`;
};
</script>
{#if goals.length > 0}
<section
class="rounded-xl border border-surface-200 bg-surface-100/30 overflow-hidden grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
>
{#each goals as goal, i}
<div
class="p-4 md:p-5 flex flex-col gap-4
border-b border-surface-200
last:border-b-0
{lastItemSpanClass(i, goals.length)}"
>
<div class="flex items-start justify-between gap-4">
<!-- Left: Big time display -->
<div>
<p
class="text-2xl font-bold tracking-tight {goal.complete
? 'text-success'
: 'text-surface-content'}"
>
{secondsToDisplay(goal.tracked_seconds)}<span
class="text-base font-normal text-muted"
>
/ {secondsToDisplay(goal.target_seconds)}</span
>
</p>
<p class="text-xs text-muted mt-0.5">{scopeSubtitle(goal)}</p>
</div>
<!-- Right: label + circular progress indicator -->
<div class="flex items-center gap-2.5 shrink-0">
<div class="text-right">
<p
class="text-sm font-medium {goal.complete
? 'text-success'
: 'text-muted'}"
>
{periodLabel(goal.period)}
</p>
<p
class="text-xs mt-0.5 {goal.complete
? 'text-success'
: 'text-muted'}"
>
{periodTimeLeft(goal)}
</p>
</div>
<!-- Circular progress indicator with percentage inside -->
<svg width="52" height="52" viewBox="0 0 52 52" class="shrink-0">
<!-- Background track -->
<circle
cx="26"
cy="26"
r={RADIUS}
fill="none"
stroke-width="3"
class={goal.complete
? "stroke-success/20"
: "stroke-surface-300"}
/>
<!-- Progress arc -->
<circle
cx="26"
cy="26"
r={RADIUS}
fill="none"
stroke-width="3"
stroke-linecap="round"
class={goal.complete ? "stroke-success" : "stroke-primary"}
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={strokeDashoffset(goal.completion_percent)}
transform="rotate(-90 26 26)"
style="transition: stroke-dashoffset 0.5s ease-out"
/>
<!-- Percentage text or checkmark -->
{#if goal.complete}
<polyline
points="18,26 23,31 34,20"
fill="none"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="stroke-success"
/>
{:else}
<text
x="26"
y="26"
text-anchor="middle"
dominant-baseline="central"
class="fill-surface-content"
font-size="12"
font-weight="700">{Math.round(goal.completion_percent)}%</text
>
{/if}
</svg>
</div>
</div>
<!-- Progress bar -->
<div class="h-1.5 w-full overflow-hidden rounded-full bg-surface-200">
<div
class="h-full rounded-full transition-all duration-500 ease-out {goal.complete
? 'bg-success'
: 'bg-primary'}"
style="width: {percentWidth(goal.completion_percent)}"
></div>
</div>
</div>
{/each}
</section>
{/if}

View file

@ -21,9 +21,15 @@
.querySelector("meta[name='csrf-token']") .querySelector("meta[name='csrf-token']")
?.getAttribute("content") || ""; ?.getAttribute("content") || "";
let selectedScopes = $state([...(application.selected_scopes || [])]); let selectedScopes = $state<string[]>([]);
let confidential = $state(Boolean(application.confidential)); let confidential = $state(false);
let redirectUri = $state(application.redirect_uri); let redirectUri = $state("");
$effect(() => {
selectedScopes = [...(application.selected_scopes || [])];
confidential = Boolean(application.confidential);
redirectUri = application.redirect_uri;
});
const nameLocked = $derived(application.persisted && application.verified); const nameLocked = $derived(application.persisted && application.verified);
</script> </script>

View file

@ -1,4 +1,4 @@
<script context="module"> <script module>
export const layout = false; export const layout = false;
</script> </script>

View file

@ -1,4 +1,4 @@
<script context="module"> <script module>
export const layout = false; export const layout = false;
</script> </script>

View file

@ -1,4 +1,4 @@
<script context="module"> <script module>
export const layout = false; export const layout = false;
</script> </script>

View file

@ -0,0 +1,270 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import Button from "../../components/Button.svelte";
import ActivityGraph from "../Home/signedIn/ActivityGraph.svelte";
import HorizontalBarList from "../Home/signedIn/HorizontalBarList.svelte";
type SocialLink = {
key: string;
label: string;
url: string;
};
type ProfileData = {
display_name: string;
username: string;
avatar_url: string;
trust_level: string;
bio?: string | null;
social_links: SocialLink[];
github_profile_url?: string | null;
github_username?: string | null;
streak_days?: number | null;
};
type TotalsData = {
today_seconds: number;
week_seconds: number;
all_seconds: number;
today_label: string;
week_label: string;
all_label: string;
};
type ProjectData = {
project: string;
duration_seconds: number;
duration_label: string;
repo_url?: string | null;
};
type StatsData = {
totals: TotalsData;
top_projects_month: ProjectData[];
top_languages: [string, number][];
top_editors: [string, number][];
activity_graph: {
start_date: string;
end_date: string;
duration_by_date: Record<string, number>;
busiest_day_seconds: number;
timezone_label: string;
timezone_settings_path: string;
};
};
let {
page_title,
profile_visible,
is_own_profile,
edit_profile_path,
profile,
stats,
}: {
page_title: string;
profile_visible: boolean;
is_own_profile: boolean;
edit_profile_path?: string | null;
profile: ProfileData;
stats?: StatsData;
} = $props();
const hasStats = $derived(Boolean(stats));
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="mx-auto max-w-6xl space-y-6">
<section
class="overflow-hidden rounded-2xl border border-surface-200 bg-surface p-6 shadow-sm"
>
<div
class="flex flex-col gap-6 md:flex-row md:items-start md:justify-between"
>
<div class="flex min-w-0 items-start gap-4">
<img
src={profile.avatar_url}
alt={profile.display_name}
class="h-20 w-20 shrink-0 rounded-full border-2 border-primary object-cover"
/>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h1 class="truncate text-3xl font-bold text-surface-content">
{profile.display_name}
</h1>
{#if profile.trust_level === "green"}
<span
class="inline-flex items-center rounded-full bg-primary/15 px-2 py-1 text-xs font-semibold text-primary"
>
Verified
</span>
{/if}
{#if profile.streak_days && profile.streak_days > 0}
<span
class="inline-flex items-center rounded-full bg-orange-500/15 px-2 py-1 text-xs font-semibold text-orange-300"
>
Streak: {profile.streak_days} days
</span>
{/if}
</div>
<p class="mt-1 text-sm text-muted">@{profile.username}</p>
{#if profile.bio}
<p
class="mt-4 whitespace-pre-wrap text-sm leading-6 text-surface-content/90"
>
{profile.bio}
</p>
{/if}
</div>
</div>
{#if is_own_profile && edit_profile_path}
<div class="md:pl-4">
<Button href={edit_profile_path}>Edit Profile</Button>
</div>
{/if}
</div>
{#if profile.social_links.length > 0}
<div class="mt-6 flex flex-wrap gap-2">
{#each profile.social_links as link}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded-full border border-surface-200 bg-darker px-3 py-1.5 text-sm text-surface-content transition-colors hover:border-primary hover:text-primary"
>
{link.label}
</a>
{/each}
</div>
{/if}
</section>
{#if profile_visible}
{#if hasStats && stats}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-xl border border-primary/60 bg-surface p-4">
<div class="text-xs uppercase tracking-wide text-muted">Today</div>
<div class="mt-2 text-2xl font-bold text-primary">
{stats.totals.today_label}
</div>
</div>
<div class="rounded-xl border border-primary/60 bg-surface p-4">
<div class="text-xs uppercase tracking-wide text-muted">
This Week
</div>
<div class="mt-2 text-2xl font-bold text-primary">
{stats.totals.week_label}
</div>
</div>
<div class="rounded-xl border border-primary/60 bg-surface p-4">
<div class="text-xs uppercase tracking-wide text-muted">All Time</div>
<div class="mt-2 text-2xl font-bold text-primary">
{stats.totals.all_label}
</div>
</div>
</section>
<section class="rounded-xl border border-surface-200 bg-surface p-6">
<div class="mb-4 flex items-end justify-between gap-3">
<h2 class="text-xl font-semibold text-surface-content">
Top Projects
</h2>
<p class="text-sm text-muted">Past month</p>
</div>
{#if stats.top_projects_month.length > 0}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each stats.top_projects_month as project}
<article
class="rounded-lg border border-surface-200 bg-darker p-4"
>
<div class="flex items-center justify-between gap-3">
<h3
class="truncate font-medium text-surface-content"
title={project.project}
>
{project.project || "Unknown"}
</h3>
<span class="text-sm font-semibold text-primary"
>{project.duration_label}</span
>
</div>
{#if project.repo_url}
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-flex text-xs text-muted underline hover:text-primary"
>
Open repository
</a>
{/if}
</article>
{/each}
</div>
{:else}
<p class="text-sm text-muted">
No project activity in the past month.
</p>
{/if}
</section>
<section class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<HorizontalBarList
title="Top Languages"
entries={stats.top_languages}
empty_message="No language activity yet."
/>
<HorizontalBarList
title="Favorite Editors"
entries={stats.top_editors}
empty_message="No editor activity yet."
/>
</section>
<section
id="profile_activity"
class="rounded-xl border border-surface-200 bg-surface p-6"
>
<h2 class="text-xl font-semibold text-surface-content">Activity</h2>
<ActivityGraph data={stats.activity_graph} />
</section>
{:else}
<section class="rounded-xl border border-surface-200 bg-surface p-6">
<p class="text-sm text-muted">Loading profile stats...</p>
</section>
{/if}
{:else}
<section
class="rounded-xl border border-yellow/35 bg-yellow/10 p-6 text-center"
>
<p class="text-lg font-semibold text-surface-content">
Stats are private
</p>
<p class="mt-2 text-sm text-muted">
This user chose not to share coding stats publicly.
</p>
{#if is_own_profile && edit_profile_path}
<div class="mt-4">
<Button href={`${edit_profile_path}#user_privacy`} variant="surface">
Update privacy settings
</Button>
</div>
{/if}
</section>
{/if}
<div class="text-sm text-muted">
<Link href="/leaderboards" class="underline hover:text-primary"
>Explore leaderboards</Link
>
</div>
</div>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import { router } from "@inertiajs/svelte";
import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte";
import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte";
import Select from "../../../components/Select.svelte";
import SettingsShell from "./Shell.svelte";
import type { GoalsPageProps, ProgrammingGoal } from "./types";
const MAX_GOALS = 5;
const QUICK_TARGETS = [
{ label: "15m", seconds: 15 * 60 },
{ label: "30m", seconds: 30 * 60 },
{ label: "1h", seconds: 60 * 60 },
{ label: "2h", seconds: 2 * 60 * 60 },
{ label: "4h", seconds: 4 * 60 * 60 },
];
let {
active_section,
section_paths,
page_title,
heading,
subheading,
create_goal_path,
user,
options,
errors,
admin_tools,
goal_form,
}: GoalsPageProps = $props();
const goals = $derived(user.programming_goals || []);
const hasReachedGoalLimit = $derived(goals.length >= MAX_GOALS);
const activeGoalSummary = $derived(
`${goals.length} Active Goal${goals.length === 1 ? "" : "s"}`,
);
let goalModalOpen = $state(false);
let editingGoal = $state<ProgrammingGoal | null>(null);
let targetAmount = $state(30);
let targetUnit = $state<"minutes" | "hours">("minutes");
let selectedPeriod = $state<ProgrammingGoal["period"]>("day");
let selectedLanguages = $state<string[]>([]);
let selectedProjects = $state<string[]>([]);
let submitting = $state(false);
const currentTargetSeconds = $derived(
Math.round(Number(targetAmount) * (targetUnit === "hours" ? 3600 : 60)),
);
const modalErrors = $derived(goal_form?.errors ?? []);
const unitOptions = [
{ value: "minutes", label: "Minutes" },
{ value: "hours", label: "Hours" },
];
function onRequestSuccess() {
goalModalOpen = false;
editingGoal = null;
}
// Restore modal state from server on validation error
$effect(() => {
selectedPeriod =
(options.goals.periods[0]?.value as ProgrammingGoal["period"]) || "day";
});
$effect(() => {
goalModalOpen = goal_form?.open ?? false;
if (!goal_form?.open) return;
const seconds = goal_form.target_seconds || 1800;
const unit = seconds % 3600 === 0 ? "hours" : "minutes";
selectedPeriod = (goal_form.period as ProgrammingGoal["period"]) || "day";
targetUnit = unit;
targetAmount = unit === "hours" ? seconds / 3600 : seconds / 60;
selectedLanguages = goal_form.languages || [];
selectedProjects = goal_form.projects || [];
if (goal_form.mode === "edit" && goal_form.goal_id) {
editingGoal =
(user.programming_goals || []).find(
(g) => g.id === goal_form.goal_id,
) ?? null;
}
});
function formatDuration(seconds: number) {
const totalMinutes = Math.max(Math.floor(seconds / 60), 0);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h`;
return `${minutes}m`;
}
function formatPeriod(period: ProgrammingGoal["period"]) {
if (period === "day") return "Daily";
if (period === "week") return "Weekly";
return "Monthly";
}
function scopeSubtitle(goal: ProgrammingGoal) {
const parts = [];
if (goal.languages.length > 0)
parts.push(`Languages: ${goal.languages.join(", ")}`);
if (goal.projects.length > 0)
parts.push(`Projects: ${goal.projects.join(", ")}`);
return parts.join(" AND ") || "All programming activity";
}
function resetBuilder() {
const defaultSeconds = options.goals.preset_target_seconds[0] || 1800;
selectedPeriod =
(options.goals.periods[0]?.value as ProgrammingGoal["period"]) || "day";
targetUnit = defaultSeconds % 3600 === 0 ? "hours" : "minutes";
targetAmount =
targetUnit === "hours" ? defaultSeconds / 3600 : defaultSeconds / 60;
selectedLanguages = [];
selectedProjects = [];
}
function openCreateModal() {
editingGoal = null;
resetBuilder();
goalModalOpen = true;
}
function openEditModal(goal: ProgrammingGoal) {
editingGoal = goal;
selectedPeriod = goal.period;
targetUnit = goal.target_seconds % 3600 === 0 ? "hours" : "minutes";
targetAmount =
targetUnit === "hours"
? goal.target_seconds / 3600
: goal.target_seconds / 60;
selectedLanguages = [...goal.languages];
selectedProjects = [...goal.projects];
goalModalOpen = true;
}
function applyQuickTarget(seconds: number) {
if (seconds % 3600 === 0) {
targetUnit = "hours";
targetAmount = seconds / 3600;
} else {
targetUnit = "minutes";
targetAmount = seconds / 60;
}
}
function goalData() {
return {
goal: {
period: selectedPeriod,
target_seconds: currentTargetSeconds,
languages: selectedLanguages,
projects: selectedProjects,
},
};
}
function saveGoal() {
if (submitting) return;
submitting = true;
const callbacks = {
preserveScroll: true,
onSuccess: onRequestSuccess,
onFinish: () => {
submitting = false;
},
};
if (editingGoal) {
router.patch(editingGoal.update_path, goalData(), callbacks);
} else {
router.post(create_goal_path, goalData(), callbacks);
}
}
function deleteGoal(goal: ProgrammingGoal) {
if (submitting) return;
submitting = true;
router.delete(goal.destroy_path, {
preserveScroll: true,
onSuccess: onRequestSuccess,
onFinish: () => {
submitting = false;
},
});
}
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div>
<section id="user_programming_goals">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-surface-content">
Programming Goals
</h2>
<p class="mt-1 text-sm text-muted">Set up to {MAX_GOALS} goals.</p>
</div>
<div class="flex items-center gap-3">
<p
class="text-xs font-semibold uppercase tracking-wider text-secondary/80 sm:text-sm"
>
{activeGoalSummary}
</p>
<Button
type="button"
variant="primary"
class="rounded-md px-3 py-2"
onclick={openCreateModal}
disabled={hasReachedGoalLimit || submitting}
>
New goal
</Button>
</div>
</div>
{#if goals.length === 0}
<div
class="mt-4 rounded-md border border-surface-200 bg-darker/30 px-4 py-5 text-center"
>
<p class="text-sm text-muted">
Set a goal to track your coding consistency.
</p>
<Button
type="button"
class="mt-4 rounded-md"
onclick={openCreateModal}
disabled={submitting}
>
Add new goal...
</Button>
</div>
{:else}
<div
class="mt-4 overflow-hidden rounded-md border border-surface-200 bg-darker/30"
>
{#each goals as goal (goal.id)}
<article
class="flex flex-wrap items-start justify-between gap-3 border-b border-surface-200 px-4 py-3 last:border-b-0"
>
<div class="min-w-0">
<p class="text-sm font-semibold text-surface-content">
{formatPeriod(goal.period)}: {formatDuration(
goal.target_seconds,
)}
</p>
<p class="mt-1 truncate text-xs text-muted">
{scopeSubtitle(goal)}
</p>
</div>
<div class="flex items-center gap-2">
<Button
type="button"
variant="surface"
size="xs"
class="rounded-md"
onclick={() => openEditModal(goal)}
disabled={submitting}
>
Edit
</Button>
<Button
type="button"
variant="surface"
size="xs"
class="rounded-md"
onclick={() => deleteGoal(goal)}
disabled={submitting}
>
Delete
</Button>
</div>
</article>
{/each}
</div>
{/if}
</section>
</div>
</SettingsShell>
<Modal
bind:open={goalModalOpen}
title={editingGoal ? "Edit target" : "Set a new target"}
maxWidth="max-w-2xl"
bodyClass="mb-6"
hasBody
hasActions
>
{#snippet body()}
<div class="space-y-4">
<div
class="grid grid-cols-1 gap-3 sm:grid-cols-[auto_auto_auto_auto] sm:items-center"
>
<span class="text-sm text-surface-content">I want to code for</span>
<input
type="number"
min="1"
step="1"
bind:value={targetAmount}
class="w-24 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Select
id="goal_target_unit"
bind:value={targetUnit}
items={unitOptions}
/>
<div class="flex items-center gap-2">
<span class="text-sm text-muted">per</span>
<Select
id="goal_period"
bind:value={selectedPeriod}
items={options.goals.periods}
/>
</div>
</div>
<div class="flex flex-wrap gap-2">
{#each QUICK_TARGETS as quickTarget}
{@const isActive = quickTarget.seconds === currentTargetSeconds}
<Button
type="button"
variant={isActive ? "primary" : "surface"}
size="xs"
class={isActive
? "rounded-full ring-2 ring-primary/40 ring-offset-1 ring-offset-surface"
: "rounded-full"}
onclick={() => applyQuickTarget(quickTarget.seconds)}
>
{quickTarget.label}
</Button>
{/each}
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<MultiSelectCombobox
label="Languages (optional)"
placeholder="Filter by language..."
emptyText="No languages found"
options={options.goals.selectable_languages}
bind:selected={selectedLanguages}
/>
<MultiSelectCombobox
label="Projects (optional)"
placeholder="Filter by project..."
emptyText="No projects found"
options={options.goals.selectable_projects}
bind:selected={selectedProjects}
/>
</div>
{#if modalErrors.length > 0}
<p
class="rounded-md border border-red/40 bg-red/10 px-3 py-2 text-xs text-red"
>
{modalErrors.join(", ")}
</p>
{/if}
</div>
{/snippet}
{#snippet actions()}
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="dark"
class="h-10 rounded-md border border-surface-300 text-muted"
onclick={() => (goalModalOpen = false)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
class="h-10 rounded-md"
onclick={saveGoal}
disabled={submitting}
>
{submitting ? "Saving..." : editingGoal ? "Update Goal" : "Create Goal"}
</Button>
</div>
{/snippet}
</Modal>

View file

@ -23,9 +23,13 @@
}: IntegrationsPageProps = $props(); }: IntegrationsPageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
let usesSlackStatus = $state(user.uses_slack_status); let usesSlackStatus = $state(false);
let unlinkGithubModalOpen = $state(false); let unlinkGithubModalOpen = $state(false);
$effect(() => {
usesSlackStatus = user.uses_slack_status;
});
onMount(() => { onMount(() => {
csrfToken = csrfToken =
document document

View file

@ -22,8 +22,13 @@
}: ProfilePageProps = $props(); }: ProfilePageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
let selectedTheme = $state(user.theme || "gruvbox_dark"); let selectedTheme = $state("gruvbox_dark");
let allowPublicStatsLookup = $state(user.allow_public_stats_lookup); let allowPublicStatsLookup = $state(false);
$effect(() => {
selectedTheme = user.theme || "gruvbox_dark";
allowPublicStatsLookup = user.allow_public_stats_lookup;
});
onMount(() => { onMount(() => {
csrfToken = csrfToken =

View file

@ -2,6 +2,7 @@ export type SectionId =
| "profile" | "profile"
| "integrations" | "integrations"
| "access" | "access"
| "goals"
| "badges" | "badges"
| "data" | "data"
| "admin"; | "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 = { export type UserProps = {
id: number; id: number;
display_name: string; display_name: string;
@ -46,6 +68,7 @@ export type UserProps = {
github_uid?: string | null; github_uid?: string | null;
github_username?: string | null; github_username?: string | null;
slack_uid?: string | null; slack_uid?: string | null;
programming_goals: ProgrammingGoal[];
}; };
export type PathsProps = { export type PathsProps = {
@ -71,6 +94,12 @@ export type OptionsProps = {
extension_text_types: Option[]; extension_text_types: Option[];
themes: ThemeOption[]; themes: ThemeOption[];
badge_themes: string[]; badge_themes: string[];
goals: {
periods: Option[];
preset_target_seconds: number[];
selectable_languages: Option[];
selectable_projects: Option[];
};
}; };
export type SlackProps = { export type SlackProps = {
@ -201,6 +230,14 @@ export type AccessPageProps = SettingsCommonProps & {
config_file: ConfigFileProps; 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 & { export type BadgesPageProps = SettingsCommonProps & {
options: OptionsProps; options: OptionsProps;
badges: BadgesProps; badges: BadgesProps;
@ -240,6 +277,12 @@ export const buildSections = (sectionPaths: SectionPaths, adminVisible: boolean)
blurb: "Time tracking setup, extension options, and API key access.", blurb: "Time tracking setup, extension options, and API key access.",
path: sectionPaths.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, id: "badges" as SectionId,
label: "Badges", label: "Badges",
@ -275,6 +318,7 @@ const hashSectionMap: Record<string, SectionId> = {
user_hackatime_extension: "access", user_hackatime_extension: "access",
user_api_key: "access", user_api_key: "access",
user_config_file: "access", user_config_file: "access",
user_programming_goals: "goals",
user_slack_status: "integrations", user_slack_status: "integrations",
user_slack_notifications: "integrations", user_slack_notifications: "integrations",
user_github_account: "integrations", user_github_account: "integrations",

86
app/models/goal.rb Normal file
View file

@ -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

View file

@ -279,6 +279,7 @@ class User < ApplicationRecord
# ex: .set_trust(:green) or set_trust(1) setting it to red # ex: .set_trust(:green) or set_trust(1) setting it to red
has_many :heartbeats has_many :heartbeats
has_many :goals, dependent: :destroy
has_many :email_addresses, dependent: :destroy has_many :email_addresses, dependent: :destroy
has_many :email_verification_requests, dependent: :destroy has_many :email_verification_requests, dependent: :destroy
has_many :sign_in_tokens, dependent: :destroy has_many :sign_in_tokens, dependent: :destroy

View file

@ -47,8 +47,15 @@ class AnonymizeUserService
username: "deleted_user_#{user.id}", username: "deleted_user_#{user.id}",
uses_slack_status: false, uses_slack_status: false,
country_code: nil, 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 end
@ -59,6 +66,7 @@ class AnonymizeUserService
user.email_verification_requests.destroy_all user.email_verification_requests.destroy_all
user.wakatime_mirrors.destroy_all user.wakatime_mirrors.destroy_all
user.project_repo_mappings.destroy_all user.project_repo_mappings.destroy_all
user.goals.destroy_all
# tombstone # tombstone
Heartbeat.unscoped.where(user_id: user.id, deleted_at: nil).update_all(deleted_at: Time.current) Heartbeat.unscoped.where(user_id: user.id, deleted_at: nil).update_all(deleted_at: Time.current)

View file

@ -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

View file

@ -38,27 +38,22 @@ test:
primary: primary:
<<: *default <<: *default
adapter: postgresql adapter: postgresql
database: test_db database: app_test
username: postgres url: <%= ENV['TEST_DATABASE_URL'] %>
password: postgres
# url: <%= ENV['TEST_DATABASE_URL'] %>
wakatime: wakatime:
adapter: postgresql adapter: postgresql
database: test_wakatime database: app_test
username: postgres url: <%= ENV['TEST_DATABASE_URL'] %>
password: postgres
replica: true replica: true
sailors_log: sailors_log:
adapter: postgresql adapter: postgresql
database: test_sailors_log database: app_test
username: postgres url: <%= ENV['TEST_DATABASE_URL'] %>
password: postgres
replica: true replica: true
warehouse: warehouse:
adapter: postgresql adapter: postgresql
database: test_warehouse database: app_test
username: postgres url: <%= ENV['TEST_DATABASE_URL'] %>
password: postgres
replica: true replica: true
# Store production database in the storage/ directory, which by default # Store production database in the storage/ directory, which by default

View file

@ -142,6 +142,10 @@ Rails.application.routes.draw do
patch "my/settings/integrations", to: "settings/integrations#update" patch "my/settings/integrations", to: "settings/integrations#update"
get "my/settings/access", to: "settings/access#show", as: :my_settings_access get "my/settings/access", to: "settings/access#show", as: :my_settings_access
patch "my/settings/access", to: "settings/access#update" 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/badges", to: "settings/badges#show", as: :my_settings_badges
get "my/settings/data", to: "settings/data#show", as: :my_settings_data get "my/settings/data", to: "settings/data#show", as: :my_settings_data
get "my/settings/admin", to: "settings/admin#show", as: :my_settings_admin get "my/settings/admin", to: "settings/admin#show", as: :my_settings_admin

View file

@ -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

View file

@ -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

23
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements" 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 t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
end 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| create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.integer "callback_priority" t.integer "callback_priority"
t.text "callback_queue_name" 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.datetime "created_at", null: false
t.boolean "default_timezone_leaderboard", default: true, null: false t.boolean "default_timezone_leaderboard", default: true, null: false
t.string "deprecated_name" t.string "deprecated_name"
t.string "display_name_override"
t.text "github_access_token" t.text "github_access_token"
t.string "github_avatar_url" t.string "github_avatar_url"
t.string "github_uid" 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_access_token"
t.string "hca_id" t.string "hca_id"
t.string "hca_scopes", default: [], array: true 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.text "slack_access_token"
t.string "slack_avatar_url" t.string "slack_avatar_url"
t.string "slack_scopes", default: [], array: true 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 "deletion_requests", "users", column: "admin_approved_by_id"
add_foreign_key "email_addresses", "users" add_foreign_key "email_addresses", "users"
add_foreign_key "email_verification_requests", "users" add_foreign_key "email_verification_requests", "users"
add_foreign_key "goals", "users"
add_foreign_key "heartbeat_branches", "users" add_foreign_key "heartbeat_branches", "users"
add_foreign_key "heartbeat_machines", "users" add_foreign_key "heartbeat_machines", "users"
add_foreign_key "heartbeat_projects", "users" add_foreign_key "heartbeat_projects", "users"

View file

@ -16,6 +16,7 @@ services:
- POSTGRES_HOST=db - POSTGRES_HOST=db
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=secureorpheus123 - POSTGRES_PASSWORD=secureorpheus123
- TEST_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_test
depends_on: depends_on:
- db - db
command: ["sleep", "infinity"] command: ["sleep", "infinity"]

View file

@ -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

View file

@ -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

118
test/models/goal_test.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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