fixed checkboxes being slow

This commit is contained in:
masongga
2026-05-23 19:58:58 -06:00
parent 8d5ffc6fed
commit 6f5508d24e

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -42,9 +43,15 @@ const reasonLabels = {
function DailyTaskList({ function DailyTaskList({
tasks, tasks,
getIsCompleted,
isPending,
getToggleError,
onToggle, onToggle,
}: { }: {
tasks: DailyTask[]; tasks: DailyTask[];
getIsCompleted: (habit: DailyTask) => boolean;
isPending: (habitId: string) => boolean;
getToggleError: (habitId: string) => string | null;
onToggle: (habitId: string) => void; onToggle: (habitId: string) => void;
}) { }) {
if (tasks.length === 0) { if (tasks.length === 0) {
@@ -57,8 +64,8 @@ function DailyTaskList({
} }
const sortedTasks = [...tasks].sort((a, b) => { const sortedTasks = [...tasks].sort((a, b) => {
const aCompleted = isHabitCompletedToday(a); const aCompleted = getIsCompleted(a);
const bCompleted = isHabitCompletedToday(b); const bCompleted = getIsCompleted(b);
if (aCompleted === bCompleted) { if (aCompleted === bCompleted) {
return 0; return 0;
@@ -70,7 +77,9 @@ function DailyTaskList({
return ( return (
<> <>
{sortedTasks.map((habit) => { {sortedTasks.map((habit) => {
const isCompleted = isHabitCompletedToday(habit); const isCompleted = getIsCompleted(habit);
const pending = isPending(habit.id);
const toggleError = getToggleError(habit.id);
return ( return (
<div <div
@@ -80,12 +89,23 @@ function DailyTaskList({
}`} }`}
> >
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex items-center gap-2">
<Checkbox <Checkbox
id={habit.id} id={habit.id}
checked={isCompleted} checked={isCompleted}
disabled={pending}
onCheckedChange={() => onToggle(habit.id)} onCheckedChange={() => onToggle(habit.id)}
className="mt-0.5"
/> />
{pending ? (
<>
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin text-muted-foreground"
/>
<span className="sr-only">Saving habit completion</span>
</>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<Label htmlFor={habit.id} className="leading-snug"> <Label htmlFor={habit.id} className="leading-snug">
{habit.label} {habit.label}
@@ -96,6 +116,9 @@ function DailyTaskList({
> >
{reasonLabels[habit.reason]} {reasonLabels[habit.reason]}
</Badge> </Badge>
{toggleError ? (
<p className="text-xs text-red-500">{toggleError}</p>
) : null}
</div> </div>
</div> </div>
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
@@ -157,6 +180,34 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [optimisticCompletion, setOptimisticCompletion] = useState<
Record<string, boolean>
>({});
const [pendingHabitIds, setPendingHabitIds] = useState<Set<string>>(
() => new Set(),
);
const [toggleErrors, setToggleErrors] = useState<Record<string, string>>({});
const getIsCompleted = useCallback(
(habit: DailyTask) => {
if (habit.id in optimisticCompletion) {
return optimisticCompletion[habit.id];
}
return isHabitCompletedToday(habit);
},
[optimisticCompletion],
);
const isPending = useCallback(
(habitId: string) => pendingHabitIds.has(habitId),
[pendingHabitIds],
);
const getToggleError = useCallback(
(habitId: string) => toggleErrors[habitId] ?? null,
[toggleErrors],
);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -189,20 +240,62 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
}; };
}, [refresh]); }, [refresh]);
const handleToggle = async (habitId: string) => { const handleToggle = (habitId: string) => {
const habit = dailyTasks.find((task) => task.id === habitId);
if (!habit || pendingHabitIds.has(habitId)) {
return;
}
const nextCompleted = !getIsCompleted(habit);
setOptimisticCompletion((current) => ({
...current,
[habitId]: nextCompleted,
}));
setPendingHabitIds((current) => new Set(current).add(habitId));
setToggleErrors((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
void (async () => {
try { try {
setIsSaving(true); const updatedHabits = await toggleHabitCompletion(habitId);
setError(null);
await toggleHabitCompletion(habitId); setDailyTasks((current) =>
current.map((task) => {
const updated = updatedHabits.find((item) => item.id === task.id);
return updated ? { ...task, ...updated } : task;
}),
);
setOptimisticCompletion((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
} catch (toggleError) { } catch (toggleError) {
setError( setOptimisticCompletion((current) => {
const next = { ...current };
delete next[habitId];
return next;
});
setToggleErrors((current) => ({
...current,
[habitId]:
toggleError instanceof Error toggleError instanceof Error
? toggleError.message ? toggleError.message
: "Could not update habit.", : "Could not update habit.",
); }));
} finally { } finally {
setIsSaving(false); setPendingHabitIds((current) => {
const next = new Set(current);
next.delete(habitId);
return next;
});
} }
})();
}; };
const handleAddHabit = async () => { const handleAddHabit = async () => {
@@ -323,7 +416,13 @@ export function HabitTracker({ mode = "daily" }: HabitTrackerProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error ? <p className="text-sm text-red-500">{error}</p> : null} {error ? <p className="text-sm text-red-500">{error}</p> : null}
<DailyTaskList tasks={dailyTasks} onToggle={handleToggle} /> <DailyTaskList
tasks={dailyTasks}
getIsCompleted={getIsCompleted}
isPending={isPending}
getToggleError={getToggleError}
onToggle={handleToggle}
/>
</CardContent> </CardContent>
</Card> </Card>
); );