Use Inertia deferred props + Prettier/svelte-check (#957)

This commit is contained in:
Mahad Kalam 2026-02-16 10:29:04 +00:00 committed by GitHub
parent d203e1118f
commit 044a1e4fea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 816 additions and 1131 deletions

View file

@ -65,6 +65,28 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop -f github
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Install JavaScript dependencies
run: npm ci
- name: Run Svelte type checks
run: npm run check:svelte
- name: Run Svelte formatting checks
run: npm run format:svelte:check
zeitwerk:
runs-on: ubuntu-latest

13
.prettierrc.json Normal file
View file

@ -0,0 +1,13 @@
{
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte",
"tabWidth": 2,
"useTabs": false
}
}
]
}

View file

@ -3,7 +3,6 @@
@source "../../../node_modules/layerchart/dist/**/*.{svelte,js}";
@import "./main.css";
@import "./nav.css";
@import "./filterable_dashboard.css";
body {
@apply flex min-h-screen;
@ -291,4 +290,3 @@ html[data-theme="nord"] {
background-color: var(--color-primary);
opacity: 0.9;
}

View file

@ -1,12 +0,0 @@
.container {
@apply max-w-6xl mx-auto my-0 px-4 py-8 pb-0;
}
#filterable_dashboard_content.loading > div {
@apply grayscale opacity-70 pointer-events-none transition-[filter] duration-200 ease-in-out;
}
.filter .option input[type="checkbox"]:checked::after {
content: "";
@apply absolute left-1 top-1 w-1 h-2 border-white border-solid rotate-45 border-0 border-r-2 border-b-2;
}

View file

@ -1,23 +0,0 @@
module Api
module V1
class DashboardStatsController < ApplicationController
include DashboardData
before_action :require_session_user!
def show
render json: {
filterable_dashboard_data: filterable_dashboard_data,
activity_graph: activity_graph_data,
today_stats: today_stats_data
}
end
private
def require_session_user!
render json: { error: "Unauthorized" }, status: :unauthorized unless current_user
end
end
end
end

View file

@ -2,10 +2,6 @@ class StaticPagesController < InertiaController
include DashboardData
layout "inertia", only: :index
before_action :ensure_current_user, only: %i[
filterable_dashboard
filterable_dashboard_content
]
def index
if current_user
@ -108,32 +104,8 @@ class StaticPagesController < InertiaController
render partial: "streak"
end
def filterable_dashboard
load_dashboard_data
%i[project language operating_system editor category].each do |f|
instance_variable_set("@selected_#{f}", params[f]&.split(",") || [])
end
@selected_interval = params[:interval]
@selected_from = params[:from]
@selected_to = params[:to]
render partial: "filterable_dashboard"
end
def filterable_dashboard_content
load_dashboard_data
render partial: "filterable_dashboard_content"
end
private
def load_dashboard_data
filterable_dashboard_data.each { |k, v| instance_variable_set("@#{k}", v) }
end
def ensure_current_user
redirect_to(root_path, alert: "You must be logged in to view this page") unless current_user
end
def set_homepage_seo_content
@page_title = @og_title = @twitter_title = "Hackatime - See How Much You Code"
@meta_description = @og_description = @twitter_description = "Free and open source. Works with VS Code, JetBrains IDEs, vim, emacs, and 70+ other editors. Built and made free for teenagers by Hack Club."
@ -151,16 +123,15 @@ class StaticPagesController < InertiaController
github_uid_blank: current_user&.github_uid.blank?,
github_auth_path: github_auth_path,
wakatime_setup_path: my_wakatime_setup_path,
dashboard_stats_url: api_v1_dashboard_stats_path(
interval: params[:interval],
from: params[:from],
to: params[:to],
project: params[:project],
language: params[:language],
editor: params[:editor],
operating_system: params[:operating_system],
category: params[:category]
)
dashboard_stats: InertiaRails.defer { dashboard_stats_payload }
}
end
def dashboard_stats_payload
{
filterable_dashboard_data: filterable_dashboard_data,
activity_graph: activity_graph_data,
today_stats: today_stats_data
}
end

View file

