191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
|
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useAuth } from 'hooks/useAuth';
|
|
import { Plus, Trash2, Flame } from 'lucide-react';
|
|
import { Widget } from '../Widget';
|
|
|
|
type Goal = {
|
|
id: string;
|
|
text: string;
|
|
done: boolean;
|
|
};
|
|
|
|
type DailyGoalsState = {
|
|
goals: Goal[];
|
|
lastDate: string;
|
|
streak: number;
|
|
lastCompletedDate: string | null;
|
|
};
|
|
|
|
const USER_STATE_KEY = ['USER_STATE'];
|
|
const GOALS_STATE_KEY = 'daily-goals';
|
|
|
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
|
const yesterdayStr = () => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - 1);
|
|
return d.toISOString().slice(0, 10);
|
|
};
|
|
|
|
function useDailyGoalsState() {
|
|
const client = useClient();
|
|
const { isAuthenticated } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
|
queryKey: USER_STATE_KEY,
|
|
enabled: isAuthenticated,
|
|
queryFn: () => client.get('/user/state'),
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
const stored = userState[GOALS_STATE_KEY] as DailyGoalsState | undefined;
|
|
const today = todayStr();
|
|
|
|
const state: DailyGoalsState = (() => {
|
|
if (!stored) return { goals: [], lastDate: today, streak: 0, lastCompletedDate: null };
|
|
if (stored.lastDate === today) return stored;
|
|
|
|
// New day — check if yesterday had all goals completed for streak
|
|
const allDoneYesterday = stored.goals.length > 0 && stored.goals.every((g) => g.done);
|
|
const wasYesterday = stored.lastDate === yesterdayStr();
|
|
const streak = allDoneYesterday && wasYesterday ? stored.streak + 1 : allDoneYesterday ? 1 : 0;
|
|
const lastCompletedDate = allDoneYesterday ? stored.lastDate : stored.lastCompletedDate;
|
|
|
|
return {
|
|
goals: stored.goals.map((g) => ({ ...g, done: false })),
|
|
lastDate: today,
|
|
streak,
|
|
lastCompletedDate,
|
|
};
|
|
})();
|
|
|
|
const setState = useCallback(
|
|
(update: DailyGoalsState | ((prev: DailyGoalsState) => DailyGoalsState)) => {
|
|
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
|
const current = (currentUserState[GOALS_STATE_KEY] as DailyGoalsState | undefined) ?? state;
|
|
const next = typeof update === 'function' ? update(current) : update;
|
|
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [GOALS_STATE_KEY]: next });
|
|
clientRef.current.patch('/user/state', { [GOALS_STATE_KEY]: next }).catch(() => {});
|
|
},
|
|
[queryClient, state],
|
|
);
|
|
|
|
return [state, setState] as const;
|
|
}
|
|
|
|
let nextGoalId = Date.now();
|
|
|
|
export const DailyGoals = () => {
|
|
const [state, setState] = useDailyGoalsState();
|
|
const [newGoal, setNewGoal] = useState('');
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const addGoal = () => {
|
|
const text = newGoal.trim();
|
|
if (!text) return;
|
|
nextGoalId++;
|
|
setState((s) => ({
|
|
...s,
|
|
goals: [...s.goals, { id: String(nextGoalId), text, done: false }],
|
|
}));
|
|
setNewGoal('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const toggleGoal = (id: string) => {
|
|
setState((s) => ({
|
|
...s,
|
|
goals: s.goals.map((g) => (g.id === id ? { ...g, done: !g.done } : g)),
|
|
}));
|
|
};
|
|
|
|
const removeGoal = (id: string) => {
|
|
setState((s) => ({
|
|
...s,
|
|
goals: s.goals.filter((g) => g.id !== id),
|
|
}));
|
|
};
|
|
|
|
const { goals, streak } = state;
|
|
const doneCount = goals.filter((g) => g.done).length;
|
|
|
|
return (
|
|
<Widget title="Daily Goals">
|
|
<div className="flex flex-col gap-2 px-4 pb-4">
|
|
{streak > 0 && (
|
|
<div className="flex items-center gap-1.5 text-xs text-warning">
|
|
<Flame size={12} />
|
|
<span>{streak} day streak</span>
|
|
</div>
|
|
)}
|
|
|
|
{goals.length > 0 && (
|
|
<div className="flex flex-col gap-1">
|
|
{goals.map((goal) => (
|
|
<div key={goal.id} className="group flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
|
|
goal.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'
|
|
}`}
|
|
onClick={() => toggleGoal(goal.id)}
|
|
>
|
|
{goal.done && (
|
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
|
<path d="M2 5l2.5 2.5L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<span className={`flex-1 text-sm ${goal.done ? 'line-through text-muted-foreground' : ''}`}>
|
|
{goal.text}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="p-0.5 rounded cursor-pointer text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100 transition-opacity"
|
|
onClick={() => removeGoal(goal.id)}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{goals.length > 0 && (
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{doneCount}/{goals.length} completed
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Add a goal..."
|
|
value={newGoal}
|
|
onChange={(ev) => setNewGoal(ev.target.value)}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter') {
|
|
ev.preventDefault();
|
|
addGoal();
|
|
}
|
|
}}
|
|
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
|
onClick={addGoal}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Widget>
|
|
);
|
|
};
|