Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions app/controllers/my/project_repo_mappings_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
class My::ProjectRepoMappingsController < InertiaController
layout "inertia", only: [ :index ]
layout "inertia", only: [ :index, :show ]

before_action :ensure_current_user
before_action :require_github_oauth, only: [ :edit, :update ]
before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ]
before_action :set_project_repo_mapping, only: [ :archive, :unarchive ]
before_action :set_project_repo_mapping, only: [ :archive, :unarchive, :toggle_share ]

def index
archived = show_archived?
Expand Down Expand Up @@ -47,6 +47,42 @@ def archive
redirect_to my_projects_path, notice: "Away it goes!"
end

def show
project_name = CGI.unescape(params[:project_name])
mapping = current_user.project_repo_mappings.find_by(project_name: project_name)
first_heartbeat = current_user.heartbeats.where(project: project_name).minimum(:time)
since_date = first_heartbeat ? Time.at(first_heartbeat).to_date.strftime("%-m/%-d/%Y") : nil

share_url = if mapping&.public_shared_at.present? && current_user.username.present?
profile_project_url(username: current_user.username, project_name: CGI.escape(project_name))
end

render inertia: "Projects/Show", props: {
page_title: "#{project_name} | My Projects",
project_name: project_name,
back_path: my_projects_path,
since_date: since_date,
repo_url: mapping&.repo_url,
is_shared: mapping&.public_shared_at.present?,
share_url: share_url,
toggle_share_path: toggle_share_my_project_repo_mapping_path(CGI.escape(project_name)),
interval: selected_interval,
from: params[:from],
to: params[:to],
project_stats: InertiaRails.defer { project_detail_payload(project_name) }
}
end

def toggle_share
if @project_repo_mapping.public_shared_at.present?
@project_repo_mapping.update_column(:public_shared_at, nil)
redirect_back fallback_location: my_projects_path, notice: "Project is now private."
else
@project_repo_mapping.update_column(:public_shared_at, Time.current)
redirect_back fallback_location: my_projects_path, notice: "Project is now shared!"
end
end

def unarchive
@project_repo_mapping.unarchive!
r = current_user.project_repo_mappings.archived.where.not(id: @project_repo_mapping.id).exists?
Expand Down Expand Up @@ -204,4 +240,88 @@ def project_count(archived)
projects = hb.select(:project).distinct.pluck(:project)
projects.count { |proj| archived_names.include?(proj) == archived }
end

def project_detail_payload(project_name)
h = ApplicationController.helpers
hb = current_user.heartbeats.where(project: project_name)
.filter_by_time_range(selected_interval, params[:from], params[:to])

total_time = hb.duration_seconds

language_stats = hb.group(:language).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
k = k == "Unknown" ? k : k.categorize_language
agg[k] = (agg[k] || 0) + dur
end.sort_by { |_, d| -d }.first(15).to_h

editor_stats = hb.group(:editor).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
agg[k.downcase] = (agg[k.downcase] || 0) + dur
end.sort_by { |_, d| -d }.first(10).map { |k, v| [ h.display_editor_name(k), v ] }.to_h

os_stats = hb.group(:operating_system).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
agg[k.downcase] = (agg[k.downcase] || 0) + dur
end.sort_by { |_, d| -d }.first(10).map { |k, v| [ h.display_os_name(k), v ] }.to_h

all_file_stats = hb.group(:entity).duration_seconds
.reject { |e, dur| e.blank? || dur < 60 }
.sort_by { |_, d| -d }

file_stats = all_file_stats.first(50)
.map { |entity, dur| [ helpers.shorten_file_path(entity), dur ] }

branch_stats = hb.group(:branch).duration_seconds
.reject { |b, _| b.blank? }
.sort_by { |_, d| -d }.first(10)

category_stats = hb.group(:category).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
agg[k] = (agg[k] || 0) + dur
end.sort_by { |_, d| -d }.first(10).to_h

language_colors = language_stats.present? ? LanguageUtils.colors_for(language_stats.keys) : {}

activity_data = project_activity_graph(project_name)

{
total_time: total_time,
total_time_label: format_duration(total_time),
file_count: hb.select(:entity).distinct.count,
language_stats: language_stats,
language_colors: language_colors,
editor_stats: editor_stats,
os_stats: os_stats,
category_stats: category_stats,
file_stats: file_stats,
branch_stats: branch_stats,
activity_graph: activity_data
}
end

