hackatime/app/javascript/pages/Users/Settings/Data.svelte
Mahad Kalam 44777ad644
Data export fix + async exports + more tests (#989)
* Fix data export + Capybara

* Continue?

* A ton of system tests :D + test cleanup

* More system tests

* Add placeholder keys for tests?

* Get rid of the double-query!

* Speed up CI Chrome setup by avoiding snap installs

* Pin CI Chrome version to reduce system test flakiness

* Stabilize integrations settings system test interaction
2026-02-21 11:28:21 +00:00

486 lines
15 KiB
Svelte

<script lang="ts">
import { usePoll, Form } 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,
page_title,
heading,
subheading,
user,
paths,
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
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
<div class="space-y-8">
<section id="user_migration_assistant">
<h2 class="text-xl font-semibold text-surface-content">
Migration Assistant
</h2>
<p class="mt-1 text-sm text-muted">
Queue migration of heartbeats and API keys from legacy Hackatime.
</p>
<form method="post" action={paths.migrate_heartbeats_path} class="mt-4">
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button type="submit" class="rounded-md" disabled={!migration.enabled}
>Start migration</Button
>
</form>
{#if !migration.enabled}
<p class="mt-2 text-xs text-muted">
Hackatime v1 import is currently disabled due to an integration issue.
We're working on reinstating imports!
</p>
{/if}
{#if migration.jobs.length > 0}
<div class="mt-4 space-y-2">
{#each migration.jobs as job}
<div
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content"
>
Job {job.id}: {job.status}
</div>
{/each}
</div>
{/if}
</section>
<section id="download_user_data">
<h2 class="text-xl font-semibold text-surface-content">Download Data</h2>
{#if data_export.is_restricted}
<p
class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200"
>
Data export is currently restricted for this account.
</p>
{:else}
<p class="mt-1 text-sm text-muted">
Download your coding history as JSON for backups or analysis.
</p>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total heartbeats
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_heartbeats}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total coding time
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_coding_time}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Last 7 days
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.heartbeats_last_7_days}
</p>
</div>
</div>
<p class="mt-2 text-sm text-muted">
Exports are generated in the background and emailed to you.
</p>
<div class="mt-4 space-y-3">
<Form method="post" action={paths.export_all_heartbeats_path}>
{#snippet children({ processing })}
<Button
type="submit"
class="rounded-md cursor-default"
disabled={processing}
>
{processing ? "Exporting..." : "Export all heartbeats"}
</Button>
{/snippet}
</Form>
<Form
method="post"
action={paths.export_range_heartbeats_path}
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
>
{#snippet children({ processing })}
<input
type="date"
name="start_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<input
type="date"
name="end_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button
type="submit"
variant="surface"
class="rounded-md"
disabled={processing}
>
{processing ? "Exporting..." : "Export date range"}
</Button>
{/snippet}
</Form>
</div>
{#if ui.show_dev_import}
<form
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
onsubmit={startImport}
>
<label
class="mb-2 block text-sm text-surface-content"
for="heartbeat_file"
>
Import heartbeats (development only)
</label>
<input
id="heartbeat_file"
type="file"
accept=".json,application/json"
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"
/>
{#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}
</section>
<section id="delete_account">
<h2 class="text-xl font-semibold text-surface-content">
Account Deletion
</h2>
{#if user.can_request_deletion}
<p class="mt-1 text-sm text-muted">
Request permanent deletion. The account enters a waiting period before
final removal.
</p>
<form
method="post"
action={paths.create_deletion_path}
class="mt-4"
onsubmit={(event) => {
if (
!window.confirm(
"Submit account deletion request? This action starts the deletion process.",
)
) {
event.preventDefault();
}
}}
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button type="submit" variant="surface" class="rounded-md">
Request deletion
</Button>
</form>
{:else}
<p
class="mt-3 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
Deletion request is unavailable for this account right now.
</p>
{/if}
</section>
</div>
</SettingsShell>