From 613bafa5f513174d3e18152ef17e656471b8b856 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Wed, 7 Jan 2026 17:34:00 +0100 Subject: [PATCH] feat: implement functional MonthSelector with infinite scroll - Add MonthSelector dropdown with dynamic month loading - Replace text buttons with Ionicons (chevron-back/forward/down) - Add shadows and themed styling to navigation buttons - Add secondaryBg color to theme for alternating list items - Update CLAUDE.md documentation --- CLAUDE.md | 5 +- apps/client/src/Themes.tsx | 2 + apps/client/src/app/(tabs)/calendar.tsx | 234 +++++++++++++++++------- 3 files changed, 176 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f8b520..1998451 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -295,7 +295,8 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin **Frontend:** - Tab navigation (Chat, Calendar) implemented with basic UI - Calendar screen fully functional: - - Month navigation with grid display + - Month navigation with grid display and Ionicons (chevron-back/forward) + - MonthSelector dropdown with infinite scroll (dynamically loads months) - Events loaded from API via EventService.getByDateRange() - Orange dot indicator for days with events - Tap-to-open modal overlay showing EventCards for selected day @@ -309,7 +310,7 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin - `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard - `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display - `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) -- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator +- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index 36497a8..b36892d 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -2,6 +2,7 @@ type Theme = { chatBot: string; primeFg: string; primeBg: string; + secondaryBg: string; messageBorderBg: string; placeholderBg: string; calenderBg: string; @@ -20,6 +21,7 @@ const defaultLight: Theme = { chatBot: "#DE6C20", primeFg: "#3B3329", primeBg: "#FFEEDE", + secondaryBg: "#FFFFFF", messageBorderBg: "#FFFFFF", placeholderBg: "#D9D9D9", calenderBg: "#FBD5B2", diff --git a/apps/client/src/app/(tabs)/calendar.tsx b/apps/client/src/app/(tabs)/calendar.tsx index 3208ef1..9d016c4 100644 --- a/apps/client/src/app/(tabs)/calendar.tsx +++ b/apps/client/src/app/(tabs)/calendar.tsx @@ -18,13 +18,49 @@ import React, { useState, } from "react"; import { useFocusEffect } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; import currentTheme from "../../Themes"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; import { EventService } from "../../services"; import { useEventsStore } from "../../stores"; -// TODO: month selection dropdown menu +// MonthSelector types and helpers +type MonthItem = { + id: string; // Format: "YYYY-MM" + year: number; + monthIndex: number; // 0-11 + label: string; // e.g. "January 2024" +}; + +const generateMonths = ( + centerYear: number, + centerMonth: number, + range: number, +): MonthItem[] => { + const months: MonthItem[] = []; + for (let offset = -range; offset <= range; offset++) { + let year = centerYear; + let month = centerMonth + offset; + + while (month < 0) { + month += 12; + year--; + } + while (month > 11) { + month -= 12; + year++; + } + + months.push({ + id: `${year}-${String(month + 1).padStart(2, "0")}`, + year, + monthIndex: month, + label: `${MONTHS[month]} ${year}`, + }); + } + return months; +}; const Calendar = () => { const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); @@ -149,6 +185,8 @@ const Calendar = () => { changeMonth={changeMonth} monthIndex={monthIndex} currentYear={currentYear} + setMonthIndex={setMonthIndex} + setYear={setCurrentYear} /> void; position: { top: number; left: number; width: number }; + currentYear: number; + currentMonthIndex: number; + onSelectMonth: (year: number, monthIndex: number) => void; }; const MonthSelector = ({ modalVisible, onClose, position, + currentYear, + currentMonthIndex, + onSelectMonth, }: MonthSelectorProps) => { const heightAnim = useRef(new Animated.Value(0)).current; - type ItemType = { id: string; text: string }; - const listRef = useRef>>(null); - const [monthSelectorData, setMonthSelectorData] = useState(() => { - const initial = []; - for (let i = 1; i <= 10; i++) { - initial.push({ id: i.toString(), text: `number ${i}` }); - } - return initial; - }); + const listRef = useRef>>(null); + const INITIAL_RANGE = 12; // 12 months before and after current - const appendToTestData = ( - startIndex: number, - numberOfEntries: number, - appendToStart: boolean, - ) => { - // create new data - const newData = []; - for (let i = 0; i < numberOfEntries; i++) { - const newIndex = startIndex + i + 1; - const newEntry = { - id: newIndex + "", - text: `number ${newIndex}`, - }; - if (appendToStart) { - newData.unshift(newEntry); - } else { - newData.push(newEntry); + const [monthSelectorData, setMonthSelectorData] = useState(() => + generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), + ); + + // Reset data when current month changes + useEffect(() => { + setMonthSelectorData( + generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE), + ); + }, [currentYear, currentMonthIndex]); + + const appendMonths = (direction: "start" | "end", count: number) => { + setMonthSelectorData((prevData) => { + if (prevData.length === 0) return prevData; + + const newMonths: MonthItem[] = []; + const referenceMonth = + direction === "start" ? prevData[0] : prevData[prevData.length - 1]; + + for (let i = 1; i <= count; i++) { + const offset = direction === "start" ? -i : i; + let year = referenceMonth.year; + let month = referenceMonth.monthIndex + offset; + + while (month < 0) { + month += 12; + year--; + } + while (month > 11) { + month -= 12; + year++; + } + + const newMonth: MonthItem = { + id: `${year}-${String(month + 1).padStart(2, "0")}`, + year, + monthIndex: month, + label: `${MONTHS[month]} ${year}`, + }; + + if (direction === "start") { + newMonths.unshift(newMonth); + } else { + newMonths.push(newMonth); + } } - } - // add new data - if (appendToStart) { - setMonthSelectorData([...newData, ...monthSelectorData]); - } else { - setMonthSelectorData([...monthSelectorData, ...newData]); - } + + return direction === "start" + ? [...newMonths, ...prevData] + : [...prevData, ...newMonths]; + }); }; useEffect(() => { @@ -321,15 +384,29 @@ const MonthSelector = ({ useNativeDriver: false, }).start(); } else { - // reset on close heightAnim.setValue(0); } - }, [modalVisible]); + }, [modalVisible, heightAnim]); - const renderItem = ({ item }: { item: ItemType }) => ( - - - {item.text} + const renderItem = ({ item }: { item: MonthItem }) => ( + { + onSelectMonth(item.year, item.monthIndex); + onClose(); + }} + > + + + {item.label} + ); @@ -341,30 +418,31 @@ const MonthSelector = ({ animationType="none" onRequestClose={onClose} > - + item.id} data={monthSelectorData} - initialScrollIndex={5} + initialScrollIndex={INITIAL_RANGE} onEndReachedThreshold={0.5} - onEndReached={() => - appendToTestData(monthSelectorData.length, 10, false) - } + onEndReached={() => appendMonths("end", 12)} onStartReachedThreshold={0.5} - onStartReached={() => - appendToTestData(monthSelectorData.length, 10, true) - } + onStartReached={() => appendMonths("start", 12)} renderItem={renderItem} /> @@ -377,6 +455,8 @@ type CalendarHeaderProps = { changeMonth: (delta: number) => void; monthIndex: number; currentYear: number; + setMonthIndex: (index: number) => void; + setYear: (year: number) => void; }; const CalendarHeader = (props: CalendarHeaderProps) => { @@ -400,54 +480,82 @@ const CalendarHeader = (props: CalendarHeaderProps) => { return (
- + - + {MONTHS[props.monthIndex]} {props.currentYear} - v + setModalVisible(false)} position={dropdownPosition} + currentYear={props.currentYear} + currentMonthIndex={props.monthIndex} + onSelectMonth={(year, month) => { + props.setYear(year); + props.setMonthIndex(month); + }} /> - "} /> +
); }; type ChangeMonthButtonProps = { onPress: () => void; - title: string; + icon: "chevron-back" | "chevron-forward"; }; const ChangeMonthButton = (props: ChangeMonthButtonProps) => ( - {props.title} + );