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 (
{format(currentMonth, "MMMM yyyy")}
{dayNames.map((d) => (
{d}
))} {Array.from({ length: startDay }).map((_, 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 ( ); })}
); } // ─── 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(null); const [energy, setEnergy] = useState(null); const [noteId, setNoteId] = useState(null); const [isNew, setIsNew] = useState(false); const textareaRef = useRef(null); const noteIdRef = useRef(null); const saveTimerRef = useRef | 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( ["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("/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("/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 (

{format(date, "EEEE, MMMM d, yyyy")}

{noteId && ( <> Saved Delete Daily Note Are you sure you want to delete this daily note? This cannot be undone. Cancel Delete )}
{/* Mood & Energy */}

Mood

{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( ))}

Energy

{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => ( ))}
{/* Editor */} {isLoading ? (
Loading...
) : isNew && !content ? (

No note for this day

{tasksDue.length > 0 &&

{tasksDue.length} tasks due · {habitsToday.length} habits

}
) : (