Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 176 additions & 121 deletions src/components/GoalTracker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import Toast from "./Toast";

interface Goal {
id: string;
Expand All @@ -19,6 +20,9 @@ export default function GoalTracker() {
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);

const [toastMessage, setToastMessage] = useState("");
const [notifiedGoals, setNotifiedGoals] = useState<string[]>([]);

const loadGoals = useCallback(async () => {
const response = await fetch("/api/goals");
const data: { goals: Goal[] } = await response.json();
Expand All @@ -27,10 +31,22 @@ export default function GoalTracker() {

useEffect(() => {
loadGoals()
.catch(() => {})
.catch(() => { })
.finally(() => setLoading(false));
}, [loadGoals]);

useEffect(() => {
goals.forEach((goal) => {
const isComplete = goal.current >= goal.target;
const alreadyNotified = notifiedGoals.includes(goal.id);

if (isComplete && !alreadyNotified) {
setToastMessage(`🎉 Goal complete: ${goal.label}!`);
setNotifiedGoals((prev) => [...prev, goal.id]);
}
});
}, [goals, notifiedGoals]);

async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
Expand All @@ -54,7 +70,7 @@ export default function GoalTracker() {

setLabel("");
setTarget(7);
await loadGoals().catch(() => {});
await loadGoals().catch(() => { });
setCreating(false);
}

Expand All @@ -66,6 +82,7 @@ export default function GoalTracker() {

try {
const res = await fetch(`/api/goals/${id}`, { method: "DELETE" });

if (!res.ok) {
setGoals(previousGoals);
}
Expand All @@ -91,134 +108,172 @@ export default function GoalTracker() {
}

return (
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">Weekly Goals</h2>
{goals.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
No goals yet. Create one below.
</p>
) : (
<ul className="space-y-4">
{goals.map((goal) => {
const pct = Math.min((goal.current / goal.target) * 100, 100);
const isConfirming = confirmingId === goal.id;
const isDeleting = deletingId === goal.id;

return (
<li key={goal.id}>
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-[var(--card-foreground)]">{goal.label}</span>

<div className="flex items-center gap-2">
<span className="text-[var(--muted-foreground)]">
{goal.current}/{goal.target}
<>
{toastMessage && (
<Toast
message={toastMessage}
onClose={() => setToastMessage("")}
/>
)}

<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">
Weekly Goals
</h2>

{goals.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
No goals yet. Create one below.
</p>
) : (
<ul className="space-y-4">
{goals.map((goal) => {
const pct = Math.min((goal.current / goal.target) * 100, 100);
const isConfirming = confirmingId === goal.id;
const isDeleting = deletingId === goal.id;

return (
<li key={goal.id}>
<div className="flex justify-between items-center text-sm mb-1">
<span className="text-[var(--card-foreground)]">
{goal.label}
</span>

{isConfirming ? (
<span className="flex items-center gap-1 text-xs">
<span className="text-[var(--muted-foreground)]">Delete?</span>
<div className="flex items-center gap-2">
<span className="text-[var(--muted-foreground)]">
{goal.current}/{goal.target}
</span>

{isConfirming ? (
<span className="flex items-center gap-1 text-xs">
<span className="text-[var(--muted-foreground)]">
Delete?
</span>

<button
onClick={() => handleDelete(goal.id)}
disabled={isDeleting}
className="text-red-400 hover:text-red-300 font-semibold transition-colors disabled:opacity-50"
aria-label={`Confirm delete goal: ${goal.label}`}
>
Yes
</button>

<span className="text-[var(--muted-foreground)]">
/
</span>

<button
onClick={() => setConfirmingId(null)}
className="text-[var(--muted-foreground)] hover:text-[var(--card-foreground)] transition-colors"
aria-label="Cancel delete"
>
No
</button>
</span>
) : (
<button
onClick={() => handleDelete(goal.id)}
onClick={() => setConfirmingId(goal.id)}
disabled={isDeleting}
className="text-red-400 hover:text-red-300 font-semibold transition-colors disabled:opacity-50"
aria-label={`Confirm delete goal: ${goal.label}`}
>
Yes
</button>
<span className="text-[var(--muted-foreground)]">/</span>
<button
onClick={() => setConfirmingId(null)}
className="text-[var(--muted-foreground)] hover:text-[var(--card-foreground)] transition-colors"
aria-label="Cancel delete"
className="text-[var(--muted-foreground)] hover:text-red-400 transition-colors disabled:opacity-50"
aria-label={`Delete goal: ${goal.label}`}
title="Delete goal"
>
No
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
) : (
<button
onClick={() => setConfirmingId(goal.id)}
disabled={isDeleting}
className="text-[var(--muted-foreground)] hover:text-red-400 transition-colors disabled:opacity-50"
aria-label={`Delete goal: ${goal.label}`}
title="Delete goal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-4 h-4"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
clipRule="evenodd"
/>
</svg>
</button>
)}
)}
</div>
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--control)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}

<form onSubmit={handleCreate} className="mt-6 space-y-3 border-t border-[var(--border)] pt-4">
<div>
<label htmlFor="goal-label" className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
Goal label
</label>
<input
id="goal-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Commit every day"
required
disabled={creating}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)]"
/>
</div>
<div>
<label htmlFor="goal-target" className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
Weekly target
</label>
<input
id="goal-target"
type="number"
min={1}
value={target}
onChange={(e) => setTarget(Number(e.target.value))}
disabled={creating}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition focus:border-[var(--accent)]"
/>
</div>
<button
type="submit"
disabled={creating || !label.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
<div className="h-2 overflow-hidden rounded-full bg-[var(--control)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}

<form
onSubmit={handleCreate}
className="mt-6 space-y-3 border-t border-[var(--border)] pt-4"
>
{creating ? (
<>
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Creating...
</>
) : (
"Add goal"
<div>
<label
htmlFor="goal-label"
className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]"
>
Goal label
</label>

<input
id="goal-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Commit every day"
required
disabled={creating}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)]"
/>
</div>

<div>
<label
htmlFor="goal-target"
className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]"
>
Weekly target
</label>

<input
id="goal-target"
type="number"
min={1}
value={target}
onChange={(e) => setTarget(Number(e.target.value))}
disabled={creating}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition focus:border-[var(--accent)]"
/>
</div>

<button
type="submit"
disabled={creating || !label.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{creating ? (
<>
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Creating...
</>
) : (
"Add goal"
)}
</button>

{createError && (
<p className="text-sm text-red-500">
{createError}
</p>
)}
</button>
{createError && (
<p className="text-sm text-red-500">{createError}</p>
)}
</form>
</div>
</form>
</div>
</>
);
}
31 changes: 31 additions & 0 deletions src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { useEffect, useRef } from "react";

interface ToastProps {
message: string;
onClose: () => void;
}

export default function Toast({
message,
onClose,
}: ToastProps) {
const onCloseRef = useRef(onClose);

useEffect(() => {
const timer = setTimeout(() => {
onCloseRef.current();
}, 3000);

return () => clearTimeout(timer);
}, []);

return (
<div className="fixed top-6 left-1/2 -translate-x-1/2 z-50 rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-3 shadow-lg">
<p className="text-sm font-medium text-[var(--card-foreground)]">
{message}
</p>
</div>
);
}