mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
898 lines
30 KiB
Svelte
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>
|