def project_activity_graph(project_name)
tz = current_user.timezone
unless TZInfo::Timezone.all_identifiers.include?(tz)
tz = "UTC"
end
hb = current_user.heartbeats.where(project: project_name)

day_trunc = Arel.sql("DATE_TRUNC('day', to_timestamp(time) AT TIME ZONE #{ActiveRecord::Base.connection.quote(tz)})")

durations = hb.select(day_trunc.as("day_group"))
Comment thread
skyfallwastaken marked this conversation as resolved.
.where(time: 365.days.ago..Time.current)
.group(day_trunc)
.duration_seconds
.map { |date, duration| [ date.to_date.iso8601, duration ] }
.to_h

{
start_date: 365.days.ago.to_date.iso8601,
end_date: Time.current.to_date.iso8601,
duration_by_date: durations,
busiest_day_seconds: 8.hours.to_i,
timezone_label: ActiveSupport::TimeZone[tz].to_s,
timezone_settings_path: "/my/settings#user_timezone"
}
end
end
44 changes: 44 additions & 0 deletions app/controllers/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,50 @@ def activity
end
end

def project
return head :not_found if @user.nil?

project_name = CGI.unescape(params[:project_name])
mapping = @user.project_repo_mappings.find_by(project_name: project_name)
return head :not_found unless mapping&.public_shared_at.present?

h = ApplicationController.helpers
hb = @user.heartbeats.where(project: project_name)
total_time = hb.duration_seconds
first_heartbeat = hb.minimum(:time)
since_date = first_heartbeat ? Time.at(first_heartbeat).to_date.strftime("%-m/%-d/%Y") : nil

language_stats = hb.group(:language).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
k = k == "Unknown" ? k : k.categorize_language
agg[k] = (agg[k] || 0) + dur
end.sort_by { |_, d| -d }.first(15).to_h

file_stats = hb.group(:entity).duration_seconds
.reject { |e, dur| e.blank? || dur < 60 }
.sort_by { |_, d| -d }.first(50)
.map { |entity, dur| [ helpers.shorten_file_path(entity), dur ] }

branch_stats = hb.group(:branch).duration_seconds
.reject { |b, _| b.blank? }
.sort_by { |_, d| -d }.first(10)

render inertia: "Projects/PublicShow", props: {
page_title: "#{project_name} — @#{@user.username} | Hackatime",
project_name: project_name,
username: @user.username,
profile_path: profile_path(username: @user.username),
since_date: since_date,
repo_url: mapping.repo_url,
total_time_label: h.short_time_detailed(total_time),
file_count: hb.select(:entity).distinct.count,
language_stats: language_stats,
language_colors: language_stats.present? ? LanguageUtils.colors_for(language_stats.keys) : {},
file_stats: file_stats,
branch_stats: branch_stats
}
end

private

def find_user
Expand Down
7 changes: 7 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ def modal_open_button(modal_id, text, **options)
}.merge(options)
end

def shorten_file_path(entity)
return entity if entity.blank?
parts = entity.split("/")
return entity if parts.length <= 3
"#{parts.first}/…/#{parts.last(2).join("/")}"
end

def safe_asset_path(asset_name, fallback: nil)
asset_path(asset_name)
rescue StandardError
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/components/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
</div>

<Dialog.Close
class="inline-flex h-12 w-12 items-center justify-center rounded-lg text-surface-content/75 outline-none transition-colors hover:bg-surface-100/60 hover:text-surface-content focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-surface-content/75 outline-none transition-colors hover:bg-surface-100/60 hover:text-surface-content focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
aria-label="Close"
>
<svg
Expand All @@ -81,7 +81,7 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-6 w-6"
class="h-5 w-5"
aria-hidden="true"
>
<path d="M18 6L6 18" />
Expand Down
57 changes: 7 additions & 50 deletions app/javascript/pages/Home/SignedIn.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<script lang="ts">
import { Deferred, router } from "@inertiajs/svelte";
import type { ActivityGraphData } from "../../types/index";
import type {
ActivityGraphData,
SocialProofUser,
FilterableDashboardData,
TodayStats,
ProgrammingGoalProgress,
} from "../../types/index";
import BanNotice from "./signedIn/BanNotice.svelte";
import GitHubLinkBanner from "./signedIn/GitHubLinkBanner.svelte";
import SetupNotice from "./signedIn/SetupNotice.svelte";
Expand All @@ -11,55 +17,6 @@
import ActivityGraph from "./signedIn/ActivityGraph.svelte";
import ActivityGraphSkeleton from "./signedIn/ActivityGraphSkeleton.svelte";

