mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
* Add goals * Fix up some migrations * Formatting * Simplify migration * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update test/controllers/settings_goals_controller_test.rb Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Fix svelte-check issues, make CI less janky on dev * svelte-check/fix tests * Fix N+1s * Formatting! * More tests, fix anonymization and N+1 --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
201 lines
6.4 KiB
Svelte
201 lines
6.4 KiB
Svelte
<script lang="ts">
|
|
import { secondsToDisplay } from "./utils";
|
|
|
|
let {
|
|
goals,
|
|
}: {
|
|
goals: {
|
|
id: string;
|
|
period: "day" | "week" | "month";
|
|
target_seconds: number;
|
|
tracked_seconds: number;
|
|
completion_percent: number;
|
|
complete: boolean;
|
|
languages: string[];
|
|
projects: string[];
|
|
period_end: string;
|
|
}[];
|
|
} = $props();
|
|
|
|
const percentWidth = (percent: number) =>
|
|
`${Math.max(0, Math.min(percent || 0, 100))}%`;
|
|
|
|
const periodLabel = (period: "day" | "week" | "month") => {
|
|
if (period === "day") return "Daily goal";
|
|
if (period === "week") return "Weekly goal";
|
|
return "Monthly goal";
|
|
};
|
|
|
|
const scopeSubtitle = (goal: { languages: string[]; projects: string[] }) => {
|
|
const languageScope =
|
|
goal.languages.length > 0
|
|
? `Languages: ${goal.languages.join(", ")}`
|
|
: "";
|
|
const projectScope =
|
|
goal.projects.length > 0 ? `Projects: ${goal.projects.join(", ")}` : "";
|
|
|
|
if (languageScope && projectScope) {
|
|
return `${languageScope} AND ${projectScope}`;
|
|
}
|
|
|
|
return languageScope || projectScope || "All programming activity";
|
|
};
|
|
|
|
function lastItemSpanClass(index: number, total: number): string {
|
|
if (index !== total - 1) return "";
|
|
const parts: string[] = [];
|
|
|
|
// 2-column grid (sm): last item fills the row if total is odd
|
|
if (total % 2 === 1) parts.push("sm:col-span-2");
|
|
|
|
// 3-column grid (lg): last item fills remaining columns
|
|
const lgRemainder = total % 3;
|
|
if (lgRemainder === 1) parts.push("lg:col-span-3");
|
|
else if (lgRemainder === 2) parts.push("lg:col-span-2");
|
|
else parts.push("lg:col-span-1"); // reset sm:col-span-2
|
|
|
|
return parts.join(" ");
|
|
}
|
|
|
|
// Arc progress ring
|
|
const RADIUS = 20;
|
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
const strokeDashoffset = (percent: number) => {
|
|
const clamped = Math.max(0, Math.min(percent || 0, 100));
|
|
return CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE;
|
|
};
|
|
|
|
const periodTimeLeft = (goal: { period_end: string; complete: boolean }) => {
|
|
if (goal.complete) return "Done!";
|
|
const now = new Date();
|
|
const end = new Date(goal.period_end);
|
|
const diffMs = end.getTime() - now.getTime();
|
|
if (diffMs <= 0) return "Period ended";
|
|
|
|
const diffHours = diffMs / (1000 * 60 * 60);
|
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffHours < 1) {
|
|
const mins = Math.ceil(diffMs / (1000 * 60));
|
|
return `${mins}m left today`;
|
|
}
|
|
if (diffHours < 24) {
|
|
return `${Math.ceil(diffHours)}h left today`;
|
|
}
|
|
return `${diffDays} day${diffDays === 1 ? "" : "s"} left`;
|
|
};
|
|
</script>
|
|
|
|
{#if goals.length > 0}
|
|
<section
|
|
class="rounded-xl border border-surface-200 bg-surface-100/30 overflow-hidden grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
|
>
|
|
{#each goals as goal, i}
|
|
<div
|
|
class="p-4 md:p-5 flex flex-col gap-4
|
|
border-b border-surface-200
|
|
last:border-b-0
|
|
{lastItemSpanClass(i, goals.length)}"
|
|
>
|
|
<div class="flex items-start justify-between gap-4">
|
|
<!-- Left: Big time display -->
|
|
<div>
|
|
<p
|
|
class="text-2xl font-bold tracking-tight {goal.complete
|
|
? 'text-success'
|
|
: 'text-surface-content'}"
|
|
>
|
|
{secondsToDisplay(goal.tracked_seconds)}<span
|
|
class="text-base font-normal text-muted"
|
|
>
|
|
/ {secondsToDisplay(goal.target_seconds)}</span
|
|
>
|
|
</p>
|
|
<p class="text-xs text-muted mt-0.5">{scopeSubtitle(goal)}</p>
|
|
</div>
|
|
|
|
<!-- Right: label + circular progress indicator -->
|
|
<div class="flex items-center gap-2.5 shrink-0">
|
|
<div class="text-right">
|
|
<p
|
|
class="text-sm font-medium {goal.complete
|
|
? 'text-success'
|
|
: 'text-muted'}"
|
|
>
|
|
{periodLabel(goal.period)}
|
|
</p>
|
|
<p
|
|
class="text-xs mt-0.5 {goal.complete
|
|
? 'text-success'
|
|
: 'text-muted'}"
|
|
>
|
|
{periodTimeLeft(goal)}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Circular progress indicator with percentage inside -->
|
|
<svg width="52" height="52" viewBox="0 0 52 52" class="shrink-0">
|
|
<!-- Background track -->
|
|
<circle
|
|
cx="26"
|
|
cy="26"
|
|
r={RADIUS}
|
|
fill="none"
|
|
stroke-width="3"
|
|
class={goal.complete
|
|
? "stroke-success/20"
|
|
: "stroke-surface-300"}
|
|
/>
|
|
<!-- Progress arc -->
|
|
<circle
|
|
cx="26"
|
|
cy="26"
|
|
r={RADIUS}
|
|
fill="none"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
class={goal.complete ? "stroke-success" : "stroke-primary"}
|
|
stroke-dasharray={CIRCUMFERENCE}
|
|
stroke-dashoffset={strokeDashoffset(goal.completion_percent)}
|
|
transform="rotate(-90 26 26)"
|
|
style="transition: stroke-dashoffset 0.5s ease-out"
|
|
/>
|
|
|
|
<!-- Percentage text or checkmark -->
|
|
{#if goal.complete}
|
|
<polyline
|
|
points="18,26 23,31 34,20"
|
|
fill="none"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="stroke-success"
|
|
/>
|
|
{:else}
|
|
<text
|
|
x="26"
|
|
y="26"
|
|
text-anchor="middle"
|
|
dominant-baseline="central"
|
|
class="fill-surface-content"
|
|
font-size="12"
|
|
font-weight="700">{Math.round(goal.completion_percent)}%</text
|
|
>
|
|
{/if}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress bar -->
|
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-surface-200">
|
|
<div
|
|
class="h-full rounded-full transition-all duration-500 ease-out {goal.complete
|
|
? 'bg-success'
|
|
: 'bg-primary'}"
|
|
style="width: {percentWidth(goal.completion_percent)}"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</section>
|
|
{/if}
|