From 53a36b4a41aa6dcaa188b940342a2af42a3a96e9 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 23:50:16 +0300 Subject: [PATCH] user profile: add pinned repos, activity heatmap, and translations Replace the flat repo list on the user profile with a 2-column card grid that shows pinned repos (falling back to top repos by stars), each card showing primary language and star count. Add a GitHub-style daily activity heatmap (last 365 days) above the contribution log. Adds a Repo.is_pinned column, supporting queries, a get_user_daily_activity aggregator, and routes all new strings through translations. --- commit/commit.v | 21 ++++++ gitly.v | 1 + repo/repo.v | 35 ++++++++++ static/css/user.scss | 159 +++++++++++++++++++++++++++++++++++++++++++ templates/user.html | 102 ++++++++++++++++++--------- translations/en.tr | 45 ++++++++++++ translations/ru.tr | 45 ++++++++++++ user/user.v | 19 ++++++ user/user_routes.v | 24 +++++-- 9 files changed, 416 insertions(+), 35 deletions(-) diff --git a/commit/commit.v b/commit/commit.v index dfadcd9..53d5977 100644 --- a/commit/commit.v +++ b/commit/commit.v @@ -112,3 +112,24 @@ fn (app App) get_repo_activity_buckets(repo_id int) []int { } return buckets } + +// get_user_daily_activity returns commit counts per day for the given user +// over the past `days` days. Index 0 is the oldest day, index `days-1` is today. +fn (app App) get_user_daily_activity(user_id int, days int) []int { + day_seconds := 24 * 3600 + now := time.now() + // Anchor to the start of today (local), so today is always the last bucket. + today_start := i64(time.new(year: now.year, month: now.month, day: now.day).unix()) + cutoff := int(today_start) - (days - 1) * day_seconds + commits := sql app.db { + select from Commit where author_id == user_id && created_at >= cutoff + } or { []Commit{} } + mut buckets := []int{len: days} + for c in commits { + idx := (c.created_at - cutoff) / day_seconds + if idx >= 0 && idx < days { + buckets[idx]++ + } + } + return buckets +} diff --git a/gitly.v b/gitly.v index 2fb81c3..54e23bd 100644 --- a/gitly.v +++ b/gitly.v @@ -333,6 +333,7 @@ fn (mut app App) migrate_tables() ! { app.add_missing_column('Repo', 'disable_projects', db_bool_column_type())! app.add_missing_column('Repo', 'disable_milestones', db_bool_column_type())! app.add_missing_column('Repo', 'disable_wiki', db_bool_column_type())! + app.add_missing_column('Repo', 'is_pinned', db_bool_column_type())! } fn (mut app App) add_missing_column(table_name string, column_name string, column_type string) ! { diff --git a/repo/repo.v b/repo/repo.v index 67a5ba7..087b43f 100644 --- a/repo/repo.v +++ b/repo/repo.v @@ -47,6 +47,7 @@ mut: disable_projects bool disable_milestones bool disable_wiki bool + is_pinned bool } fn (r &Repo) discussions_enabled() bool { @@ -154,6 +155,40 @@ fn (mut app App) find_user_public_repos(user_id int) []Repo { } or { []Repo{} } } +const profile_repos_limit = 6 + +fn (mut app App) find_user_pinned_repos(user_id int, include_private bool) []Repo { + limit := profile_repos_limit + if include_private { + return sql app.db { + select from Repo where user_id == user_id && is_pinned == true && is_deleted == false limit limit + } or { []Repo{} } + } + return sql app.db { + select from Repo where user_id == user_id && is_pinned == true && is_public == true + && is_deleted == false limit limit + } or { []Repo{} } +} + +fn (mut app App) find_user_top_repos_by_stars(user_id int, include_private bool, l int) []Repo { + if include_private { + return sql app.db { + select from Repo where user_id == user_id && is_deleted == false order by nr_stars desc limit l + } or { []Repo{} } + } + return sql app.db { + select from Repo where user_id == user_id && is_public == true && is_deleted == false order by nr_stars desc limit l + } or { []Repo{} } +} + +fn (mut app App) find_user_profile_repos(user_id int, include_private bool) []Repo { + pinned := app.find_user_pinned_repos(user_id, include_private) + if pinned.len > 0 { + return pinned + } + return app.find_user_top_repos_by_stars(user_id, include_private, profile_repos_limit) +} + fn (app &App) search_public_repos(query string) []Repo { repo_rows := db_exec_values(app.db, 'select id, name, user_id, description, stars_count from ${sql_table('Repo')} where is_public is true and is_deleted is false and name like ${sql_like_pattern(query)}') or { diff --git a/static/css/user.scss b/static/css/user.scss index f15c0b0..470a62a 100644 --- a/static/css/user.scss +++ b/static/css/user.scss @@ -63,3 +63,162 @@ padding: 10px; margin-top: 10px; } + +.profile-repos-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 0 0 12px; + + h2 { + margin: 0; + font-size: 18px; + } +} + +.profile-repos-all { + font-size: 13px; +} + +.profile-repos-empty { + color: #586069; + font-size: 14px; + margin: 0; +} + +.profile-repo-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.profile-repo-card { + border: 1px solid #dfdfdf; + border-radius: $small-radius; + padding: 12px; + display: flex; + flex-direction: column; + min-height: 110px; +} + +.profile-repo-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.profile-repo-card__title { + font-weight: 600; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-repo-card__badge { + font-size: 11px; + border: 1px solid #dfdfdf; + border-radius: 999px; + padding: 1px 7px; + color: #586069; + + &--private { + color: #b08800; + border-color: #f1d27c; + background: #fff9e6; + } +} + +.profile-repo-card__desc { + color: #586069; + font-size: 13px; + margin: 0 0 10px; + flex: 1; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.profile-repo-card__footer { + display: flex; + align-items: center; + gap: 14px; + font-size: 12px; + color: #586069; +} + +.profile-repo-card__lang { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.profile-repo-card__lang-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; +} + +.profile-activity-heatmap { + margin-top: 25px; + border: 1px solid #dfdfdf; + border-radius: $small-radius; + padding: 14px 16px; +} + +.profile-activity-heatmap__header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + } +} + +.profile-activity-heatmap__range { + color: #586069; + font-size: 12px; +} + +.profile-activity-heatmap__grid { + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(7, 11px); + grid-auto-columns: 11px; + gap: 2px; + overflow-x: auto; +} + +.profile-activity-heatmap__cell { + width: 11px; + height: 11px; + border-radius: 2px; + background: #ebedf0; + + &--empty { + background: transparent; + } + + &[data-level="1"] { background: #9be9a8; } + &[data-level="2"] { background: #40c463; } + &[data-level="3"] { background: #30a14e; } + &[data-level="4"] { background: #216e39; } +} + +.profile-activity-heatmap__legend { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-top: 8px; + font-size: 11px; + color: #586069; +} diff --git a/templates/user.html b/templates/user.html index e7488bc..9360919 100644 --- a/templates/user.html +++ b/templates/user.html @@ -20,68 +20,108 @@ @if is_page_owner - + @end
-