@ -1,13 +1,10 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import type { Snippet } from "svelte";
type ButtonType = "button" | "submit" | "reset";
type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl";
type ButtonVariant =
| "primary"
| "surface"
| "dark"
| "outlinePrimary";
type ButtonVariant = "primary" | "surface" | "dark" | "outlinePrimary";
let {
href = "",
@ -15,6 +12,7 @@
size = "md",
variant = "primary",
unstyled = false,
children,
class: className = "",
...rest
}: {
@ -23,6 +21,7 @@
size?: ButtonSize;
variant?: ButtonVariant;
unstyled?: boolean;
children?: Snippet;
class?: string;
[key: string]: unknown;
} = $props();
@ -59,11 +58,11 @@
</script>
{#if href}
<Link href={href} class={classes} {...rest}>
<slot />
<Link {href} class={classes} {...rest}>
{@render children?.()}
</Link>
{:else}
<button type={type} class={classes} {...rest}>
<slot />
<button {type} class={classes} {...rest}>
{@render children?.()}
</button>
{/if}

View file

@ -2,7 +2,7 @@
import { Link, usePoll } from "@inertiajs/svelte";
import Button from "../components/Button.svelte";
import type { Snippet } from "svelte";
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, untrack } from "svelte";
import plur from "plur";
type NavLink = {
@ -88,17 +88,20 @@
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 pollInterval = untrack(
() => 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(pollInterval, {
only: ["currently_hacking"],
});
@ -117,6 +120,13 @@
}
};
const handleModalBackdropKeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
closeLogout();
}
};
const countLabel = () =>
`${layout.currently_hacking.count} ${plur("person", layout.currently_hacking.count)} currently hacking`;
@ -189,7 +199,8 @@
};
};
const streakLabel = (streakDays: number) => (streakDays > 30 ? "30+" : `${streakDays}`);
const streakLabel = (streakDays: number) =>
streakDays > 30 ? "30+" : `${streakDays}`;
const adminLevelLabel = (adminLevel?: AdminLevel | null) => {
if (adminLevel === "superadmin") return "Superadmin";
@ -218,9 +229,7 @@
document.documentElement.setAttribute("data-theme", layout.theme.name);
const colorSchemeMeta = document.querySelector(
"meta[name='color-scheme']",
);
const colorSchemeMeta = document.querySelector("meta[name='color-scheme']");
colorSchemeMeta?.setAttribute("content", layout.theme.color_scheme);
const themeColorMeta = document.querySelector("meta[name='theme-color']");
@ -312,7 +321,13 @@
/>
</svg>
</Button>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div>
<Button
type="button"
unstyled
class={`nav-overlay ${navOpen ? "open" : ""}`}
aria-label="Close navigation"
onclick={closeNav}
/>
<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"
@ -326,7 +341,10 @@
class="flex flex-col items-center gap-2 pb-3 border-b border-darkless"
>
{#if layout.nav.current_user}
<div class="user-info flex items-center gap-2" title={layout.nav.current_user.title}>
<div
class="user-info flex items-center gap-2"
title={layout.nav.current_user.title}
>
{#if layout.nav.current_user.avatar_url}
<img
src={layout.nav.current_user.avatar_url}
@ -343,7 +361,8 @@
{#if layout.nav.current_user.country_code}
<span
class="flex items-center"
title={layout.nav.current_user.country_name || layout.nav.current_user.country_code}
title={layout.nav.current_user.country_name ||
layout.nav.current_user.country_code}
>
{countryFlagEmoji(layout.nav.current_user.country_code)}
</span>
@ -351,10 +370,14 @@
</div>
{#if layout.nav.current_user.streak_days && layout.nav.current_user.streak_days > 0}
{@const streakTheme = streakThemeClasses(layout.nav.current_user.streak_days)}
{@const streakTheme = streakThemeClasses(
layout.nav.current_user.streak_days,
)}
<div
class={`inline-flex items-center gap-1 px-2 py-1 bg-gradient-to-r ${streakTheme.bg} border ${streakTheme.bc} rounded-lg transition-all duration-200 ${streakTheme.hbg} group`}
title={layout.nav.current_user.streak_days > 30 ? "30+ daily streak" : `${layout.nav.current_user.streak_days} day streak`}
title={layout.nav.current_user.streak_days > 30
? "30+ daily streak"
: `${layout.nav.current_user.streak_days} day streak`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -369,9 +392,13 @@
></path>
</svg>
<span class={`text-md font-semibold ${streakTheme.tc} transition-colors duration-200`}>
<span
class={`text-md font-semibold ${streakTheme.tc} transition-colors duration-200`}
>
{streakLabel(layout.nav.current_user.streak_days)}
<span class={`ml-1 font-normal ${streakTheme.tm}`}>day streak</span>
<span class={`ml-1 font-normal ${streakTheme.tm}`}
>day streak</span
>
</span>
</div>
{/if}
@ -401,22 +428,21 @@
<button
type="button"
onclick={openLogout}
class={`${navLinkClass(false)} cursor-pointer w-full text-left`}>Logout</button
class={`${navLinkClass(false)} cursor-pointer w-full text-left`}
>Logout</button
>
{:else if link.inertia}
<Link
href={link.href || "#"}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</Link
>
{:else}
{#if link.inertia}
<Link
href={link.href || "#"}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</Link
>
{:else}
<a
href={link.href || "#"}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</a
>
{/if}
<a
href={link.href || "#"}
onclick={handleNavLinkClick}
class={navLinkClass(link.active)}>{link.label}</a
>
{/if}
{/each}
@ -584,8 +610,8 @@
>
from {layout.footer.server_start_time_ago} ago.
{plur("heartbeat", layout.footer.heartbeat_recent_count)}
({layout.footer.heartbeat_recent_imported_count} imported) in the past
24 hours. (DB: {layout.footer.query_count}
({layout.footer.heartbeat_recent_imported_count} imported) in the past 24
hours. (DB: {layout.footer.query_count}
{plur("query", layout.footer.query_count)}, {layout.footer
.query_cache_count} cached) (CACHE: {layout.footer.cache_hits} hits,
{layout.footer.cache_misses} misses) ({layout.footer
@ -617,19 +643,21 @@
<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"
unstyled
class="currently-hacking p-3 bg-dark cursor-pointer select-none flex items-center justify-between"
aria-expanded={currentlyExpanded}
aria-label="Toggle currently hacking users"
onclick={toggleCurrentlyHacking}
>
<div class="text-surface-content text-sm font-medium">
<div class="flex items-center">
<div
class="w-2 h-2 rounded-full bg-green animate-pulse mr-2"
></div>
<div class="w-2 h-2 rounded-full bg-green animate-pulse mr-2"></div>
<span class="text-base">{countLabel()}</span>
</div>
</div>
</div>
</Button>
{#if currentlyExpanded}
{#if layout.currently_hacking.users.length === 0}
@ -707,7 +735,11 @@
class:opacity-0={!logoutOpen}
class:pointer-events-none={!logoutOpen}
style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);"
role="button"
tabindex="0"
aria-label="Close logout dialog"
onclick={(e) => e.target === e.currentTarget && closeLogout()}
onkeydown={handleModalBackdropKeydown}
>
<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"}`}
@ -727,7 +759,9 @@
</svg>
</div>
<h3 class="text-2xl font-bold text-surface-content mb-2 text-center w-full">
<h3
class="text-2xl font-bold text-surface-content mb-2 text-center w-full"
>
Woah hold on a sec
</h3>
<p class="text-muted mb-6 text-center w-full">
@ -741,8 +775,7 @@
type="button"
onclick={closeLogout}
variant="dark"
class="w-full h-10 text-muted m-0"
>Go back</Button
class="w-full h-10 text-muted m-0">Go back</Button
>
</div>
<div class="flex-1 min-w-0">
@ -753,10 +786,7 @@
value={layout.csrf_token}
/>
<input type="hidden" name="_method" value="delete" />
<Button
type="submit"
variant="primary"
class="w-full h-10 m-0"
<Button type="submit" variant="primary" class="w-full h-10 m-0"
>Log out now</Button
>
</form>

View file

@ -31,8 +31,16 @@
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"
>
<svg class="w-6 h-6 mb-2 text-primary" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z" clip-rule="evenodd" />
<svg
class="w-6 h-6 mb-2 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 01.359.852L12.982 9.75h7.268a.75.75 0 01.548 1.262l-10.5 11.25a.75.75 0 01-1.272-.71l1.992-7.302H3.75a.75.75 0 01-.548-1.262l10.5-11.25a.75.75 0 01.913-.143z"
clip-rule="evenodd"
/>
</svg>
<h3 class="font-semibold text-surface-content">Quick Start</h3>
<p class="text-sm text-muted text-center mt-1">
@ -44,8 +52,16 @@
href="/docs/getting-started/installation"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<svg class="w-6 h-6 mb-2 text-primary" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2.25 5.25a3 3 0 013-3h13.5a3 3 0 013 3V15a3 3 0 01-3 3h-3v.257c0 .597.237 1.17.659 1.591l.621.622H7.071l.621-.622A2.25 2.25 0 008.25 18.257V18h-3a3 3 0 01-3-3V5.25zm1.5 0v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5z" clip-rule="evenodd" />
<svg
class="w-6 h-6 mb-2 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2.25 5.25a3 3 0 013-3h13.5a3 3 0 013 3V15a3 3 0 01-3 3h-3v.257c0 .597.237 1.17.659 1.591l.621.622H7.071l.621-.622A2.25 2.25 0 008.25 18.257V18h-3a3 3 0 01-3-3V5.25zm1.5 0v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5z"
clip-rule="evenodd"
/>
</svg>
<h3 class="font-semibold text-surface-content">Installation</h3>
<p class="text-sm text-muted text-center mt-1">Add to your editor</p>
@ -55,8 +71,16 @@
href="/api-docs"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<svg class="w-6 h-6 mb-2 text-primary" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M14.447 3.027a.75.75 0 01.527.92l-4.5 16.5a.75.75 0 01-1.448-.394l4.5-16.5a.75.75 0 01.921-.526zM16.72 6.22a.75.75 0 011.06 0l5.25 5.25a.75.75 0 010 1.06l-5.25 5.25a.75.75 0 11-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 010-1.06zm-9.44 0a.75.75 0 010 1.06L2.56 12l4.72 4.72a.75.75 0 11-1.06 1.06L.97 12.53a.75.75 0 010-1.06l5.25-5.25a.75.75 0 011.06 0z" clip-rule="evenodd" />
<svg
class="w-6 h-6 mb-2 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M14.447 3.027a.75.75 0 01.527.92l-4.5 16.5a.75.75 0 01-1.448-.394l4.5-16.5a.75.75 0 01.921-.526zM16.72 6.22a.75.75 0 011.06 0l5.25 5.25a.75.75 0 010 1.06l-5.25 5.25a.75.75 0 11-1.06-1.06L21.44 12l-4.72-4.72a.75.75 0 010-1.06zm-9.44 0a.75.75 0 010 1.06L2.56 12l4.72 4.72a.75.75 0 11-1.06 1.06L.97 12.53a.75.75 0 010-1.06l5.25-5.25a.75.75 0 011.06 0z"
clip-rule="evenodd"
/>
</svg>
<h3 class="font-semibold text-surface-content">API Docs</h3>
<p class="text-sm text-muted text-center mt-1">Interactive reference</p>
@ -66,8 +90,16 @@
href="/docs/oauth/oauth-apps"
class="flex flex-col items-center justify-center p-6 bg-surface border border-surface-200 rounded-lg hover:border-primary transition-colors"
>
<svg class="w-6 h-6 mb-2 text-primary" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M15.75 1.5a6.75 6.75 0 00-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 00-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 00.75-.75v-1.5h1.5A.75.75 0 009 19.5V18h1.5a.75.75 0 00.53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1015.75 1.5zm0 3a.75.75 0 000 1.5A2.25 2.25 0 0118 8.25a.75.75 0 001.5 0 3.75 3.75 0 00-3.75-3.75z" clip-rule="evenodd" />
<svg
class="w-6 h-6 mb-2 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M15.75 1.5a6.75 6.75 0 00-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 00-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 00.75-.75v-1.5h1.5A.75.75 0 009 19.5V18h1.5a.75.75 0 00.53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1015.75 1.5zm0 3a.75.75 0 000 1.5A2.25 2.25 0 0118 8.25a.75.75 0 001.5 0 3.75 3.75 0 00-3.75-3.75z"
clip-rule="evenodd"
/>
</svg>
<h3 class="font-semibold text-surface-content">OAuth Apps</h3>
<p class="text-sm text-muted text-center mt-1">Build integrations</p>

View file

@ -22,11 +22,7 @@
<h1 class="text-6xl font-bold text-primary mb-4">{status_code}</h1>
<h2 class="text-2xl font-semibold text-surface-content mb-4">{title}</h2>
<p class="text-secondary mb-8">{message}</p>
<Button
href="/"
size="lg"
class="hover:brightness-110 transition-all"
>
<Button href="/" size="lg" class="hover:brightness-110 transition-all">
Go Home
</Button>
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import { Deferred, router } from "@inertiajs/svelte";
import type { ActivityGraphData } from "../../types/index";
import BanNotice from "./signedIn/BanNotice.svelte";
import GitHubLinkBanner from "./signedIn/GitHubLinkBanner.svelte";
@ -58,7 +58,7 @@
github_uid_blank,
github_auth_path,
wakatime_setup_path,
dashboard_stats_url,
dashboard_stats,
}: {
flavor_text: string;
trust_level_red: boolean;
@ -69,53 +69,22 @@
github_uid_blank: boolean;
github_auth_path: string;
wakatime_setup_path: string;
dashboard_stats_url: string;
dashboard_stats?: {
filterable_dashboard_data: FilterableDashboardData;
activity_graph: ActivityGraphData;
today_stats: TodayStats;
};
} = $props();
let loading = $state(true);
let todayStats = $state<TodayStats | null>(null);
let dashboardData = $state<FilterableDashboardData | null>(null);
let activityGraph = $state<ActivityGraphData | null>(null);
let requestSequence = 0;
function buildDashboardStatsUrl(search: string) {
const url = new URL(dashboard_stats_url, window.location.origin);
if (search.startsWith("?")) {
url.search = search;
} else if (search) {
url.search = `?${search}`;
} else {
url.search = "";
}
return `${url.pathname}${url.search}`;
function refreshDashboardData(search: string) {
router.visit(`${window.location.pathname}${search}`, {
only: ["dashboard_stats"],
preserveState: true,
preserveScroll: true,
replace: true,
async: true,
});
}
async function refreshDashboardData(search: string) {
const requestId = ++requestSequence;
try {
const res = await fetch(buildDashboardStatsUrl(search), {
headers: { Accept: "application/json" },
});
if (!res.ok) return;
const data = await res.json();
if (requestId !== requestSequence) return;
todayStats = data.today_stats;
dashboardData = data.filterable_dashboard_data;
activityGraph = data.activity_graph;
} catch {
return;
}
}
onMount(async () => {
try {
await refreshDashboardData(window.location.search);
} finally {
loading = false;
}
});
</script>
<div>
@ -149,33 +118,46 @@
<GitHubLinkBanner {github_auth_path} />
{/if}
<div class="flex flex-col gap-8">
<!-- Today Stats -->
<div>
{#if loading}
<TodaySentenceSkeleton />
{:else if todayStats}
<TodaySentence
show_logged_time_sentence={todayStats.show_logged_time_sentence}
todays_duration_display={todayStats.todays_duration_display}
todays_languages={todayStats.todays_languages}
todays_editors={todayStats.todays_editors}
/>
{/if}
</div>
<Deferred data="dashboard_stats">
{#snippet fallback()}
<div class="flex flex-col gap-8">
<div>
<TodaySentenceSkeleton />
</div>
<DashboardSkeleton />
<ActivityGraphSkeleton />
</div>
{/snippet}
<!-- Main Dashboard -->
{#if loading}
<DashboardSkeleton />
{:else if dashboardData}
<Dashboard data={dashboardData} onFiltersChange={refreshDashboardData} />
{/if}
{#snippet children({ reloading })}
<div class="flex flex-col gap-8" class:opacity-60={reloading}>
<!-- Today Stats -->
<div>
{#if dashboard_stats?.today_stats}
<TodaySentence
show_logged_time_sentence={dashboard_stats.today_stats
.show_logged_time_sentence}
todays_duration_display={dashboard_stats.today_stats
.todays_duration_display}
todays_languages={dashboard_stats.today_stats.todays_languages}
todays_editors={dashboard_stats.today_stats.todays_editors}
/>
{/if}
</div>
<!-- Activity Graph -->
{#if loading}
<ActivityGraphSkeleton />
{:else if activityGraph}
<ActivityGraph data={activityGraph} />
{/if}
</div>
<!-- Main Dashboard -->
{#if dashboard_stats?.filterable_dashboard_data}
<Dashboard
data={dashboard_stats.filterable_dashboard_data}
onFiltersChange={refreshDashboardData}
/>
{/if}
<!-- Activity Graph -->
{#if dashboard_stats?.activity_graph}
<ActivityGraph data={dashboard_stats.activity_graph} />
{/if}
</div>
{/snippet}
</Deferred>
</div>

View file

@ -73,7 +73,9 @@
<div class="hidden md:flex gap-8 text-sm font-medium text-text-muted">
<a href="#stats" class="hover:text-white transition-colors">Stats</a>
<a href="#editors" class="hover:text-white transition-colors">Editors</a>
<Link href="/docs" class="hover:text-white transition-colors">Developers</Link>
<Link href="/docs" class="hover:text-white transition-colors"
>Developers</Link
>
</div>
<div class="min-w-[140px] flex justify-end">
<a

View file

@ -1,11 +1,49 @@
<div class="text-primary bg-red-500/10 border-2 border-red-500/20 p-4 text-center rounded-lg mb-4">
<div
class="text-primary bg-red-500/10 border-2 border-red-500/20 p-4 text-center rounded-lg mb-4"
>
<div class="flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M8 14.5a6.5 6.5 0 1 0 0-13a6.5 6.5 0 0 0 0 13M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m1-5a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0z" clip-rule="evenodd" /></svg>
<span class="text-3xl font-bold block ml-2">Hold up! Your account has been banned for suspicious activity.</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 16 16"
><path
fill="currentColor"
fill-rule="evenodd"
d="M8 14.5a6.5 6.5 0 1 0 0-13a6.5 6.5 0 0 0 0 13M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m1-5a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0z"
clip-rule="evenodd"
/></svg
>
<span class="text-3xl font-bold block ml-2"
>Hold up! Your account has been banned for suspicious activity.</span
>
</div>
<div>
<p class="text-primary text-left text-lg mb-2"><b>What does this mean?</b> Your account was convicted for fraud or abuse of Hackatime, such as using methods to gain an unfair advantage on the leaderboards or attempting to manipulate your coding time in any way. This restricts your access to participate in public leaderboards, but Hackatime will still track and display your time. This may also affect your ability to participate in current and future Hack Club events.</p>
<p class="text-primary text-left text-lg mb-2"><b>What can I do?</b> Account bans are non-negotiable, and will not be removed unless determined to have been issued incorrectly. In that case, it will automatically be removed. We take fraud very seriously and have a zero-tolerance policy for abuse. If you believe this was a mistake, please DM the <a href="https://hackclub.slack.com/team/U091HC53CE8" target="_blank" class="underline">Fraud Department</a> on Slack. We do not respond in any other channel, DM or thread.</p>
<p class="text-primary text-left text-lg mb-0"><b>Can I know what caused this?</b> No. We do not disclose the patterns that were detected. Releasing this information would only benefit fraudsters. The fraud team regularly investigates claims of false bans to increase the effectiveness of our detection systems to combat fraud.</p>
<p class="text-primary text-left text-lg mb-2">
<b>What does this mean?</b> Your account was convicted for fraud or abuse of
Hackatime, such as using methods to gain an unfair advantage on the leaderboards
or attempting to manipulate your coding time in any way. This restricts your
access to participate in public leaderboards, but Hackatime will still track
and display your time. This may also affect your ability to participate in current
and future Hack Club events.
</p>
<p class="text-primary text-left text-lg mb-2">
<b>What can I do?</b> Account bans are non-negotiable, and will not be
removed unless determined to have been issued incorrectly. In that case,
it will automatically be removed. We take fraud very seriously and have a
zero-tolerance policy for abuse. If you believe this was a mistake, please
DM the
<a
href="https://hackclub.slack.com/team/U091HC53CE8"
target="_blank"
class="underline">Fraud Department</a
> on Slack. We do not respond in any other channel, DM or thread.
</p>
<p class="text-primary text-left text-lg mb-0">
<b>Can I know what caused this?</b> No. We do not disclose the patterns that
were detected. Releasing this information would only benefit fraudsters. The
fraud team regularly investigates claims of false bans to increase the effectiveness
of our detection systems to combat fraud.
</p>
</div>
</div>

View file

@ -12,11 +12,9 @@
onFiltersChange,
}: {
data: Record<string, any>;
onFiltersChange?: (search: string) => Promise<void> | void;
onFiltersChange?: (search: string) => void;
} = $props();
let loading = $state(false);
const langStats = $derived(
(data.language_stats || {}) as Record<string, number>,
);
@ -36,7 +34,7 @@
const capitalize = (s: string) =>
s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
async function applyFilters(overrides: Record<string, string>) {
function applyFilters(overrides: Record<string, string>) {
const current = new URL(window.location.href);
for (const [k, v] of Object.entries(overrides)) {
if (v) {
@ -45,31 +43,23 @@
current.searchParams.delete(k);
}
}
window.history.pushState({}, "", current.pathname + current.search);
loading = true;
try {
await onFiltersChange?.(current.search);
} finally {
loading = false;
}
onFiltersChange?.(current.search);
}
function onIntervalChange(interval: string, from: string, to: string) {
if (from || to) {
void applyFilters({ interval: "custom", from, to });
applyFilters({ interval: "custom", from, to });
} else {
void applyFilters({ interval, from: "", to: "" });
applyFilters({ interval, from: "", to: "" });
}
}
function onFilterChange(param: string, selected: string[]) {
void applyFilters({ [param]: selected.join(",") });
applyFilters({ [param]: selected.join(",") });
}
</script>
<div class="flex flex-col gap-6 w-full" class:opacity-60={loading}>
<div class="flex flex-col gap-6 w-full">
<!-- Filters -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-2">
<IntervalSelect

View file

@ -3,7 +3,9 @@
</script>
<div class="bg-dark border border-primary/40 rounded-xl p-4 md:p-5 mb-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div class="flex items-center gap-3 min-w-0">
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -61,7 +61,8 @@
$effect(() => {
if (open) {
document.addEventListener("click", handleClickOutside, true);
return () => document.removeEventListener("click", handleClickOutside, true);
return () =>
document.removeEventListener("click", handleClickOutside, true);
}
});
@ -82,11 +83,15 @@
</script>
<div class="filter relative" bind:this={container}>
<span class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider">
<span
class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider"
>
Date Range
</span>
<div class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200">
<div
class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200"
>
<Button
type="button"
unstyled
@ -94,8 +99,18 @@
onclick={() => (open = !open)}
>
<span>{displayLabel}</span>
<svg class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary" 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"></path>
<svg
class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary"
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"
></path>
</svg>
</Button>
@ -112,10 +127,14 @@
</div>
{#if open}
<div class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2">
<div
class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2"
>
<div class="overflow-y-auto m-0 max-h-56">
{#each INTERVALS as interval}
<label class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150">
<label
class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150"
>
<input
type="radio"
name="interval"

View file

@ -68,18 +68,26 @@
</script>
<div class="filter relative" bind:this={container}>
<span class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider">
<span
class="block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider"
>
{label}
</span>
<div class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200">
<div
class="group flex items-center border border-surface-200 rounded-lg bg-surface-100 m-0 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200"
>
<Button
type="button"
unstyled
class="flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-surface-content m-0 bg-transparent flex items-center justify-between border-0 min-w-0"
onclick={() => (open = !open)}
>
<span class="truncate {selected.length === 0 ? 'text-surface-content/60' : ''}">
<span
class="truncate {selected.length === 0
? 'text-surface-content/60'
: ''}"
>
{displayText}
</span>
<svg
@ -88,7 +96,12 @@
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
@ -105,7 +118,9 @@
</div>
{#if open}
<div class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2">
<div
class="absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-200 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2"
>
<input
type="text"
placeholder="Search..."
@ -115,7 +130,9 @@
<div class="overflow-y-auto m-0 max-h-64">
{#each filtered as value}
<label class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150">
<label
class="flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150"
>
<input
type="checkbox"
checked={selected.includes(value)}

View file

@ -15,10 +15,7 @@
{#if entries.length > 0}
<div class="flex flex-col gap-2 max-h-96 overflow-y-auto">
{#each entries as [week, stats]}
{@const total = Object.values(stats).reduce(
(a, v) => a + (v || 0),
0,
)}
{@const total = Object.values(stats).reduce((a, v) => a + (v || 0), 0)}
<div class="flex items-center gap-3">
<div class="w-28 text-sm text-muted">{week}</div>
<div class="flex-1 bg-darkless rounded h-3 overflow-hidden">

View file

@ -8,10 +8,26 @@
} = $props();
const PIE_COLORS = [
"#60a5fa", "#f472b6", "#fb923c", "#facc15", "#4ade80",
"#2dd4bf", "#a78bfa", "#f87171", "#38bdf8", "#e879f9",
"#34d399", "#fbbf24", "#818cf8", "#fb7185", "#22d3ee",
"#a3e635", "#c084fc", "#f97316", "#14b8a6", "#8b5cf6",
"#60a5fa",
"#f472b6",
"#fb923c",
"#facc15",
"#4ade80",
"#2dd4bf",
"#a78bfa",
"#f87171",
"#38bdf8",
"#e879f9",
"#34d399",
"#fbbf24",
"#818cf8",
"#fb7185",
"#22d3ee",
"#a3e635",
"#c084fc",
"#f97316",
"#14b8a6",
"#8b5cf6",
];
const sortedWeeks = $derived(Object.keys(weeklyStats).sort());
@ -74,7 +90,10 @@
type TimelineDatum = Record<string, string | number>;
function getSeriesValue(datum: TimelineDatum | null | undefined, key: string): number {
function getSeriesValue(
datum: TimelineDatum | null | undefined,
key: string,
): number {
const value = datum?.[key];
return typeof value === "number" ? value : 0;
}
@ -83,7 +102,9 @@
<div
class="bg-dark/50 border border-surface-200 rounded-xl p-6 flex flex-col min-h-[400px]"
>
<h2 class="mb-4 text-lg font-semibold text-surface-content/90">Project Timeline</h2>
<h2 class="mb-4 text-lg font-semibold text-surface-content/90">
Project Timeline
</h2>
{#if data.length > 0}
<div class="h-[350px]">
<BarChart
@ -108,7 +129,7 @@
{@const value = getSeriesValue(data, s.key)}
<Tooltip.Item
label={s.label ?? s.key}
value={value}
{value}
color={s.color}
format={formatDuration}
valueAlign="right"

View file

@ -19,13 +19,18 @@
<div class="text-left mt-2 mb-4 flex flex-col">
<p class="mb-4 text-xl text-primary">
Hello friend! Looks like you are new around here, let's get you set up
so you can start tracking your coding time.
Hello friend! Looks like you are new around here, let's get you set up so
you can start tracking your coding time.
</p>
<div class="mb-4 rounded-xl border border-primary/40 bg-surface-100/70 p-4 md:p-5">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div
class="mb-4 rounded-xl border border-primary/40 bg-surface-100/70 p-4 md:p-5"
>
<div
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<p class="m-0 text-base font-medium text-surface-content">
Finish setup once and we&apos;ll start tracking your coding time automatically.
Finish setup once and we&apos;ll start tracking your coding time
automatically.
</p>
<Button
href={wakatime_setup_path}

View file

@ -34,7 +34,8 @@
{value || "—"}
</div>
{#if subtitle}
<span class="absolute bottom-2 left-4 text-xs text-secondary font-normal opacity-70"
<span
class="absolute bottom-2 left-4 text-xs text-secondary font-normal opacity-70"
>{subtitle}</span
>
{/if}

View file

@ -89,20 +89,21 @@
>
<div class="space-y-8">
<section>
<h2 class="text-xl font-semibold text-surface-content">Time Tracking Setup</h2>
<h2 class="text-xl font-semibold text-surface-content">
Time Tracking Setup
</h2>
<p class="mt-1 text-sm text-muted">
Use the setup guide if you are configuring a new editor or device.
</p>
<Button
href={paths.wakatime_setup_path}
class="mt-4"
>
<Button href={paths.wakatime_setup_path} class="mt-4">
Open setup guide
</Button>
</section>
<section id="user_hackatime_extension">
<h2 class="text-xl font-semibold text-surface-content">Extension Display</h2>
<h2 class="text-xl font-semibold text-surface-content">
Extension Display
</h2>
<p class="mt-1 text-sm text-muted">
Choose how coding time appears in the extension status text.
</p>
@ -111,7 +112,10 @@
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="extension_type" class="mb-2 block text-sm text-surface-content">
<label
for="extension_type"
class="mb-2 block text-sm text-surface-content"
>
Display style
</label>
<select
@ -126,12 +130,7 @@
</select>
</div>
<Button
type="submit"
variant="primary"
>
Save extension settings
</Button>
<Button type="submit" variant="primary">Save extension settings</Button>
</form>
</section>
@ -150,7 +149,9 @@
</Button>
{#if rotatedApiKeyError}
<p class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red">
<p
class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red"
>
{rotatedApiKeyError}
</p>
{/if}
@ -160,7 +161,9 @@
<p class="text-xs font-semibold uppercase tracking-wide text-muted">
New API key
</p>
<code class="mt-2 block break-all text-sm text-surface-content">{rotatedApiKey}</code>
<code class="mt-2 block break-all text-sm text-surface-content"
>{rotatedApiKey}</code
>
<Button
type="button"
variant="surface"
@ -175,15 +178,22 @@
</section>
<section id="user_config_file">
<h2 class="text-xl font-semibold text-surface-content">WakaTime Config File</h2>
<h2 class="text-xl font-semibold text-surface-content">
WakaTime Config File
</h2>
<p class="mt-1 text-sm text-muted">
Copy this into your <code class="rounded bg-darker px-1 py-0.5 text-xs">~/.wakatime.cfg</code> file.
Copy this into your <code class="rounded bg-darker px-1 py-0.5 text-xs"
>~/.wakatime.cfg</code
> file.
</p>
{#if config_file.has_api_key && config_file.content}
<pre class="mt-4 overflow-x-auto rounded-md border border-surface-200 bg-darker p-4 text-xs text-surface-content">{config_file.content}</pre>
<pre
class="mt-4 overflow-x-auto rounded-md border border-surface-200 bg-darker p-4 text-xs text-surface-content">{config_file.content}</pre>
{:else}
<p class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
<p
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
{config_file.empty_message}
</p>
{/if}

View file

@ -37,7 +37,9 @@
{#if admin_tools.visible}
<div class="space-y-8">
<section id="wakatime_mirror">
<h2 class="text-xl font-semibold text-surface-content">WakaTime Mirrors</h2>
<h2 class="text-xl font-semibold text-surface-content">
WakaTime Mirrors
</h2>
<p class="mt-1 text-sm text-muted">
Mirror heartbeats to external WakaTime-compatible endpoints.
</p>
@ -49,7 +51,9 @@
<p class="text-sm font-semibold text-surface-content">
{mirror.endpoint_url}
</p>
<p class="mt-1 text-xs text-muted">Last synced: {mirror.last_synced_ago}</p>
<p class="mt-1 text-xs text-muted">
Last synced: {mirror.last_synced_ago}
</p>
<form
method="post"
action={mirror.destroy_path}
@ -61,12 +65,12 @@
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="surface"
size="xs"
>
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<Button type="submit" variant="surface" size="xs">
Delete
</Button>
</form>
@ -75,10 +79,17 @@
</div>
{/if}
<form method="post" action={paths.user_wakatime_mirrors_path} class="mt-5 space-y-3">
<form
method="post"
action={paths.user_wakatime_mirrors_path}
class="mt-5 space-y-3"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="endpoint_url" class="mb-2 block text-sm text-surface-content">
<label
for="endpoint_url"
class="mb-2 block text-sm text-surface-content"
>
Endpoint URL
</label>
<input
@ -91,7 +102,10 @@
/>
</div>
<div>
<label for="mirror_key" class="mb-2 block text-sm text-surface-content">
<label
for="mirror_key"
class="mb-2 block text-sm text-surface-content"
>
WakaTime API Key
</label>
<input
@ -102,17 +116,14 @@
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<Button
type="submit"
variant="primary"
>
Add mirror
</Button>
<Button type="submit" variant="primary">Add mirror</Button>
</form>
</section>
</div>
{:else}
<p class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
<p
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
You are not authorized to access this section.
</p>
{/if}

View file

@ -21,7 +21,10 @@
let selectedProject = $state("");
$effect(() => {
if (options.badge_themes.length > 0 && !options.badge_themes.includes(selectedTheme)) {
if (
options.badge_themes.length > 0 &&
!options.badge_themes.includes(selectedTheme)
) {
selectedTheme = defaultTheme(options.badge_themes);
}
});
@ -67,7 +70,10 @@
<div class="mt-4 space-y-4">
<div>
<label for="badge_theme" class="mb-2 block text-sm text-surface-content">
<label
for="badge_theme"
class="mb-2 block text-sm text-surface-content"
>
Theme
</label>
<select
@ -82,14 +88,22 @@
</div>
<div class="rounded-md border border-surface-200 bg-darker p-4">
<img src={badgeUrl()} alt="General coding stats badge preview" class="max-w-full rounded" />
<pre class="mt-3 overflow-x-auto text-xs text-surface-content">{badgeUrl()}</pre>
<img
src={badgeUrl()}
alt="General coding stats badge preview"
class="max-w-full rounded"
/>
<pre
class="mt-3 overflow-x-auto text-xs text-surface-content">{badgeUrl()}</pre>
</div>
</div>
{#if badges.projects.length > 0 && badges.project_badge_base_url}
<div class="mt-6 border-t border-surface-200 pt-6">
<label for="badge_project" class="mb-2 block text-sm text-surface-content">
<label
for="badge_project"
class="mb-2 block text-sm text-surface-content"
>
Project
</label>
<select
@ -107,20 +121,24 @@
alt="Project stats badge preview"
class="max-w-full rounded"
/>
<pre class="mt-3 overflow-x-auto text-xs text-surface-content">{projectBadgeUrl()}</pre>
<pre
class="mt-3 overflow-x-auto text-xs text-surface-content">{projectBadgeUrl()}</pre>
</div>
</div>
{/if}
</section>
<section id="user_markscribe">
<h2 class="text-xl font-semibold text-surface-content">Markscribe Template</h2>
<h2 class="text-xl font-semibold text-surface-content">
Markscribe Template
</h2>
<p class="mt-1 text-sm text-muted">
Use this snippet with markscribe to include your coding stats in a
README.
</p>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
<pre class="overflow-x-auto text-sm text-surface-content">{badges.markscribe_template}</pre>
<pre
class="overflow-x-auto text-sm text-surface-content">{badges.markscribe_template}</pre>
</div>
<p class="mt-3 text-sm text-muted">
Reference:

View file

@ -40,24 +40,23 @@
>
<div class="space-y-8">
<section id="user_migration_assistant">
<h2 class="text-xl font-semibold text-surface-content">Migration Assistant</h2>
<h2 class="text-xl font-semibold text-surface-content">
Migration Assistant
</h2>
<p class="mt-1 text-sm text-muted">
Queue migration of heartbeats and API keys from legacy Hackatime.
</p>
<form method="post" action={paths.migrate_heartbeats_path} class="mt-4">
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
class="rounded-md"
>
Start migration
</Button>
<Button type="submit" class="rounded-md">Start migration</Button>
</form>
{#if migration.jobs.length > 0}
<div class="mt-4 space-y-2">
{#each migration.jobs as job}
<div class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content">
<div
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content"
>
Job {job.id}: {job.status}
</div>
{/each}
@ -69,7 +68,9 @@
<h2 class="text-xl font-semibold text-surface-content">Download Data</h2>
{#if data_export.is_restricted}
<p class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200">
<p
class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200"
>
Data export is currently restricted for this account.
</p>
{:else}
@ -79,19 +80,25 @@
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Total heartbeats</p>
<p class="text-xs uppercase tracking-wide text-muted">
Total heartbeats
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_heartbeats}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Total coding time</p>
<p class="text-xs uppercase tracking-wide text-muted">
Total coding time
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_coding_time}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Last 7 days</p>
<p class="text-xs uppercase tracking-wide text-muted">
Last 7 days
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.heartbeats_last_7_days}
</p>
@ -99,10 +106,7 @@
</div>
<div class="mt-4 space-y-3">
<Button
href={paths.export_all_heartbeats_path}
class="rounded-md"
>
<Button href={paths.export_all_heartbeats_path} class="rounded-md">
Export all heartbeats
</Button>
@ -123,11 +127,7 @@
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button
type="submit"
variant="surface"
class="rounded-md"
>
<Button type="submit" variant="surface" class="rounded-md">
Export date range
</Button>
</form>
@ -141,7 +141,10 @@
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="mb-2 block text-sm text-surface-content" for="heartbeat_file">
<label
class="mb-2 block text-sm text-surface-content"
for="heartbeat_file"
>
Import heartbeats (development only)
</label>
<input
@ -152,11 +155,7 @@
required
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content"
/>
<Button
type="submit"
variant="surface"
class="mt-3 rounded-md"
>
<Button type="submit" variant="surface" class="mt-3 rounded-md">
Import file
</Button>
</form>
@ -165,11 +164,13 @@
</section>
<section id="delete_account">
<h2 class="text-xl font-semibold text-surface-content">Account Deletion</h2>
<h2 class="text-xl font-semibold text-surface-content">
Account Deletion
</h2>
{#if user.can_request_deletion}
<p class="mt-1 text-sm text-muted">
Request permanent deletion. The account enters a waiting period
before final removal.
Request permanent deletion. The account enters a waiting period before
final removal.
</p>
<form
method="post"
@ -186,16 +187,14 @@
}}
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="surface"
class="rounded-md"
>
<Button type="submit" variant="surface" class="rounded-md">
Request deletion
</Button>
</form>
{:else}
<p class="mt-3 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
<p
class="mt-3 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
Deletion request is unavailable for this account right now.
</p>
{/if}

View file

@ -41,7 +41,9 @@
>
<div class="space-y-8">
<section id="user_slack_status">
<h2 class="text-xl font-semibold text-surface-content">Slack Status Sync</h2>
<h2 class="text-xl font-semibold text-surface-content">
Slack Status Sync
</h2>
<p class="mt-1 text-sm text-muted">
Keep your Slack status updated while you are actively coding.
</p>
@ -76,10 +78,14 @@
</section>
<section id="user_slack_notifications">
<h2 class="text-xl font-semibold text-surface-content">Slack Channel Notifications</h2>
<h2 class="text-xl font-semibold text-surface-content">
Slack Channel Notifications
</h2>
<p class="mt-1 text-sm text-muted">
Enable notifications in any channel by running
<code class="rounded bg-darker px-1 py-0.5 text-xs text-surface-content">
<code
class="rounded bg-darker px-1 py-0.5 text-xs text-surface-content"
>
/sailorslog on
</code>
in that channel.
@ -88,26 +94,36 @@
{#if slack.notification_channels.length > 0}
<ul class="mt-4 space-y-2">
{#each slack.notification_channels as channel}
<li class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content">
<a href={channel.url} target="_blank" class="underline">{channel.label}</a>
<li
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content"
>
<a href={channel.url} target="_blank" class="underline"
>{channel.label}</a
>
</li>
{/each}
</ul>
{:else}
<p class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
<p
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
No channel notifications are enabled.
</p>
{/if}
</section>
<section id="user_github_account">
<h2 class="text-xl font-semibold text-surface-content">Connected GitHub Account</h2>
<h2 class="text-xl font-semibold text-surface-content">
Connected GitHub Account
</h2>
<p class="mt-1 text-sm text-muted">
Connect GitHub to show project links in dashboards and leaderboards.
</p>
{#if github.connected && github.username}
<div class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content">
<div
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content"
>
Connected as
<a href={github.profile_url || "#"} target="_blank" class="underline">
@{github.username}
@ -135,11 +151,7 @@
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="surface"
class="rounded-md"
>
<Button type="submit" variant="surface" class="rounded-md">
Unlink GitHub
</Button>
</form>
@ -155,7 +167,9 @@
</section>
<section id="user_email_addresses">
<h2 class="text-xl font-semibold text-surface-content">Email Addresses</h2>
<h2 class="text-xl font-semibold text-surface-content">
Email Addresses
</h2>
<p class="mt-1 text-sm text-muted">
Add or remove email addresses used for sign-in and verification.
</p>
@ -163,7 +177,9 @@
<div class="mt-4 space-y-2">
{#if emails.length > 0}
{#each emails as email}
<div class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2">
<div
class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2"
>
<div class="grow text-sm text-surface-content">
<p>{email.email}</p>
<p class="text-xs text-muted">{email.source}</p>
@ -171,7 +187,11 @@
{#if email.can_unlink}
<form method="post" action={paths.unlink_email_path}>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<input type="hidden" name="email" value={email.email} />
<Button
type="submit"
@ -186,13 +206,19 @@
</div>
{/each}
{:else}
<p class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted">
<p
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
No email addresses are linked.
</p>
{/if}
</div>
<form method="post" action={paths.add_email_path} class="mt-4 flex flex-col gap-3 sm:flex-row">
<form
method="post"
action={paths.add_email_path}
class="mt-4 flex flex-col gap-3 sm:flex-row"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<input
type="email"
@ -201,12 +227,7 @@
placeholder="name@example.com"
class="grow rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button
type="submit"
class="rounded-md"
>
Add email
</Button>
<Button type="submit" class="rounded-md">Add email</Button>
</form>
</section>
</div>

View file

@ -20,7 +20,7 @@
}: ProfilePageProps = $props();
let csrfToken = $state("");
let selectedTheme = $state(user.theme || "gruvbox_dark");
let selectedTheme = $state("gruvbox_dark");
onMount(() => {
csrfToken =
@ -28,6 +28,10 @@
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
$effect(() => {
selectedTheme = user.theme || "gruvbox_dark";
});
</script>
<SettingsShell
@ -41,7 +45,9 @@
>
<div class="space-y-8">
<section id="user_region">
<h2 class="text-xl font-semibold text-surface-content">Region and Timezone</h2>
<h2 class="text-xl font-semibold text-surface-content">
Region and Timezone
</h2>
<p class="mt-1 text-sm text-muted">
Use your local region and timezone for accurate dashboards and
leaderboards.
@ -51,7 +57,10 @@
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="country_code" class="mb-2 block text-sm text-surface-content">
<label
for="country_code"
class="mb-2 block text-sm text-surface-content"
>
Country
</label>
<select
@ -83,12 +92,7 @@
</select>
</div>
<Button
type="submit"
variant="primary"
>
Save region settings
</Button>
<Button type="submit" variant="primary">Save region settings</Button>
</form>
</section>
@ -118,12 +122,7 @@
{/if}
</div>
<Button
type="submit"
variant="primary"
>
Save username
</Button>
<Button type="submit" variant="primary">Save username</Button>
</form>
{#if badges.profile_url}
@ -150,7 +149,11 @@
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="flex items-center gap-3 text-sm text-surface-content">
<input type="hidden" name="user[allow_public_stats_lookup]" value="0" />
<input
type="hidden"
name="user[allow_public_stats_lookup]"
value="0"
/>
<input
type="checkbox"
name="user[allow_public_stats_lookup]"
@ -161,12 +164,7 @@
Allow public stats lookup
</label>
<Button
type="submit"
variant="primary"
>
Save privacy settings
</Button>
<Button type="submit" variant="primary">Save privacy settings</Button>
</form>
</section>
@ -200,11 +198,15 @@
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-surface-content">{theme.label}</p>
<p class="text-sm font-semibold text-surface-content">
{theme.label}
</p>
<p class="mt-1 text-xs text-muted">{theme.description}</p>
</div>
{#if isSelected}
<span class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
<span
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
Selected
</span>
{/if}
@ -223,26 +225,36 @@
</div>
<div class="mt-2 grid grid-cols-[1fr_auto] items-center gap-2">
<span class="h-2 rounded" style={`background:${theme.preview.primary};`}></span>
<span class="h-2 w-8 rounded" style={`background:${theme.preview.darkless};`}></span>
<span
class="h-2 rounded"
style={`background:${theme.preview.primary};`}
></span>
<span
class="h-2 w-8 rounded"
style={`background:${theme.preview.darkless};`}
></span>
</div>
<div class="mt-2 flex gap-1.5">
<span class="h-1.5 w-6 rounded" style={`background:${theme.preview.info};`}></span>
<span class="h-1.5 w-6 rounded" style={`background:${theme.preview.success};`}></span>
<span class="h-1.5 w-6 rounded" style={`background:${theme.preview.warning};`}></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.info};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.success};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.warning};`}
></span>
</div>
</div>
</label>
{/each}
</div>
<Button
type="submit"
variant="primary"
>
Save theme
</Button>
<Button type="submit" variant="primary">Save theme</Button>
</form>
</section>
</div>

View file

@ -17,7 +17,9 @@
}: SettingsCommonProps & { children?: Snippet } = $props();
const sections = $derived(buildSections(section_paths, admin_tools.visible));
const knownSectionIds = $derived(new Set(sections.map((section) => section.id)));
const knownSectionIds = $derived(
new Set(sections.map((section) => section.id)),
);
const sectionButtonClass = (sectionId: keyof SectionPaths) =>
`block w-full px-4 py-4 text-left transition-colors ${
@ -32,7 +34,9 @@
if (!section || !knownSectionIds.has(section)) return;
if (section === active_section || !section_paths[section]) return;
window.location.replace(`${section_paths[section]}${window.location.hash}`);
window.location.replace(
`${section_paths[section]}${window.location.hash}`,
);
};
syncSectionFromHash();
@ -52,7 +56,9 @@
</header>
{#if errors.full_messages.length > 0}
<div class="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-red">
<div
class="mb-6 rounded-lg border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-red"
>
<p class="font-semibold">Some changes could not be saved:</p>
<ul class="mt-2 list-disc pl-5">
{#each errors.full_messages as message}
@ -64,7 +70,9 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside class="h-max lg:sticky lg:top-8">
<div class="overflow-hidden rounded-xl border border-surface-200 bg-surface divide-y divide-surface-200">
<div
class="overflow-hidden rounded-xl border border-surface-200 bg-surface divide-y divide-surface-200"
>
{#each sections as section}
<Link href={section.path} class={sectionButtonClass(section.id)}>
<p class="text-sm font-semibold">{section.label}</p>

View file

@ -112,15 +112,14 @@
</div>
{:else}
<div class="text-center py-2">
<h4 class="text-xl font-bold text-surface-content">Setup complete!</h4>
<h4 class="text-xl font-bold text-surface-content">
Setup complete!
</h4>
<p class="text-secondary text-sm mb-6">
Heartbeat detected {heartbeatTimeAgo}.
</p>
<Button
href="/my/wakatime_setup/step-2"
size="lg"
>
<Button href="/my/wakatime_setup/step-2" size="lg">
Continue to Step 2 →
</Button>
</div>
@ -225,6 +224,7 @@
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
title="macOS setup video tutorial"
width="100%"
height="300"
src="https://www.youtube.com/embed/QTwhJy7nT_w?modestbranding=1&rel=0"
@ -312,6 +312,7 @@
class="mt-4 rounded-lg overflow-hidden border border-darkless"
>
<iframe
title="Windows setup video tutorial"
width="100%"
height="300"
src="https://www.youtube.com/embed/fX9tsiRvzhg?modestbranding=1&rel=0"

View file

@ -1,17 +1,33 @@
<script lang="ts">
import { Link } from '@inertiajs/svelte';
import Stepper from './Stepper.svelte';
import { Link } from "@inertiajs/svelte";
import Stepper from "./Stepper.svelte";
const editors = [
{ id: 'vscode', name: 'VS Code', icon: '/images/editor-icons/vs-code-128.png' },
{ id: 'vim', name: 'Vim', icon: '/images/editor-icons/vim-128.png' },
{ id: 'neovim', name: 'Neovim', icon: '/images/editor-icons/neovim-128.png' },
{ id: 'emacs', name: 'Emacs', icon: '/images/editor-icons/emacs-128.png' },
{ id: 'jetbrains', name: 'JetBrains', icon: '/images/editor-icons/jetbrains-128.png' },
{ id: 'sublime', name: 'Sublime', icon: '/images/editor-icons/sublime-text-128.png' },
{ id: 'unity', name: 'Unity', icon: '/images/editor-icons/unity-128.png' },
{ id: 'godot', name: 'Godot', icon: '/images/editor-icons/godot-128.png' },
{ id: 'other', name: 'Other', icon: null, emoji: '🔧' }
{
id: "vscode",
name: "VS Code",
icon: "/images/editor-icons/vs-code-128.png",
},
{ id: "vim", name: "Vim", icon: "/images/editor-icons/vim-128.png" },
{
id: "neovim",
name: "Neovim",
icon: "/images/editor-icons/neovim-128.png",
},
{ id: "emacs", name: "Emacs", icon: "/images/editor-icons/emacs-128.png" },
{
id: "jetbrains",
name: "JetBrains",
icon: "/images/editor-icons/jetbrains-128.png",
},
{
id: "sublime",
name: "Sublime",
icon: "/images/editor-icons/sublime-text-128.png",
},
{ id: "unity", name: "Unity", icon: "/images/editor-icons/unity-128.png" },
{ id: "godot", name: "Godot", icon: "/images/editor-icons/godot-128.png" },
{ id: "other", name: "Other", icon: null, emoji: "🔧" },
];
</script>
@ -25,22 +41,36 @@
<div class="text-center mb-10">
<h2 class="text-2xl font-bold mb-2">Choose your editor</h2>
<p class="text-secondary">Select the editor you'll be using. You can set up more later!</p>
<p class="text-secondary">
Select the editor you'll be using. You can set up more later!
</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
{#each editors as editor}
<Link
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">
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}
<img src={editor.icon} alt={editor.name} class="w-12 h-12 object-contain">
<img
src={editor.icon}
alt={editor.name}
class="w-12 h-12 object-contain"
/>
{:else}
<div class="w-12 h-12 flex items-center justify-center text-3xl">{editor.emoji}</div>
<div class="w-12 h-12 flex items-center justify-center text-3xl">
{editor.emoji}
</div>
{/if}
</div>
<span class="font-medium text-surface-content group-hover:text-primary transition-colors">{editor.name}</span>
<span
class="font-medium text-surface-content group-hover:text-primary transition-colors"
>{editor.name}</span
>
</Link>
{/each}
</div>

View file

@ -223,7 +223,8 @@
</summary>
<div class="mt-4 pl-6">
<p class="text-sm mb-3 text-secondary">
You'll see a clock icon and time spent coding in your status bar:
You'll see a clock icon and time spent coding in your status
bar:
</p>
<img
src="/images/editor-toolbars/vs-code.png"
@ -349,11 +350,7 @@
</div>
</div>
<Button
href="/my/wakatime_setup/step-4"
size="xl"
class="w-full"
>
<Button href="/my/wakatime_setup/step-4" size="xl" class="w-full">
Next Step
</Button>
{:else if editor === "jetbrains"}
@ -367,7 +364,8 @@
<div>
<h3 class="text-xl font-semibold">Set Up JetBrains IDEs</h3>
<p class="text-secondary text-sm">
Install the WakaTime extension for JetBrains IDEs (like IntelliJ and PyCharm).
Install the WakaTime extension for JetBrains IDEs (like IntelliJ
and PyCharm).
</p>
</div>
</div>
@ -386,7 +384,9 @@
<div>
<p class="font-medium mb-1">Open Settings</p>
<p class="text-sm text-secondary">
Open your IDE and go to <b>Settings</b> (Ctrl+Alt+S on Windows/Linux, Command+, on macOS), <b>Plugins</b>, then <b>Marketplace</b>.
Open your IDE and go to <b>Settings</b> (Ctrl+Alt+S on
Windows/Linux, Command+, on macOS), <b>Plugins</b>, then
<b>Marketplace</b>.
</p>
</div>
</div>
@ -403,11 +403,11 @@
Search for <b>WakaTime</b> in the marketplace and click Install.
<a
href="https://plugins.jetbrains.com/plugin/7425-wakatime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Marketplace</a
>
href="https://plugins.jetbrains.com/plugin/7425-wakatime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Marketplace</a
>
</p>
</div>
</div>
@ -449,7 +449,8 @@
</summary>
<div class="mt-4 pl-6">
<p class="text-sm mb-3 text-secondary">
You'll see a WakaTime icon and time spent coding in your status bar:
You'll see a WakaTime icon and time spent coding in your
status bar:
</p>
<img
src="/images/editor-toolbars/jetbrains.png"
@ -462,11 +463,7 @@
</div>
</div>
<Button
href="/my/wakatime_setup/step-4"
size="xl"
class="w-full"
>
<Button href="/my/wakatime_setup/step-4" size="xl" class="w-full">
Next Step
</Button>
{:else if editor === "sublime"}
@ -515,13 +512,17 @@
<div>
<p class="font-medium mb-1">Install WakaTime Plugin</p>
<p class="text-sm text-secondary">
Open the Command Palette (Ctrl+Shift+P on Windows/Linux, Command+Shift+P on macOS), type <b>Package Control: Install Package</b>, and press Enter. Then type <b>WakaTime</b> and press Enter to install.
<a
href="https://packagecontrol.io/packages/WakaTime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Package Control</a
>
Open the Command Palette (Ctrl+Shift+P on Windows/Linux,
Command+Shift+P on macOS), type <b
>Package Control: Install Package</b
>, and press Enter. Then type <b>WakaTime</b> and press Enter to
install.
<a
href="https://packagecontrol.io/packages/WakaTime"
target="_blank"
rel="noopener noreferrer"
class="text-cyan hover:underline">View on Package Control</a
>
</p>
</div>
</div>
@ -535,7 +536,8 @@
<div>
<p class="font-medium mb-1">Start Coding</p>
<p class="text-sm text-secondary">
After installing WakaTime, open any file and start typing to send your first heartbeat.
After installing WakaTime, open any file and start typing to
send your first heartbeat.
</p>
</div>
</div>
@ -562,7 +564,8 @@
</summary>
<div class="mt-4 pl-6">
<p class="text-sm mb-3 text-secondary">
You'll see your time spent coding in your status bar, which looks something like <code>Today: 1h 23m</code>.
You'll see your time spent coding in your status bar, which
looks something like <code>Today: 1h 23m</code>.
</p>
</div>
</details>
@ -570,11 +573,7 @@
</div>
</div>
<Button
href="/my/wakatime_setup/step-4"
size="xl"
class="w-full"
>
<Button href="/my/wakatime_setup/step-4" size="xl" class="w-full">
Next Step
</Button>
{:else if editorData[editor]}
@ -601,7 +600,9 @@
<div class="pt-6 border-t border-darkless"></div>
{/if}
<div>
<h4 class="text-sm font-medium mb-2 text-surface-content">{method.name}</h4>
<h4 class="text-sm font-medium mb-2 text-surface-content">
{method.name}
</h4>
<div class="relative group">
<div
class="bg-darker border border-darkless rounded-lg overflow-x-auto"
@ -620,11 +621,7 @@
</div>
</div>
<Button
href="/my/wakatime_setup/step-4"
size="xl"
class="w-full"
>
<Button href="/my/wakatime_setup/step-4" size="xl" class="w-full">
Next Step
</Button>
{:else}
@ -697,11 +694,7 @@
</div>
</div>
<Button
href="/my/wakatime_setup/step-4"
size="xl"
class="w-full"
>
<Button href="/my/wakatime_setup/step-4" size="xl" class="w-full">
Next Step
</Button>
{/if}

View file

@ -9,29 +9,49 @@
{ number: 1, label: "Install" },
{ number: 2, label: "Editor" },
{ number: 3, label: "Plugin" },
{ number: 4, label: "Finish" }
{ number: 4, label: "Finish" },
];
</script>
<div class="mb-10">
<div class="relative flex items-center justify-between w-full max-w-2xl mx-auto">
<div
class="relative flex items-center justify-between w-full max-w-2xl mx-auto"
>
<div class="absolute top-5 left-0 w-full h-0.5 bg-darkless -z-10"></div>
{#each steps as step}
<div class="flex flex-col items-center gap-2 bg-darker z-10 px-2">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors duration-200
{currentStep > step.number ? 'bg-green border-green text-darker' :
currentStep === step.number ? 'bg-primary border-primary text-on-primary' :
'bg-dark border-darkless text-secondary'}">
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors duration-200
{currentStep > step.number
? 'bg-green border-green text-darker'
: currentStep === step.number
? 'bg-primary border-primary text-on-primary'
: 'bg-dark border-darkless text-secondary'}"
>
{#if currentStep > step.number}
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
stroke-width="3"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
{step.number}
{/if}
</div>
<span class="text-xs font-medium {currentStep === step.number ? 'text-surface-content' : 'text-secondary'}">{step.label}</span>
<span
class="text-xs font-medium {currentStep === step.number
? 'text-surface-content'
: 'text-secondary'}">{step.label}</span
>
</div>
{/each}
</div>

View file

@ -1,38 +0,0 @@
<div class="filter flex-1 min-w-37.5 relative">
<label class="filter-label block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider">Date Range</label>
<div class="custom-select relative w-full" id="interval-select" data-param="interval">
<div class="select-header-container group flex items-center border border-surface-content/10 rounded-lg bg-darkless m-0 p-0 transition-all duration-200 hover:border-surface-content/20">
<div class="select-header flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-muted m-0 bg-transparent flex items-center justify-between" id="interval-header">
<span><%= human_interval_name(selected_interval, from: selected_from, to: selected_to) %></span>
<svg class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary" 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"></path></svg>
</div>
<button class="clear-button px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-content/10 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150 <%= selected_interval.blank? && selected_from.blank? && selected_to.blank? ? 'hidden' : '' %>" id="interval-clear-button">×</button>
</div>
<div class="options-container absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-content/10 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2" id="interval-options" style="display: none;">
<div class="options-list overflow-y-auto m-0 max-h-56 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<% TimeRangeFilterable::RANGES.each do |key, config| %>
<label class="option flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150" data-interval-key="<%= key %>" data-interval-label="<%= config[:human_name] %>">
<input type="radio" name="interval-option" class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-content/20 rounded-full bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-content/40 transition-colors duration-150" value="<%= key %>" <%= 'checked' if selected_interval == key.to_s %>>
<span><%= config[:human_name] %></span>
</label>
<% end %>
<label class="option flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150" data-interval-key="" data-interval-label="All Time">
<input type="radio" name="interval-option" class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-content/20 rounded-full bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-content/40 transition-colors duration-150" value="" <%= 'checked' if selected_interval.blank? && selected_from.blank? && selected_to.blank? %>>
<span>All Time</span>
</label>
</div>
<hr class="my-2 border-surface-content/10">
<div class="flex flex-col gap-2.5 pt-1">
<label class="flex items-center justify-between text-sm text-muted">
<span class="text-secondary/80">Start</span>
<input type="date" class="ml-2 py-2 px-3 bg-dark border border-surface-content/10 rounded-md text-sm text-muted focus:outline-none focus:border-surface-content/20 transition-colors duration-150" id="interval-custom-start" value="<%= selected_from %>">
</label>
<label class="flex items-center justify-between text-sm text-muted">
<span class="text-secondary/80">End</span>
<input type="date" class="ml-2 py-2 px-3 bg-dark border border-surface-content/10 rounded-md text-sm text-muted focus:outline-none focus:border-surface-content/20 transition-colors duration-150" id="interval-custom-end" value="<%= selected_to %>">
</label>
<button type="button" class="px-3 py-2.5 mt-1 rounded-md font-medium text-sm transition-all duration-200 cursor-pointer bg-primary text-on-primary hover:bg-primary/90" id="interval-apply-custom">Apply Custom Range</button>
</div>
</div>
</div>
</div>

View file

@ -1,23 +0,0 @@
<div class="filter flex-1 min-w-37.5 relative">
<label class="filter-label block text-xs font-medium mb-1.5 text-secondary/80 uppercase tracking-wider"><%= label %></label>
<div class="custom-select relative w-full" id="<%= param %>-select" data-param="<%= param %>">
<div class="select-header-container group flex items-center border border-surface-content/10 rounded-lg bg-darkless m-0 p-0 transition-all duration-200 hover:border-surface-content/20">
<div class="select-header flex-1 px-3 py-2.5 text-sm cursor-pointer select-none text-muted m-0 bg-transparent flex items-center justify-between">
<span>Filter by <%= label.downcase %>...</span>
<svg class="w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary" 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"></path></svg>
</div>
<button class="clear-button px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-content/10 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150 hidden">×</button>
</div>
<div class="options-container absolute top-full left-0 right-0 min-w-64 bg-darkless border border-surface-content/10 rounded-lg mt-2 shadow-xl shadow-black/50 z-1000 p-2 hidden">
<input type="text" class="search-input w-full border border-surface-content/10 px-3 py-2.5 mb-2 bg-dark text-surface-content text-sm rounded-md h-auto placeholder:text-secondary/60 focus:outline-none focus:border-surface-content/20" placeholder="Search <%= label.downcase %>...">
<div class="options-list overflow-y-auto m-0 max-h-64 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<% values.reject(&:blank?).each do |value| %>
<label class="option flex items-center px-3 py-2.5 cursor-pointer text-sm text-muted m-0 bg-transparent rounded-md hover:bg-dark transition-colors duration-150">
<input type="checkbox" class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-content/20 rounded bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-content/40 transition-colors duration-150" value="<%= value %>" <%= 'checked' if selected&.include?(value) %>>
<span><%= value %></span>
</label>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -1,410 +0,0 @@
<%= turbo_frame_tag "filterable_dashboard" do %>
<div class="max-w-6xl mx-auto my-0">
<div class="flex gap-4 mt-2 mb-6 flex-wrap">
<%= render partial: "shared/dashboard_interval_selector", locals: { selected_interval: @selected_interval, selected_from: @selected_from, selected_to: @selected_to } %>
<%= render partial: "shared/multi_select", locals: { label: "Project", param: "project", values: @project, selected: @selected_project } %>
<%= render partial: "shared/multi_select", locals: { label: "Language", param: "language", values: @language, selected: @selected_language } %>
<%= render partial: "shared/multi_select", locals: { label: "OS", param: "operating_system", values: @operating_system, selected: @selected_operating_system } %>
<%= render partial: "shared/multi_select", locals: { label: "Editor", param: "editor", values: @editor, selected: @selected_editor } %>
<%= render partial: "shared/multi_select", locals: { label: "Category", param: "category", values: @category, selected: @selected_category } %>
</div>
</div>
<div id="filterable_dashboard_content">
<%= render partial: "filterable_dashboard_content" %>
</div>
<script>
// UI only: handle dropdown behavior, clear buttons, etc.
window.initializeMultiSelect =
window.initializeMultiSelect ||
function (selectId) {
const select = document.getElementById(selectId);
if (!select || select.dataset.initialized) return;
select.dataset.initialized = "true";
const header = select.querySelector(".select-header");
const container = select.querySelector(".options-container");
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const clearButton = select.querySelector(".clear-button");
const searchInput = select.querySelector(".search-input");
// Header and clear button visibility
const checkedBoxes = Array.from(checkboxes).filter((cb) => cb.checked);
const headerSpanInit = header.querySelector("span") || header;
if (checkedBoxes.length > 0 && clearButton) {
clearButton.style.display = "block";
if (checkedBoxes.length === 1) {
headerSpanInit.textContent = checkedBoxes[0].value;
} else {
headerSpanInit.textContent = `${checkedBoxes.length} selected`;
}
}
// Toggle dropdown
header.addEventListener("click", function (e) {
e.stopPropagation();
const isVisible = container.style.display === "block";
document.querySelectorAll(".options-container").forEach((c) => {
if (c !== container) c.style.display = "none";
});
container.style.display = isVisible ? "none" : "block";
if (!isVisible && searchInput) searchInput.focus();
});
// Clear filter
if (clearButton) {
clearButton.addEventListener("click", function (e) {
e.stopPropagation();
checkboxes.forEach((cb) => (cb.checked = false));
updateSelect(select);
});
}
// Handle search input
if (searchInput) {
searchInput.addEventListener("input", function (e) {
const searchTerm = e.target.value.toLowerCase().trim();
const options = select.querySelectorAll(".option");
options.forEach((option) => {
const text = option.querySelector("span").textContent.toLowerCase().trim();
option.style.display = text.includes(searchTerm) ? "" : "none";
});
});
searchInput.addEventListener("click", function (e) {
e.stopPropagation();
});
}
// Update header text and URL when checkboxes change
checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", function () {
updateSelect(select);
});
});
};
window.updateSelect =
window.updateSelect ||
function (select) {
const header = select.querySelector(".select-header");
const clearButton = select.querySelector(".clear-button");
const checkboxes = select.querySelectorAll('input[type="checkbox"]');
const param = select.dataset.param;
const frame = document.querySelector("#filterable_dashboard_content");
frame.classList.add("loading");
const selected = Array.from(checkboxes)
.filter((cb) => cb.checked)
.map((cb) => cb.value);
// Header text, clear button
const headerSpan = header.querySelector("span") || header;
if (selected.length === 0) {
headerSpan.textContent = `Filter by ${header.closest(".filter").querySelector(".filter-label").textContent.toLowerCase()}...`;
if (clearButton) clearButton.style.display = "none";
} else if (selected.length === 1) {
headerSpan.textContent = selected[0];
if (clearButton) clearButton.style.display = "block";
} else {
headerSpan.textContent = `${selected.length} selected`;
if (clearButton) clearButton.style.display = "block";
}
// Update URL params
const rootUrl = new URL(window.location);
if (selected.length > 0) {
rootUrl.searchParams.set(param, selected.join(","));
} else {
rootUrl.searchParams.delete(param);
}
window.history.pushState({}, "", rootUrl);
// update content-frame url for Turbo
const contentUrl = new URL(window.location);
contentUrl.pathname = <%== filterable_dashboard_content_static_pages_path.to_json %>;
contentUrl.searchParams.set(param, selected.join(","));
frame.src = contentUrl.toString();
const requestTimestamp = Date.now();
window.lastRequestTimestamp = requestTimestamp;
fetch(contentUrl.toString(), {
headers: { Accept: "text/html" },
})
.then((response) => response.text())
.then((html) => {
if (requestTimestamp === window.lastRequestTimestamp) {
frame.innerHTML = html;
frame.classList.remove("loading");
window.hackatimeCharts?.initializeCharts();
}
});
};
window.initializeIntervalSelect = window.initializeIntervalSelect || function () {
const s = document.getElementById("interval-select");
if (!s || s.dataset.initialized) return;
s.dataset.initialized = "true";
const h = document.getElementById("interval-header");
const c = document.getElementById("interval-options");
const clr = document.getElementById("interval-clear-button");
const radios = s.querySelectorAll('input[type="radio"]');
const start = document.getElementById("interval-custom-start");
const end = document.getElementById("interval-custom-end");
h.addEventListener("click", (e) => {
e.stopPropagation();
document.querySelectorAll(".options-container").forEach((x) => { if (x !== c) x.style.display = "none"; });
c.style.display = c.style.display === "block" ? "none" : "block";
});
clr?.addEventListener("click", (e) => {
e.stopPropagation();
radios.forEach((r) => (r.checked = false));
s.querySelector('input[value=""]')?.click();
start.value = end.value = "";
updateIntervalFilter("", "", "");
});
const hSpan = h.querySelector("span") || h;
radios.forEach((r) => r.addEventListener("change", function () {
hSpan.textContent = this.closest(".option").dataset.intervalLabel;
clr.classList.toggle("hidden", this.value === "");
c.style.display = "none";
start.value = end.value = "";
updateIntervalFilter(this.value, "", "");
}));
document.getElementById("interval-apply-custom").addEventListener("click", () => {
const sv = start.value, ev = end.value;
if (!sv && !ev) return;
hSpan.textContent = sv && ev ? `${sv} to ${ev}` : sv ? `From ${sv}` : `Until ${ev}`;
clr.classList.remove("hidden");
c.style.display = "none";
radios.forEach((r) => (r.checked = false));
updateIntervalFilter("custom", sv, ev);
});
[start, end].forEach((i) => i.addEventListener("click", (e) => e.stopPropagation()));
};
window.updateIntervalFilter = window.updateIntervalFilter || function (interval, from, to) {
const f = document.querySelector("#filterable_dashboard_content");
f.classList.add("loading");
const url = new URL(window.location);
["interval", "from", "to"].forEach((k) => url.searchParams.delete(k));
if (interval) url.searchParams.set("interval", interval);
if (from) url.searchParams.set("from", from);
if (to) url.searchParams.set("to", to);
window.history.pushState({}, "", url);
url.pathname = <%== filterable_dashboard_content_static_pages_path.to_json %>;
fetch(url.toString(), { headers: { Accept: "text/html" } })
.then((r) => r.text())
.then((html) => {
f.innerHTML = html;
f.classList.remove("loading");
window.hackatimeCharts?.initializeCharts();
});
};
document.addEventListener("turbo:frame-load", function (event) {
if (event.target.id === "filterable_dashboard") {
["project", "language", "editor", "operating_system", "category"].forEach((type) => {
window.initializeMultiSelect(`${type}-select`);
});
window.initializeIntervalSelect();
document.addEventListener("click", function (e) {
if (!e.target.closest(".custom-select")) {
document.querySelectorAll(".options-container").forEach((container) => {
container.style.display = "none";
});
}
});
}
});
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" data-turbo-track="reload"></script>
<script>
window.chartInstances = window.chartInstances || {};
if (!window.hackatimeCharts) {
window.hackatimeCharts = {
formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
},
createPieChart(elementId) {
const canvas = document.getElementById(elementId);
if (!canvas) return;
const stats = JSON.parse(canvas.dataset.stats);
const labels = Object.keys(stats);
const data = Object.values(stats);
if (window.chartInstances[elementId]) {
window.chartInstances[elementId].destroy();
}
const ctx = canvas.getContext("2d");
const pieColors = ["#60a5fa", "#f472b6", "#fb923c", "#facc15", "#4ade80", "#2dd4bf", "#a78bfa", "#f87171", "#38bdf8", "#e879f9", "#34d399", "#fbbf24", "#818cf8", "#fb7185", "#22d3ee", "#a3e635", "#c084fc", "#f97316", "#14b8a6", "#8b5cf6", "#ec4899", "#84cc16", "#06b6d4", "#d946ef", "#10b981"];
const backgroundColors = labels.map((_, i) => pieColors[i % pieColors.length]);
window.chartInstances[elementId] = new Chart(ctx, {
type: "pie",
data: { labels, datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 1 }] },
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1.2,
plugins: {
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || "";
const value = context.raw || 0;
const duration = window.hackatimeCharts.formatDuration(value);
const percentage = ((value / data.reduce((a, b) => a + b, 0)) * 100).toFixed(1);
return `${label}: ${duration} (${percentage}%)`;
},
},
},
legend: {
position: "right",
align: "center",
labels: {
boxWidth: 10,
padding: 8,
font: { size: 10 },
},
},
},
},
});
},
createProjectTimelineChart() {
const canvas = document.getElementById("projectTimelineChart");
if (!canvas) return;
const weeklyStats = JSON.parse(canvas.dataset.stats);
const allProjects = new Set();
Object.values(weeklyStats).forEach((weekData) => {
Object.keys(weekData).forEach((project) => allProjects.add(project));
});
const sortedWeeks = Object.keys(weeklyStats).sort();
const datasets = Array.from(allProjects).map((project) => ({
label: project,
data: sortedWeeks.map((week) => weeklyStats[week][project] || 0),
stack: "stack0",
}));
datasets.sort((a, b) => {
const sumA = a.data.reduce((acc, val) => acc + val, 0);
const sumB = b.data.reduce((acc, val) => acc + val, 0);
return sumB - sumA;
});
if (window.chartInstances["projectTimelineChart"]) {
window.chartInstances["projectTimelineChart"].destroy();
}
const ctx = canvas.getContext("2d");
window.chartInstances["projectTimelineChart"] = new Chart(ctx, {
type: "bar",
data: {
labels: sortedWeeks.map((week) => {
const date = new Date(week);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}),
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: { display: false },
},
y: {
stacked: true,
type: "linear",
grid: {
color: (ctx) => {
if (ctx.tick.value === 0) return "transparent";
return ctx.tick.value % 1 === 0 ? "rgba(0, 0, 0, 0.1)" : "rgba(0, 0, 0, 0.05)";
},
},
ticks: {
callback: function (value) {
if (value === 0) return "0s";
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) return `${hours}h`;
return `${minutes}m`;
},
},
},
},
plugins: {
legend: {
position: "right",
labels: {
boxWidth: 12,
padding: 15,
},
},
tooltip: {
callbacks: {
label: function (context) {
const value = context.raw;
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
if (hours > 0) {
return `${context.dataset.label}: ${hours}h ${minutes}m`;
}
return `${context.dataset.label}: ${minutes}m`;
},
},
},
},
},
});
},
initializeCharts() {
this.createPieChart("languageChart");
this.createPieChart("editorChart");
this.createPieChart("operatingSystemChart");
this.createProjectTimelineChart();
},
};
}
if (!window.chartListenersInitialized) {
window.chartListenersInitialized = true;
document.addEventListener("turbo:frame-load", () => {
if (typeof Chart === "undefined") {
const checkChart = setInterval(() => {
if (typeof Chart !== "undefined") {
clearInterval(checkChart);
window.hackatimeCharts.initializeCharts();
}
}, 50);
setTimeout(() => clearInterval(checkChart), 5000);
} else {
window.hackatimeCharts.initializeCharts();
}
});
}
if (typeof Chart !== "undefined") {
window.hackatimeCharts.initializeCharts();
}
</script>
<% end %>

View file

@ -1,138 +0,0 @@
<div class="flex flex-col gap-6 w-full">
<div class="grid grid-cols-[repeat(auto-fill,minmax(9.375rem,1fr))] gap-4">
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOTAL TIME</div>
<div class="text-lg font-semibold text-surface-content" data-stat="total_time"><%= ApplicationController.helpers.short_time_simple(@total_time) %></div>
</div>
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOP PROJECT</div>
<div class="text-lg font-semibold text-surface-content" data-stat="top_project">
<%= @top_project || "None" %>
<% if @singular_project %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOP LANGUAGE</div>
<div class="text-lg font-semibold text-surface-content" data-stat="top_language">
<%= display_language_name(@top_language) || "Unknown" %>
<% if @singular_language %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOP OS</div>
<div class="text-lg font-semibold text-surface-content" data-stat="top_operating_system">
<%= display_os_name(@top_operating_system) || "Unknown" %>
<% if @singular_operating_system %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOP EDITOR</div>
<div class="text-lg font-semibold text-surface-content" data-stat="top_editor">
<%= display_editor_name(@top_editor) || "Unknown" %>
<% if @singular_editor %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
<div class="bg-dark border border-primary rounded-xl p-4 transition-all duration-200 h-full">
<div class="text-secondary text-xs mb-1 uppercase tracking-tight">TOP CATEGORY</div>
<div class="text-lg font-semibold text-surface-content" data-stat="top_category">
<%= @top_category&.capitalize || "Unknown" %>
<% if @singular_category %>
<span class="super"><%= FlavorText.obvious.sample %></span>
<% end %>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full">
<% if @project_durations&.size&.> 1 %>
<div class="bg-dark border border-primary rounded-xl p-6 flex flex-col">
<h2 class="mb-4 text-xl font-semibold text-surface-content">Project Durations</h2>
<div class="mt-2">
<%
max_duration = @project_durations.values.max
# Use logarithmic scale for better visibility of smaller values
# Add 1 to avoid log(0), scale to 15-100 range
def log_scale(value, max_val)
return 0 if value == 0
min_percent = 5 # Minimum bar width percentage
max_percent = 100 # Maximum bar width percentage
# Mix linear and logarithmic scaling
# 80% linear, 20% logarithmic
linear_ratio = value.to_f / max_val
log_ratio = Math.log(value + 1) / Math.log(max_val + 1)
linear_weight = 0.8
log_weight = 0.2
scaled = min_percent + (linear_weight * linear_ratio + log_weight * log_ratio) * (max_percent - min_percent)
[scaled, max_percent].min.round
end
%>
<% @project_durations.each do |project, duration| %>
<div class="flex items-center mb-3">
<div class="w-37.5 text-right pr-4 text-sm text-muted whitespace-nowrap overflow-hidden text-ellipsis"><%= h(project.presence || 'Unknown') %></div>
<div class="flex-1 h-7 bg-darkless rounded-lg overflow-hidden">
<div class="h-full bg-primary rounded-lg relative transition-[width] duration-300 ease-in-out" style="width: <%= log_scale(duration, max_duration) %>%">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-on-primary"><%= ApplicationController.helpers.short_time_simple(duration) %></span>
</div>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Language distribution %>
<% if @language_stats.present? %>
<div class="bg-dark border border-primary rounded-xl p-6 flex flex-col">
<h2 class="mb-4 text-xl font-semibold text-surface-content">Languages</h2>
<div class="flex-1 relative min-h-48">
<canvas id="languageChart" class="max-h-full w-full" data-stats="<%= @language_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<%# Editor distribution %>
<% if @editor_stats.present? %>
<div class="bg-dark border border-primary rounded-xl p-6 flex flex-col">
<h2 class="mb-4 text-xl font-semibold text-surface-content">Editors</h2>
<div class="flex-1 relative min-h-48">
<canvas id="editorChart" class="max-h-full w-full" data-stats="<%= @editor_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<%# OS distribution %>
<% if @operating_system_stats.present? %>
<div class="bg-dark border border-primary rounded-xl p-6 flex flex-col">
<h2 class="mb-4 text-xl font-semibold text-surface-content">Operating Systems</h2>
<div class="flex-1 relative min-h-48">
<canvas id="operatingSystemChart" class="max-h-full w-full" data-stats="<%= @operating_system_stats.to_json %>"></canvas>
</div>
</div>
<% end %>
<div class="bg-dark border border-primary rounded-xl p-6 flex flex-col">
<h2 class="mb-4 text-xl font-semibold text-surface-content">Project Timeline</h2>
<div class="flex-1 relative min-h-48">
<canvas id="projectTimelineChart" class="max-h-full w-full" data-stats="<%= @weekly_project_stats.to_json %>"></canvas>
</div>
</div>
</div>
</div>

View file

@ -24,6 +24,10 @@
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.2",
},
"devDependencies": {
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
},
},
},
"packages": {
@ -489,6 +493,10 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],

View file

@ -9,6 +9,10 @@ Rails.application.configure do
# While tests run files are not watched, reloading is not necessary.
config.enable_reloading = false
# Avoid stale precompiled asset manifests in public/assets during tests.
# Propshaft switches to dynamic resolution when this manifest file does not exist.
config.assets.manifest_path = Rails.root.join("tmp/assets/.manifest.json")
# Eager loading loads your entire application. When running a single test locally,
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager

View file

@ -86,8 +86,6 @@ Rails.application.routes.draw do
get :project_durations
get :currently_hacking
get :currently_hacking_count
get :filterable_dashboard_content
get :filterable_dashboard
get :streak
# get :timeline # Removed: Old route for timeline
end
@ -204,8 +202,6 @@ Rails.application.routes.draw do
post :claim, on: :collection
end
get "dashboard_stats", to: "dashboard_stats#show"
namespace :my do
get "heartbeats/most_recent", to: "heartbeats#most_recent"
get "heartbeats", to: "heartbeats#index"

111
package-lock.json generated
View file

@ -1,5 +1,5 @@
{
"name": "app",
"name": "hackatime-mahadk",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -24,6 +24,10 @@
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.2"
},
"devDependencies": {
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1"
}
},
"node_modules/@alloc/quick-lru": {
@ -507,37 +511,12 @@
}
},
"node_modules/@inertiajs/core": {
"version": "2.3.14",
"resolved": "file:vendor/inertia/packages/core",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
"resolved": "vendor/inertia/packages/core",
"link": true
},
"node_modules/@inertiajs/svelte": {
"version": "2.3.14",
"resolved": "file:vendor/inertia/packages/svelte",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "file:../core",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
"resolved": "vendor/inertia/packages/svelte",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
@ -1116,6 +1095,7 @@
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.2.4",
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
@ -1443,6 +1423,7 @@
"node_modules/acorn": {
"version": "8.15.0",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1505,6 +1486,7 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@ -2967,6 +2949,7 @@
"node_modules/picomatch": {
"version": "4.0.3",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3024,6 +3007,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -3172,6 +3156,34 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",
"integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -3396,6 +3408,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.51.2.tgz",
"integrity": "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -3453,7 +3466,8 @@
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@ -3526,6 +3540,7 @@
"node_modules/typescript": {
"version": "5.9.3",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3541,6 +3556,7 @@
"node_modules/vite": {
"version": "7.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -3652,6 +3668,39 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"vendor/inertia/packages/core": {
"name": "@inertiajs/core",
"version": "2.3.14",
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"axios": "^1.13.2"
},
"peerDependenciesMeta": {
"axios": {
"optional": true
}
}
},
"vendor/inertia/packages/svelte": {
"name": "@inertiajs/svelte",
"version": "2.3.14",
"license": "MIT",
"dependencies": {
"@inertiajs/core": "file:../core",
"@types/lodash-es": "^4.17.12",
"laravel-precognition": "2.0.0-beta.0",
"lodash-es": "^4.17.23"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}
}
}

View file

@ -2,7 +2,10 @@
"private": true,
"type": "module",
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json"
"check": "npm run check:svelte && tsc -p tsconfig.node.json",
"check:svelte": "svelte-check --tsconfig ./tsconfig.svelte-check.json",
"format:svelte": "prettier --write \"app/javascript/**/*.svelte\"",
"format:svelte:check": "prettier --check \"app/javascript/**/*.svelte\""
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
@ -24,5 +27,9 @@
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.2"
},
"devDependencies": {
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1"
}
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"checkJs": false
},
"include": ["app/javascript/**/*.svelte"]
}