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
This commit is contained in:
2026-01-07 17:34:00 +01:00
parent 8da054bbef
commit 613bafa5f5
3 changed files with 176 additions and 65 deletions

View File

@@ -295,7 +295,8 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
**Frontend:** **Frontend:**
- Tab navigation (Chat, Calendar) implemented with basic UI - Tab navigation (Chat, Calendar) implemented with basic UI
- Calendar screen fully functional: - 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() - Events loaded from API via EventService.getByDateRange()
- Orange dot indicator for days with events - Orange dot indicator for days with events
- Tap-to-open modal overlay showing EventCards for selected day - 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 - `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 - `EventCard`: Uses EventCardBase + edit/delete buttons for calendar display
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) - `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[] - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
- `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches - `ChatStore`: Zustand store with addMessage(), updateMessage(), clearMessages() - persists messages across tab switches
- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons - Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons

View File

@@ -2,6 +2,7 @@ type Theme = {
chatBot: string; chatBot: string;
primeFg: string; primeFg: string;
primeBg: string; primeBg: string;
secondaryBg: string;
messageBorderBg: string; messageBorderBg: string;
placeholderBg: string; placeholderBg: string;
calenderBg: string; calenderBg: string;
@@ -20,6 +21,7 @@ const defaultLight: Theme = {
chatBot: "#DE6C20", chatBot: "#DE6C20",
primeFg: "#3B3329", primeFg: "#3B3329",
primeBg: "#FFEEDE", primeBg: "#FFEEDE",
secondaryBg: "#FFFFFF",
messageBorderBg: "#FFFFFF", messageBorderBg: "#FFFFFF",
placeholderBg: "#D9D9D9", placeholderBg: "#D9D9D9",
calenderBg: "#FBD5B2", calenderBg: "#FBD5B2",

View File

@@ -18,13 +18,49 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { useFocusEffect } from "expo-router"; import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import currentTheme from "../../Themes"; import currentTheme from "../../Themes";
import BaseBackground from "../../components/BaseBackground"; import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { EventService } from "../../services"; import { EventService } from "../../services";
import { useEventsStore } from "../../stores"; 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 Calendar = () => {
const [monthIndex, setMonthIndex] = useState(new Date().getMonth()); const [monthIndex, setMonthIndex] = useState(new Date().getMonth());
@@ -149,6 +185,8 @@ const Calendar = () => {
changeMonth={changeMonth} changeMonth={changeMonth}
monthIndex={monthIndex} monthIndex={monthIndex}
currentYear={currentYear} currentYear={currentYear}
setMonthIndex={setMonthIndex}
setYear={setCurrentYear}
/> />
<WeekDaysLine /> <WeekDaysLine />
<CalendarGrid <CalendarGrid
@@ -268,49 +306,74 @@ type MonthSelectorProps = {
modalVisible: boolean; modalVisible: boolean;
onClose: () => void; onClose: () => void;
position: { top: number; left: number; width: number }; position: { top: number; left: number; width: number };
currentYear: number;
currentMonthIndex: number;
onSelectMonth: (year: number, monthIndex: number) => void;
}; };
const MonthSelector = ({ const MonthSelector = ({
modalVisible, modalVisible,
onClose, onClose,
position, position,
currentYear,
currentMonthIndex,
onSelectMonth,
}: MonthSelectorProps) => { }: MonthSelectorProps) => {
const heightAnim = useRef(new Animated.Value(0)).current; const heightAnim = useRef(new Animated.Value(0)).current;
type ItemType = { id: string; text: string }; const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
const listRef = useRef<React.ComponentRef<typeof FlashList<ItemType>>>(null); const INITIAL_RANGE = 12; // 12 months before and after current
const [monthSelectorData, setMonthSelectorData] = useState(() => {
const initial = [];
for (let i = 1; i <= 10; i++) {
initial.push({ id: i.toString(), text: `number ${i}` });
}
return initial;
});
const appendToTestData = ( const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() =>
startIndex: number, generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
numberOfEntries: number, );
appendToStart: boolean,
) => { // Reset data when current month changes
// create new data useEffect(() => {
const newData = []; setMonthSelectorData(
for (let i = 0; i < numberOfEntries; i++) { generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
const newIndex = startIndex + i + 1; );
const newEntry = { }, [currentYear, currentMonthIndex]);
id: newIndex + "",
text: `number ${newIndex}`, const appendMonths = (direction: "start" | "end", count: number) => {
}; setMonthSelectorData((prevData) => {
if (appendToStart) { if (prevData.length === 0) return prevData;
newData.unshift(newEntry);
} else { const newMonths: MonthItem[] = [];
newData.push(newEntry); 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 return direction === "start"
if (appendToStart) { ? [...newMonths, ...prevData]
setMonthSelectorData([...newData, ...monthSelectorData]); : [...prevData, ...newMonths];
} else { });
setMonthSelectorData([...monthSelectorData, ...newData]);
}
}; };
useEffect(() => { useEffect(() => {
@@ -321,15 +384,29 @@ const MonthSelector = ({
useNativeDriver: false, useNativeDriver: false,
}).start(); }).start();
} else { } else {
// reset on close
heightAnim.setValue(0); heightAnim.setValue(0);
} }
}, [modalVisible]); }, [modalVisible, heightAnim]);
const renderItem = ({ item }: { item: ItemType }) => ( const renderItem = ({ item }: { item: MonthItem }) => (
<Pressable> <Pressable
<View className="w-full flex justify-center items-center"> onPress={() => {
<Text className="text-3xl">{item.text}</Text> onSelectMonth(item.year, item.monthIndex);
onClose();
}}
>
<View
className="w-full flex justify-center items-center py-2"
style={{
backgroundColor:
item.monthIndex % 2 === 0
? currentTheme.primeBg
: currentTheme.secondaryBg,
}}
>
<Text className="text-xl" style={{ color: currentTheme.primeFg }}>
{item.label}
</Text>
</View> </View>
</Pressable> </Pressable>
); );
@@ -341,30 +418,31 @@ const MonthSelector = ({
animationType="none" animationType="none"
onRequestClose={onClose} onRequestClose={onClose}
> >
<Pressable className="flex-1" onPress={onClose}> <Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View <Animated.View
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden" className="absolute overflow-hidden"
style={{ style={{
top: position.top, top: position.top,
left: position.left, left: position.left,
width: position.width, width: position.width,
height: heightAnim, height: heightAnim,
backgroundColor: currentTheme.primeBg,
borderWidth: 2,
borderColor: currentTheme.borderPrimary,
borderRadius: 8,
}} }}
> >
<FlashList <FlashList
className="w-full" className="w-full"
style={{ borderRadius: 8 }}
ref={listRef} ref={listRef}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
data={monthSelectorData} data={monthSelectorData}
initialScrollIndex={5} initialScrollIndex={INITIAL_RANGE}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
onEndReached={() => onEndReached={() => appendMonths("end", 12)}
appendToTestData(monthSelectorData.length, 10, false)
}
onStartReachedThreshold={0.5} onStartReachedThreshold={0.5}
onStartReached={() => onStartReached={() => appendMonths("start", 12)}
appendToTestData(monthSelectorData.length, 10, true)
}
renderItem={renderItem} renderItem={renderItem}
/> />
</Animated.View> </Animated.View>
@@ -377,6 +455,8 @@ type CalendarHeaderProps = {
changeMonth: (delta: number) => void; changeMonth: (delta: number) => void;
monthIndex: number; monthIndex: number;
currentYear: number; currentYear: number;
setMonthIndex: (index: number) => void;
setYear: (year: number) => void;
}; };
const CalendarHeader = (props: CalendarHeaderProps) => { const CalendarHeader = (props: CalendarHeaderProps) => {
@@ -400,54 +480,82 @@ const CalendarHeader = (props: CalendarHeaderProps) => {
return ( return (
<Header className="flex flex-row items-center justify-between"> <Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} title={"<"} /> <ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View <View
ref={containerRef} ref={containerRef}
className="relative flex flex-row items-center justify-around" className="relative flex flex-row items-center justify-around"
> >
<Text className="text-4xl"> <Text className="text-4xl px-1">
{MONTHS[props.monthIndex]} {props.currentYear} {MONTHS[props.monthIndex]} {props.currentYear}
</Text> </Text>
<Pressable <Pressable
className={ className="flex justify-center items-center w-12 h-12 border rounded-lg"
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
"border border-solid rounded-full ml-2"
}
style={{ style={{
borderColor: currentTheme.primeFg, borderColor: currentTheme.primeFg,
backgroundColor: currentTheme.chatBot,
// iOS shadow
shadowColor: "#000",
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.35,
shadowRadius: 5,
// Android shadow
elevation: 6,
}} }}
onPress={measureAndOpen} onPress={measureAndOpen}
> >
<Text className="text-4xl">v</Text> <Ionicons
name="chevron-down"
size={28}
color={currentTheme.primeFg}
/>
</Pressable> </Pressable>
</View> </View>
<MonthSelector <MonthSelector
modalVisible={modalVisible} modalVisible={modalVisible}
onClose={() => setModalVisible(false)} onClose={() => setModalVisible(false)}
position={dropdownPosition} position={dropdownPosition}
currentYear={props.currentYear}
currentMonthIndex={props.monthIndex}
onSelectMonth={(year, month) => {
props.setYear(year);
props.setMonthIndex(month);
}}
/> />
<ChangeMonthButton onPress={nextMonth} title={">"} /> <ChangeMonthButton onPress={nextMonth} icon="chevron-forward" />
</Header> </Header>
); );
}; };
type ChangeMonthButtonProps = { type ChangeMonthButtonProps = {
onPress: () => void; onPress: () => void;
title: string; icon: "chevron-back" | "chevron-forward";
}; };
const ChangeMonthButton = (props: ChangeMonthButtonProps) => ( const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
<Pressable <Pressable
onPress={props.onPress} onPress={props.onPress}
className={ className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
"w-16 h-16 bg-white rounded-full flex items-center " +
"justify-center border border-solid border-1 mx-2"
}
style={{ style={{
backgroundColor: currentTheme.chatBot,
borderColor: currentTheme.primeFg, borderColor: currentTheme.primeFg,
// iOS shadow
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
// Android shadow
elevation: 6,
}} }}
> >
<Text className="text-4xl">{props.title}</Text> <Ionicons
name={props.icon}
size={48}
color={currentTheme.primeFg}
style={{
marginLeft: props.icon === "chevron-forward" ? 4 : 0,
marginRight: props.icon === "chevron-back" ? 4 : 0,
}}
/>
</Pressable> </Pressable>
); );