Repositories

+
+

%user_pinned_repos

+ %user_view_all_repos +
@if repos.len > 0 - @for repo in repos -
- -

@repo.name

-
- @if repo.description.len > 0 -

@repo.description

- @end - @if is_page_owner - @if repo.is_public -

Public

- @else -

Private

+
+ @for repo in repos +
+
+ @repo.name + @if is_page_owner + @if repo.is_public + %user_repo_public + @else + %user_repo_private + @end + @end +
+ @if repo.description.len > 0 +

@repo.description

@end - @end -
- @end - @else -

No repositories

- @end + +
+ @end +
+ @else +

%user_no_repos

+ @end +
+ + +
+
+

@activity_total %user_contributions_in_year

+ @activity_start_label — @activity_end_label +
+ +
+ %user_heatmap_less +
+
+
+
+
+ %user_heatmap_more
-

Contribution activity

+

%user_contribution_activity

@for activity in activities @if activity.name == 'joined'
-

Joined Gitly

+

%user_activity_joined

-

Joined Gitly

-

on @user.created_at.ddmmy()

+

%user_activity_joined

+

%user_activity_on @user.created_at.ddmmy()

@end @if activity.name == 'first_repo'
-

First repository

+

%user_activity_first_repo

-

First repository

-

on @activity.created_at.ddmmy()

