Modals! New projects page! Better dev imports! Fix OAuth2 projects! (#958)

* Modals! New projects page!

* Update modal close buttons

* Make progress bar better

* Various fixes + tests

* Formatting

* Fix tests?
This commit is contained in:
Mahad Kalam 2026-02-16 23:11:25 +00:00 committed by GitHub
parent 044a1e4fea
commit f3350234f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2841 additions and 532 deletions

View file

@ -128,9 +128,19 @@ jobs:
ruby-version: .ruby-version
bundler-cache: true
- 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 tests
env:
RAILS_ENV: test
PARALLEL_WORKERS: 4
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
PGHOST: localhost
PGUSER: postgres
@ -142,10 +152,21 @@ jobs:
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;" || true
# Create per-worker variants for parallelized tests (e.g., test_wakatime_0)
for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime_${worker};" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log_${worker};" || true
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse_${worker};" || true
done
# Mirror schema from primary test DB so cross-db models can query safely in tests
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse
for worker in $(seq 0 $((PARALLEL_WORKERS - 1))); do
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime_${worker}
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log_${worker}
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse_${worker}
done
bin/rails test
- name: Ensure Swagger docs are up to date

View file

@ -147,6 +147,101 @@ select {
}
}
@keyframes bits-modal-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes bits-modal-overlay-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes bits-modal-content-in {
from {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bits-modal-content-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
}
@keyframes dashboard-select-content-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes dashboard-select-content-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
}
.bits-modal-overlay[data-state="open"] {
animation: bits-modal-overlay-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.bits-modal-overlay[data-state="closed"] {
animation: bits-modal-overlay-out 180ms cubic-bezier(0.22, 1, 0.5, 1) both;
}
.bits-modal-content {
will-change: transform, opacity;
}
.bits-modal-content[data-state="open"] {
animation: bits-modal-content-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.bits-modal-content[data-state="closed"] {
animation: bits-modal-content-out 200ms cubic-bezier(0.4, 0, 1, 1) both;
}
.dashboard-select-popover {
transform-origin: top left;
will-change: opacity, transform;
}
.dashboard-select-popover[data-state="open"] {
animation: dashboard-select-content-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.dashboard-select-popover[data-state="closed"] {
animation: dashboard-select-content-out 130ms cubic-bezier(0.32, 0, 0.67, 0) both;
}
.animate-spin {
animation: spin 1s linear infinite;
}

View file

@ -64,7 +64,7 @@ class InertiaController < ApplicationController
links << inertia_link("Leaderboards", leaderboards_path, active: helpers.current_page?(leaderboards_path))
if current_user
links << inertia_link("Projects", my_projects_path, active: helpers.current_page?(my_projects_path))
links << inertia_link("Projects", my_projects_path, active: request.path.start_with?("/my/projects"), inertia: true)
links << inertia_link("Docs", docs_path, active: helpers.current_page?(docs_path) || request.path.start_with?("/docs"), inertia: true)
links << inertia_link("Extensions", extensions_path, active: helpers.current_page?(extensions_path), inertia: true)
links << inertia_link("Settings", my_settings_path, active: request.path.start_with?("/my/settings"), inertia: true)

View file

@ -0,0 +1,55 @@
class My::HeartbeatImportsController < ApplicationController
before_action :ensure_current_user
before_action :ensure_development
def create
unless params[:heartbeat_file].present?
render json: { error: "pls select a file to import" }, status: :unprocessable_entity
return
end
file = params[:heartbeat_file]
unless valid_json_file?(file)
render json: { error: "pls upload only json (download from the button above it)" }, status: :unprocessable_entity
return
end
import_id = HeartbeatImportRunner.start(user: current_user, uploaded_file: file)
status = HeartbeatImportRunner.status(user: current_user, import_id: import_id)
render json: {
import_id: import_id,
status: status
}, status: :accepted
rescue => e
Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}")
render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error
end
def show
status = HeartbeatImportRunner.status(user: current_user, import_id: params[:id])
if status.present?
render json: status
else
render json: { error: "Import not found" }, status: :not_found
end
end
private
def valid_json_file?(file)
file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json")
end
def ensure_current_user
return if current_user
render json: { error: "You must be logged in to view this page." }, status: :unauthorized
end
def ensure_development
return if Rails.env.development?
render json: { error: "Heartbeat import is only available in development." }, status: :forbidden
end
end

View file

@ -1,17 +1,29 @@
class My::ProjectRepoMappingsController < ApplicationController
class My::ProjectRepoMappingsController < InertiaController
layout "inertia", only: [ :index ]
before_action :ensure_current_user
before_action :require_github_oauth, only: [ :edit, :update ]
before_action :set_project_repo_mapping_for_edit, only: [ :edit, :update ]
before_action :set_project_repo_mapping, only: [ :archive, :unarchive ]
def index
@project_repo_mappings = current_user.project_repo_mappings.active
@interval = params[:interval] || "daily"
@from = params[:from]
@to = params[:to]
archived = show_archived?
archived = params[:show_archived] == "true"
@project_count = project_count(archived)
render inertia: "Projects/Index", props: {
page_title: "My Projects",
index_path: my_projects_path,
show_archived: archived,
archived_count: current_user.project_repo_mappings.archived.count,
github_connected: current_user.github_uid.present?,
github_auth_path: github_auth_path,
settings_path: my_settings_path(anchor: "user_github_account"),
interval: selected_interval,
from: params[:from],
to: params[:to],
interval_label: helpers.human_interval_name(selected_interval, from: params[:from], to: params[:to]),
total_projects: project_count(archived),
projects_data: InertiaRails.defer { projects_payload(archived: archived) }
}
end
def edit
@ -73,9 +85,115 @@ class My::ProjectRepoMappingsController < ApplicationController
params.require(:project_repo_mapping).permit(:repo_url)
end
def show_archived?
params[:show_archived] == "true"
end
def selected_interval
params[:interval]
end
def project_durations_cache_key
key = "user_#{current_user.id}_project_durations_#{selected_interval}_v3"
if selected_interval == "custom"
sanitized_from = sanitized_cache_date(params[:from]) || "none"
sanitized_to = sanitized_cache_date(params[:to]) || "none"
key += "_#{sanitized_from}_#{sanitized_to}"
end
key
end
def sanitized_cache_date(value)
value.to_s.gsub(/[^0-9-]/, "")[0, 10].presence
end
def projects_payload(archived:)
mappings = current_user.project_repo_mappings.includes(:repository)
scoped_mappings = archived ? mappings.archived : mappings.active
mappings_by_name = scoped_mappings.index_by(&:project_name)
archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true)
labels_by_project_key = current_user.project_labels.pluck(:project_key, :label).to_h
cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do
hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to])
{
durations: hb.group(:project).duration_seconds,
total_time: hb.duration_seconds
}
end
projects = cached[:durations].filter_map do |project_key, duration|
next if duration <= 0
next if archived_names.key?(project_key) != archived
mapping = mappings_by_name[project_key]
display_name = labels_by_project_key[project_key].presence || project_key.presence || "Unknown"
{
id: project_card_id(project_key),
name: display_name,
project_key: project_key,
duration_seconds: duration,
duration_label: format_duration(duration),
duration_percent: 0,
repo_url: mapping&.repo_url,
repository: repository_payload(mapping&.repository),
broken_name: broken_project_name?(project_key, display_name),
manage_enabled: current_user.github_uid.present? && project_key.present?,
edit_path: project_key.present? ? edit_my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
update_path: project_key.present? ? my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
archive_path: project_key.present? ? archive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil,
unarchive_path: project_key.present? ? unarchive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil
}
end.sort_by { |project| -project[:duration_seconds] }
max_duration = projects.map { |project| project[:duration_seconds].to_f }.max || 1.0
projects.each do |project|
project[:duration_percent] = ((project[:duration_seconds].to_f / max_duration) * 100).round(1)
end
total_time = cached[:total_time].to_i
{
total_time_seconds: total_time,
total_time_label: format_duration(total_time),
has_activity: total_time.positive?,
projects: projects
}
end
def format_duration(seconds)
helpers.short_time_detailed(seconds).presence || "0m"
end
def project_card_id(project_key)
raw_key = project_key.nil? ? "__nil__" : "str:#{project_key}"
"project-#{raw_key.unpack1('H*')}"
end
def broken_project_name?(project_key, display_name)
key = project_key.to_s
name = display_name.to_s
key.blank? || name.downcase == "unknown" || key.match?(/<<.*>>/) || name.match?(/<<.*>>/)
end
def repository_payload(repository)
return nil unless repository
{
homepage: repository.homepage,
stars: repository.stars,
description: repository.description,
formatted_languages: repository.formatted_languages,
last_commit_ago: repository.last_commit_at ? "#{helpers.time_ago_in_words(repository.last_commit_at)} ago" : nil
}
end
def project_count(archived)
archived_names = current_user.project_repo_mappings.archived.pluck(:project_name)
hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to])
projects = hb.select(:project).distinct.pluck(:project)
projects.count { |proj| archived_names.include?(proj) == archived }
end

View file

