Files
platform/src/workspaces/widgets/DailyGoals/index.tsx
T

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