Workspaces in Workspaces all around
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user