diff --git a/src/app/timeline/page.tsx b/src/app/timeline/page.tsx index 4fa1ad4..4141862 100644 --- a/src/app/timeline/page.tsx +++ b/src/app/timeline/page.tsx @@ -1,18 +1,25 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { AppShell } from "@/components/AppShell"; import { WeeklyReflection } from "@/components/WeeklyReflection"; import { FilterPanel } from "@/components/FilterPanel"; import { EntryRow } from "@/components/EntryRow"; -import { Loader2 } from "lucide-react"; +import { CalendarView } from "@/components/CalendarView"; +import { Loader2, Search, Calendar, List, X } from "lucide-react"; import type { EntryWithTags, FilterState } from "@/lib/types"; import { APP_NAME } from "@/lib/constants"; +type ViewMode = "list" | "calendar"; + export default function TimelinePage() { const [entries, setEntries] = useState([]); const [filteredEntries, setFilteredEntries] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState("list"); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedDate, setSelectedDate] = useState(null); + const [currentMonth, setCurrentMonth] = useState(new Date()); const [filters, setFilters] = useState({ moods: [], tagId: null, @@ -35,18 +42,41 @@ export default function TimelinePage() { loadEntries(); }, []); - // Apply filters client-side + // Get all dates that have entries + const entryDates = useMemo(() => { + return new Set(entries.map((e) => e.date)); + }, [entries]); + + // Apply filters and search client-side useEffect(() => { let result = entries; + // Search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (e) => + e.text.toLowerCase().includes(query) || + e.tags.some((t) => t.name.toLowerCase().includes(query)) + ); + } + + // Date filter + if (selectedDate) { + result = result.filter((e) => e.date === selectedDate); + } + + // Mood filter if (filters.moods.length > 0) { result = result.filter((e) => e.mood && filters.moods.includes(e.mood)); } + // Tag filter if (filters.tagId) { result = result.filter((e) => e.tags.some((t) => t.id === filters.tagId)); } + // Rough day filter if (filters.roughDay === true) { result = result.filter((e) => e.roughDay); } else if (filters.roughDay === false) { @@ -54,7 +84,20 @@ export default function TimelinePage() { } setFilteredEntries(result); - }, [entries, filters]); + }, [entries, filters, searchQuery, selectedDate]); + + const clearAllFilters = () => { + setSearchQuery(""); + setSelectedDate(null); + setFilters({ moods: [], tagId: null, roughDay: null }); + }; + + const hasActiveFilters = + searchQuery.trim() || + selectedDate || + filters.moods.length > 0 || + filters.tagId || + filters.roughDay !== null; return ( @@ -70,20 +113,133 @@ export default function TimelinePage() { ) : ( <> + + {/* Search and View Toggle */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search entries..." + className="w-full pl-10 pr-10 py-2 bg-surface border border-border rounded-xl text-sm focus:outline-none focus:border-muted" + /> + {searchQuery && ( + + )} +
+ + {/* View toggle and clear filters */} +
+
+ + +
+ + {hasActiveFilters && ( + + )} +
+
+ + {/* Calendar View */} + {viewMode === "calendar" && ( +
+ +
+ )} + + {/* Active filter indicator */} + {selectedDate && ( +
+ Showing entries for: + + {new Date(selectedDate + "T12:00:00").toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + })} + + +
+ )} + + {/* Results count */} + {hasActiveFilters && ( +

+ {filteredEntries.length} {filteredEntries.length === 1 ? "entry" : "entries"} found +

+ )} + {filteredEntries.length === 0 ? (
{entries.length === 0 ? (

No entries yet. Start your first check-in!

) : ( -

No entries match your filters.

+
+

No entries match your filters.

+ +
)}
) : (
{filteredEntries.map((entry) => ( - + ))}
)} diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx new file mode 100644 index 0000000..2ccb4c6 --- /dev/null +++ b/src/components/CalendarView.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useMemo } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface CalendarViewProps { + entryDates: Set; // YYYY-MM-DD format + selectedDate: string | null; + onSelectDate: (date: string | null) => void; + currentMonth: Date; + onChangeMonth: (date: Date) => void; +} + +export function CalendarView({ + entryDates, + selectedDate, + onSelectDate, + currentMonth, + onChangeMonth, +}: CalendarViewProps) { + const { days, monthLabel } = useMemo(() => { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); + + // First day of month + const firstDay = new Date(year, month, 1); + const startDayOfWeek = firstDay.getDay(); + + // Days in month + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + // Build calendar grid + const days: { date: string; day: number; isCurrentMonth: boolean; hasEntry: boolean }[] = []; + + // Previous month padding + const prevMonth = new Date(year, month, 0); + const prevMonthDays = prevMonth.getDate(); + for (let i = startDayOfWeek - 1; i >= 0; i--) { + const day = prevMonthDays - i; + const date = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) }); + } + + // Current month + for (let day = 1; day <= daysInMonth; day++) { + const date = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + days.push({ date, day, isCurrentMonth: true, hasEntry: entryDates.has(date) }); + } + + // Next month padding + const remaining = 42 - days.length; // 6 rows * 7 days + for (let day = 1; day <= remaining; day++) { + const nextMonth = new Date(year, month + 2, 0); + const date = `${nextMonth.getFullYear()}-${String(month + 2).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + days.push({ date, day, isCurrentMonth: false, hasEntry: entryDates.has(date) }); + } + + const monthLabel = firstDay.toLocaleDateString("en-US", { month: "long", year: "numeric" }); + + return { days, monthLabel }; + }, [currentMonth, entryDates]); + + const goToPrevMonth = () => { + onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)); + }; + + const goToNextMonth = () => { + onChangeMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)); + }; + + const goToToday = () => { + onChangeMonth(new Date()); + onSelectDate(null); + }; + + return ( +
+ {/* Header */} +
+ +
+ {monthLabel} + +
+ +
+ + {/* Day headers */} +
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar grid */} +
+ {days.map(({ date, day, isCurrentMonth, hasEntry }) => { + const isSelected = selectedDate === date; + const isToday = date === new Date().toISOString().split("T")[0]; + + return ( + + ); + })} +
+ + {/* Legend */} +
+
+ + Has entry +
+ {selectedDate && ( + + )} +
+
+ ); +} diff --git a/src/components/EntryForm.tsx b/src/components/EntryForm.tsx index cb7159e..16f4d37 100644 --- a/src/components/EntryForm.tsx +++ b/src/components/EntryForm.tsx @@ -99,45 +99,22 @@ export function EntryForm({ date, entryId, isNewEntry = false, onSaved }: EntryF /> - {/* Save buttons */} + {/* Save button */}
- {isNewEntry ? ( - <> - - - - ) : ( - - )} + {/* Save status */}
diff --git a/src/components/EntryRow.tsx b/src/components/EntryRow.tsx index 07bc4f8..88589de 100644 --- a/src/components/EntryRow.tsx +++ b/src/components/EntryRow.tsx @@ -8,9 +8,27 @@ import type { EntryWithTags } from "@/lib/types"; interface EntryRowProps { entry: EntryWithTags; + searchQuery?: string; } -export function EntryRow({ entry }: EntryRowProps) { +function highlightText(text: string, query: string): React.ReactNode { + if (!query.trim()) return text; + + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + const parts = text.split(regex); + + return parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); +} + +export function EntryRow({ entry, searchQuery = "" }: EntryRowProps) { // Truncate text to one line preview const preview = entry.text.length > 80 ? entry.text.slice(0, 80) + "..." : entry.text; @@ -31,10 +49,12 @@ export function EntryRow({ entry }: EntryRowProps) { ) : null}
-

{preview}

+

+ {highlightText(preview, searchQuery)} +

{entry.tags.length > 0 && (
- +
)}
diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx index 0c1ca87..bbb6576 100644 --- a/src/components/TagInput.tsx +++ b/src/components/TagInput.tsx @@ -120,9 +120,27 @@ export function TagInput({ selectedTags, onAddTag, onRemoveTag }: TagInputProps) interface TagChipsProps { tags: { id: string; name: string }[]; maxDisplay?: number; + highlightQuery?: string; } -export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) { +function highlightTagText(text: string, query: string): React.ReactNode { + if (!query.trim()) return text; + + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + const parts = text.split(regex); + + return parts.map((part, i) => + regex.test(part) ? ( + + {part} + + ) : ( + part + ) + ); +} + +export function TagChips({ tags, maxDisplay = 2, highlightQuery = "" }: TagChipsProps) { if (tags.length === 0) return null; const displayed = tags.slice(0, maxDisplay); @@ -135,7 +153,7 @@ export function TagChips({ tags, maxDisplay = 2 }: TagChipsProps) { key={tag.id} className="px-2 py-0.5 bg-surface text-muted text-xs rounded" > - {tag.name} + {highlightTagText(tag.name, highlightQuery)} ))} {remaining > 0 && (