type SocialProofUser = { display_name: string; avatar_url: string };

type FilterableDashboardData = {
total_time: number;
total_heartbeats: number;
top_project: string | null;
top_language: string | null;
top_editor: string | null;
top_operating_system: string | null;
project_durations: Record<string, number>;
language_stats: Record<string, number>;
editor_stats: Record<string, number>;
operating_system_stats: Record<string, number>;
category_stats: Record<string, number>;
weekly_project_stats: Record<string, Record<string, number>>;
project: string[];
language: string[];
editor: string[];
operating_system: string[];
category: string[];
selected_interval: string;
selected_from: string;
selected_to: string;
selected_project: string[];
selected_language: string[];
selected_editor: string[];
selected_operating_system: string[];
selected_category: string[];
};

type TodayStats = {
show_logged_time_sentence: boolean;
todays_duration_display: string;
todays_languages: string[];
todays_editors: string[];
};

type ProgrammingGoalProgress = {
id: string;
period: "day" | "week" | "month";
target_seconds: number;
tracked_seconds: number;
completion_percent: number;
complete: boolean;
languages: string[];
projects: string[];
period_end: string;
};

let {
flavor_text,
trust_level_red,
Expand Down
13 changes: 2 additions & 11 deletions app/javascript/pages/Home/signedIn/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,15 @@
import IntervalSelect from "./IntervalSelect.svelte";
import MultiSelect from "./MultiSelect.svelte";
import GoalsProgressCard from "./GoalsProgressCard.svelte";
import type { ProgrammingGoalProgress } from "../../../types/index";

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;
}[];
programmingGoalsProgress?: ProgrammingGoalProgress[];
onFiltersChange?: (search: string) => void;
} = $props();

Expand Down
73 changes: 73 additions & 0 deletions app/javascript/pages/Projects/FileList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script lang="ts">
import { secondsToDisplay } from "../Home/signedIn/utils";

let {
entries,
initialVisible = 5,
totalFileCount = 0,
}: {
entries: [string, number][];
initialVisible?: number;
totalFileCount?: number;
} = $props();

let expanded = $state(false);

const visibleEntries = $derived(
expanded ? entries : entries.slice(0, initialVisible),
);
const hasMore = $derived(entries.length > initialVisible);
const hiddenCount = $derived(entries.length - initialVisible);
const unlistedCount = $derived(
totalFileCount > entries.length ? totalFileCount - entries.length : 0,
);
</script>

<div class="rounded-xl border border-surface-200 bg-dark/50 p-6">
<div class="mb-4 flex items-baseline justify-between gap-2">
<h3 class="text-lg font-semibold text-surface-content/90">Files</h3>
{#if totalFileCount > 0}
<span class="text-xs text-muted">
{entries.length} shown · {totalFileCount} total
</span>
{/if}
</div>

{#if entries.length > 0}
<div class="divide-y divide-surface-200/30">
{#each visibleEntries as [label, seconds]}
<div class="flex items-center justify-between gap-4 py-2.5">
<span
class="min-w-0 flex-1 truncate font-mono text-sm text-surface-content/80"
title={label}
>
{label}
</span>
<span class="shrink-0 font-mono text-sm font-medium text-primary">
{secondsToDisplay(seconds)}
</span>
</div>
{/each}
</div>

{#if hasMore}
<button
type="button"
class="mt-3 w-full rounded-lg border border-surface-200/40 py-2 text-center text-sm text-muted transition-colors hover:border-primary/40 hover:text-primary"
onclick={() => (expanded = !expanded)}
>
{expanded
? "Show fewer"
: `Show ${hiddenCount} more file${hiddenCount === 1 ? "" : "s"}`}
</button>
{/if}

{#if unlistedCount > 0}
<p class="mt-2 text-center text-xs text-muted">
+ {unlistedCount} file{unlistedCount === 1 ? "" : "s"} under 1 min not shown
</p>
{/if}
{:else}
<p class="text-sm italic text-muted">No file data yet.</p>
{/if}
</div>
Loading
Loading