Re-add test param (#921)

* Re-add test_param

* Revert "`types_from_initializers` + `js_from_routes` + performance fixes (#918)"

This reverts commit 384a618c15.

* bin/rubocop -A
This commit is contained in:
Mahad Kalam 2026-02-10 23:14:49 +00:00 committed by GitHub
parent 82a11a52d0
commit 0d089a01e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 436 additions and 568 deletions

3
.gitignore vendored
View file

@ -49,6 +49,3 @@ public/vite-dev
public/vite-ssr
.vite
app/javascript/api/
app/javascript/types/serializers/

View file

@ -77,8 +77,6 @@ gem "activerecord-import"
# Fast JSON parsing
gem "oj"
gem "oj_serializers"
gem "types_from_serializers"
# Rack Mini Profiler [https://github.com/MiniProfiler/rack-mini-profiler]
gem "rack-mini-profiler"
@ -163,6 +161,5 @@ gem "tailwindcss-ruby", "~> 4.1"
gem "tailwindcss-rails", "~> 4.2"
gem "inertia_rails", "~> 3.17"
gem "js_from_routes"
gem "vite_rails", "~> 3.0"

View file

@ -151,7 +151,7 @@ GEM
tzinfo
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@ -225,17 +225,14 @@ GEM
inertia_rails (3.17.0)
railties (>= 6)
io-console (0.8.2)
irb (1.17.0)
irb (1.16.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
js_from_routes (4.0.2)
railties (>= 5.1, < 9)
json (2.18.1)
json (2.18.0)
json-schema (6.1.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
@ -263,10 +260,6 @@ GEM
railties (>= 6.1)
rexml
lint_roller (1.1.0)
listen (3.10.0)
logger
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0)
rake (~> 13.0)
@ -326,11 +319,8 @@ GEM
faraday (>= 1.0, < 3.0)
faraday-net_http_persistent
net-http-persistent
oj (3.16.15)
oj (3.16.14)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
oj_serializers (2.1.0)
oj (>= 3.14.0)
ostruct (0.6.3)
paper_trail (17.0.0)
activerecord (>= 7.1)
@ -422,10 +412,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rdoc (7.2.0)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
@ -464,7 +451,7 @@ GEM
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.84.1)
rubocop (1.84.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -568,10 +555,6 @@ GEM
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
types_from_serializers (2.5.0)
listen (~> 3.2)
oj_serializers (~> 2.0, >= 2.0.2)
railties (>= 5.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unaccent (0.4.0)
@ -642,14 +625,12 @@ DEPENDENCIES
importmap-rails
inertia_rails (~> 3.17)
jbuilder
js_from_routes
kamal
letter_opener
letter_opener_web (~> 3.0)
memory_profiler
norairrecord (~> 0.5.1)
oj
oj_serializers
paper_trail
pg
propshaft
@ -681,7 +662,6 @@ DEPENDENCIES
tailwindcss-ruby (~> 4.1)
thruster
turbo-rails
types_from_serializers
tzinfo-data
vite_rails (~> 3.0)
web-console

View file

@ -63,34 +63,47 @@ class Api::V1::StatsController < ApplicationController
service_params[:end_date] = end_date
service_params[:scope] = scope if scope.present?
if params[:total_seconds] == "true"
query = @user.heartbeats
.coding_only
.with_valid_timestamps
.where(time: start_date..end_date)
# use TestWakatimeService when test_param=true for all requests
if params[:test_param] == "true"
service_params[:boundary_aware] = true # always and i mean always use boundary aware in testwakatime service
if params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
query = query.where(project: filter_by_project)
if params[:total_seconds] == "true"
summary = TestWakatimeService.new(**service_params).generate_summary
return render json: { total_seconds: summary[:total_seconds] }
end
if params[:filter_by_category].present?
filter_by_category = params[:filter_by_category].split(",")
query = query.where(category: filter_by_category)
summary = TestWakatimeService.new(**service_params).generate_summary
else
if params[:total_seconds] == "true"
query = @user.heartbeats
.coding_only
.with_valid_timestamps
.where(time: start_date..end_date)
if params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
query = query.where(project: filter_by_project)
end
if params[:filter_by_category].present?
filter_by_category = params[:filter_by_category].split(",")
query = query.where(category: filter_by_category)
end
# do the boundary thingie if requested
use_boundary_aware = params[:boundary_aware] == "true"
total_seconds = if use_boundary_aware
Heartbeat.duration_seconds_boundary_aware(query, start_date.to_f, end_date.to_f) || 0
else
query.duration_seconds || 0
end
return render json: { total_seconds: total_seconds }
end
use_boundary_aware = params[:boundary_aware] == "true"
total_seconds = if use_boundary_aware
Heartbeat.duration_seconds_boundary_aware(query, start_date.to_f, end_date.to_f) || 0
else
query.duration_seconds || 0
end
return render json: { total_seconds: total_seconds }
summary = WakatimeService.new(**service_params).generate_summary
end
summary = WakatimeService.new(**service_params).generate_summary
if params[:features]&.include?("projects") && params[:filter_by_project].present?
filter_by_project = params[:filter_by_project].split(",")
heartbeats = @user.heartbeats

View file

@ -209,18 +209,15 @@ class StaticPagesController < InertiaController
interval = params[:interval]
key = [ current_user ] + filters.map { |f| params[f] } + [ interval.to_s, params[:from], params[:to] ]
hb = current_user.heartbeats
base_hb = hb
h = ApplicationController.helpers
Rails.cache.fetch(key, expires_in: 5.minutes) do
archived = current_user.project_repo_mappings.archived.pluck(:project_name)
result = {}
grouped_durations = {}
Time.use_zone(current_user.timezone) do
filters.each do |f|
raw_options = base_hb.distinct.pluck(f).compact_blank
options = raw_options
options = current_user.heartbeats.distinct.pluck(f).compact_blank
options = options.reject { |n| archived.include?(n) } if f == :project
result[f] = options.map { |k|
f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k)
@ -231,7 +228,7 @@ class StaticPagesController < InertiaController
hb = if %i[operating_system editor].include?(f)
hb.where(f => arr.flat_map { |v| [ v.downcase, v.capitalize ] }.uniq)
elsif f == :language
raw = raw_options.select { |l| arr.include?(l.categorize_language) }
raw = current_user.heartbeats.distinct.pluck(f).compact_blank.select { |l| arr.include?(l.categorize_language) }
raw.any? ? hb.where(f => raw) : hb
else
hb.where(f => arr)
@ -244,8 +241,7 @@ class StaticPagesController < InertiaController
result[:total_heartbeats] = hb.count
filters.each do |f|
grouped_durations[f] ||= hb.group(f).duration_seconds
stats = grouped_durations[f]
stats = hb.group(f).duration_seconds
stats = stats.reject { |n, _| archived.include?(n) } if f == :project
result["top_#{f}"] = stats.max_by { |_, v| v }&.first
end
@ -255,15 +251,13 @@ class StaticPagesController < InertiaController
result["top_language"] &&= h.display_language_name(result["top_language"])
unless result["singular_project"]
grouped_durations[:project] ||= hb.group(:project).duration_seconds
result[:project_durations] = grouped_durations[:project]
result[:project_durations] = hb.group(:project).duration_seconds
.reject { |p, _| archived.include?(p) }.sort_by { |_, d| -d }.first(10).to_h
end
%i[language editor operating_system category].each do |f|
next if result["singular_#{f}"]
grouped_durations[f] ||= hb.group(f).duration_seconds
stats = grouped_durations[f].each_with_object({}) do |(raw, dur), agg|
stats = hb.group(f).duration_seconds.each_with_object({}) do |(raw, dur), agg|
k = raw.to_s.presence || "Unknown"
k = f == :language ? (k == "Unknown" ? k : k.categorize_language) : (%i[editor operating_system].include?(f) ? k.downcase : k)
agg[k] = (agg[k] || 0) + dur
@ -279,13 +273,10 @@ class StaticPagesController < InertiaController
}.to_h
end
weekly_project_durations = Heartbeat.weekly_grouped_durations(hb,
group_column: :project,
timezone: current_user.timezone,
weeks: 12)
result[:weekly_project_stats] = (0..11).to_h do |w|
ws = w.weeks.ago.beginning_of_week.to_date
[ ws.iso8601, (weekly_project_durations[ws] || {}).reject { |p, _| archived.include?(p) } ]
ws = w.weeks.ago.beginning_of_week
[ ws.to_date.iso8601, hb.where(time: ws.to_f..w.weeks.ago.end_of_week.to_f)
.group(:project).duration_seconds.reject { |p, _| archived.include?(p) } ]
end
end
result[:selected_interval] = interval.to_s

View file

@ -3,9 +3,67 @@
import { usePoll } from "@inertiajs/svelte";
import { onMount, onDestroy } from "svelte";
import plur from "plur";
import type InertiaLayoutProps from "../types/serializers/Inertia/LayoutProps";
let { layout, children }: { layout: InertiaLayoutProps; children?: import('svelte').Snippet } =
type NavLink = {
label: string;
href?: string;
active?: boolean;
badge?: number | null;
action?: string;
};
type LayoutNav = {
flash: { message: string; class_name: string }[];
user_present: boolean;
user_mention_html?: string | null;
streak_html?: string | null;
admin_level_html?: string | null;
login_path: string;
links: NavLink[];
dev_links: NavLink[];
admin_links: NavLink[];
viewer_links: NavLink[];
superadmin_links: NavLink[];
activities_html?: string | null;
};
type Footer = {
git_version: string;
commit_link: string;
server_start_time_ago: string;
heartbeat_recent_count: number;
heartbeat_recent_imported_count: number;
query_count: number;
query_cache_count: number;
cache_hits: number;
cache_misses: number;
requests_per_second: string;
active_users_graph: { height: number; title: string }[];
};
type CurrentlyHackingUser = {
id: number;
display_name?: string;
slack_uid?: string;
avatar_url?: string;
active_project?: { name: string; repo_url?: string | null };
};
type LayoutProps = {
nav: LayoutNav;
footer: Footer;
currently_hacking: {
count: number;
users: CurrentlyHackingUser[];
interval: number;
};
csrf_token: string;
signout_path: string;
show_stop_impersonating: boolean;
stop_impersonating_path: string;
};
let { layout, children }: { layout: LayoutProps; children?: () => unknown } =
$props();
const isBrowser =
@ -14,7 +72,7 @@
let navOpen = $state(false);
let logoutOpen = $state(false);
let currentlyExpanded = $state(false);
let flashVisible = $state(false);
let flashVisible = $state(layout.nav.flash.length > 0);
let flashHiding = $state(false);
const flashHideDelay = 6000;
const flashExitDuration = 250;
@ -46,7 +104,7 @@
const countLabel = () =>
`${layout.currently_hacking.count} ${plur("person", layout.currently_hacking.count)} currently hacking`;
const visualizeGitUrl = (url?: string | null): string =>
const visualizeGitUrl = (url?: string | null) =>
url?.startsWith("https://github.com/")
? url.replace(
"https://github.com/",
@ -62,10 +120,6 @@
if (isBrowser) document.body.classList.toggle("overflow-hidden", navOpen);
});
$effect(() => {
flashVisible = layout.nav.flash.length > 0;
});
$effect(() => {
if (!layout.nav.flash.length) {
flashVisible = false;
@ -144,7 +198,7 @@
/>
</svg>
</button>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && closeNav()}></div>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div>
<aside
class="flex flex-col min-h-screen w-52 bg-dark text-white px-3 py-4 rounded-r-lg overflow-y-auto lg:block"
@ -176,10 +230,10 @@
<nav class="space-y-1">
{#each layout.nav.links as link}
{#if link.action === "logout"}
<button
<a
type="button"
onclick={openLogout}
class={`${navLinkClass(false)} cursor-pointer text-left w-full bg-transparent border-0`}>Logout</button
class={`${navLinkClass(false)} cursor-pointer`}>Logout</a
>
{:else}
<a
@ -323,9 +377,8 @@
<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"
>
<button
type="button"
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between w-full border-0"
<div
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between"
onclick={toggleCurrentlyHacking}
>
<div class="text-white text-sm font-medium">
@ -336,7 +389,7 @@
<span class="text-base">{countLabel()}</span>
</div>
</div>
</button>
</div>
{#if currentlyExpanded}
{#if layout.currently_hacking.users.length === 0}
@ -381,24 +434,22 @@
<div class="text-xs text-muted ml-8">
working on
{#if user.active_project.repo_url}
{@const repoUrl = user.active_project.repo_url as string}
<a
href={repoUrl}
href={user.active_project.repo_url}
target="_blank"
class="text-accent hover:text-cyan-400 transition-colors"
>
{user.active_project.name as string}
{user.active_project.name}
</a>
{@const vizUrl = visualizeGitUrl(repoUrl)}
{#if vizUrl}
<a
href={vizUrl}
target="_blank"
class="ml-1">🌌</a
>
{/if}
{:else}
{user.active_project.name as string}
{user.active_project.name}
{/if}
{#if visualizeGitUrl(user.active_project.repo_url)}
<a
href={visualizeGitUrl(user.active_project.repo_url)}
target="_blank"
class="ml-1">🌌</a
>
{/if}
</div>
{/if}
@ -417,10 +468,6 @@
class:pointer-events-none={!logoutOpen}
style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);"
onclick={(e) => e.target === e.currentTarget && closeLogout()}
role="button"
tabindex="-1"
aria-label="Close logout modal"
onkeydown={(e) => e.key === 'Escape' && closeLogout()}
>
<div
class={`bg-dark border border-primary rounded-lg p-6 max-w-md w-full mx-4 flex flex-col items-center justify-center transform transition-transform duration-300 ease-in-out ${logoutOpen ? "scale-100" : "scale-95"}`}

View file

@ -1,11 +1,11 @@
<script lang="ts">
import type InertiaDocsIndexProps from "../../types/serializers/Inertia/DocsIndexProps";
import { users } from "../../api";
let {
popular_editors,
all_editors,
}: InertiaDocsIndexProps = $props();
}: {
popular_editors: [string, string][];
all_editors: [string, string][];
} = $props();
</script>
<svelte:head>
@ -26,7 +26,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<a
href={users.wakatimeSetup.path()}
href="/my/wakatime_setup"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<div class="text-2xl mb-2"></div>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { inertia } from "@inertiajs/svelte";
import type InertiaDocsShowProps from "../../types/serializers/Inertia/DocsShowProps";
let {
doc_path,
@ -9,7 +8,14 @@
breadcrumbs,
edit_url,
meta,
}: InertiaDocsShowProps = $props();
}: {
doc_path: string;
title: string;
rendered_content: string;
breadcrumbs: { name: string; href: string | null; is_link: boolean }[];
edit_url: string;
meta: { description: string; keywords: string };
} = $props();
</script>
<svelte:head>

View file

@ -1,12 +1,13 @@
<script lang="ts">
import type InertiaDocsNotFoundProps from "../../types/serializers/Inertia/DocsNotFoundProps";
import { staticPages } from "../../api";
let {
status_code = 404,
title = "Page Not Found",
message = "The page you were looking for doesn't exist.",
}: Partial<InertiaDocsNotFoundProps> = $props();
}: {
status_code?: number;
title?: string;
message?: string;
} = $props();
</script>
<svelte:head>
@ -20,7 +21,7 @@
<h2 class="text-2xl font-semibold text-white mb-4">{title}</h2>
<p class="text-secondary mb-8">{message}</p>
<a
href={staticPages.index.path()}
href="/"
class="inline-flex items-center justify-center px-6 py-3 rounded-lg bg-primary text-white font-medium hover:brightness-110 transition-all"
>
Go Home

View file

@ -1,5 +1,7 @@
<script lang="ts">
import type InertiaHomeSignedInProps from "../../types/serializers/Inertia/HomeSignedInProps";
import type {
ActivityGraphData,
} from "../../types/index";
import BanNotice from "./signedIn/BanNotice.svelte";
import GitHubLinkBanner from "./signedIn/GitHubLinkBanner.svelte";
import SetupNotice from "./signedIn/SetupNotice.svelte";
@ -7,6 +9,36 @@
import Dashboard from "./signedIn/Dashboard.svelte";
import ActivityGraph from "./signedIn/ActivityGraph.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[];
};
let {
flavor_text,
trust_level_red,
@ -23,7 +55,23 @@
todays_editors,
filterable_dashboard_data,
activity_graph,
}: InertiaHomeSignedInProps = $props();
}: {
flavor_text: string;
trust_level_red: boolean;
show_wakatime_setup_notice: boolean;
ssp_message?: string | null;
ssp_users_recent: SocialProofUser[];
ssp_users_size: number;
github_uid_blank: boolean;
github_auth_path: string;
wakatime_setup_path: string;
show_logged_time_sentence: boolean;
todays_duration_display: string;
todays_languages: string[];
todays_editors: string[];
filterable_dashboard_data: FilterableDashboardData;
activity_graph: ActivityGraphData;
} = $props();
</script>
<div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type InertiaHomeSignedOutProps from "../../types/serializers/Inertia/HomeSignedOutProps";
type HomeStats = { seconds_tracked?: number; users_tracked?: number };
let {
hca_auth_path,
@ -10,7 +10,16 @@
dev_magic_link,
csrf_token,
home_stats,
}: InertiaHomeSignedOutProps = $props();
}: {
hca_auth_path: string;
slack_auth_path: string;
email_auth_path: string;
sign_in_email: boolean;
show_dev_tool: boolean;
dev_magic_link?: string | null;
csrf_token: string;
home_stats: HomeStats;
} = $props();
let isSigningIn = $state(false);
@ -31,15 +40,11 @@
const formatNumber = (value: number) => numberFormatter.format(value);
const hoursTracked = $derived(
home_stats?.seconds_tracked && typeof home_stats.seconds_tracked === 'number'
home_stats?.seconds_tracked
? Math.floor(home_stats.seconds_tracked / 3600)
: 0,
);
const usersTracked = $derived(
home_stats?.users_tracked && typeof home_stats.users_tracked === 'number'
? home_stats.users_tracked
: 0
);
const usersTracked = $derived(home_stats?.users_tracked ?? 0);
// Grid background pattern
const gridPattern = `background-image: linear-gradient(to right, #4A2D3133 1px, transparent 1px), linear-gradient(to bottom, #4A2D3133 1px, transparent 1px); background-size: 6rem 6rem;`;

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type InertiaSspUser from "../../../types/serializers/Inertia/SspUser";
import SocialProofUsers from "./SocialProofUsers.svelte";
type SocialProofUser = { display_name: string; avatar_url: string };
let {
wakatime_setup_path,
ssp_message,
@ -10,7 +11,7 @@
}: {
wakatime_setup_path: string;
ssp_message?: string | null;
ssp_users_recent: InertiaSspUser[];
ssp_users_recent: SocialProofUser[];
ssp_users_size: number;
} = $props();
</script>

View file

@ -1,12 +1,12 @@
<script lang="ts">
import type InertiaSspUser from "../../../types/serializers/Inertia/SspUser";
type SocialProofUser = { display_name: string; avatar_url: string };
let {
users,
total_size,
message,
}: {
users: InertiaSspUser[];
users: SocialProofUser[];
total_size: number;
message?: string | null;
} = $props();

View file

@ -1,10 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
import type InertiaWakatimeSetupIndexProps from "../../types/serializers/Inertia/WakatimeSetupIndexProps";
import { users } from "../../api";
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
let { current_user_api_key, setup_os, api_url, heartbeat_check_url }: InertiaWakatimeSetupIndexProps =
interface Props {
current_user_api_key: string;
setup_os: string;
api_url: string;
heartbeat_check_url: string;
}
let { current_user_api_key, setup_os, api_url, heartbeat_check_url }: Props =
$props();
let activeSection = $derived(
@ -111,7 +117,7 @@
</p>
<a
href={users.wakatimeSetupStep2.path()}
href="/my/wakatime_setup/step-2"
class="inline-flex items-center justify-center bg-primary text-white px-6 py-2 rounded-lg font-semibold transition-all"
>
Continue to Step 2 →
@ -218,7 +224,6 @@
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
title="Mac/Linux setup tutorial video"
width="100%"
height="300"
src="https://www.youtube.com/embed/QTwhJy7nT_w?modestbranding=1&rel=0"
@ -306,7 +311,6 @@
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
title="Windows setup tutorial video"
width="100%"
height="300"
src="https://www.youtube.com/embed/fX9tsiRvzhg?modestbranding=1&rel=0"
@ -354,7 +358,7 @@ heartbeat_rate_limit_seconds = 30</code
<div class="text-center">
<a
href={users.wakatimeSetupStep2.path()}
href="/my/wakatime_setup/step-2"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to next step</a
>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { users } from "../../api";
import { Link } from '@inertiajs/svelte';
import Stepper from './Stepper.svelte';
const editors = [
@ -31,7 +31,7 @@
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
{#each editors as editor}
<a
href={`${users.wakatimeSetupStep3.path()}?editor=${editor.id}`}
href={`/my/wakatime_setup/step-3?editor=${editor.id}`}
class="group flex flex-col items-center justify-center p-6 bg-dark border border-darkless rounded-xl hover:border-primary transition-all duration-200 hover:shadow-lg hover:shadow-primary/10">
<div class="w-16 h-16 mb-4 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
{#if editor.icon}

View file

@ -1,10 +1,15 @@
<script lang="ts">
import { onMount } from "svelte";
import type InertiaWakatimeSetupStep3Props from "../../types/serializers/Inertia/WakatimeSetupStep3Props";
import { users } from "../../api";
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
let { current_user_api_key, editor, heartbeat_check_url }: InertiaWakatimeSetupStep3Props = $props();
interface Props {
current_user_api_key: string;
editor: string;
heartbeat_check_url: string;
}
let { current_user_api_key, editor, heartbeat_check_url }: Props = $props();
let hasHeartbeat = $state(false);
let heartbeatTimeAgo = $state("");
@ -275,7 +280,7 @@
</p>
<a
href={users.wakatimeSetupStep4.path()}
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full transition-all transform hover:scale-[1.02] active:scale-[0.98]"
>
Continue →
@ -286,7 +291,7 @@
<div class="text-center">
<a
href={users.wakatimeSetupStep4.path()}
href="/my/wakatime_setup/step-4"
class="text-xs text-secondary hover:text-white transition-colors"
>Skip to finish</a
>
@ -343,23 +348,22 @@
</div>
<a
href={users.wakatimeSetupStep4.path()}
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step
</a>
{:else if editor && editorData[editor]}
{@const editorInfo = editorData[editor]}
{:else if editorData[editor]}
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
<div class="flex items-center gap-4 mb-6">
<img
src={editorInfo.icon}
alt={editorInfo.name}
src={editorData[editor].icon}
alt={editorData[editor].name}
class="w-12 h-12 object-contain"
/>
<div>
<h3 class="text-xl font-semibold">
{editorInfo.name} Setup
{editorData[editor].name} Setup
</h3>
<p class="text-secondary text-sm">
Install the plugin with your preferred package manager.
@ -368,7 +372,7 @@
</div>
<div class="space-y-6">
{#each editorInfo.methods as method, index}
{#each editorData[editor].methods as method, index}
{#if index > 0}
<div class="pt-6 border-t border-darkless"></div>
{/if}
@ -393,7 +397,7 @@
</div>
<a
href={users.wakatimeSetupStep4.path()}
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step
@ -469,7 +473,7 @@
</div>
<a
href={users.wakatimeSetupStep4.path()}
href="/my/wakatime_setup/step-4"
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
>
Next Step

View file

@ -1,9 +1,13 @@
<script lang="ts">
import type InertiaWakatimeSetupStep4Props from "../../types/serializers/Inertia/WakatimeSetupStep4Props";
import { staticPages } from "../../api";
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
let { return_url, return_button_text }: InertiaWakatimeSetupStep4Props = $props();
interface Props {
return_url?: string;
return_button_text: string;
}
let { return_url, return_button_text }: Props = $props();
let agreed = $state(false);
</script>
@ -55,7 +59,7 @@
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href={staticPages.index.path()}
href="/"
class="px-8 py-3 bg-primary border border-transparent text-white rounded-lg transition-all font-semibold transform active:scale-[0.98] text-center {agreed
? ''
: 'opacity-50 cursor-not-allowed pointer-events-none'}"

View file

@ -7,11 +7,3 @@ declare module '@inertiajs/core' {
errorValueType: string[]
}
}
declare global {
interface Window {
showLogout?: () => void;
}
}
export {}

View file

@ -249,41 +249,6 @@ module Heartbeatable
end
end
def weekly_grouped_durations(scope, group_column:, timezone:, weeks: 12)
scope = scope.with_valid_timestamps
timeout = heartbeat_timeout_duration.to_i
tz = timezone.presence || "UTC"
weeks = weeks.to_i
weeks = 1 if weeks < 1
group_expr = group_column.to_s.include?("(") ? group_column : connection.quote_column_name(group_column)
week_trunc = "DATE_TRUNC('week', to_timestamp(time) AT TIME ZONE #{connection.quote(tz)})"
range_start, range_end = Time.use_zone(tz) do
[ (weeks - 1).weeks.ago.beginning_of_week, Time.current.end_of_week ]
end
scoped = scope.where(time: range_start.to_f..range_end.to_f)
capped_diffs = scoped
.select("#{group_expr} as grouped_time, #{week_trunc} as week_group, CASE
WHEN LAG(time) OVER (PARTITION BY #{group_expr}, #{week_trunc} ORDER BY time) IS NULL THEN 0
ELSE LEAST(time - LAG(time) OVER (PARTITION BY #{group_expr}, #{week_trunc} ORDER BY time), #{timeout})
END as diff")
.where.not(time: nil)
.unscope(:group)
rows = connection.select_all(
"SELECT week_group, grouped_time, COALESCE(SUM(diff), 0)::integer as duration
FROM (#{capped_diffs.to_sql}) AS diffs
GROUP BY week_group, grouped_time"
)
rows.each_with_object(Hash.new { |h, k| h[k] = {} }) do |row, hash|
week = row["week_group"].to_date
hash[week][row["grouped_time"]] = row["duration"].to_i
end
end
def duration_seconds_boundary_aware(scope, start_time, end_time)
scope = scope.with_valid_timestamps

View file

@ -1,5 +0,0 @@
# frozen_string_literal: true
class BaseSerializer < Oj::Serializer
include TypesFromSerializers::DSL
end

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
module Inertia
class ActiveUsersGraphEntrySerializer < BaseSerializer
attribute :height, type: :number
attribute :title, type: :string
end
end

View file

@ -1,12 +0,0 @@
# frozen_string_literal: true
module Inertia
class ActivityGraphSerializer < BaseSerializer
attribute :start_date, type: :string
attribute :end_date, type: :string
attribute :duration_by_date, type: "Record<string, number>"
attribute :busiest_day_seconds, type: :number
attribute :timezone_label, type: :string
attribute :timezone_settings_path, type: :string
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class CurrentlyHackingSerializer < BaseSerializer
attribute :count, type: :number
has_many :users, serializer: Inertia::CurrentlyHackingUserSerializer
attribute :interval, type: :number
end
end

View file

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Inertia
class CurrentlyHackingUserSerializer < BaseSerializer
attribute :id, type: :number
attribute :display_name, type: "string | null"
attribute :slack_uid, type: "string | null"
attribute :avatar_url, type: "string | null"
attribute :active_project, type: "Record<string, unknown> | null"
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class DocsBreadcrumbSerializer < BaseSerializer
attribute :name, type: :string
attribute :href, type: "string | null"
attribute :is_link, type: :boolean
end
end

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
module Inertia
class DocsIndexPropsSerializer < BaseSerializer
attribute :popular_editors, type: "Array<[string, string]>"
attribute :all_editors, type: "Array<[string, string]>"
end
end

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
module Inertia
class DocsMetaSerializer < BaseSerializer
attribute :description, type: :string
attribute :keywords, type: :string
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class DocsNotFoundPropsSerializer < BaseSerializer
attribute :status_code, type: :number
attribute :title, type: :string
attribute :message, type: :string
end
end

View file

@ -1,12 +0,0 @@
# frozen_string_literal: true
module Inertia
class DocsShowPropsSerializer < BaseSerializer
attribute :doc_path, type: :string
attribute :title, type: :string
attribute :rendered_content, type: :string
has_many :breadcrumbs, serializer: Inertia::DocsBreadcrumbSerializer
attribute :edit_url, type: :string
has_one :meta, serializer: Inertia::DocsMetaSerializer
end
end

View file

@ -1,6 +0,0 @@
# frozen_string_literal: true
module Inertia
class ExtensionsIndexPropsSerializer < BaseSerializer
end
end

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
module Inertia
class FlashMessageSerializer < BaseSerializer
attribute :message, type: :string
attribute :class_name, type: :string
end
end

View file

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Inertia
class FooterSerializer < BaseSerializer
attribute :git_version, type: :string
attribute :commit_link, type: :string
attribute :server_start_time_ago, type: :string
attribute :heartbeat_recent_count, type: :number
attribute :heartbeat_recent_imported_count, type: :number
attribute :query_count, type: :number
attribute :query_cache_count, type: :number
attribute :cache_hits, type: :number
attribute :cache_misses, type: :number
attribute :requests_per_second, type: :string
has_many :active_users_graph, serializer: Inertia::ActiveUsersGraphEntrySerializer
end
end

View file

@ -1,21 +0,0 @@
# frozen_string_literal: true
module Inertia
class HomeSignedInPropsSerializer < BaseSerializer
attribute :flavor_text, type: :string
attribute :trust_level_red, type: :boolean
attribute :show_wakatime_setup_notice, type: :boolean
attribute :ssp_message, type: "string | null"
has_many :ssp_users_recent, serializer: Inertia::SspUserSerializer
attribute :ssp_users_size, type: :number
attribute :github_uid_blank, type: :boolean
attribute :github_auth_path, type: :string
attribute :wakatime_setup_path, type: :string
attribute :show_logged_time_sentence, type: :boolean
attribute :todays_duration_display, type: :string
attribute :todays_languages, type: "string[]"
attribute :todays_editors, type: "string[]"
attribute :filterable_dashboard_data, type: "Record<string, unknown>"
has_one :activity_graph, serializer: Inertia::ActivityGraphSerializer
end
end

View file

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Inertia
class HomeSignedOutPropsSerializer < BaseSerializer
attribute :flavor_text, type: :string
attribute :hca_auth_path, type: :string
attribute :slack_auth_path, type: :string
attribute :email_auth_path, type: :string
attribute :sign_in_email, type: :boolean
attribute :show_dev_tool, type: :boolean
attribute :dev_magic_link, type: "string | null"
attribute :csrf_token, type: :string
attribute :home_stats, type: "Record<string, unknown>"
end
end

View file

@ -1,18 +0,0 @@
# frozen_string_literal: true
module Inertia
class LayoutNavSerializer < BaseSerializer
has_many :flash, serializer: Inertia::FlashMessageSerializer
attribute :user_present, type: :boolean
attribute :user_mention_html, type: "string | null"
attribute :streak_html, type: "string | null"
attribute :admin_level_html, type: "string | null"
attribute :login_path, type: :string
has_many :links, serializer: Inertia::NavLinkSerializer
has_many :dev_links, serializer: Inertia::NavLinkSerializer
has_many :admin_links, serializer: Inertia::NavLinkSerializer
has_many :viewer_links, serializer: Inertia::NavLinkSerializer
has_many :superadmin_links, serializer: Inertia::NavLinkSerializer
attribute :activities_html, type: "string | null"
end
end

View file

@ -1,13 +0,0 @@
# frozen_string_literal: true
module Inertia
class LayoutPropsSerializer < BaseSerializer
has_one :nav, serializer: Inertia::LayoutNavSerializer
has_one :footer, serializer: Inertia::FooterSerializer
has_one :currently_hacking, serializer: Inertia::CurrentlyHackingSerializer
attribute :csrf_token, type: :string
attribute :signout_path, type: :string
attribute :show_stop_impersonating, type: :boolean
attribute :stop_impersonating_path, type: :string
end
end

View file

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Inertia
class NavLinkSerializer < BaseSerializer
attribute :label, type: :string
attribute :href, type: :string, optional: true
attribute :active, type: :boolean, optional: true
attribute :badge, type: "number | null", optional: true
attribute :action, type: :string, optional: true
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class SspUserSerializer < BaseSerializer
attribute :id, type: :number
attribute :avatar_url, type: :string
attribute :display_name, type: :string
end
end

View file

@ -1,10 +0,0 @@
# frozen_string_literal: true
module Inertia
class WakatimeSetupIndexPropsSerializer < BaseSerializer
attribute :current_user_api_key, type: "string | null"
attribute :setup_os, type: :string
attribute :api_url, type: :string
attribute :heartbeat_check_url, type: :string
end
end

View file

@ -1,6 +0,0 @@
# frozen_string_literal: true
module Inertia
class WakatimeSetupStep2PropsSerializer < BaseSerializer
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class WakatimeSetupStep3PropsSerializer < BaseSerializer
attribute :current_user_api_key, type: "string | null"
attribute :editor, type: "string | null"
attribute :heartbeat_check_url, type: :string
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Inertia
class WakatimeSetupStep4PropsSerializer < BaseSerializer
attribute :dino_video_url, type: :string
attribute :return_url, type: "string | null"
attribute :return_button_text, type: :string
end
end

View file

@ -6,16 +6,15 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@js-from-routes/inertia": "^1.10.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.0",
"@tsconfig/svelte": "5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "^5.50.1",
"svelte": "5",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
@ -94,8 +93,6 @@
"@inertiajs/core": ["@inertiajs/core@2.3.13", "", { "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.2", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23", "qs": "^6.14.1" } }, "sha512-qMHRnb59k/HehXw/WfQt5kPV0k9RapfFcWJZINJnYMwfHDEJ21iNVZjsJHmDN7yWdZmG1Dxi9FP4xarWWgdosQ=="],
"@inertiajs/inertia": ["@inertiajs/inertia@0.11.1", "", { "dependencies": { "axios": "^0.21.1", "deepmerge": "^4.0.0", "qs": "^6.9.0" } }, "sha512-btmV53c54oW4Z9XF0YyTdIUnM7ue0ONy3/KJOz6J1C5CYIwimiKfDMpz8ZbGJuxS+SPdOlNsqj2ZhlHslpJRZg=="],
"@inertiajs/svelte": ["@inertiajs/svelte@2.3.13", "", { "dependencies": { "@inertiajs/core": "2.3.13", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-tKqAGn3FCdLA57bmZjm+26exVjZVQ0I15/KuoEofZKjZ8/4bndyHhhx79jmelZKlDNj4O3ECz15L5mHfo7YPSQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@ -108,10 +105,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-from-routes/core": ["@js-from-routes/core@1.0.3", "", {}, "sha512-rUYNMTYFKRoo4w28BJOAderskjhYOtkLgj00x14FNjGu5PYurnimjYQp70H34PTtWRXIqJKprGHr15VOZZzPiw=="],
"@js-from-routes/inertia": ["@js-from-routes/inertia@1.10.2", "", { "peerDependencies": { "@inertiajs/inertia": "^0.11", "@js-from-routes/core": "^1.0.0" } }, "sha512-4Lv016v6ir7Ayp8ZsWzlMZfOEHaajd53ZwiueUzrtzkkZ5+HmUW9AmtEK29jom4qmBLVksVyreQL6BjxdEx6/w=="],
"@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "d3-scale": "^4.0.2", "date-fns": "^4.1.0", "lodash-es": "^4.17.21" } }, "sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ=="],
"@layerstack/svelte-stores": ["@layerstack/svelte-stores@1.0.2", "", { "dependencies": { "@layerstack/utils": "1.0.1", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "immer": "^10.1.1", "lodash-es": "^4.17.21", "zod": "^3.24.2" } }, "sha512-IxK0UKD0PVxg1VsyaR+n7NyJ+NlvyqvYYAp+J10lkjDQxm0yx58CaF2LBV08T22C3aY1iTlqJaatn/VHV4SoQg=="],
@ -536,7 +529,7 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.50.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A=="],
"svelte": ["svelte@5.49.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PYLwnngYzyhKzqDlGVlCH4z+NVI8mC0/bTv15vw25CcdOhxENsOHIbQ36oj5DIf3oBazM+STbCAvaskpxtBmWA=="],
"svelte-check": ["svelte-check@4.3.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q=="],
@ -572,8 +565,6 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@inertiajs/inertia/axios": ["axios@0.21.4", "", { "dependencies": { "follow-redirects": "^1.14.0" } }, "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg=="],
"@layerstack/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],

View file

@ -1,8 +0,0 @@
# frozen_string_literal: true
JsFromRoutes.config do |config|
config.client_library = "@js-from-routes/inertia"
config.file_suffix = "Api.ts"
config.all_helpers_file = "index.ts"
config.output_folder = Rails.root.join("app/javascript/api")
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
if Rails.env.development? then
TypesFromSerializers.config do |config|
config.output_dir = Rails.root.join("app/javascript/types/serializers")
config.custom_types_dir = Rails.root.join("app/javascript/types")
config.transform_keys = ->(key) { key.to_s }
end
end

View file

@ -20,9 +20,9 @@ Rails.application.routes.draw do
mount Rswag::Ui::Engine => "/api-docs"
use_doorkeeper
root "static_pages#index", defaults: { export: true }
root "static_pages#index"
resources :extensions, only: [ :index ], defaults: { export: true }
resources :extensions, only: [ :index ]
constraints AdminLevelConstraint.new(:superadmin) do
mount GoodJob::Engine => "good_job"
@ -114,8 +114,8 @@ Rails.application.routes.draw do
end
# Docs routes
get "docs", to: "docs#index", as: :docs, defaults: { export: true }
get "docs/*path", to: "docs#show", as: :doc, defaults: { export: true }
get "docs", to: "docs#index", as: :docs
get "docs/*path", to: "docs#show", as: :doc
# Nested under users for admin access
resources :users, only: [] do
@ -157,10 +157,10 @@ Rails.application.routes.draw do
post "deletion", to: "deletion_requests#create", as: :create_deletion
delete "deletion", to: "deletion_requests#cancel", as: :cancel_deletion
get "my/wakatime_setup", to: "users#wakatime_setup", defaults: { export: true }
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2", defaults: { export: true }
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3", defaults: { export: true }
get "my/wakatime_setup/step-4", to: "users#wakatime_setup_step_4", defaults: { export: true }
get "my/wakatime_setup", to: "users#wakatime_setup"
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2"
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3"
get "my/wakatime_setup/step-4", to: "users#wakatime_setup_step_4"
post "/sailors_log/slack/commands", to: "slack#create"
post "/timedump/slack/commands", to: "slack#create"

View file

@ -1,34 +0,0 @@
class AddIndexesForDashboardPerformance < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
def change
add_index :heartbeats, [ :user_id, :language, :time ],
name: "idx_heartbeats_user_language_time",
where: "deleted_at IS NULL",
algorithm: :concurrently,
if_not_exists: true
add_index :heartbeats, [ :user_id, :editor, :time ],
name: "idx_heartbeats_user_editor_time",
where: "deleted_at IS NULL",
algorithm: :concurrently,
if_not_exists: true
add_index :heartbeats, [ :user_id, :operating_system, :time ],
name: "idx_heartbeats_user_operating_system_time",
where: "deleted_at IS NULL",
algorithm: :concurrently,
if_not_exists: true
add_index :heartbeats, [ :user_id, :category, :time ],
name: "idx_heartbeats_user_category_time",
where: "deleted_at IS NULL",
algorithm: :concurrently,
if_not_exists: true
add_index :heartbeats, [ :user_id, :project ],
where: "deleted_at IS NULL",
algorithm: :concurrently,
if_not_exists: true
end
end

8
db/schema.rb generated
View file

@ -10,10 +10,11 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_02_10_094354) do
ActiveRecord::Schema[8.1].define(version: 2026_02_04_113033) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements"
create_schema "pganalyze"
create_table "activities", force: :cascade do |t|
t.datetime "created_at", null: false
@ -328,13 +329,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_10_094354) do
t.index ["raw_heartbeat_upload_id"], name: "index_heartbeats_on_raw_heartbeat_upload_id"
t.index ["source_type", "time", "user_id", "project"], name: "index_heartbeats_on_source_type_time_user_project"
t.index ["user_agent_id"], name: "index_heartbeats_on_user_agent_id"
t.index ["user_id", "category", "time"], name: "idx_heartbeats_user_category_time", where: "(deleted_at IS NULL)"
t.index ["user_id", "editor", "time"], name: "idx_heartbeats_user_editor_time", where: "(deleted_at IS NULL)"
t.index ["user_id", "id"], name: "index_heartbeats_on_user_id_with_ip", order: { id: :desc }, where: "((ip_address IS NOT NULL) AND (deleted_at IS NULL))"
t.index ["user_id", "language", "time"], name: "idx_heartbeats_user_language_time", where: "(deleted_at IS NULL)"
t.index ["user_id", "operating_system", "time"], name: "idx_heartbeats_user_operating_system_time", where: "(deleted_at IS NULL)"
t.index ["user_id", "project", "time"], name: "idx_heartbeats_user_project_time_stats", where: "((deleted_at IS NULL) AND (project IS NOT NULL))"
t.index ["user_id", "project"], name: "index_heartbeats_on_user_id_and_project", where: "(deleted_at IS NULL)"
t.index ["user_id", "time", "category"], name: "index_heartbeats_on_user_time_category"
t.index ["user_id", "time", "language"], name: "idx_heartbeats_user_time_language_stats", where: "(deleted_at IS NULL)"
t.index ["user_id", "time", "language_id"], name: "idx_heartbeats_user_time_language_id", where: "(deleted_at IS NULL)"

View file

@ -0,0 +1,165 @@
include ApplicationHelper
class TestWakatimeService
def initialize(user: nil, specific_filters: [], allow_cache: true, limit: 10, start_date: nil, end_date: nil, scope: nil, boundary_aware: false)
@scope = scope || Heartbeat.all
# trusting time from hackatime extensions.....
# @scope = @scope.coding_only
@scope = @scope.with_valid_timestamps
# yeah macha we're removing unwated categories
@scope = @scope.where.not("LOWER(category) IN (?)", [ "browsing", "ai coding", "meeting", "communicating" ])
@user = user
@boundary_aware = boundary_aware
@start_date = convert_to_unix_timestamp(start_date)
@end_date = convert_to_unix_timestamp(end_date)
# Default to 1 year ago if no start_date provided or if no data exists
@start_date = @start_date || @scope.minimum(:time) || 1.year.ago.to_i
@end_date = @end_date || @scope.maximum(:time) || Time.current.to_i
@scope = @scope.where("time >= ? AND time < ?", @start_date, @end_date)
@limit = limit
@limit = nil if @limit&.zero?
@scope = @scope.where(user_id: @user.id) if @user.present?
@specific_filters = specific_filters
@allow_cache = allow_cache
end
def generate_summary
summary = {}
summary[:username] = @user.display_name if @user.present?
summary[:user_id] = @user.id.to_s if @user.present?
summary[:is_coding_activity_visible] = true if @user.present?
summary[:is_other_usage_visible] = true if @user.present?
summary[:status] = "ok"
@start_time = @start_date
@end_time = @end_date
summary[:start] = Time.at(@start_time).strftime("%Y-%m-%dT%H:%M:%SZ")
summary[:end] = Time.at(@end_time).strftime("%Y-%m-%dT%H:%M:%SZ")
summary[:range] = "all_time"
summary[:human_readable_range] = "All Time"
@total_seconds = if @boundary_aware
result = Heartbeat.duration_seconds_boundary_aware(@scope, @start_date, @end_date) || 0
result
else
result = @scope.duration_seconds || 0
result
end
summary[:total_seconds] = @total_seconds
@total_days = (@end_time - @start_time) / 86400
summary[:daily_average] = @total_days.zero? ? 0 : @total_seconds / @total_days
summary[:human_readable_total] = ApplicationController.helpers.short_time_detailed(@total_seconds)
summary[:human_readable_daily_average] = ApplicationController.helpers.short_time_detailed(summary[:daily_average])
summary[:languages] = generate_summary_chunk(:language) if @specific_filters.include?(:languages)
summary[:projects] = generate_summary_chunk(:project) if @specific_filters.include?(:projects)
summary
end
def generate_summary_chunk(group_by)
result = []
@scope.group(group_by).duration_seconds.each do |key, value|
result << {
name: key.presence || "Other",
total_seconds: value,
text: ApplicationController.helpers.short_time_simple(value),
hours: value / 3600,
minutes: (value % 3600) / 60,
percent: (100.0 * value / @total_seconds).round(2),
digital: ApplicationController.helpers.digital_time(value)
}
end
result = result.sort_by { |item| -item[:total_seconds] }
result = result.first(@limit) if @limit.present?
result
end
def self.parse_user_agent(user_agent)
# Based on https://github.com/muety/wakapi/blob/b3668085c01dc0724d8330f4d51efd5b5aecaeb2/utils/http.go#L89
# Regex pattern to match wakatime client user agents
user_agent_pattern = /wakatime\/[^ ]+ \(([^)]+)\)(?: [^ ]+ ([^\/]+)(?:\/([^\/]+))?)?/
if matches = user_agent.match(user_agent_pattern)
os = matches[1].split("-").first
editor = matches[2]
editor ||= ""
{ os: os, editor: editor, err: nil }
else
# Try parsing as browser user agent as fallback
if browser_ua = user_agent.match(/^([^\/]+)\/([^\/\s]+)/)
# If "wakatime" is present, assume it's the browser extension
if user_agent.include?("wakatime") then
full_os = user_agent.split(" ")[1]
if full_os.present?
os = full_os.include?("_") ? full_os.split("_")[0] : full_os
{ os: os, editor: browser_ua[1].downcase, err: nil }
else
{ os: "", editor: "", err: "failed to parse user agent string" }
end
else
{ os: browser_ua[1], editor: browser_ua[2], err: nil }
end
else
{ os: "", editor: "", err: "failed to parse user agent string" }
end
end
rescue => e
Rails.logger.error("Error parsing user agent string: #{e.message}")
{ os: "", editor: "", err: "failed to parse user agent string" }
end
def categorize_os(os)
case os.downcase
when "win" then "Windows"
when "darwin" then "MacOS"
when os.include?("windows") then "Windows"
else os.capitalize
end
end
def categorize_editor(editor)
case editor.downcase
when "vscode" then "VSCode"
when "KTextEditor" then "Kate"
else editor.capitalize
end
end
private
def convert_to_unix_timestamp(timestamp)
# our lord and savior stack overflow for this bit of code
return nil if timestamp.nil?
case timestamp
when String
Time.parse(timestamp).to_i
when Time, DateTime, Date
timestamp.to_i
when Numeric
timestamp.to_i
else
nil
end
rescue ArgumentError => e
Rails.logger.error("Error converting timestamp: #{e.message}")
nil
end
end

49
package-lock.json generated
View file

@ -7,16 +7,15 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@js-from-routes/inertia": "^1.10.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.0",
"@tsconfig/svelte": "5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "^5.50.1",
"svelte": "5",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
@ -514,29 +513,6 @@
"qs": "^6.14.1"
}
},
"node_modules/@inertiajs/inertia": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@inertiajs/inertia/-/inertia-0.11.1.tgz",
"integrity": "sha512-btmV53c54oW4Z9XF0YyTdIUnM7ue0ONy3/KJOz6J1C5CYIwimiKfDMpz8ZbGJuxS+SPdOlNsqj2ZhlHslpJRZg==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"peer": true,
"dependencies": {
"axios": "^0.21.1",
"deepmerge": "^4.0.0",
"qs": "^6.9.0"
}
},
"node_modules/@inertiajs/inertia/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/@inertiajs/svelte": {
"version": "2.3.13",
"license": "MIT",
@ -585,23 +561,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-from-routes/core": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@js-from-routes/core/-/core-1.0.3.tgz",
"integrity": "sha512-rUYNMTYFKRoo4w28BJOAderskjhYOtkLgj00x14FNjGu5PYurnimjYQp70H34PTtWRXIqJKprGHr15VOZZzPiw==",
"license": "MIT",
"peer": true
},
"node_modules/@js-from-routes/inertia": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@js-from-routes/inertia/-/inertia-1.10.2.tgz",
"integrity": "sha512-4Lv016v6ir7Ayp8ZsWzlMZfOEHaajd53ZwiueUzrtzkkZ5+HmUW9AmtEK29jom4qmBLVksVyreQL6BjxdEx6/w==",
"license": "MIT",
"peerDependencies": {
"@inertiajs/inertia": "^0.11",
"@js-from-routes/core": "^1.0.0"
}
},
"node_modules/@layerstack/svelte-actions": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1.tgz",
@ -3462,9 +3421,7 @@
}
},
"node_modules/svelte": {
"version": "5.50.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.50.1.tgz",
"integrity": "sha512-/Jlom4ddkISyVHXpM2O5dXP9pYnaiFrVQzPbIL1/pEoOa77ZunCb6nDgUCTNCQ/X3t64z9ukrK6R+BbB3kPR3A==",
"version": "5.49.2",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",

View file

@ -7,16 +7,15 @@
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@inertiajs/svelte": "^2.3.13",
"@js-from-routes/inertia": "^1.10.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.0",
"@tsconfig/svelte": "5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
"svelte": "^5.50.1",
"svelte": "5",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
@ -24,4 +23,4 @@
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.1"
}
}
}

View file

@ -25,6 +25,5 @@
},
"include": ["app/javascript/**/*.ts", "app/javascript/**/*.js", "app/javascript/**/*.svelte"],
"exclude": ["app/javascript/controllers"],
"references": [{ "path": "./tsconfig.node.json" }]
}