@ -53,6 +53,13 @@ class Settings::BaseController < InertiaController
def settings_page_props(active_section:, settings_update_path:)
heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count
channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id)
heartbeat_import_id = nil
heartbeat_import_status = nil
if active_section.to_s == "data" && Rails.env.development?
heartbeat_import_id = params[:heartbeat_import_id].presence
heartbeat_import_status = HeartbeatImportRunner.status(user: @user, import_id: heartbeat_import_id) if heartbeat_import_id
end
{
active_section: active_section,
@ -97,7 +104,7 @@ class Settings::BaseController < InertiaController
migrate_heartbeats_path: my_settings_migrate_heartbeats_path,
export_all_heartbeats_path: export_my_heartbeats_path(format: :json, all_data: "true"),
export_range_heartbeats_path: export_my_heartbeats_path(format: :json),
import_heartbeats_path: import_my_heartbeats_path,
create_heartbeat_import_path: my_heartbeat_imports_path,
create_deletion_path: create_deletion_path,
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user)
},
@ -187,6 +194,10 @@ class Settings::BaseController < InertiaController
ui: {
show_dev_import: Rails.env.development?
},
heartbeat_import: {
import_id: heartbeat_import_id,
status: heartbeat_import_status
},
errors: {
full_messages: @user.errors.full_messages,
username: @user.errors[:username]

View file

@ -210,8 +210,13 @@ module ApplicationHelper
def modal_open_button(modal_id, text, **options)
button_tag text, {
type: "button",
data: { action: "click->modal#open" },
onclick: "document.getElementById('#{modal_id}').querySelector('[data-controller=\"modal\"]').dispatchEvent(new CustomEvent('modal:open', { bubbles: true }))"
onclick: "document.getElementById('#{modal_id}')?.dispatchEvent(new CustomEvent('modal:open'))"
}.merge(options)
end
def safe_asset_path(asset_name, fallback: nil)
asset_path(asset_name)
rescue StandardError
fallback.present? ? asset_path(fallback) : asset_name
end
end

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { Button as BitsButton } from "bits-ui";
import type { Snippet } from "svelte";
type ButtonType = "button" | "submit" | "reset";
@ -11,6 +12,7 @@
type = "button",
size = "md",
variant = "primary",
native = false,
unstyled = false,
children,
class: className = "",
@ -20,6 +22,7 @@
type?: ButtonType;
size?: ButtonSize;
variant?: ButtonVariant;
native?: boolean;
unstyled?: boolean;
children?: Snippet;
class?: string;
@ -58,11 +61,17 @@
</script>
{#if href}
<Link {href} class={classes} {...rest}>
{@render children?.()}
</Link>
{#if native}
<BitsButton.Root {href} class={classes} {...rest}>
{@render children?.()}
</BitsButton.Root>
{:else}
<Link {href} class={classes} {...rest}>
{@render children?.()}
</Link>
{/if}
{:else}
<button {type} class={classes} {...rest}>
<BitsButton.Root {type} class={classes} {...rest}>
{@render children?.()}
</button>
</BitsButton.Root>
{/if}

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import type { Snippet } from "svelte";
let {
open = $bindable(false),
title,
description = "",
maxWidth = "max-w-lg",
bodyClass = "mb-6 rounded-xl border border-surface-200/60 bg-darker/30 p-4",
onContentClick,
hasIcon = false,
hasBody = false,
hasActions = false,
icon,
body,
actions,
}: {
open?: boolean;
title: string;
description?: string;
maxWidth?: string;
bodyClass?: string;
onContentClick?: (event: MouseEvent) => void;
hasIcon?: boolean;
hasBody?: boolean;
hasActions?: boolean;
icon?: Snippet;
body?: Snippet;
actions?: Snippet;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay
class="bits-modal-overlay fixed inset-0 z-9999 bg-darker/80 backdrop-blur-md"
/>
<Dialog.Content
class={`bits-modal-content fixed inset-0 z-10000 m-auto h-fit w-[calc(100vw-2rem)] ${maxWidth} overflow-hidden rounded-2xl border border-surface-300/70 bg-surface shadow-[0_28px_90px_rgba(0,0,0,0.5)] outline-none`}
onclick={onContentClick}
>
<div class="absolute inset-x-0 top-0 h-1 bg-primary"></div>
<div class="p-6 sm:p-8">
<div class="mb-5 flex items-start justify-between gap-4">
<div class="min-w-0">
{#if hasIcon}
<div
class="mb-3 inline-flex items-center justify-center rounded-xl border border-surface-200/70 bg-surface-100/50 p-2.5 text-primary"
>
{@render icon?.()}
</div>
{/if}
<Dialog.Title
class="text-balance text-2xl font-semibold tracking-tight text-surface-content"
>
{title}
</Dialog.Title>
{#if description}
<Dialog.Description
class="mt-2 text-sm leading-relaxed text-muted sm:text-[15px]"
>
{description}
</Dialog.Description>
{/if}
</div>
<Dialog.Close
class="inline-flex h-12 w-12 items-center justify-center rounded-lg text-surface-content/75 outline-none transition-colors hover:bg-surface-100/60 hover:text-surface-content focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-6 w-6"
aria-hidden="true"
>
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</Dialog.Close>
</div>
{#if hasBody}
<div class={bodyClass}>
{@render body?.()}
</div>
{/if}
{#if hasActions}
<div>{@render actions?.()}</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import Modal from "./Modal.svelte";
import { onMount } from "svelte";
// `Modal` is the shared visual shell for every modal in the app.
// `RailsModal` stays as a thin adapter for server-rendered Rails views:
// - pulls HTML fragments from the host element dataset/templates
// - listens for legacy `modal:open` / `modal:close` DOM events
// - closes when Rails action markup marks elements with `data-modal-close='true'`
let {
modalId,
title,
description = "",
iconHtml = "",
customHtml = "",
actionsHtml = "",
maxWidth = "max-w-md",
}: {
modalId: string;
title: string;
description?: string;
iconHtml?: string;
customHtml?: string;
actionsHtml?: string;
maxWidth?: string;
} = $props();
let open = $state(false);
const handleActionClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest("[data-modal-close='true']")) {
open = false;
}
};
onMount(() => {
const host = document.getElementById(modalId);
if (!host) return;
const onOpen = () => (open = true);
const onClose = () => (open = false);
host.addEventListener("modal:open", onOpen as EventListener);
host.addEventListener("modal:close", onClose as EventListener);
return () => {
host.removeEventListener("modal:open", onOpen as EventListener);
host.removeEventListener("modal:close", onClose as EventListener);
};
});
</script>
<Modal
bind:open
{title}
{description}
{maxWidth}
onContentClick={handleActionClick}
hasIcon={Boolean(iconHtml)}
hasBody={Boolean(customHtml)}
hasActions={Boolean(actionsHtml)}
>
{#snippet icon()}
{@html iconHtml}
{/snippet}
{#snippet body()}
{@html customHtml}
{/snippet}
{#snippet actions()}
{@html actionsHtml}
{/snippet}
</Modal>

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Select as BitsSelect } from "bits-ui";
type SelectItem = {
value: string;
label: string;
disabled?: boolean;
};
let {
id,
name,
value = $bindable(""),
items = [],
placeholder = "Select an option",
allowDeselect = false,
disabled = false,
class: className = "",
}: {
id?: string;
name?: string;
value?: string;
items: SelectItem[];
placeholder?: string;
allowDeselect?: boolean;
disabled?: boolean;
class?: string;
} = $props();
const selectedLabel = $derived(
items.find((item) => item.value === value)?.label ?? placeholder,
);
</script>
<BitsSelect.Root
type="single"
bind:value={value as never}
{name}
{allowDeselect}
{disabled}
{items}
>
<BitsSelect.Trigger
{id}
class={`inline-flex w-full items-center justify-between rounded-md border border-surface-200 bg-darker px-3 py-2 text-left text-sm text-surface-content transition-all duration-200 hover:border-surface-300 focus-visible:border-primary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 focus-visible:ring-offset-2 focus-visible:ring-offset-surface data-[placeholder]:text-surface-content/60 ${className}`}
>
<span class="truncate">{selectedLabel}</span>
<svg
class="ml-2 h-4 w-4 shrink-0 text-secondary/70"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</BitsSelect.Trigger>
<BitsSelect.Portal>
<BitsSelect.Content
sideOffset={6}
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
>
<BitsSelect.Viewport
class="max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
>
{#each items as item}
<BitsSelect.Item
value={item.value}
label={item.label}
disabled={item.disabled}
class="flex w-full select-none items-center justify-between rounded-md px-3 py-2 text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[highlighted]:text-surface-content data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
>
{#snippet children({ selected })}
<span class="truncate">{item.label}</span>
{#if selected}
<span class="ml-2 text-primary"></span>
{/if}
{/snippet}
</BitsSelect.Item>
{/each}
</BitsSelect.Viewport>
</BitsSelect.Content>
</BitsSelect.Portal>
</BitsSelect.Root>

View file

@ -0,0 +1,54 @@
import { mount, unmount } from "svelte";
import RailsModal from "../components/RailsModal.svelte";
type ModalComponent = ReturnType<typeof mount>;
const mountedModals = new Map<HTMLElement, ModalComponent>();
function templateHtml(host: HTMLElement, selector: string): string {
const template = host.querySelector<HTMLTemplateElement>(selector);
return template?.innerHTML.trim() ?? "";
}
function mountModal(host: HTMLElement) {
if (mountedModals.has(host)) return;
if (!host.id) return;
const props = {
modalId: host.id,
title: host.dataset.modalTitle ?? "Confirm",
description: host.dataset.modalDescription ?? "",
maxWidth: host.dataset.modalMaxWidth ?? "max-w-md",
iconHtml: templateHtml(host, "template[data-modal-icon]"),
customHtml: templateHtml(host, "template[data-modal-custom]"),
actionsHtml: templateHtml(host, "template[data-modal-actions]"),
};
host.replaceChildren();
const component = mount(RailsModal, { target: host, props });
mountedModals.set(host, component);
}
function pruneUnmounted() {
for (const [host, component] of mountedModals) {
if (!host.isConnected) {
unmount(component);
mountedModals.delete(host);
}
}
}
function mountAllRailsModals() {
pruneUnmounted();
document
.querySelectorAll<HTMLElement>("[data-bits-modal]")
.forEach((host) => mountModal(host));
}
["DOMContentLoaded", "turbo:load", "turbo:render", "turbo:frame-load"].forEach(
(eventName) => {
document.addEventListener(eventName, mountAllRailsModals);
},
);
mountAllRailsModals();

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { Link, usePoll } from "@inertiajs/svelte";
import Button from "../components/Button.svelte";
import Modal from "../components/Modal.svelte";
import type { Snippet } from "svelte";
import { onMount, onDestroy, untrack } from "svelte";
import { onMount, onDestroy } from "svelte";
import plur from "plur";
type NavLink = {
@ -88,20 +89,17 @@
let navOpen = $state(false);
let logoutOpen = $state(false);
let currentlyExpanded = $state(false);
let flashVisible = $state(false);
let flashVisible = $state(layout.nav.flash.length > 0);
let flashHiding = $state(false);
const flashHideDelay = 6000;
const flashExitDuration = 250;
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(pollInterval, {
usePoll(layout.currently_hacking?.interval || 30000, {
only: ["currently_hacking"],
});
@ -120,13 +118,6 @@
}
};
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`;
@ -321,13 +312,7 @@
/>
</svg>
</Button>
<Button
type="button"
unstyled
class={`nav-overlay ${navOpen ? "open" : ""}`}
aria-label="Close navigation"
onclick={closeNav}
/>
<div class="nav-overlay" class:open={navOpen} onclick={closeNav}></div>
<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"
@ -643,12 +628,8 @@
<div
class="fixed top-0 right-5 max-w-sm max-h-[80vh] bg-dark border border-darkless rounded-b-xl shadow-lg z-1000 overflow-hidden transform transition-transform duration-300 ease-out"
>
<Button
type="button"
unstyled
<div
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">
@ -657,7 +638,7 @@
<span class="text-base">{countLabel()}</span>
</div>
</div>
</Button>
</div>
{#if currentlyExpanded}
{#if layout.currently_hacking.users.length === 0}
@ -730,71 +711,54 @@
</div>
{/if}
<div
class="fixed inset-0 flex items-center justify-center z-9999 transition-opacity duration-300 ease-in-out"
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}
<Modal
bind:open={logoutOpen}
title="Woah, hold on a sec!"
description="You sure you want to log out? You can sign back in later but that is a bit of a hassle..."
maxWidth="max-w-lg"
hasIcon
hasActions
>
<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"}`}
>
<div class="flex flex-col items-center w-full">
<div class="mb-4 flex justify-center w-full">
<svg
class="w-12 h-12 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"
/>
</svg>
</div>
{#snippet icon()}
<svg
class="h-8 w-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"
/>
</svg>
{/snippet}
<h3
class="text-2xl font-bold text-surface-content mb-2 text-center w-full"
{#snippet actions()}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
onclick={closeLogout}
variant="dark"
class="h-10 w-full border border-surface-300 text-muted">Go back</Button
>
Woah hold on a sec
</h3>
<p class="text-muted mb-6 text-center w-full">
You sure you want to log out? You can sign back in later but that is a
bit of a hassle...
</p>
<div class="flex w-full gap-3">
<div class="flex-1 min-w-0">
<Button
type="button"
onclick={closeLogout}
variant="dark"
class="w-full h-10 text-muted m-0">Go back</Button
>
</div>
<div class="flex-1 min-w-0">
<form method="post" action={layout.signout_path} class="m-0">
<input
type="hidden"
name="authenticity_token"
value={layout.csrf_token}
/>
<input type="hidden" name="_method" value="delete" />
<Button type="submit" variant="primary" class="w-full h-10 m-0"
>Log out now</Button
>
</form>
</div>
</div>
<form method="post" action={layout.signout_path} class="m-0">
<input
type="hidden"
name="authenticity_token"
value={layout.csrf_token}
/>
<input type="hidden" name="_method" value="delete" />
<Button
type="submit"
variant="primary"
class="h-10 w-full text-on-primary">Log out now</Button
>
</form>
</div>
</div>
</div>
{/snippet}
</Modal>
<style>
:global(#app) {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Popover, RadioGroup } from "bits-ui";
import Button from "../../../components/Button.svelte";
const INTERVALS = [
@ -33,7 +34,6 @@
let open = $state(false);
let customFrom = $state("");
let customTo = $state("");
let container: HTMLDivElement | undefined = $state();
$effect(() => {
customFrom = from;
@ -51,20 +51,9 @@
});
const isDefault = $derived(!selected && !from && !to);
function handleClickOutside(e: MouseEvent) {
if (container && !container.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener("click", handleClickOutside, true);
return () =>
document.removeEventListener("click", handleClickOutside, true);
}
});
const selectedIntervalValue = $derived(
selected && !from && !to ? selected : "",
);
function selectInterval(key: string) {
onchange(key, "", "");
@ -82,99 +71,112 @@
}
</script>
<div class="filter relative" bind:this={container}>
<div class="filter relative">
<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"
>
<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"
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>
</Button>
{#if !isDefault}
<Button
type="button"
unstyled
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-200 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
onclick={clear}
>
</Button>
{/if}
</div>
{#if open}
<Popover.Root bind: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"
class="group m-0 flex items-center rounded-lg border border-surface-200 bg-surface-100 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200 focus-within:border-primary/70 focus-within:ring-2 focus-within:ring-primary/35 focus-within:ring-offset-1 focus-within:ring-offset-surface"
>
<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"
<Popover.Trigger>
{#snippet child({ props })}
<Button
type="button"
unstyled
class="m-0 flex flex-1 cursor-pointer select-none items-center justify-between border-0 bg-transparent px-3 py-2.5 text-sm text-surface-content"
{...props}
>
<input
type="radio"
name="interval"
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-200 rounded-full bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-300 transition-colors duration-150"
checked={selected === interval.key && !from && !to}
onchange={() => selectInterval(interval.key)}
/>
{interval.label}
</label>
{/each}
</div>
<span class="font-medium">{displayLabel}</span>
<svg
class={`h-4 w-4 text-secondary/60 transition-all duration-200 group-hover:text-secondary ${open ? "rotate-180 text-primary" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</Button>
{/snippet}
</Popover.Trigger>
<hr class="my-2 border-surface-200" />
<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-200 rounded-md text-sm text-muted focus:outline-none focus:border-surface-300 transition-colors duration-150"
bind:value={customFrom}
/>
</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-200 rounded-md text-sm text-muted focus:outline-none focus:border-surface-300 transition-colors duration-150"
bind:value={customTo}
/>
</label>
{#if !isDefault}
<Button
type="button"
size="sm"
class="mt-1 border-0 py-2.5"
onclick={applyCustomRange}
unstyled
class="m-0 cursor-pointer border-0 border-l border-surface-200 bg-transparent px-2.5 py-2 text-sm leading-none text-secondary/60 transition-colors duration-150 hover:bg-red/10 hover:text-red"
onclick={clear}
>
Apply
</Button>
</div>
{/if}
</div>
{/if}
<Popover.Portal>
<Popover.Content
sideOffset={8}
align="start"
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-4 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
>
<div class="m-0 max-h-56 overflow-y-auto">
<RadioGroup.Root
value={selectedIntervalValue}
onValueChange={selectInterval}
class="flex flex-col gap-1 overflow-hidden"
>
{#each INTERVALS as interval}
<RadioGroup.Item
value={interval.key}
class="flex w-full items-center rounded-md px-3 py-2 text-left text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content"
>
{#snippet children({ checked })}
<span
class={`mr-3 h-4 w-4 min-w-4 rounded-full border transition-colors ${checked ? "border-primary bg-primary shadow-[0_0_0_3px_rgba(0,0,0,0.2)]" : "border-surface-content/35 bg-surface/40"}`}
></span>
<span>{interval.label}</span>
{/snippet}
</RadioGroup.Item>
{/each}
</RadioGroup.Root>
</div>
<div class="mt-2 border-t border-surface-content/15 pt-2">
<div class="flex flex-col gap-2">
<label class="flex items-center justify-between text-sm text-muted">
<span class="text-secondary/80">Start</span>
<input
type="date"
class="ml-2 h-9 rounded-md border border-surface-content/20 bg-dark px-3 text-sm text-muted transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
bind:value={customFrom}
/>
</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 h-9 rounded-md border border-surface-content/20 bg-dark px-3 text-sm text-muted transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
bind:value={customTo}
/>
</label>
</div>
<Button
type="button"
size="sm"
class="mt-2 h-9 border-0"
onclick={applyCustomRange}
>
Apply
</Button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Checkbox, Popover } from "bits-ui";
import Button from "../../../components/Button.svelte";
let {
@ -17,7 +18,6 @@
let open = $state(false);
let search = $state("");
let container: HTMLDivElement | undefined = $state();
let filtered = $derived(
search
@ -33,33 +33,11 @@
: `${selected.length} selected`,
);
function toggle(value: string) {
if (selected.includes(value)) {
onchange(selected.filter((s) => s !== value));
} else {
onchange([...selected, value]);
}
}
function clear(e: MouseEvent) {
e.stopPropagation();
onchange([]);
}
function handleClickOutside(e: MouseEvent) {
if (container && !container.contains(e.target as Node)) {
open = false;
}
}
$effect(() => {
if (open) {
document.addEventListener("click", handleClickOutside, true);
return () =>
document.removeEventListener("click", handleClickOutside, true);
}
});
$effect(() => {
if (!open) {
search = "";
@ -67,86 +45,104 @@
});
</script>
<div class="filter relative" bind:this={container}>
<div class="filter relative">
<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"
>
<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'
: ''}"
>
{displayText}
</span>
<svg
class={`w-4 h-4 text-secondary/60 transition-transform duration-200 group-hover:text-secondary ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{#if selected.length > 0}
<Button
type="button"
unstyled
class="px-2.5 py-2 text-sm leading-none text-secondary/60 bg-transparent border-0 border-l border-surface-200 cursor-pointer m-0 hover:text-red hover:bg-red/10 transition-colors duration-150"
onclick={clear}
>
×
</Button>
{/if}
</div>
{#if open}
<Popover.Root bind: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"
class="group m-0 flex items-center rounded-lg border border-surface-200 bg-surface-100 p-0 transition-all duration-200 hover:border-surface-300 hover:bg-surface-200 focus-within:border-primary/70 focus-within:ring-2 focus-within:ring-primary/35 focus-within:ring-offset-1 focus-within:ring-offset-surface"
>
<input
type="text"
placeholder="Search..."
class="w-full border border-surface-200 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-300"
bind:value={search}
/>
<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"
<Popover.Trigger>
{#snippet child({ props })}
<Button
type="button"
unstyled
class="m-0 flex min-w-0 flex-1 cursor-pointer select-none items-center justify-between border-0 bg-transparent px-3 py-2.5 text-sm text-surface-content"
{...props}
>
<input
type="checkbox"
checked={selected.includes(value)}
onchange={() => toggle(value)}
class="mr-3 mb-0 h-4 w-4 min-w-4 appearance-none border border-surface-200 rounded bg-dark relative cursor-pointer p-0 checked:bg-primary checked:border-primary hover:border-surface-300 transition-colors duration-150"
/>
{value}
</label>
{/each}
<span
class="truncate font-medium {selected.length === 0
? 'text-surface-content/60'
: ''}"
>
{displayText}
</span>
<svg
class={`h-4 w-4 text-secondary/60 transition-all duration-200 group-hover:text-secondary ${open ? "rotate-180 text-primary" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{/snippet}
</Popover.Trigger>
{#if filtered.length === 0}
<div class="px-3 py-2.5 text-sm text-secondary/60">No results</div>
{/if}
</div>
{#if selected.length > 0}
<Button
type="button"
unstyled
class="m-0 cursor-pointer border-0 border-l border-surface-200 bg-transparent px-2.5 py-2 text-sm leading-none text-secondary/60 transition-colors duration-150 hover:bg-red/10 hover:text-red"
onclick={clear}
>
×
</Button>
{/if}
</div>
{/if}
<Popover.Portal>
<Popover.Content
sideOffset={8}
align="start"
class="dashboard-select-popover z-1000 w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-surface-content/20 bg-darkless/95 p-2 shadow-xl shadow-black/50 outline-none backdrop-blur-sm"
>
<input
type="text"
placeholder="Search..."
class="mb-2 h-10 w-full rounded-lg border border-surface-content/20 bg-dark px-3 text-sm text-surface-content placeholder:text-secondary/60 transition-colors duration-150 focus:border-primary/70 focus:outline-none focus:ring-2 focus:ring-primary/45 focus:ring-offset-1 focus:ring-offset-dark"
bind:value={search}
/>
<div
class="m-0 max-h-64 overflow-y-auto rounded-lg border border-surface-content/15 bg-dark/55 p-1"
>
<Checkbox.Group
value={selected}
onValueChange={(next) => onchange(next as string[])}
class="flex flex-col"
>
{#each filtered as value}
<Checkbox.Root
{value}
class="flex w-full items-center rounded-md px-3 py-2 text-sm text-muted outline-none transition-all duration-150 hover:bg-surface-100/60 hover:text-surface-content data-[highlighted]:bg-surface-100/70 data-[state=checked]:bg-primary/12 data-[state=checked]:text-surface-content"
>
{#snippet children({ checked })}
<span
class={`mr-3 inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border text-[10px] font-bold transition-all duration-150 ${checked ? "border-primary bg-primary text-on-primary" : "border-surface-content/35 bg-surface/40 text-transparent"}`}
>
</span>
<span class="truncate">{value}</span>
{/snippet}
</Checkbox.Root>
{/each}
</Checkbox.Group>
{#if filtered.length === 0}
<div class="px-3 py-2 text-sm text-secondary/60">No results</div>
{/if}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>

View file

@ -0,0 +1,584 @@
<script lang="ts">
import { Link } from "@inertiajs/svelte";
import { onMount } from "svelte";
import Button from "../../components/Button.svelte";
import Modal from "../../components/Modal.svelte";
import IntervalSelect from "../Home/signedIn/IntervalSelect.svelte";
type RepositorySummary = {
homepage?: string | null;
stars?: number | null;
description?: string | null;
formatted_languages?: string | null;
last_commit_ago?: string | null;
};
type ProjectCard = {
id: string;
name: string;
project_key?: string | null;
duration_seconds: number;
duration_label: string;
duration_percent: number;
repo_url?: string | null;
repository?: RepositorySummary | null;
broken_name: boolean;
manage_enabled: boolean;
update_path?: string | null;
archive_path?: string | null;
unarchive_path?: string | null;
};
type ProjectsData = {
total_time_seconds: number;
total_time_label: string;
has_activity: boolean;
projects: ProjectCard[];
};
type PageProps = {
page_title: string;
index_path: string;
show_archived: boolean;
archived_count: number;
github_connected: boolean;
github_auth_path: string;
settings_path: string;
interval?: string | null;
from?: string | null;
to?: string | null;
interval_label: string;
total_projects: number;
projects_data?: ProjectsData;
};
let {
page_title,
index_path,
show_archived,
archived_count,
github_connected,
github_auth_path,
settings_path,
interval = "",
from = "",
to = "",
interval_label,
total_projects,
projects_data,
}: PageProps = $props();
let csrfToken = $state("");
let editingProjectKey = $state<string | null>(null);
let repoUrlDraft = $state("");
let statusChangeModalOpen = $state(false);
let pendingStatusAction = $state<{
path: string;
title: string;
description: string;
confirmLabel: string;
} | null>(null);
const skeletonCount = $derived.by(() => {
const safeCount = Number.isFinite(total_projects) ? total_projects : 0;
return Math.min(Math.max(safeCount, 4), 10);
});
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
const buildProjectsPath = ({
nextShowArchived = show_archived,
nextInterval = interval || "",
nextFrom = from || "",
nextTo = to || "",
}: {
nextShowArchived?: boolean;
nextInterval?: string;
nextFrom?: string;
nextTo?: string;
} = {}) => {
const query = new URLSearchParams();
if (nextShowArchived) query.set("show_archived", "true");
if (nextInterval) query.set("interval", nextInterval);
if (nextFrom) query.set("from", nextFrom);
if (nextTo) query.set("to", nextTo);
const queryString = query.toString();
return queryString ? `${index_path}?${queryString}` : index_path;
};
const changeInterval = (
nextInterval: string,
nextFrom: string,
nextTo: string,
) => {
const isCustom = Boolean(nextFrom || nextTo);
window.location.href = buildProjectsPath({
nextInterval: isCustom ? "custom" : nextInterval,
nextFrom: isCustom ? nextFrom : "",
nextTo: isCustom ? nextTo : "",
});
};
const openMappingEditor = (project: ProjectCard) => {
editingProjectKey = project.project_key || null;
repoUrlDraft = project.repo_url || "";
};
const closeMappingEditor = () => {
editingProjectKey = null;
repoUrlDraft = "";
};
const openStatusChangeModal = (project: ProjectCard, restoring: boolean) => {
const path = restoring ? project.unarchive_path : project.archive_path;
if (!path) return;
pendingStatusAction = {
path,
title: restoring
? `Restore ${project.name}?`
: `Archive ${project.name}?`,
description: restoring
? "This project will return to your active projects list and stats."
: "This project will be hidden from most stats and listings, but it'll still be visible to you and any time logged will still count towards it. You can restore it anytime from the Archived Projects page.",
confirmLabel: restoring ? "Restore project" : "Archive project",
};
statusChangeModalOpen = true;
};
const closeStatusChangeModal = () => {
statusChangeModalOpen = false;
pendingStatusAction = null;
};
const cardActionClass =
"inline-flex h-9 w-9 items-center justify-center rounded-lg bg-surface-content/5 text-surface-content/70 transition-colors duration-200 hover:bg-surface-content/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
</script>
<svelte:head>
<title>{page_title}</title>
</svelte:head>
<div class="mx-auto max-w-7xl">
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-surface-content">My Projects</h1>
{#if archived_count > 0}
<div class="project-toggle-group">
<Link
href={buildProjectsPath({ nextShowArchived: false })}
class={`project-toggle-btn ${!show_archived ? "active" : "inactive"}`}
>
Active
</Link>
<Link
href={buildProjectsPath({ nextShowArchived: true })}
class={`project-toggle-btn ${show_archived ? "active" : "inactive"}`}
>
Archived
</Link>
</div>
{/if}
</div>
</div>
{#if !github_connected}
<div class="mb-4 rounded-xl border border-yellow/30 bg-yellow/10 p-4">
<p class="text-base font-medium text-yellow">
Heads up! You can't link projects to GitHub until you connect your
account.
</p>
<div class="mt-3 flex flex-wrap gap-2">
<Button href={github_auth_path} native class="w-full sm:w-fit">
Sign in with GitHub
</Button>
<Button href={settings_path} variant="surface" class="w-full sm:w-fit">
Open settings
</Button>
</div>
</div>
{/if}
<div class="sm:max-w-3xs">
<IntervalSelect
from={from || ""}
selected={interval || ""}
to={to || ""}
onchange={changeInterval}
/>
</div>
{#if projects_data}
<section class="mt-6">
<p class="text-lg text-surface-content">
{#if projects_data.has_activity}
You've spent
<span class="font-semibold text-primary"
>{projects_data.total_time_label}</span
>
coding across {show_archived ? "archived" : "active"} projects.
{:else}
You haven't logged any time for this interval yet.
{/if}
</p>
{#if projects_data.projects.length == 0}
<div
class="mt-4 rounded-xl border border-surface-200 bg-dark p-8 text-center"
>
<p class="text-muted">
{show_archived
? "No archived projects match this filter."
: "No active projects match this filter."}
</p>
</div>
{:else}
<div
class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6"
>
{#each projects_data.projects as project (project.id)}
<article
class="flex h-full flex-col gap-4 rounded-xl border border-primary bg-dark p-6 shadow-lg backdrop-blur-sm transition-all duration-300"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h3
class="truncate text-lg font-semibold text-surface-content"
title={project.name}
>
{project.name}
</h3>
{#if project.repository?.stars}
<p
class="mt-2 inline-flex items-center gap-1 text-sm text-yellow"
>
<svg
class="h-4 w-4 fill-current"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
{project.repository.stars}
</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if project.repository?.homepage}
<a
href={project.repository.homepage}
target="_blank"
rel="noopener noreferrer"
title="View project website"
class={cardActionClass}
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2m-5.15 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95a8.03 8.03 0 0 1-4.33 3.56M14.34 14H9.66c-.1-.66-.16-1.32-.16-2s.06-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2M12 19.96c-.83-1.2-1.5-2.53-1.91-3.96h3.82c-.41 1.43-1.08 2.76-1.91 3.96M8 8H5.08A7.92 7.92 0 0 1 9.4 4.44C8.8 5.55 8.35 6.75 8 8m-2.92 8H8c.35 1.25.8 2.45 1.4 3.56A8 8 0 0 1 5.08 16m-.82-2C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2M12 4.03c.83 1.2 1.5 2.54 1.91 3.97h-3.82c.41-1.43 1.08-2.77 1.91-3.97M18.92 8h-2.95a15.7 15.7 0 0 0-1.38-3.56c1.84.63 3.37 1.9 4.33 3.56M12 2C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2"
/>
</svg>
</a>
{/if}
{#if project.repo_url}
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
title="View repository"
class={cardActionClass}
>
<svg
class="h-5 w-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2.6 10.59L8.38 4.8l1.69 1.7c-.24.85.15 1.78.93 2.23v5.54c-.6.34-1 .99-1 1.73a2 2 0 0 0 2 2a2 2 0 0 0 2-2c0-.74-.4-1.39-1-1.73V9.41l2.07 2.09c-.07.15-.07.32-.07.5a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2c-.18 0-.35 0-.5.07L13.93 7.5a1.98 1.98 0 0 0-1.15-2.34c-.43-.16-.88-.2-1.28-.09L9.8 3.38l.79-.78c.78-.79 2.04-.79 2.82 0l7.99 7.99c.79.78.79 2.04 0 2.82l-7.99 7.99c-.78.79-2.04.79-2.82 0L2.6 13.41c-.79-.78-.79-2.04 0-2.82"
/>
</svg>
</a>
{/if}
{#if project.manage_enabled}
<Button
type="button"
unstyled
class={cardActionClass}
title={project.repo_url
? "Edit mapping"
: "Link repository"}
onclick={() => openMappingEditor(project)}
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16.862 3.487a2.1 2.1 0 0 1 2.97 2.97L9.75 16.54 6 17.25l.71-3.75z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.5 5.85l3.65 3.65"
/>
</svg>
</Button>
{/if}
{#if show_archived && project.unarchive_path}
<Button
type="button"
unstyled
class={cardActionClass}
title="Restore project"
onclick={() => openStatusChangeModal(project, true)}
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l-3-3 3-3"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 11h9a4 4 0 0 1 0 8h-2"
/>
</svg>
</Button>
{:else if !show_archived && project.archive_path}
<Button
type="button"
unstyled
class={cardActionClass}
title="Archive project"
onclick={() => openStatusChangeModal(project, false)}
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7h18l-2 11H5z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V4h8v3"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 11h4"
/>
</svg>
</Button>
{/if}
</div>
</div>
<p class="text-2xl font-bold text-primary">
{project.duration_label}
</p>
{#if project.repository?.description}
<p
class="line-clamp-2 text-sm leading-relaxed text-surface-content/70"
>
{project.repository.description}
</p>
{/if}
{#if project.repository?.formatted_languages}
<p class="flex items-center gap-1 text-sm">
<svg
class="h-4 w-4 text-surface-content/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="M5.59 3.41L7 4.82L3.82 8L7 11.18L5.59 12.6L1 8zm5.82 0L16 8l-4.59 4.6L10 11.18L13.18 8L10 4.82zM22 6v12c0 1.11-.89 2-2 2H4a2 2 0 0 1-2-2v-4h2v4h16V6h-2.97V4H20c1.11 0 2 .89 2 2"
/>
</svg>
<span class="truncate text-surface-content/50"
>{project.repository.formatted_languages}</span
>
</p>
{/if}
{#if project.repository?.last_commit_ago}
<p class="flex items-center gap-1 text-sm">
<svg
class="h-4 w-4 text-surface-content/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="M12 20c4.4 0 8-3.6 8-8s-3.6-8-8-8s-8 3.6-8 8s3.6 8 8 8m0-18c5.5 0 10 4.5 10 10s-4.5 10-10 10S2 17.5 2 12S6.5 2 12 2m.5 10.8l-4.8 2.8l-.7-1.4l4-2.3V7h1.5z"
/>
</svg>
<span class="text-surface-content/50"
>Last commit {project.repository.last_commit_ago}</span
>
</p>
{/if}
{#if project.broken_name}
<div
class="rounded-lg border border-yellow/30 bg-yellow/10 p-3"
>
<p class="text-sm leading-relaxed text-yellow/80">
Your editor may be sending invalid project names. Time is
shown here but can't be submitted to Hack Club programs.
</p>
</div>
{/if}
{#if project.manage_enabled && editingProjectKey === project.project_key && project.update_path}
<div class="mt-1 border-t border-surface-200/40 pt-4">
<form
method="post"
action={project.update_path}
class="space-y-3"
>
<input type="hidden" name="_method" value="patch" />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<input
type="url"
name="project_repo_mapping[repo_url]"
bind:value={repoUrlDraft}
placeholder="https://github.com/owner/repo"
class="w-full rounded-lg border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<div class="flex gap-2">
<Button
type="submit"
variant="primary"
size="sm"
class="flex-1">Save</Button
>
<Button
type="button"
variant="dark"
size="sm"
class="flex-1"
onclick={closeMappingEditor}
>
Cancel
</Button>
</div>
</form>
</div>
{/if}
</article>
{/each}
</div>
{/if}
</section>
{:else}
<section class="mt-6 animate-pulse">
<div class="h-7 w-80 rounded bg-darkless"></div>
<div
class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6"
>
{#each Array.from({ length: skeletonCount }) as _unused, index (index)}
<div class="rounded-xl border border-primary bg-dark p-6">
<div class="h-6 w-28 rounded bg-darkless"></div>
<div class="mt-3 h-7 w-20 rounded bg-darkless"></div>
<div class="mt-4 h-4 w-full rounded bg-darkless"></div>
<div class="mt-2 h-4 w-3/4 rounded bg-darkless"></div>
<div class="mt-4 h-8 w-full rounded bg-darkless"></div>
</div>
{/each}
</div>
</section>
{/if}
</div>
<Modal
bind:open={statusChangeModalOpen}
title={pendingStatusAction
? pendingStatusAction.title
: "Confirm project change"}
description={pendingStatusAction
? pendingStatusAction.description
: "Confirm this project status change."}
maxWidth="max-w-md"
hasActions
>
{#snippet actions()}
{#if pendingStatusAction}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
variant="dark"
class="h-10 w-full border border-surface-300 text-muted"
onclick={closeStatusChangeModal}
>
Cancel
</Button>
<form method="post" action={pendingStatusAction.path} class="m-0">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="primary"
class="h-10 w-full text-on-primary"
>
{pendingStatusAction.confirmLabel}
</Button>
</form>
</div>
{/if}
{/snippet}
</Modal>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte";
import Select from "../../../components/Select.svelte";
import SettingsShell from "./Shell.svelte";
import type { AccessPageProps } from "./types";
@ -24,15 +26,11 @@
let rotatedApiKey = $state("");
let rotatedApiKeyError = $state("");
let apiKeyCopied = $state(false);
let rotateApiKeyModalOpen = $state(false);
const rotateApiKey = async () => {
if (rotatingApiKey || typeof window === "undefined") return;
const confirmed = window.confirm(
"Rotate your API key now? This immediately invalidates the current key.",
);
if (!confirmed) return;
rotatingApiKey = true;
rotatedApiKey = "";
rotatedApiKeyError = "";
@ -64,6 +62,16 @@
}
};
const openRotateApiKeyModal = () => {
if (rotatingApiKey) return;
rotateApiKeyModalOpen = true;
};
const confirmRotateApiKey = async () => {
rotateApiKeyModalOpen = false;
await rotateApiKey();
};
const copyApiKey = async () => {
if (!rotatedApiKey || typeof navigator === "undefined") return;
await navigator.clipboard.writeText(rotatedApiKey);
@ -118,16 +126,12 @@
>
Display style
</label>
<select
<Select
id="extension_type"
name="user[hackatime_extension_text_type]"
value={user.hackatime_extension_text_type}
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"
>
{#each options.extension_text_types as textType}
<option value={textType.value}>{textType.label}</option>
{/each}
</select>
items={options.extension_text_types}
/>
</div>
<Button type="submit" variant="primary">Save extension settings</Button>
@ -142,7 +146,7 @@
<Button
type="button"
class="mt-4"
onclick={rotateApiKey}
onclick={openRotateApiKeyModal}
disabled={rotatingApiKey}
>
{rotatingApiKey ? "Rotating..." : "Rotate API key"}
@ -200,3 +204,33 @@
</section>
</div>
</SettingsShell>
<Modal
bind:open={rotateApiKeyModalOpen}
title="Rotate API key?"
description="This immediately invalidates your current API key. Any integrations using the old key will stop until updated."
maxWidth="max-w-md"
hasActions
>
{#snippet actions()}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
variant="dark"
class="h-10 w-full border border-surface-300 text-muted"
onclick={() => (rotateApiKeyModalOpen = false)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
class="h-10 w-full text-on-primary"
onclick={confirmRotateApiKey}
disabled={rotatingApiKey}
>
{rotatingApiKey ? "Rotating..." : "Rotate key"}
</Button>
</div>
{/snippet}
</Modal>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte";
import SettingsShell from "./Shell.svelte";
import type { AdminPageProps } from "./types";
@ -16,6 +17,24 @@
}: AdminPageProps = $props();
let csrfToken = $state("");
let deleteMirrorModalOpen = $state(false);
let selectedMirror = $state<{
endpoint_url: string;
destroy_path: string;
} | null>(null);
const openDeleteMirrorModal = (mirror: {
endpoint_url: string;
destroy_path: string;
}) => {
selectedMirror = mirror;
deleteMirrorModalOpen = true;
};
const closeDeleteMirrorModal = () => {
deleteMirrorModalOpen = false;
selectedMirror = null;
};
onMount(() => {
csrfToken =
@ -54,26 +73,16 @@
<p class="mt-1 text-xs text-muted">
Last synced: {mirror.last_synced_ago}
</p>
<form
method="post"
action={mirror.destroy_path}
class="mt-3"
onsubmit={(event) => {
if (!window.confirm("Delete this mirror endpoint?")) {
event.preventDefault();
}
}}
>
<input type="hidden" name="_method" value="delete" />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<Button type="submit" variant="surface" size="xs">
<div class="mt-3">
<Button
type="button"
variant="surface"
size="xs"
onclick={() => openDeleteMirrorModal(mirror)}
>
Delete
</Button>
</form>
</div>
</div>
{/each}
</div>
@ -128,3 +137,39 @@
</p>
{/if}
</SettingsShell>
<Modal
bind:open={deleteMirrorModalOpen}
title="Delete mirror endpoint?"
description={selectedMirror
? `${selectedMirror.endpoint_url} will stop receiving mirrored heartbeats.`
: "This mirror endpoint will be removed."}
maxWidth="max-w-lg"
hasActions
>
{#snippet actions()}
{#if selectedMirror}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
variant="dark"
class="h-10 w-full border border-surface-300 text-muted"
onclick={closeDeleteMirrorModal}
>
Cancel
</Button>
<form method="post" action={selectedMirror.destroy_path} class="m-0">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="primary"
class="h-10 w-full text-on-primary"
>
Delete mirror
</Button>
</form>
</div>
{/if}
{/snippet}
</Modal>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import Select from "../../../components/Select.svelte";
import SettingsShell from "./Shell.svelte";
import type { BadgesPageProps } from "./types";
@ -76,15 +77,14 @@
>
Theme
</label>
<select
<Select
id="badge_theme"
bind:value={selectedTheme}
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"
>
{#each options.badge_themes as theme}
<option value={theme}>{theme}</option>
{/each}
</select>
items={options.badge_themes.map((theme) => ({
value: theme,
label: theme,
}))}
/>
</div>
<div class="rounded-md border border-surface-200 bg-darker p-4">
@ -106,15 +106,14 @@
>
Project
</label>
<select
<Select
id="badge_project"
bind:value={selectedProject}
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"
>
{#each badges.projects as project}
<option value={project}>{project}</option>
{/each}
</select>
items={badges.projects.map((project) => ({
value: project,
label: project,
}))}
/>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
<img
src={projectBadgeUrl()}

View file

@ -1,9 +1,21 @@
<script lang="ts">
import { usePoll } from "@inertiajs/svelte";
import { onMount } from "svelte";
import { tweened } from "svelte/motion";
import { cubicOut } from "svelte/easing";
import Button from "../../../components/Button.svelte";
import SettingsShell from "./Shell.svelte";
import type { DataPageProps } from "./types";
type ImportStatusPayload = NonNullable<
DataPageProps["heartbeat_import"]["status"]
>;
type ImportCreateResponse = {
import_id?: string;
status?: ImportStatusPayload;
error?: string;
};
let {
active_section,
section_paths,
@ -15,18 +27,213 @@
migration,
data_export,
ui,
heartbeat_import,
errors,
admin_tools,
}: DataPageProps = $props();
let csrfToken = $state("");
let selectedFile = $state<File | null>(null);
let importId = $state("");
let importState = $state("idle");
let importMessage = $state("");
let submitError = $state("");
let processedCount = $state<number | null>(null);
let totalCount = $state<number | null>(null);
let importedCount = $state<number | null>(null);
let skippedCount = $state<number | null>(null);
let errorsCount = $state(0);
let isStartingImport = $state(false);
let isPolling = $state(false);
const importPollParams: { heartbeat_import_id?: string } = {};
const tweenedProgress = tweened(0, { duration: 320, easing: cubicOut });
const importInProgress = $derived(
importState === "queued" ||
importState === "counting" ||
importState === "running",
);
const { start: startStatusPolling, stop: stopStatusPolling } = usePoll(
1000,
{
only: ["heartbeat_import"],
data: importPollParams,
preserveUrl: true,
onHttpException: () => {
if (importInProgress) {
importMessage =
"Connection issue while checking import status. Retrying...";
}
},
onNetworkError: () => {
if (importInProgress) {
importMessage =
"Connection issue while checking import status. Retrying...";
}
},
},
{ autoStart: false },
);
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
syncImportFromProps(heartbeat_import);
});
$effect(() => {
syncImportFromProps(heartbeat_import);
});
function isTerminalImportState(state: string) {
return state === "completed" || state === "failed";
}
function stopPolling() {
stopStatusPolling();
delete importPollParams.heartbeat_import_id;
isPolling = false;
}
function startPolling() {
if (!importId) {
return;
}
if (isPolling && importPollParams.heartbeat_import_id === importId) {
return;
}
stopStatusPolling();
importPollParams.heartbeat_import_id = importId;
startStatusPolling();
isPolling = true;
}
function formatCount(value: number | null) {
if (value === null || value === undefined) {
return "—";
}
return value.toLocaleString();
}
function applyImportStatus(status: Partial<ImportStatusPayload>) {
const state = status.state || "idle";
const progress = Number(status.progress_percent ?? 0);
const normalizedProgress = Number.isFinite(progress)
? Math.min(Math.max(progress, 0), 100)
: 0;
importState = state;
importMessage = status.message || importMessage;
processedCount = status.processed_count ?? processedCount;
totalCount = status.total_count ?? totalCount;
importedCount = status.imported_count ?? importedCount;
skippedCount = status.skipped_count ?? skippedCount;
errorsCount = status.errors_count ?? errorsCount;
void tweenedProgress.set(normalizedProgress);
}
function syncImportFromProps(
serverImport: DataPageProps["heartbeat_import"],
) {
if (!serverImport) {
return;
}
if (serverImport.import_id) {
if (importId && serverImport.import_id !== importId) {
return;
}
importId = serverImport.import_id;
}
if (serverImport.import_id && !serverImport.status) {
stopPolling();
importState = "failed";
importMessage = "Import status could not be found.";
return;
}
if (!serverImport.status) {
return;
}
applyImportStatus(serverImport.status);
if (isTerminalImportState(serverImport.status.state)) {
stopPolling();
return;
}
if (importId) {
startPolling();
}
}
function resetImportState() {
importState = "queued";
importMessage = "Queued import.";
submitError = "";
processedCount = 0;
totalCount = null;
importedCount = null;
skippedCount = null;
errorsCount = 0;
void tweenedProgress.set(0);
}
async function startImport(event: SubmitEvent) {
event.preventDefault();
submitError = "";
if (!selectedFile) {
submitError = "Please choose a JSON file to import.";
return;
}
isStartingImport = true;
resetImportState();
stopPolling();
const formData = new FormData();
formData.append("heartbeat_file", selectedFile);
try {
const response = await fetch(paths.create_heartbeat_import_path, {
method: "POST",
headers: {
"X-CSRF-Token": csrfToken,
Accept: "application/json",
},
credentials: "same-origin",
body: formData,
});
const payload = (await response.json()) as ImportCreateResponse;
if (!response.ok) {
throw new Error(payload.error || "Unable to start import.");
}
if (!payload.import_id) {
throw new Error("Unable to start import.");
}
importId = payload.import_id;
if (payload.status) {
applyImportStatus(payload.status);
}
startPolling();
} catch (error) {
importState = "failed";
importMessage = "Import failed to start.";
submitError =
error instanceof Error ? error.message : "Unable to start import.";
} finally {
isStartingImport = false;
}
}
</script>
<SettingsShell
@ -135,12 +342,9 @@
{#if ui.show_dev_import}
<form
method="post"
action={paths.import_heartbeats_path}
enctype="multipart/form-data"
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
onsubmit={startImport}
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label
class="mb-2 block text-sm text-surface-content"
for="heartbeat_file"
@ -150,14 +354,66 @@
<input
id="heartbeat_file"
type="file"
name="heartbeat_file"
accept=".json,application/json"
required
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content"
disabled={importInProgress || isStartingImport}
onchange={(event) => {
const target = event.currentTarget as HTMLInputElement;
selectedFile = target.files?.[0] ?? null;
}}
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content disabled:cursor-not-allowed disabled:opacity-60"
/>
<Button type="submit" variant="surface" class="mt-3 rounded-md">
Import file
{#if submitError}
<p class="mt-2 text-sm text-red-300">{submitError}</p>
{/if}
<Button
type="submit"
variant="surface"
class="mt-3 rounded-md"
disabled={!selectedFile || importInProgress || isStartingImport}
>
{#if isStartingImport}
Starting import...
{:else if importInProgress}
Importing...
{:else}
Import file
{/if}
</Button>
{#if importState !== "idle"}
<div
class="mt-4 rounded-md border border-surface-200 bg-surface p-3"
>
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-surface-content">
Status: {importState}
</p>
<p class="text-sm font-semibold text-primary">
{Math.round($tweenedProgress)}%
</p>
</div>
<progress
max="100"
value={$tweenedProgress}
class="mt-2 h-2 w-full rounded-full bg-surface-200 accent-primary"
></progress>
<p class="mt-2 text-sm text-muted">
{formatCount(processedCount)} / {formatCount(totalCount)} processed
</p>
{#if importMessage}
<p class="mt-1 text-sm text-muted">{importMessage}</p>
{/if}
{#if importState === "completed"}
<p class="mt-1 text-sm text-muted">
Imported: {formatCount(importedCount)}. Skipped {formatCount(
skippedCount,
)} duplicates and {errorsCount.toLocaleString()} errors
</p>
{/if}
</div>
{/if}
</form>
{/if}
{/if}

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { Checkbox } from "bits-ui";
import { onMount } from "svelte";
import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte";
import SettingsShell from "./Shell.svelte";
import type { IntegrationsPageProps } from "./types";
@ -21,6 +23,8 @@
}: IntegrationsPageProps = $props();
let csrfToken = $state("");
let usesSlackStatus = $state(user.uses_slack_status);
let unlinkGithubModalOpen = $state(false);
onMount(() => {
csrfToken =
@ -63,13 +67,16 @@
<label class="flex items-center gap-3 text-sm text-surface-content">
<input type="hidden" name="user[uses_slack_status]" value="0" />
<input
type="checkbox"
<Checkbox.Root
bind:checked={usesSlackStatus}
name="user[uses_slack_status]"
value="1"
checked={user.uses_slack_status}
class="h-4 w-4 rounded border-surface-200 bg-darker text-primary"
/>
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Update my Slack status automatically
</label>
@ -136,33 +143,19 @@
>
Reconnect GitHub
</a>
<form
method="post"
action={paths.github_unlink_path}
onsubmit={(event) => {
if (
!window.confirm(
"Unlink this GitHub account? GitHub-based features will stop until relinked.",
)
) {
event.preventDefault();
}
}}
<Button
type="button"
variant="surface"
class="rounded-md"
onclick={() => (unlinkGithubModalOpen = true)}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button type="submit" variant="surface" class="rounded-md">
Unlink GitHub
</Button>
</form>
Unlink GitHub
</Button>
</div>
{:else}
<a
href={paths.github_auth_path}
class="mt-4 inline-flex rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
<Button href={paths.github_auth_path} native class="mt-4 rounded-md">
Connect GitHub
</a>
</Button>
{/if}
</section>
@ -232,3 +225,35 @@
</section>
</div>
</SettingsShell>
<Modal
bind:open={unlinkGithubModalOpen}
title="Unlink GitHub account?"
description="GitHub-based features will stop until you reconnect."
maxWidth="max-w-md"
hasActions
>
{#snippet actions()}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
type="button"
variant="dark"
class="h-10 w-full border border-surface-300 text-muted"
onclick={() => (unlinkGithubModalOpen = false)}
>
Cancel
</Button>
<form method="post" action={paths.github_unlink_path} class="m-0">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button
type="submit"
variant="primary"
class="h-10 w-full text-on-primary"
>
Unlink GitHub
</Button>
</form>
</div>
{/snippet}
</Modal>

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { Checkbox, RadioGroup } from "bits-ui";
import { onMount } from "svelte";
import Button from "../../../components/Button.svelte";
import Select from "../../../components/Select.svelte";
import SettingsShell from "./Shell.svelte";
import type { ProfilePageProps } from "./types";
@ -20,7 +22,8 @@
}: ProfilePageProps = $props();
let csrfToken = $state("");
let selectedTheme = $state("gruvbox_dark");
let selectedTheme = $state(user.theme || "gruvbox_dark");
let allowPublicStatsLookup = $state(user.allow_public_stats_lookup);
onMount(() => {
csrfToken =
@ -28,10 +31,6 @@
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
});
$effect(() => {
selectedTheme = user.theme || "gruvbox_dark";
});
</script>
<SettingsShell
@ -63,33 +62,27 @@
>
Country
</label>
<select
<Select
id="country_code"
name="user[country_code]"
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"
value={user.country_code || ""}
>
<option value="">Select a country</option>
{#each options.countries as country}
<option value={country.value}>{country.label}</option>
{/each}
</select>
items={[
{ value: "", label: "Select a country" },
...options.countries,
]}
/>
</div>
<div id="user_timezone">
<label for="timezone" class="mb-2 block text-sm text-surface-content">
Timezone
</label>
<select
<Select
id="timezone"
name="user[timezone]"
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"
value={user.timezone}
>
{#each options.timezones as timezone}
<option value={timezone.value}>{timezone.label}</option>
{/each}
</select>
items={options.timezones}
/>
</div>
<Button type="submit" variant="primary">Save region settings</Button>
@ -154,13 +147,16 @@
name="user[allow_public_stats_lookup]"
value="0"
/>
<input
type="checkbox"
<Checkbox.Root
bind:checked={allowPublicStatsLookup}
name="user[allow_public_stats_lookup]"
value="1"
checked={user.allow_public_stats_lookup}
class="h-4 w-4 rounded border-surface-200 bg-darker text-primary"
/>
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Allow public stats lookup
</label>
@ -177,82 +173,77 @@
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<RadioGroup.Root
name="user[theme]"
bind:value={selectedTheme}
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
{#each options.themes as theme}
{@const isSelected = selectedTheme === theme.value}
<label
class={`block cursor-pointer rounded-xl border p-4 transition-colors ${
isSelected
? "border-primary bg-surface-100"
: "border-surface-200 bg-darker/40 hover:border-surface-300"
}`}
<RadioGroup.Item
value={theme.value}
class="block cursor-pointer rounded-xl border p-4 text-left outline-none transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-surface-100 data-[state=unchecked]:border-surface-200 data-[state=unchecked]:bg-darker/40 data-[state=unchecked]:hover:border-surface-300"
>
<input
type="radio"
name="user[theme]"
value={theme.value}
checked={isSelected}
class="sr-only"
onchange={() => (selectedTheme = theme.value)}
/>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-surface-content">
{theme.label}
</p>
<p class="mt-1 text-xs text-muted">{theme.description}</p>
{#snippet children({ checked })}
<div class="flex items-start justify-between gap-3">
<div>
<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 checked}
<span
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
Selected
</span>
{/if}
</div>
{#if isSelected}
<span
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
Selected
</span>
{/if}
</div>
<div
class="mt-3 rounded-lg border p-2"
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`}
>
<div
class="flex items-center justify-between rounded-md px-2 py-1"
style={`background:${theme.preview.dark};`}
class="mt-3 rounded-lg border p-2"
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`}
>
<span class="text-[11px] font-semibold">Dashboard</span>
<span class="text-[10px] opacity-80">2h 14m</span>
</div>
<div
class="flex items-center justify-between rounded-md px-2 py-1"
style={`background:${theme.preview.dark};`}
>
<span class="text-[11px] font-semibold">Dashboard</span>
<span class="text-[10px] opacity-80">2h 14m</span>
</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>
</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>
</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>
<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>
</div>
</div>
</div>
</label>
{/snippet}
</RadioGroup.Item>
{/each}
</div>
</RadioGroup.Root>
<Button type="submit" variant="primary">Save theme</Button>
</form>

View file

@ -60,7 +60,7 @@ export type PathsProps = {
migrate_heartbeats_path: string;
export_all_heartbeats_path: string;
export_range_heartbeats_path: string;
import_heartbeats_path: string;
create_heartbeat_import_path: string;
create_deletion_path: string;
user_wakatime_mirrors_path: string;
};
@ -138,6 +138,26 @@ export type UiProps = {
show_dev_import: boolean;
};
export type HeartbeatImportStatusProps = {
import_id: string;
state: string;
progress_percent: number;
processed_count: number;
total_count: number | null;
imported_count: number | null;
skipped_count: number | null;
errors_count: number;
message: string;
updated_at: string;
started_at?: string;
finished_at?: string;
};
export type HeartbeatImportProps = {
import_id?: string | null;
status?: HeartbeatImportStatusProps | null;
};
export type ErrorsProps = {
full_messages: string[];
username: string[];
@ -189,6 +209,7 @@ export type DataPageProps = SettingsCommonProps & {
migration: MigrationProps;
data_export: DataExportProps;
ui: UiProps;
heartbeat_import: HeartbeatImportProps;
};
export type AdminPageProps = SettingsCommonProps & {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { Checkbox } from "bits-ui";
import Button from "../../components/Button.svelte";
import Stepper from "./Stepper.svelte";
@ -47,11 +48,17 @@
<label
class="flex items-center gap-3 cursor-pointer select-none group"
>
<input
type="checkbox"
<Checkbox.Root
bind:checked={agreed}
class="w-5 h-5 rounded border-darkless bg-darker text-primary focus:ring-primary focus:ring-offset-darker transition-colors cursor-pointer"
/>
class="inline-flex h-5 w-5 min-w-5 items-center justify-center rounded border border-darkless bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span
class={checked ? "text-xs font-bold leading-none" : "hidden"}
>✓</span
>
{/snippet}
</Checkbox.Root>
<span class="font-medium">I understand and agree to the rules</span>
</label>
</div>

View file

@ -0,0 +1,7 @@
class HeartbeatImportJob < ApplicationJob
queue_as :default
def perform(user_id, import_id, file_path)
HeartbeatImportRunner.run_import(user_id: user_id, import_id: import_id, file_path: file_path)
end
end

View file

@ -0,0 +1,140 @@
require "fileutils"
require "securerandom"
class HeartbeatImportRunner
STATUS_TTL = 12.hours
PROGRESS_INTERVAL = 250
def self.start(user:, uploaded_file:)
import_id = SecureRandom.uuid
file_path = persist_uploaded_file(uploaded_file, import_id)
write_status(user_id: user.id, import_id: import_id, attributes: {
state: "queued",
progress_percent: 0,
processed_count: 0,
total_count: nil,
imported_count: nil,
skipped_count: nil,
errors_count: 0,
message: "Queued import."
})
HeartbeatImportJob.perform_later(user.id, import_id, file_path)
import_id
end
def self.status(user:, import_id:)
Rails.cache.read(cache_key(user.id, import_id))
end
def self.run_import(user_id:, import_id:, file_path:)
ActiveRecord::Base.connection_pool.with_connection do
user = User.find_by(id: user_id)
unless user
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "failed",
progress_percent: 0,
message: "User not found.",
finished_at: Time.current.iso8601
})
return
end
file_content = File.read(file_path).force_encoding("UTF-8")
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "running",
total_count: nil,
progress_percent: 0,
processed_count: 0,
started_at: Time.current.iso8601,
message: "Importing heartbeats..."
})
result = HeartbeatImportService.import_from_file(
file_content,
user,
progress_interval: PROGRESS_INTERVAL,
on_progress: lambda { |processed_count|
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "running",
progress_percent: 0,
processed_count: processed_count,
total_count: nil,
message: "Importing heartbeats..."
})
}
)
if result[:success]
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "completed",
progress_percent: 100,
processed_count: result[:total_count],
total_count: result[:total_count],
imported_count: result[:imported_count],
skipped_count: result[:skipped_count],
errors_count: result[:errors].length,
message: build_success_message(result),
finished_at: Time.current.iso8601
})
else
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "failed",
progress_percent: 0,
imported_count: result[:imported_count],
skipped_count: result[:skipped_count],
errors_count: result[:errors].length,
message: "Import failed: #{result[:error]}",
finished_at: Time.current.iso8601
})
end
end
rescue => e
write_status(user_id: user_id, import_id: import_id, attributes: {
state: "failed",
message: "Import failed: #{e.message}",
finished_at: Time.current.iso8601
})
ensure
FileUtils.rm_f(file_path) if file_path.present?
ActiveRecord::Base.clear_active_connections!
end
def self.write_status(user_id:, import_id:, attributes:)
key = cache_key(user_id, import_id)
existing = Rails.cache.read(key) || {}
payload = existing.merge(attributes).merge(
import_id: import_id,
updated_at: Time.current.iso8601
)
Rails.cache.write(key, payload, expires_in: STATUS_TTL)
payload
end
def self.persist_uploaded_file(uploaded_file, import_id)
tmp_dir = Rails.root.join("tmp", "heartbeat_imports")
FileUtils.mkdir_p(tmp_dir)
ext = File.extname(uploaded_file.original_filename.to_s)
ext = ".json" if ext.blank?
file_path = tmp_dir.join("#{import_id}#{ext}")
FileUtils.cp(uploaded_file.tempfile.path, file_path)
file_path.to_s
end
def self.cache_key(user_id, import_id)
"heartbeat_import_status:user:#{user_id}:import:#{import_id}"
end
def self.build_success_message(result)
message = "Imported #{result[:imported_count]} out of #{result[:total_count]} heartbeats in #{result[:time_taken]}s."
return message if result[:skipped_count].zero?
"#{message} Skipped #{result[:skipped_count]} duplicate heartbeats."
end
end

View file

@ -1,7 +1,7 @@
class HeartbeatImportService
BATCH_SIZE = 50_000
def self.import_from_file(file_content, user)
def self.import_from_file(file_content, user, on_progress: nil, progress_interval: 250)
unless Rails.env.development?
raise StandardError, "Not dev env, not running"
end
@ -16,6 +16,7 @@ class HeartbeatImportService
handler = HeartbeatSaxHandler.new do |hb|
total_count += 1
on_progress&.call(total_count) if progress_interval.positive? && (total_count % progress_interval).zero?
begin
time_value = hb["time"].is_a?(String) ? Time.parse(hb["time"]).to_f : hb["time"].to_f
@ -62,6 +63,7 @@ class HeartbeatImportService
end
Oj.saj_parse(handler, file_content)
on_progress&.call(total_count)
if total_count.zero?
raise StandardError, "Not correct format, download from /my/settings on the offical hackatime then import here"
@ -90,6 +92,17 @@ class HeartbeatImportService
}
end
def self.count_heartbeats(file_content)
total_count = 0
handler = HeartbeatSaxHandler.new do |_hb|
total_count += 1
end
Oj.saj_parse(handler, file_content)
total_count
end
def self.flush_batch(seen_hashes)
return 0 if seen_hashes.empty?
@ -100,8 +113,10 @@ class HeartbeatImportService
r[:updated_at] = timestamp
end
result = Heartbeat.upsert_all(records, unique_by: [ :fields_hash ])
result.length
ActiveRecord::Base.logger.silence do
result = Heartbeat.upsert_all(records, unique_by: [ :fields_hash ])
result.length
end
end
class HeartbeatSaxHandler < Oj::Saj

View file

@ -84,10 +84,14 @@ class ProjectStatsQuery
end
def scoped_heartbeats(start_time, end_time)
start_timestamp = timestamp_value(start_time)
end_timestamp = timestamp_value(end_time)
return @user.heartbeats.none if start_timestamp.nil? || end_timestamp.nil?
@user.heartbeats
.with_valid_timestamps
.where.not(project: [ nil, "" ])
.where(time: start_time..end_time)
.where(time: start_timestamp..end_timestamp)
end
def archived_project_names
@ -138,4 +142,15 @@ class ProjectStatsQuery
Time.at(time_value.to_f).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
end
def timestamp_value(value)
case value
when Numeric
value.to_f
when Time, ActiveSupport::TimeWithZone
value.to_f
when DateTime
value.to_time.to_f
end
end
end

View file

@ -26,7 +26,7 @@
<meta property="og:description" content="<%= @og_description || content_for(:og_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta property="og:url" content="<%= content_for(:og_url) || request.original_url %>">
<meta property="og:type" content="<%= content_for(:og_type) || 'website' %>">
<meta property="og:image" content="<%= content_for(:og_image) || asset_path('og.jpg') %>">
<meta property="og:image" content="<%= content_for(:og_image) || safe_asset_path('og.jpg', fallback: 'favicon.png') %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Hackatime">
@ -38,7 +38,7 @@
<meta name="twitter:creator" content="@hackclub">
<meta name="twitter:title" content="<%= @twitter_title || content_for(:twitter_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
<meta name="twitter:description" content="<%= @twitter_description || content_for(:twitter_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta name="twitter:image" content="<%= content_for(:twitter_image) || asset_path('favicon.png') %>">
<meta name="twitter:image" content="<%= content_for(:twitter_image) || safe_asset_path('favicon.png', fallback: 'og.jpg') %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@ -184,15 +184,16 @@
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app %>
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
<%= vite_client_tag %>
<%= vite_typescript_tag "rails_modals" %>
<% if Sentry.get_trace_propagation_meta %>
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
<% end %>
<%# Vite assets are only required for the Inertia layout. %>
<!-- Lets users record their screen from your site -->
<meta name="jam:team" content="a5978e52-2479-4dd3-9883-593aa7a4f121">
<script type="module" src="https://js.jam.dev/recorder.js"></script>
<!-- Captures user events and developer logs -->
<script type="module" src="https://js.jam.dev/capture.js"></script>
@ -249,6 +250,6 @@
</div>
<div data-currently-hacking-target="content" style="display: none;"></div>
</div>
<%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah hold on a sec', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"/>', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %>
<%= render 'shared/modal', modal_id: 'logout-modal', title: 'Woah, hold on a sec!', description: 'You sure you want to log out? You can sign back in later but that is a bit of a hassle...', icon_svg: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h14V5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm6.65-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.65L9.8 9.15q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L14.8 11.3q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.575 3.575q-.3.3-.712.288T9.8 16.25q-.275-.3-.288-.7t.288-.7z"/>', icon_color: 'text-primary', buttons: [{ text: 'Go back', class: 'bg-dark hover:bg-darkless border border-darkless text-muted', action: 'click->modal#close' }, { text: 'Log out now', class: 'bg-primary hover:bg-primary/75 text-on-primary font-medium', form: true, url: signout_path, method: 'delete' }] %>
</body>
</html>

View file

@ -17,7 +17,7 @@
<meta property="og:description" content="<%= @og_description || content_for(:og_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta property="og:url" content="<%= content_for(:og_url) || request.original_url %>">
<meta property="og:type" content="<%= content_for(:og_type) || 'website' %>">
<meta property="og:image" content="<%= content_for(:og_image) || asset_path('og.jpg') %>">
<meta property="og:image" content="<%= content_for(:og_image) || safe_asset_path('og.jpg', fallback: 'favicon.png') %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Hackatime">
@ -27,7 +27,7 @@
<meta name="twitter:creator" content="@hackclub">
<meta name="twitter:title" content="<%= @twitter_title || content_for(:twitter_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
<meta name="twitter:description" content="<%= @twitter_description || content_for(:twitter_description) || @meta_description || content_for(:meta_description) || 'Free and open-source coding time tracker built by Hack Club. Track your time across 75+ editors.' %>">
<meta name="twitter:image" content="<%= content_for(:twitter_image) || asset_path('favicon.png') %>">
<meta name="twitter:image" content="<%= content_for(:twitter_image) || safe_asset_path('favicon.png', fallback: 'og.jpg') %>">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

View file

@ -7,60 +7,88 @@
buttons ||= []
max_width ||= 'max-w-md'
custom ||= nil
custom_html =
if custom.is_a?(String)
sanitize(
custom,
tags: %w[div p span a button form input label select option textarea h1 h2 h3 h4 h5 h6 ul ol li strong em code pre hr br svg path circle rect line polyline polygon],
attributes: %w[class id href type name value placeholder data-controller data-action data-target data-token for method required disabled readonly accept maxlength action style target rel]
)
elsif custom.present?
custom
else
nil
end
actions_html = capture do
if buttons.any?
%>
<div id="<%= modal_id %>" class="fixed inset-0 flex items-center justify-center z-9999 opacity-0 pointer-events-none transition-opacity duration-300 ease-in-out hidden" style="background-color: rgba(0, 0, 0, 0.5);backdrop-filter: blur(4px);" data-controller="modal">
<div class="bg-dark border border-primary rounded-lg p-6 <%= max_width %> w-full mx-4 flex flex-col items-center justify-center transform scale-95 transition-transform duration-300 ease-in-out" data-modal-target="content">
<div class="flex flex-col items-center w-full">
<% if icon_svg %>
<div class="mb-4 flex justify-center w-full">
<svg class="w-12 h-12 <%= icon_color %>" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<%= sanitize icon_svg, tags: %w[path circle rect line polyline polygon], attributes: %w[d cx cy r x y x1 y1 x2 y2 points stroke-linecap stroke-linejoin stroke-width fill] %>
</svg>
</div>
<% end %>
<h3 class="text-2xl font-bold text-surface-content mb-2 text-center w-full"><%= title %></h3>
<% if description %>
<p class="text-muted mb-6 text-center w-full"><%= description %></p>
<% end %>
<% if custom.is_a?(String) %>
<%= sanitize custom, tags: %w[div p span a button form input label select option textarea h1 h2 h3 h4 h5 h6 ul ol li strong em code pre], attributes: %w[class id href type name value placeholder data-controller data-action data-target data-token for method required disabled readonly accept maxlength action] %>
<% elsif custom.present? %>
<%= custom %>
<% end %>
<% if buttons.any? %>
<div class="flex w-full gap-3">
<% buttons.each do |button| %>
<div class="flex-1 min-w-0">
<% if button[:form] %>
<% if button[:form_id] %>
<button type="submit" form="<%= button[:form_id] %>" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 font-medium cursor-pointer m-0 <%= button[:class] %>">
<%= button[:text] %>
</button>
<% else %>
<form method="post" action="<%= button[:url] %>" class="m-0">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<% if button[:method] && button[:method] != 'post' %>
<input type="hidden" name="_method" value="<%= button[:method] %>">
<% end %>
<button type="submit" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 font-medium cursor-pointer m-0 <%= button[:class] %>">
<%= button[:text] %>
</button>
</form>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<% buttons.each do |button| %>
<% button_classes = "inline-flex w-full items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors duration-150 #{button[:class]}" %>
<% if button[:form] %>
<% if button[:form_id] %>
<button type="submit" form="<%= button[:form_id] %>" class="<%= button_classes %>">
<%= button[:text] %>
</button>
<% else %>
<form method="post" action="<%= button[:url] %>" class="m-0 w-full">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<% if button[:method] && button[:method] != 'post' %>
<input type="hidden" name="_method" value="<%= button[:method] %>">
<% end %>
<% else %>
<button type="button" data-action="<%= button[:action] || 'click->modal#close' %>" class="w-full h-10 px-4 rounded-lg transition-colors duration-200 cursor-pointer m-0 <%= button[:class] %>">
<button type="submit" class="<%= button_classes %>">
<%= button[:text] %>
</button>
<% end %>
</div>
</form>
<% end %>
<% else %>
<% close_on_click = if button.key?(:close)
button[:close]
else
button[:action].blank? || button[:action].to_s.include?("modal#close")
end %>
<button
type="button"
data-modal-close="<%= close_on_click %>"
class="<%= button_classes %>"
>
<%= button[:text] %>
</button>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<%
end
end
%>
<div
id="<%= modal_id %>"
class="hidden"
data-bits-modal
data-modal-title="<%= title %>"
data-modal-description="<%= description.to_s %>"
data-modal-max-width="<%= max_width %>"
>
<% if icon_svg %>
<template data-modal-icon>
<svg class="h-10 w-10 <%= icon_color %>" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<%= sanitize icon_svg, tags: %w[path circle rect line polyline polygon], attributes: %w[d cx cy r x y x1 y1 x2 y2 points stroke-linecap stroke-linejoin stroke-width fill] %>
</svg>
</template>
<% end %>
<% if custom_html.present? %>
<template data-modal-custom>
<%= custom_html %>
</template>
<% end %>
<% if actions_html.present? %>
<template data-modal-actions>
<%= actions_html %>
</template>
<% end %>
</div>

View file

@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.7",
"axios": "^1.13.2",
"bits-ui": "^2.15.5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
@ -101,6 +102,8 @@
"@inertiajs/svelte": ["@inertiajs/svelte@file:vendor/inertia/packages/svelte", { "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" } }],
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@ -181,6 +184,8 @@
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
"@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@ -243,6 +248,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bits-ui": ["bits-ui@2.15.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -319,6 +326,8 @@
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
@ -381,6 +390,8 @@
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"irregular-plurals": ["irregular-plurals@4.2.0", "", {}, "sha512-bW9UXHL7bnUcNtTo+9ccSngbxc+V40H32IgvdVin0Xs8gbo+AVYD5g/72ce/54Kjfhq66vcZr8H8TKEvsifeOw=="],
@ -437,6 +448,8 @@
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@ -515,6 +528,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
@ -523,6 +538,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@ -531,6 +548,10 @@
"svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],

View file

@ -145,6 +145,8 @@ Rails.application.routes.draw do
post "my/settings/rotate_api_key", to: "settings/access#rotate_api_key", as: :my_settings_rotate_api_key
namespace :my do
resources :heartbeat_imports, only: [ :create, :show ]
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
member do
patch :archive

127
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.7",
"axios": "^1.13.2",
"bits-ui": "^2.15.5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",
@ -518,6 +519,16 @@
"resolved": "vendor/inertia/packages/svelte",
"link": true
},
"node_modules/@internationalized/date": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"license": "MIT",
@ -1126,6 +1137,15 @@
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.11",
"license": "MIT",
@ -1512,6 +1532,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bits-ui": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.5.tgz",
"integrity": "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"@internationalized/date": "^3.8.1",
"svelte": "^5.33.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@ -1962,6 +2006,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"license": "Apache-2.0",
@ -2349,6 +2402,12 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@ -2783,6 +2842,15 @@
"version": "4.17.23",
"license": "MIT"
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
@ -3331,6 +3399,30 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/runed": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@ -3360,6 +3452,15 @@
"node": ">=0.10.0"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -3454,6 +3555,32 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",

View file

@ -17,6 +17,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tsconfig/svelte": "^5.0.7",
"axios": "^1.13.2",
"bits-ui": "^2.15.5",
"d3-scale": "^4.0.2",
"layerchart": "^1.0.13",
"plur": "^6.0.0",

View file

@ -0,0 +1,118 @@
require "test_helper"
class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
test "create rejects guests" do
post my_heartbeat_imports_path
assert_response :unauthorized
assert_equal "You must be logged in to view this page.", JSON.parse(response.body)["error"]
end
test "create rejects non-development environment" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
post my_heartbeat_imports_path
assert_response :forbidden
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
end
test "show rejects non-development environment" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
get my_heartbeat_import_path("import-123")
assert_response :forbidden
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
end
test "create returns error when file is missing" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
with_development_env do
post my_heartbeat_imports_path
end
assert_response :unprocessable_entity
assert_equal "pls select a file to import", JSON.parse(response.body)["error"]
end
test "create returns error when file type is invalid" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
with_development_env do
post my_heartbeat_imports_path, params: {
heartbeat_file: uploaded_file(filename: "heartbeats.txt", content_type: "text/plain", content: "hello")
}
end
assert_response :unprocessable_entity
assert_equal "pls upload only json (download from the button above it)", JSON.parse(response.body)["error"]
end
test "create starts import and returns status" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
expected_status = { "state" => "queued", "progress_percent" => 0 }
with_development_env do
with_runner_stubs(start_return: "import-123", status_return: expected_status) do
post my_heartbeat_imports_path, params: {
heartbeat_file: uploaded_file
}
end
end
assert_response :accepted
body = JSON.parse(response.body)
assert_equal "import-123", body["import_id"]
assert_equal "queued", body.dig("status", "state")
end
private
def sign_in_as(user)
token = user.sign_in_tokens.create!(auth_type: :email)
get auth_token_path(token: token.token)
assert_equal user.id, session[:user_id]
end
def with_development_env
rails_singleton = class << Rails; self; end
rails_singleton.alias_method :__original_env_for_test, :env
rails_singleton.define_method(:env) { ActiveSupport::StringInquirer.new("development") }
yield
ensure
rails_singleton.remove_method :env
rails_singleton.alias_method :env, :__original_env_for_test
rails_singleton.remove_method :__original_env_for_test
end
def with_runner_stubs(start_return:, status_return:)
runner_singleton = class << HeartbeatImportRunner; self; end
runner_singleton.alias_method :__original_start_for_test, :start
runner_singleton.alias_method :__original_status_for_test, :status
runner_singleton.define_method(:start) { |**| start_return }
runner_singleton.define_method(:status) { |**| status_return }
yield
ensure
runner_singleton.remove_method :start
runner_singleton.remove_method :status
runner_singleton.alias_method :start, :__original_start_for_test
runner_singleton.alias_method :status, :__original_status_for_test
runner_singleton.remove_method :__original_start_for_test
runner_singleton.remove_method :__original_status_for_test
end
def uploaded_file(filename: "heartbeats.json", content_type: "application/json", content: '{"heartbeats":[]}')
Rack::Test::UploadedFile.new(
StringIO.new(content),
content_type,
original_filename: filename
)
end
end

View file

@ -0,0 +1,54 @@
require "test_helper"
class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest
test "index redirects guests" do
get my_projects_path
assert_response :redirect
assert_redirected_to root_path
end
test "index renders projects page with deferred props" do
user = User.create!(timezone: "UTC")
user.project_repo_mappings.create!(project_name: "alpha")
create_project_heartbeats(user, "alpha")
sign_in_as(user)
get my_projects_path
assert_response :success
assert_includes response.body, "\"component\":\"Projects/Index\""
assert_includes response.body, "\"deferredProps\":{\"default\":[\"projects_data\"]}"
assert_includes response.body, "\"show_archived\":false"
assert_includes response.body, "\"total_projects\":1"
end
test "index supports archived view state" do
user = User.create!(timezone: "UTC")
mapping = user.project_repo_mappings.create!(project_name: "beta")
mapping.archive!
create_project_heartbeats(user, "beta")
sign_in_as(user)
get my_projects_path(show_archived: true)
assert_response :success
assert_includes response.body, "\"component\":\"Projects/Index\""
assert_includes response.body, "\"show_archived\":true"
assert_includes response.body, "\"total_projects\":1"
end
private
def create_project_heartbeats(user, project_name)
now = Time.current.to_i
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now - 1800, source_type: :test_entry)
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now, source_type: :test_entry)
end
def sign_in_as(user)
token = user.sign_in_tokens.create!(auth_type: :email)
get auth_token_path(token: token.token)
assert_equal user.id, session[:user_id]
end
end

View file

@ -42,6 +42,218 @@ class ProjectStatsQueryTest < ActiveSupport::TestCase
assert_equal [ "new_project" ], query.project_names
end
test "project names supports since filter with default end time" do
create_heartbeat(project: "old_project", time: 20.days.ago.to_f)
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(
user: @user,
params: {
since: 7.days.ago.iso8601
}
)
assert_equal [ "new_project" ], query.project_names
end
test "project details supports numeric default start values" do
in_range_time = 3.days.ago.to_f
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
query = ProjectStatsQuery.new(
user: @user,
params: {},
default_discovery_start: 0,
default_stats_start: 0
)
project = query.project_details(names: [ "alpha" ]).first
assert_equal "alpha", project[:name]
assert_equal Time.at(in_range_time).utc.strftime("%Y-%m-%dT%H:%M:%SZ"), project[:most_recent_heartbeat]
end
test "project names excludes archived projects unless include_archived is true" do
create_heartbeat(project: "active_project", time: 2.days.ago.to_f)
create_heartbeat(project: "archived_project", time: 2.days.ago.to_f)
archived_mapping = ProjectRepoMapping.create!(user: @user, project_name: "archived_project")
archived_mapping.archive!
excluded = ProjectStatsQuery.new(user: @user, params: {})
included = ProjectStatsQuery.new(user: @user, params: {}, include_archived: true)
assert_equal [ "active_project" ], excluded.project_names
assert_equal [ "active_project", "archived_project" ], included.project_names.sort
end
test "project details uses projects csv params when explicit names are not provided" do
create_heartbeat(project: "alpha", language: "Ruby", time: 2.days.ago.to_f)
create_heartbeat(project: "beta", language: "Go", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(
user: @user,
params: {
projects: " alpha, beta,alpha, ,"
},
default_discovery_start: 0,
default_stats_start: 0
)
project_names = query.project_details.map { |project| project[:name] }
assert_equal [ "alpha", "beta" ], project_names.sort
end
test "project details normalizes explicit names list" do
create_heartbeat(project: "alpha", language: "Ruby", time: 2.days.ago.to_f)
create_heartbeat(project: "beta", language: "Ruby", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
details = query.project_details(names: [ " alpha ", "alpha", "", nil, :beta ])
names = details.map { |project| project[:name] }
assert_equal [ "alpha", "beta" ], names.sort
end
test "project details computes total_seconds and returns heartbeat metadata" do
base = 5.days.ago.to_f
create_heartbeat(project: "alpha", language: "Ruby", time: base)
create_heartbeat(project: "alpha", language: "Ruby", time: base + 30)
create_heartbeat(project: "alpha", language: "TypeScript", time: base + 90)
create_heartbeat(project: "alpha", language: nil, time: base + 120)
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
project = query.project_details(names: [ "alpha" ]).first
assert_equal 120, project[:total_seconds]
assert_equal 4, project[:total_heartbeats]
assert_equal [ "Ruby", "TypeScript" ], project[:languages].sort
assert_equal formatted_time(base), project[:first_heartbeat]
assert_equal formatted_time(base + 120), project[:last_heartbeat]
assert_equal project[:last_heartbeat], project[:most_recent_heartbeat]
end
test "project details sorts projects by total_seconds descending" do
base = 4.days.ago.to_f
create_heartbeat(project: "alpha", time: base)
create_heartbeat(project: "alpha", time: base + 90)
create_heartbeat(project: "beta", time: base)
create_heartbeat(project: "beta", time: base + 20)
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
details = query.project_details(names: [ "alpha", "beta" ])
assert_equal [ "alpha", "beta" ], details.map { |project| project[:name] }
end
test "project details ignores out-of-range timestamps from with_valid_timestamps scope" do
valid_time = 2.days.ago.to_f
create_heartbeat(project: "alpha", language: "Ruby", time: valid_time)
create_heartbeat(project: "alpha", language: "Ruby", time: -1)
create_heartbeat(project: "alpha", language: "Ruby", time: 253402300800)
query = ProjectStatsQuery.new(user: @user, params: {}, default_discovery_start: 0, default_stats_start: 0)
project = query.project_details(names: [ "alpha" ]).first
assert_equal 1, project[:total_heartbeats]
assert_equal 0, project[:total_seconds]
assert_equal formatted_time(valid_time), project[:first_heartbeat]
assert_equal formatted_time(valid_time), project[:last_heartbeat]
end
test "project names supports until_date alias" do
create_heartbeat(project: "older_project", time: 20.days.ago.to_f)
create_heartbeat(project: "newer_project", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(
user: @user,
params: {
since: 30.days.ago.iso8601,
until_date: 7.days.ago.iso8601
}
)
assert_equal [ "older_project" ], query.project_names
end
test "invalid date params fall back to provided defaults for project details" do
in_range_time = 3.days.ago.to_f
out_of_range_time = 20.days.ago.to_f
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
create_heartbeat(project: "alpha", language: "Ruby", time: out_of_range_time)
query = ProjectStatsQuery.new(
user: @user,
params: {
start: "not-a-date",
end: "still-not-a-date"
},
default_stats_start: 7.days.ago,
default_stats_end: 1.day.ago
)
project = query.project_details(names: [ "alpha" ]).first
assert_equal 1, project[:total_heartbeats]
assert_equal formatted_time(in_range_time), project[:most_recent_heartbeat]
end
test "invalid date params fall back to provided defaults for project names" do
create_heartbeat(project: "old_project", time: 20.days.ago.to_f)
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(
user: @user,
params: {
since: "not-a-date",
until: "still-not-a-date"
},
default_discovery_start: 7.days.ago,
default_discovery_end: 1.day.ago
)
assert_equal [ "new_project" ], query.project_names
end
test "project discovery uses since over start when both are provided" do
create_heartbeat(project: "old_project", time: 10.days.ago.to_f)
create_heartbeat(project: "new_project", time: 2.days.ago.to_f)
query = ProjectStatsQuery.new(
user: @user,
params: {
since: 3.days.ago.iso8601,
start: 30.days.ago.iso8601
}
)
assert_equal [ "new_project" ], query.project_names
end
test "project details supports start_date and end_date aliases" do
in_range_time = 3.days.ago.to_f
out_of_range_time = 15.days.ago.to_f
create_heartbeat(project: "alpha", language: "Ruby", time: in_range_time)
create_heartbeat(project: "alpha", language: "Ruby", time: out_of_range_time)
query = ProjectStatsQuery.new(
user: @user,
params: {
start_date: 7.days.ago.iso8601,
end_date: 1.day.ago.iso8601
}
)
project = query.project_details(names: [ "alpha" ]).first
assert_equal 1, project[:total_heartbeats]
assert_equal formatted_time(in_range_time), project[:most_recent_heartbeat]
end
test "project details hides archived projects unless include_archived is true" do
create_heartbeat(project: "archived_project", language: "Ruby", time: 2.days.ago.to_f)
mapping = ProjectRepoMapping.create!(user: @user, project_name: "archived_project")
@ -66,4 +278,8 @@ class ProjectStatsQueryTest < ActiveSupport::TestCase
time: time
)
end
def formatted_time(time_value)
Time.at(time_value).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
end
end