hackatime/app/javascript/pages/Users/Settings/Data.svelte
Mahad Kalam 7317cc45e7
Imports + mirrors :DD (#993)
* Imports + mirrors :DD

* Stuff and things

* Fixes

* Fixes x2

* Tests!

* Hmm
2026-02-23 15:00:43 +00:00

898 lines
30 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,
import_source,
mirrors,
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);
let importSource = $state<DataPageProps["import_source"] | null>(null);
let backfillMode = $state<"all_time" | "date_range">("all_time");
let importStartDate = $state("");
let importEndDate = $state("");
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 },
);
const { start: startImportSourcePolling, stop: stopImportSourcePolling } =
usePoll(
10000,
{
only: ["import_source"],
preserveUrl: true,
},
{ autoStart: false },
);
onMount(() => {
csrfToken =
document
.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || "";
syncImportFromProps(heartbeat_import);
if (ui.show_imports_and_mirrors) {
importSource = import_source || null;
importStartDate = importSource?.initial_backfill_start_date || "";
importEndDate = importSource?.initial_backfill_end_date || "";
backfillMode =
importStartDate || importEndDate ? "date_range" : "all_time";
startImportSourcePolling();
}
return () => {
if (ui.show_imports_and_mirrors) {
stopImportSourcePolling();
}
};
});
$effect(() => {
syncImportFromProps(heartbeat_import);
});
$effect(() => {
if (!ui.show_imports_and_mirrors) {
importSource = null;
importStartDate = "";
importEndDate = "";
backfillMode = "all_time";
return;
}
importSource = import_source || null;
importStartDate = importSource?.initial_backfill_start_date || "";
importEndDate = importSource?.initial_backfill_end_date || "";
backfillMode = importStartDate || importEndDate ? "date_range" : "all_time";
});
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>
{#if ui.show_imports_and_mirrors}
<section
id="imports_and_mirrors"
class="rounded-md border border-surface-200 bg-darker p-4 sm:p-5"
>
<h2 class="text-xl font-semibold text-surface-content">
Imports & Mirrors
</h2>
<p class="mt-1 text-sm text-muted">
Connect WakaTime-compatible sources and destinations.
</p>
<div class="mt-4 space-y-7">
<section id="wakatime_import_source">
<h3 class="text-lg font-semibold text-surface-content">
Import from WakaTime
</h3>
{#if importSource}
<div
class="mt-3 rounded-md border border-surface-200 bg-surface p-3"
>
<p class="text-sm text-surface-content">
Status: <span class="font-semibold"
>{importSource.status}</span
>
</p>
<p class="mt-1 text-xs text-muted">
Last synced: {importSource.last_synced_ago || "Never"}
</p>
<p class="mt-1 text-xs text-muted">
Imported: {importSource.imported_count.toLocaleString()}
</p>
{#if importSource.last_error_message}
<p class="mt-1 text-xs text-red-300">
Last error: {importSource.last_error_message}
</p>
{/if}
</div>
{/if}
<form
method="post"
action={paths.heartbeat_import_source_path}
class="mt-3 space-y-3 rounded-md border border-surface-200 bg-surface p-3"
>
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
{#if importSource}
<input type="hidden" name="_method" value="patch" />
{/if}
<div>
<label
for="import_endpoint_url"
class="mb-2 block text-sm text-surface-content"
>
Endpoint URL
</label>
<input
id="import_endpoint_url"
type="url"
name="heartbeat_import_source[endpoint_url]"
required
value={importSource?.endpoint_url ||
"https://wakatime.com/api/v1"}
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<div>
<label
for="import_api_key"
class="mb-2 block text-sm text-surface-content"
>
API Key
</label>
<input
id="import_api_key"
type="password"
name="heartbeat_import_source[encrypted_api_key]"
required={!importSource}
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"
/>
{#if importSource}
<p class="mt-1 text-xs text-muted">
Leave blank to keep the existing key.
</p>
{/if}
</div>
<div class="rounded-md border border-surface-200 bg-darker p-3">
<p class="text-sm font-semibold text-surface-content">
Backfill scope
</p>
<div class="mt-2 flex flex-wrap items-center gap-4">
<label
class="inline-flex items-center gap-2 text-sm text-surface-content"
>
<input
type="radio"
name="backfill_mode"
value="all_time"
checked={backfillMode === "all_time"}
onchange={() => {
backfillMode = "all_time";
importStartDate = "";
importEndDate = "";
}}
class="peer sr-only"
/>
<span
class="h-4 w-4 rounded-full border border-surface-200 bg-surface ring-offset-2 ring-offset-surface transition peer-checked:border-primary peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40"
></span>
All time
</label>
<label
class="inline-flex items-center gap-2 text-sm text-surface-content"
>
<input
type="radio"
name="backfill_mode"
value="date_range"
checked={backfillMode === "date_range"}
onchange={() => {
backfillMode = "date_range";
}}
class="peer sr-only"
/>
<span
class="h-4 w-4 rounded-full border border-surface-200 bg-surface ring-offset-2 ring-offset-surface transition peer-checked:border-primary peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40"
></span>
Specific date range
</label>
</div>
{#if backfillMode === "all_time"}
<input
type="hidden"
name="heartbeat_import_source[initial_backfill_start_date]"
value=""
/>
<input
type="hidden"
name="heartbeat_import_source[initial_backfill_end_date]"
value=""
/>
{:else}
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label
for="import_start_date"
class="mb-2 block text-sm text-surface-content"
>
Start date
</label>
<input
id="import_start_date"
type="date"
name="heartbeat_import_source[initial_backfill_start_date]"
bind:value={importStartDate}
required
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<div>
<label
for="import_end_date"
class="mb-2 block text-sm text-surface-content"
>
End date
</label>
<input
id="import_end_date"
type="date"
name="heartbeat_import_source[initial_backfill_end_date]"
bind:value={importEndDate}
required
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
</div>
{/if}
</div>
<div class="flex flex-wrap items-center gap-3">
<input
type="hidden"
name="heartbeat_import_source[sync_enabled]"
value="0"
/>
<label
class="inline-flex items-center gap-2 text-sm text-surface-content"
>
<input
type="checkbox"
name="heartbeat_import_source[sync_enabled]"
value="1"
checked={importSource ? importSource.sync_enabled : true}
class="h-4 w-4 rounded border-surface-200 bg-surface text-primary"
/>
Continuous sync enabled
</label>
{#if importSource}
<label
class="inline-flex items-center gap-2 text-sm text-surface-content"
>
<input
type="checkbox"
name="heartbeat_import_source[rerun_backfill]"
value="1"
class="h-4 w-4 rounded border-surface-200 bg-surface text-primary"
/>
Re-run backfill
</label>
{/if}
</div>
<div class="flex flex-wrap gap-2">
<Button type="submit" variant="primary">
{importSource ? "Update source" : "Create source"}
</Button>
</div>
</form>
{#if importSource}
<div class="mt-3 flex flex-wrap gap-2">
<form
method="post"
action={paths.heartbeat_import_source_sync_path}
>
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<Button type="submit" variant="surface">Sync now</Button>
</form>
<form
method="post"
action={paths.heartbeat_import_source_path}
onsubmit={(event) => {
if (
!window.confirm("Remove import source configuration?")
) {
event.preventDefault();
}
}}
>
<input type="hidden" name="_method" value="delete" />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<Button type="submit" variant="surface">Remove source</Button>
</form>
</div>
{/if}
</section>
<section id="wakatime_mirror">
<h3 class="text-lg font-semibold text-surface-content">
Mirror to WakaTime
</h3>
{#if mirrors.length > 0}
<div class="mt-3 space-y-2">
{#each mirrors as mirror}
<div
class="rounded-md border border-surface-200 bg-surface p-3"
>
<p class="text-sm font-semibold text-surface-content">
{mirror.endpoint_url}
</p>
<p class="mt-1 text-xs text-muted">
Status: {mirror.enabled ? "enabled" : "paused"}
</p>
<p class="mt-1 text-xs text-muted">
Last synced: {mirror.last_synced_ago}
</p>
{#if mirror.last_error_message}
<p class="mt-1 text-xs text-red-300">
Last error: {mirror.last_error_message}
</p>
{/if}
{#if mirror.consecutive_failures && mirror.consecutive_failures > 0}
<p class="mt-1 text-xs text-muted">
Consecutive failures: {mirror.consecutive_failures}
</p>
{/if}
<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">
Delete mirror
</Button>
</form>
</div>
{/each}
</div>
{/if}
<form
method="post"
action={paths.user_wakatime_mirrors_path}
class="mt-3 space-y-3 rounded-md border border-surface-200 bg-surface p-3"
>
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<div>
<label
for="mirror_endpoint_url"
class="mb-2 block text-sm text-surface-content"
>
Endpoint URL
</label>
<input
id="mirror_endpoint_url"
type="url"
name="wakatime_mirror[endpoint_url]"
value="https://wakatime.com/api/v1"
required
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<div>
<label
for="mirror_key"
class="mb-2 block text-sm text-surface-content"
>
WakaTime API Key
</label>
<input
id="mirror_key"
type="password"
name="wakatime_mirror[encrypted_api_key]"
required
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
</div>
<Button type="submit" variant="primary">Add mirror</Button>
</form>
</section>
</div>
</section>
{/if}
<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>