375 lines
16 KiB
TypeScript
375 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { createRoute } from "@tanstack/react-router";
|
|
import { Route as appRoute } from "../_app";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
|
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { cn } from "@/lib/utils";
|
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
|
import type { DailyNote } from "@/lib/types";
|
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
|
|
|
// ─── Calendar Sidebar ────────────────────────────────────────────────────
|
|
|
|
function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; onSelectDate: (d: Date) => void }) {
|
|
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
|
|
|
|
const days = eachDayOfInterval({ start: startOfMonth(currentMonth), end: endOfMonth(currentMonth) });
|
|
const startDay = getDay(days[0]);
|
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
|
|
// Check which dates have notes
|
|
const activeDomainId = useApiDomain();
|
|
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list", activeDomainId], "/daily-notes" + (activeDomainId ? "?domain=" + activeDomainId : ""));
|
|
const notes = data?.items || [];
|
|
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
|
|
// Slicing off the time portion yields the calendar date the note belongs to
|
|
// regardless of the browser's timezone. parseISO + format would re-render the
|
|
// UTC instant in the local zone and shift the marker to the previous day for
|
|
// users west of UTC.
|
|
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
|
|
|
return (
|
|
<div className="w-64 shrink-0">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-sm font-semibold">{format(currentMonth, "MMMM yyyy")}</span>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-0.5 text-center">
|
|
{dayNames.map((d) => (
|
|
<div key={d} className="text-[10px] text-muted-foreground font-medium py-1">{d}</div>
|
|
))}
|
|
{Array.from({ length: startDay }).map((_, i) => (
|
|
<div key={"empty-" + i} />
|
|
))}
|
|
{days.map((d) => {
|
|
const key = format(d, "yyyy-MM-dd");
|
|
const hasNote = noteDates.has(key);
|
|
const isSelected = isSameDay(d, selectedDate);
|
|
const today = isToday(d);
|
|
return (
|
|
<button
|
|
key={key}
|
|
onClick={() => onSelectDate(d)}
|
|
className={cn(
|
|
"h-8 w-8 rounded-full text-xs flex items-center justify-center transition-colors relative",
|
|
isSelected ? "bg-primary text-primary-foreground" : today ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted",
|
|
)}
|
|
>
|
|
{format(d, "d")}
|
|
{hasNote && !isSelected && (
|
|
<div className="absolute bottom-0.5 w-1 h-1 rounded-full bg-primary" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-3">
|
|
<Button variant="outline" size="sm" className="w-full" onClick={() => onSelectDate(new Date())}>
|
|
<Calendar className="h-3.5 w-3.5 mr-2" />Today
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Daily Note Editor ────────────────────────────────────────────────────
|
|
|
|
function DailyNoteEditor({ date }: { date: Date }) {
|
|
const queryClient = useQueryClient();
|
|
const activeDomainId = useApiDomain();
|
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
|
const dateStr = format(date, "yyyy-MM-dd");
|
|
const { data: tasksData } = useApiQuery<{ items: Array<{ id:string; title:string; status:string; dueDate:string|null }> }>(["tasks-daily", dateStr, activeDomainId], "/tasks?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
|
const { data: habitsData } = useApiQuery<{ items: Array<{ id:string; name:string }> }>(["habits-daily", activeDomainId], "/habits?limit=50" + (activeDomainId ? "&domain=" + activeDomainId : ""));
|
|
const tasksDue = (tasksData?.items || []).filter(t => t.dueDate && t.dueDate.slice(0,10) === dateStr && t.status !== "done");
|
|
const habitsToday = habitsData?.items || [];
|
|
const insertTemplate = () => {
|
|
const tpl = `# ${format(date, "EEEE, MMM d")}\n\n## Tasks Due Today\n${tasksDue.length ? tasksDue.map(t => `- [ ] ${t.title}`).join("\n") : "- No tasks due"}\n\n## Habits\n${habitsToday.slice(0,5).map(h => `- [ ] ${h.name}`).join("\n") || "- No habits"}\n\n## Notes\n`;
|
|
setContent(tpl);
|
|
autoSave(tpl, mood, energy);
|
|
};
|
|
const [content, setContent] = useState("");
|
|
const [mood, setMood] = useState<number | null>(null);
|
|
const [energy, setEnergy] = useState<number | null>(null);
|
|
const [noteId, setNoteId] = useState<string | null>(null);
|
|
const [isNew, setIsNew] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const noteIdRef = useRef<string | null>(null);
|
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const prevDateStrRef = useRef(dateStr);
|
|
|
|
// Mirror noteId into a ref so a pending autosave timer can always read the
|
|
// latest id. Without this, a timer scheduled while no note existed yet would
|
|
// fire with a stale null and double-create the note once createMutation
|
|
// resolves (noteId is set asynchronously in onSuccess).
|
|
noteIdRef.current = noteId;
|
|
|
|
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
|
["daily-note", dateStr, activeDomainId],
|
|
"/daily-notes?date=" + dateStr + domainSuffix
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Switching days must cancel any pending autosave so it can't fire against
|
|
// the newly loaded note (or with the previous day's closure state). The
|
|
// guard on prevDateStrRef keeps refetches of the same day from wiping a
|
|
// debounce that is still in flight.
|
|
if (prevDateStrRef.current !== dateStr) {
|
|
prevDateStrRef.current = dateStr;
|
|
if (saveTimerRef.current) {
|
|
clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = null;
|
|
}
|
|
}
|
|
if (note) {
|
|
setContent(note.content || "");
|
|
setMood(note.mood);
|
|
setEnergy(note.energy);
|
|
setNoteId(note.id);
|
|
setIsNew(false);
|
|
} else if (!isLoading) {
|
|
setContent("");
|
|
setMood(null);
|
|
setEnergy(null);
|
|
setNoteId(null);
|
|
setIsNew(true);
|
|
}
|
|
}, [note, isLoading, dateStr]);
|
|
|
|
// Clear any pending autosave when the editor unmounts so a stale timer can't
|
|
// fire after navigation away from the page.
|
|
useEffect(() => {
|
|
return () => {
|
|
if (saveTimerRef.current) {
|
|
clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
|
|
onSuccess: (saved) => {
|
|
setNoteId(saved.id);
|
|
setIsNew(false);
|
|
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
|
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DailyNote>("/daily-notes/" + id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
|
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
|
},
|
|
});
|
|
|
|
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
|
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = setTimeout(() => {
|
|
saveTimerRef.current = null;
|
|
const id = noteIdRef.current;
|
|
if (id) {
|
|
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
|
} else if (newContent.trim()) {
|
|
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
|
}
|
|
}, 1500);
|
|
}, [dateStr, activeDomainId, updateMutation, createMutation]);
|
|
|
|
const handleContentChange = (value: string) => {
|
|
setContent(value);
|
|
autoSave(value, mood, energy);
|
|
};
|
|
|
|
const handleMoodChange = (value: number) => {
|
|
setMood(value);
|
|
if (noteId) {
|
|
updateMutation.mutate({ id: noteId, data: { mood: value } });
|
|
} else if (isNew && !createMutation.isPending) {
|
|
// No note exists for this day yet — create it so the mood is recorded
|
|
// even before any content is typed.
|
|
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
|
}
|
|
};
|
|
|
|
const handleEnergyChange = (value: number) => {
|
|
setEnergy(value);
|
|
if (noteId) {
|
|
updateMutation.mutate({ id: noteId, data: { energy: value } });
|
|
} else if (isNew && !createMutation.isPending) {
|
|
// No note exists for this day yet — create it so the energy is recorded
|
|
// even before any content is typed.
|
|
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value, ...(activeDomainId ? { domain: activeDomainId } : {}) });
|
|
}
|
|
};
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (id: string) => {
|
|
try {
|
|
await api.delete("/daily-notes/" + id);
|
|
} catch (error) {
|
|
// The API responds 204 No Content, which has no JSON body, so api.delete
|
|
// (which resolves res.json()) rejects with a SyntaxError on the empty
|
|
// body even though the server-side delete succeeded. Re-throw anything
|
|
// else (real HTTP/network failures).
|
|
if (!(error instanceof SyntaxError)) throw error;
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
setContent("");
|
|
setMood(null);
|
|
setEnergy(null);
|
|
setNoteId(null);
|
|
setIsNew(true);
|
|
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
|
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
|
},
|
|
});
|
|
|
|
const handleDelete = () => {
|
|
if (noteId) deleteMutation.mutate(noteId);
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
|
<div className="flex items-center gap-2">
|
|
{noteId && (
|
|
<>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
<Save className="h-3 w-3 mr-1" />Saved
|
|
</Badge>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Daily Note</AlertDialogTitle>
|
|
<AlertDialogDescription>Are you sure you want to delete this daily note? This cannot be undone.</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mood & Energy */}
|
|
<div className="flex gap-6">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
|
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
|
<button
|
|
key={v}
|
|
role="radio"
|
|
aria-checked={mood === v}
|
|
aria-label={`Mood ${v}`}
|
|
onClick={() => handleMoodChange(v)}
|
|
className={cn(
|
|
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
|
mood === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
|
)}
|
|
>
|
|
{v}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
|
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
|
<button
|
|
key={v}
|
|
role="radio"
|
|
aria-checked={energy === v}
|
|
aria-label={`Energy ${v}`}
|
|
onClick={() => handleEnergyChange(v)}
|
|
className={cn(
|
|
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
|
energy === v ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/70 text-muted-foreground"
|
|
)}
|
|
>
|
|
{v}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Editor */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>
|
|
) : isNew && !content ? (
|
|
<div className="text-center py-8 space-y-4">
|
|
<p className="text-muted-foreground">No note for this day</p>
|
|
{tasksDue.length > 0 && <p className="text-xs text-muted-foreground">{tasksDue.length} tasks due · {habitsToday.length} habits</p>}
|
|
<div className="flex gap-2 justify-center">
|
|
<Button variant="outline" onClick={() => textareaRef.current?.focus()}>
|
|
<Plus className="h-4 w-4 mr-2" />Start Writing
|
|
</Button>
|
|
<Button variant="secondary" onClick={insertTemplate}>Insert Daily Template</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={content}
|
|
onChange={(e) => handleContentChange(e.target.value)}
|
|
placeholder="Write your daily note here..."
|
|
className="w-full min-h-[300px] bg-transparent border-none outline-none resize-none text-base leading-relaxed placeholder:text-muted-foreground/50"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Daily Notes Page ────────────────────────────────────────────────────
|
|
|
|
function DailyNotesPage() {
|
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
|
|
|
return (
|
|
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
|
<CalendarSidebar selectedDate={selectedDate} onSelectDate={setSelectedDate} />
|
|
<Separator orientation="vertical" />
|
|
<ScrollArea className="flex-1 pr-4">
|
|
<DailyNoteEditor date={selectedDate} />
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Route = createRoute({
|
|
getParentRoute: () => appRoute,
|
|
path: "/daily",
|
|
component: DailyNotesPage,
|
|
});
|