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 (
setCurrentMonth(subMonths(currentMonth, 1))}>
{format(currentMonth, "MMMM yyyy")}
setCurrentMonth(addMonths(currentMonth, 1))}>
{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 (
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 && (
)}
);
})}
onSelectDate(new Date())}>
Today
);
}
// ─── 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) => (
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}
))}
Energy
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
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}
))}
{/* Editor */}
{isLoading ? (
Loading...
) : isNew && !content ? (
No note for this day
{tasksDue.length > 0 &&
{tasksDue.length} tasks due · {habitsToday.length} habits
}
textareaRef.current?.focus()}>
Start Writing
Insert Daily Template
) : (
);
}
// ─── Daily Notes Page ────────────────────────────────────────────────────
function DailyNotesPage() {
const [selectedDate, setSelectedDate] = useState(new Date());
return (
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "/daily",
component: DailyNotesPage,
});