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:**
- 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

View File

@@ -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",

View File

@@ -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}
/>
<WeekDaysLine />
<CalendarGrid
@@ -268,49 +306,74 @@ type MonthSelectorProps = {
modalVisible: boolean;
onClose: () => 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<React.ComponentRef<typeof FlashList<ItemType>>>(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<React.ComponentRef<typeof FlashList<MonthItem>>>(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}`,
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() =>
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 (appendToStart) {
newData.unshift(newEntry);
if (direction === "start") {
newMonths.unshift(newMonth);
} else {
newData.push(newEntry);
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 }) => (
<Pressable>
<View className="w-full flex justify-center items-center">
<Text className="text-3xl">{item.text}</Text>
const renderItem = ({ item }: { item: MonthItem }) => (
<Pressable
onPress={() => {
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>
</Pressable>
);
@@ -341,30 +418,31 @@ const MonthSelector = ({
animationType="none"
onRequestClose={onClose}
>
<Pressable className="flex-1" onPress={onClose}>
<Pressable className="flex-1 rounded-lg" onPress={onClose}>
<Animated.View
className="absolute bg-white border-2 border-solid rounded-lg overflow-hidden"
className="absolute overflow-hidden"
style={{
top: position.top,
left: position.left,
width: position.width,
height: heightAnim,
backgroundColor: currentTheme.primeBg,
borderWidth: 2,
borderColor: currentTheme.borderPrimary,
borderRadius: 8,
}}
>
<FlashList
className="w-full"
style={{ borderRadius: 8 }}
ref={listRef}
keyExtractor={(item) => 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}
/>
</Animated.View>
@@ -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 (
<Header className="flex flex-row items-center justify-between">
<ChangeMonthButton onPress={prevMonth} title={"<"} />
<ChangeMonthButton onPress={prevMonth} icon="chevron-back" />
<View
ref={containerRef}
className="relative flex flex-row items-center justify-around"
>
<Text className="text-4xl">
<Text className="text-4xl px-1">
{MONTHS[props.monthIndex]} {props.currentYear}
</Text>
<Pressable
className={
"flex justify-center items-center bg-white w-12 h-12 p-2 " +
"border border-solid rounded-full ml-2"
}
className="flex justify-center items-center w-12 h-12 border rounded-lg"
style={{
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}
>
<Text className="text-4xl">v</Text>
<Ionicons
name="chevron-down"
size={28}
color={currentTheme.primeFg}
/>
</Pressable>
</View>
<MonthSelector
modalVisible={modalVisible}
onClose={() => setModalVisible(false)}
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>
);
};
type ChangeMonthButtonProps = {
onPress: () => void;
title: string;
icon: "chevron-back" | "chevron-forward";
};
const ChangeMonthButton = (props: ChangeMonthButtonProps) => (
<Pressable
onPress={props.onPress}
className={
"w-16 h-16 bg-white rounded-full flex items-center " +
"justify-center border border-solid border-1 mx-2"
}
className="w-16 h-16 flex items-center justify-center mx-2 rounded-xl border border-solid"
style={{
backgroundColor: currentTheme.chatBot,
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>
);