Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
242 changes: 124 additions & 118 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 @@ -12,13 +13,18 @@ interface Goal {
export default function GoalTracker() {
const [goals, setGoals] = useState<Goal[]>([]);
const [loading, setLoading] = useState(true);

const [label, setLabel] = useState("");
const [target, setTarget] = useState(7);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);

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,12 +33,13 @@ export default function GoalTracker() {

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

async function handleCreate(e: React.FormEvent) {
e.preventDefault();

setCreating(true);
setCreateError(null);

Expand All @@ -44,7 +51,7 @@ export default function GoalTracker() {
});

if (!response.ok) {
throw new Error("Failed to create goal");
throw new Error();
}
} catch {
setCreateError("Failed to create goal. Please try again.");
Expand All @@ -54,19 +61,26 @@ export default function GoalTracker() {

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

await loadGoals().catch(() => { });

setCreating(false);
}

async function handleDelete(id: string) {
const previousGoals = goals;

setGoals((prev) => prev.filter((g) => g.id !== id));

setConfirmingId(null);
setDeletingId(id);

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

if (!response.ok) {
setGoals(previousGoals);
}
} catch {
Expand All @@ -76,149 +90,141 @@ export default function GoalTracker() {
}
}

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]);

if (loading) {
return (
<div className="h-full rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="mb-4 h-5 w-32 rounded bg-[var(--card-muted)] animate-pulse" />
{[1, 2, 3].map((i) => (
<div key={i} className="mb-4">
<div className="h-4 bg-[var(--card-muted)] rounded animate-pulse mb-2" />
<div className="h-2 bg-[var(--card-muted)] rounded animate-pulse" />
</div>
))}
</div>
);
}

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}
</span>

{isConfirming ? (
<span className="flex items-center gap-1 text-xs">
<span className="text-[var(--muted-foreground)]">Delete?</span>
<>
{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>{goal.label}</span>

<div className="flex items-center gap-2">
<span>
{goal.current}/{goal.target}
</span>

{isConfirming ? (
<span className="flex items-center gap-1 text-xs">
<span>Delete?</span>

<button
onClick={() => handleDelete(goal.id)}
disabled={isDeleting}
>
Yes
</button>

<span>/</span>

<button
onClick={() => setConfirmingId(null)}
>
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"
aria-label={`Delete goal: ${goal.label}`}
>
No
🗑️
</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>
<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"
>
<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
type="submit"
disabled={creating || !label.trim()}
>
{creating ? "Creating..." : "Add goal"}
</button>

{createError && (
<p>{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>
);
}