mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
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>
This commit is contained in:
parent
a5ad8bf6cb
commit
e3456be187
38 changed files with 2106 additions and 82 deletions
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
39
AGENTS.md
39
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
CMD ["rails", "server", "-b", "0.0.0.0"]
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
65
app/controllers/settings/goals_controller.rb
Normal file
65
app/controllers/settings/goals_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
<div class="absolute inset-x-0 top-0 h-1 bg-primary"></div>
|
||||
|
||||
<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">
|
||||
{#if hasIcon}
|
||||
<div
|
||||
|
|
|
|||
150
app/javascript/components/MultiSelectCombobox.svelte
Normal file
150
app/javascript/components/MultiSelectCombobox.svelte
Normal 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>
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
<BitsSelect.Content
|
||||
align="start"
|
||||
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
|
||||
class="max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
|
||||
|
|
|
|||
|
|
@ -90,17 +90,19 @@
|
|||
let navOpen = $state(false);
|
||||
let logoutOpen = $state(false);
|
||||
let currentlyExpanded = $state(false);
|
||||
let flashVisible = $state(layout.nav.flash.length > 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 @@
|
|||
/>
|
||||
</svg>
|
||||
</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
|
||||
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
|
||||
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"
|
||||
onclick={toggleCurrentlyHacking}
|
||||
aria-expanded={currentlyExpanded}
|
||||
aria-label="Toggle currently hacking list"
|
||||
>
|
||||
<div class="text-surface-content text-sm font-medium">
|
||||
<div class="flex items-center">
|
||||
|
|
@ -629,7 +640,7 @@
|
|||
<span class="text-base">{countLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if currentlyExpanded}
|
||||
{#if layout.currently_hacking.users.length === 0}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,18 @@
|
|||
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 {
|
||||
flavor_text,
|
||||
trust_level_red,
|
||||
|
|
@ -73,6 +85,7 @@
|
|||
filterable_dashboard_data: FilterableDashboardData;
|
||||
activity_graph: ActivityGraphData;
|
||||
today_stats: TodayStats;
|
||||
programming_goals_progress: ProgrammingGoalProgress[];
|
||||
};
|
||||
} = $props();
|
||||
|
||||
|
|
@ -149,6 +162,8 @@
|
|||
{#if dashboard_stats?.filterable_dashboard_data}
|
||||
<Dashboard
|
||||
data={dashboard_stats.filterable_dashboard_data}
|
||||
programmingGoalsProgress={dashboard_stats.programming_goals_progress ||
|
||||
[]}
|
||||
onFiltersChange={refreshDashboardData}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,25 @@
|
|||
import ProjectTimelineChart from "./ProjectTimelineChart.svelte";
|
||||
import IntervalSelect from "./IntervalSelect.svelte";
|
||||
import MultiSelect from "./MultiSelect.svelte";
|
||||
import GoalsProgressCard from "./GoalsProgressCard.svelte";
|
||||
|
||||
let {
|
||||
data,
|
||||
programmingGoalsProgress = [],
|
||||
onFiltersChange,
|
||||
}: {
|
||||
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;
|
||||
} = $props();
|
||||
|
||||
|
|
@ -105,6 +118,8 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<GoalsProgressCard goals={programmingGoalsProgress} />
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
|
|
|
|||
201
app/javascript/pages/Home/signedIn/GoalsProgressCard.svelte
Normal file
201
app/javascript/pages/Home/signedIn/GoalsProgressCard.svelte
Normal 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}
|
||||
|
|
@ -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<string[]>([]);
|
||||
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);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script context="module">
|
||||
<script module>
|
||||
export const layout = false;
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script context="module">
|
||||
<script module>
|
||||
export const layout = false;
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script context="module">
|
||||
<script module>
|
||||
export const layout = false;
|
||||
</script>
|
||||
|
||||
|
|
|
|||
270
app/javascript/pages/Profiles/Show.svelte
Normal file
270
app/javascript/pages/Profiles/Show.svelte
Normal 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>
|
||||
403
app/javascript/pages/Users/Settings/Goals.svelte
Normal file
403
app/javascript/pages/Users/Settings/Goals.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string, SectionId> = {
|
|||
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",
|
||||
|
|
|
|||
86
app/models/goal.rb
Normal file
86
app/models/goal.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
78
app/services/programming_goals_progress_service.rb
Normal file
78
app/services/programming_goals_progress_service.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
16
db/migrate/20260219153152_create_goals.rb
Normal file
16
db/migrate/20260219153152_create_goals.rb
Normal 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
23
db/schema.rb
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
62
test/controllers/profiles_controller_test.rb
Normal file
62
test/controllers/profiles_controller_test.rb
Normal 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
|
||||
124
test/controllers/settings_goals_controller_test.rb
Normal file
124
test/controllers/settings_goals_controller_test.rb
Normal 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
118
test/models/goal_test.rb
Normal 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
|
||||
40
test/services/anonymize_user_service_test.rb
Normal file
40
test/services/anonymize_user_service_test.rb
Normal 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
|
||||
141
test/services/programming_goals_progress_service_test.rb
Normal file
141
test/services/programming_goals_progress_service_test.rb
Normal 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
|
||||
Loading…
Add table
Reference in a new issue