+

%user_activity_first_repo

+

%user_activity_on @activity.created_at.ddmmy()

@end @if activity.name == 'first_issue'
-

First issue

+

%user_activity_first_issue

-

First issue

-

on @activity.created_at.ddmmy()

+

%user_activity_first_issue

+

%user_activity_on @activity.created_at.ddmmy()

@end diff --git a/translations/en.tr b/translations/en.tr index 42e8b51..21555b8 100644 --- a/translations/en.tr +++ b/translations/en.tr @@ -944,3 +944,48 @@ Watch unwatch_action Unwatch ----- +user_edit_profile +Edit profile +----- +user_pinned_repos +Pinned repositories +----- +user_view_all_repos +View all +----- +user_no_repos +No repositories yet. +----- +user_repo_public +Public +----- +user_repo_private +Private +----- +user_contributions_in_year +contributions in the last year +----- +user_commits_label +commits +----- +user_heatmap_less +Less +----- +user_heatmap_more +More +----- +user_contribution_activity +Contribution activity +----- +user_activity_joined +Joined Gitly +----- +user_activity_first_repo +First repository +----- +user_activity_first_issue +First issue +----- +user_activity_on +on +----- diff --git a/translations/ru.tr b/translations/ru.tr index 5adcd14..4982b57 100644 --- a/translations/ru.tr +++ b/translations/ru.tr @@ -944,3 +944,48 @@ watch_action unwatch_action Не наблюдать ----- +user_edit_profile +Изменить профиль +----- +user_pinned_repos +Закреплённые репозитории +----- +user_view_all_repos +Все +----- +user_no_repos +Репозиториев пока нет. +----- +user_repo_public +Публичный +----- +user_repo_private +Приватный +----- +user_contributions_in_year +коммитов за последний год +----- +user_commits_label +коммитов +----- +user_heatmap_less +Меньше +----- +user_heatmap_more +Больше +----- +user_contribution_activity +Активность +----- +user_activity_joined +Регистрация в Gitly +----- +user_activity_first_repo +Первый репозиторий +----- +user_activity_first_issue +Первый ишью +----- +user_activity_on +от +----- diff --git a/user/user.v b/user/user.v index c706880..91179c6 100644 --- a/user/user.v +++ b/user/user.v @@ -452,3 +452,22 @@ pub fn (mut app App) get_user_from_cookies(ctx &Context) ?User { mut user := app.get_user_by_id(token.user_id) or { return none } return user } + +// activity_level maps a per-day commit count to a heatmap intensity level 0..4, +// scaled by the user's busiest day across the window. +fn activity_level(count int, max int) int { + if count <= 0 || max <= 0 { + return 0 + } + ratio := f64(count) / f64(max) + if ratio > 0.75 { + return 4 + } + if ratio > 0.5 { + return 3 + } + if ratio > 0.25 { + return 2 + } + return 1 +} diff --git a/user/user_routes.v b/user/user_routes.v index 841523a..49acaf7 100644 --- a/user/user_routes.v +++ b/user/user_routes.v @@ -69,11 +69,27 @@ pub fn (mut app App) user(mut ctx Context, username string) veb.Result { return ctx.not_found() } is_page_owner := username == ctx.user.username - repos := if is_page_owner { - app.find_user_repos(user.id) - } else { - app.find_user_public_repos(user.id) + mut repos := app.find_user_profile_repos(user.id, is_page_owner) + for mut repo in repos { + repo.lang_stats = app.find_repo_lang_stats(repo.id) + repo.latest_commit_at = app.find_repo_last_commit_time(repo.id) + } + activity_days := 365 + activity_buckets := app.get_user_daily_activity(user.id, activity_days) + mut activity_total := 0 + mut activity_max := 0 + for v in activity_buckets { + activity_total += v + if v > activity_max { + activity_max = v + } } + activity_oldest := time.now().add_days(-(activity_days - 1)) + // Render as a 7-row grid (Mon top → Sun bottom), columns are weeks. + // We need to pad leading cells so the first day lands on its weekday row. + activity_leading := activity_oldest.day_of_week() - 1 + activity_start_label := activity_oldest.md() + activity_end_label := time.now().md() activities := app.find_activities(user.id) return $veb.html() } -- 2.39.5