react-native-calendars icon indicating copy to clipboard operation
react-native-calendars copied to clipboard

TimelineList gray bottom part.

Open valavanisleonidas opened this issue 8 months ago • 3 comments

Hello,

I am using the latest version "react-native-calendars": "^1.1313.0" and react native "0.80.0". I have a screen that uses timelinelist and inputs some filters that loads some data from an api. When i load the data/events into the calendar there is a gray part in the bottom .

any ideas on the issue? thanks a lot.

code and image shared below.

Image

code for the screen is


// ---- Config ----
const INITIAL_TIME = { hour: 9, minutes: 0 };
const MAX_WEEKS_CACHE = 8;

const keyFromArray = (arr?: Array<string | number> | null) =>
  Array.isArray(arr) && arr.length ? [...arr].map(String).sort().join(',') : 'none';

type EventData = { caregiverIds: number[]; startISO: string; endISO: string };

const getInitials = (full?: string) => {
  const parts = (full || '').trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return '';
  if (parts.length === 1) return (parts[0][0] || '').toUpperCase();
  return ((parts[0][0] || '') + (parts[1][0] || '')).toUpperCase();
};

// do it outside to be sure its rendered before the calendar
const lang = useThemeStore.getState().language;
setCalendarLocale(lang === 'gr' ? 'gr' : 'en');

export default function AvailabilityScreen() {
  const [currentDate, setCurrentDate] = useState<string>(getDate());
  const [eventsByDate, setEventsByDate] = useState<Record<string, TimelineEventProps[]>>({});
  const [isLoading, setIsLoading] = useState(false);

  const [serviceIds, setServiceIds] = useState<Array<number | string>>([]);
  const [caregiverFilterIds, setCaregiverFilterIds] = useState<number[]>([]);
  const [durationMinutes, setDurationMinutes] = useState<number | null>(null);

  const [shouldFetch, setShouldFetch] = useState(false);

  const weekCacheRef = useRef<Map<string, Record<string, TimelineEventProps[]>>>(new Map());

  useEffect(() => {
    (async () => {
      try {
        const all = await getAllCaregivers();
        for (const caregiver of all) {
          const id = caregiver.caregiverId;
          const label = [caregiver.firstName, caregiver.lastName].filter(Boolean).join(' ').trim() || `#${id}`;
          caregiversByIdRef.current.set(id, label);
        }
      } catch (e) {
        console.warn('Failed to load caregivers:', e);
      }
    })();
  }, []);

  const baseTimelineProps: Partial<TimelineProps> = useMemo(
    () => ({
      format24h: true,
      unavailableHours: [
        { start: 0, end: 6 },
        { start: 20, end: 24 },
      ],
      overlapEventsSpacing: 8,
      rightEdgeSpacing: 24,
    }),
    [],
  );

  const cacheKey = useMemo(() => {
    const wk = weekKey(currentDate);
    const d = durationMinutes ?? 'none';
    const s = keyFromArray(serviceIds);
    const c = keyFromArray(caregiverFilterIds);
    return `${wk}|d:${d}|s:${s}|c:${c}`;
  }, [currentDate, durationMinutes, serviceIds, caregiverFilterIds]);

  const hasEvents = useMemo(
    () => Object.keys(eventsByDate).some((k) => (eventsByDate[k] ?? []).length > 0),
    [eventsByDate],
  );

  const safeEventsByDate = useMemo(
    () => ({ ...eventsByDate, [currentDate]: eventsByDate[currentDate] ?? [] }),
    [eventsByDate, currentDate],
  );

  const markedDates = useMemo(() => {
    const marked: Record<string, { marked: boolean }> = {};
    Object.entries(eventsByDate).forEach(([date, list]) => {
      if ((list ?? []).length > 0) marked[date] = { marked: true };
    });
    return marked;
  }, [eventsByDate]);

  const caregiversByIdRef = useRef<Map<number, string>>(new Map());

  const mapApiToTimeline = useCallback((rows: CaregiverAvailabilityResponse): TimelineEventProps[] => {
    type Entry = { startISO: string; endISO: string; caregivers: Set<number> };
    const slotMap = new Map<string, Entry>();

    rows.forEach((row: any) => {
      const cid = row.caregiverId;
      if (cid && !caregiversByIdRef.current.has(cid)) {
        caregiversByIdRef.current.set(cid, `#${cid}`);
      }

      (row.availability || []).forEach((day: any) => {
        (day.slots || []).forEach((slot: any) => {
          const key = `${slot.start}|${slot.end}`;
          let entry = slotMap.get(key);
          if (!entry) {
            entry = { startISO: slot.start, endISO: slot.end, caregivers: new Set<number>() };
            slotMap.set(key, entry);
          }
          entry.caregivers.add(cid);
        });
      });
    });

    const events: TimelineEventProps[] = [];
    for (const [key, entry] of slotMap.entries()) {
      const startStr = formatStringDateTime(new Date(entry.startISO));
      const endStr = formatStringDateTime(new Date(entry.endISO));
      const ids = Array.from(entry.caregivers).sort((a, b) => a - b);

      const initials = ids.map((id) => getInitials(caregiversByIdRef.current.get(id) || `#${id}`)).slice(0, 3);

      const title =
        ids.length === 1
          ? strings.availableCaregivers_one || '1 available caregiver'
          : (strings.availableCaregivers_other || '{count} available caregivers').replace(
              '{count}',
              String(ids.length),
            );

      events.push({
        id: `${key}|${ids.join('-')}`,
        start: startStr,
        end: endStr,
        title: `${title} • (${initials.join(', ')}${ids.length > 3 ? '…' : ''})`,
        color: 'lightgreen',
        data: { caregiverIds: ids, startISO: entry.startISO, endISO: entry.endISO } as EventData,
      } as TimelineEventProps);
    }

    return events;
  }, []);

  useEffect(() => {
    if (!shouldFetch) return;
    if (durationMinutes == null) return;

    if (weekCacheRef.current.has(cacheKey)) {
      setEventsByDate(weekCacheRef.current.get(cacheKey)!);
      return;
    }

    const weekDates = getWeekDates(currentDate);

    setIsLoading(true);
    (async () => {
      try {
        const payload: any = {
          dates: weekDates,
          durationMinutes: durationMinutes as number,
        };
        if (serviceIds && serviceIds.length) payload.serviceIds = serviceIds.map((x) => Number(x));
        if (caregiverFilterIds && caregiverFilterIds.length) payload.caregiverIds = caregiverFilterIds;

        const res = await getCaregiversAvailabilityByDates(payload);
        const evs = mapApiToTimeline(res as CaregiverAvailabilityResponse);

        const grouped = groupBy(evs, (e) => e.start.slice(0, 10)) as Record<string, TimelineEventProps[]>;
        weekCacheRef.current.set(cacheKey, grouped);
        if (weekCacheRef.current.size > MAX_WEEKS_CACHE) {
          const firstKey = weekCacheRef.current.keys().next().value;
          if (firstKey !== undefined) {
            weekCacheRef.current.delete(firstKey);
          }
        }

        setEventsByDate(grouped);
      } catch (e) {
        console.warn('Failed to fetch availability:', e);
      } finally {
        setIsLoading(false);
      }
    })();
  }, [shouldFetch, cacheKey, currentDate, durationMinutes, serviceIds, caregiverFilterIds, mapApiToTimeline]);

  const [keyboardVisible, setKeyboardVisible] = useState(false);
  useEffect(() => {
    const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
    const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
    return () => {
      show.remove();
      hide.remove();
    };
  }, []);

  // -------- Modal state ----------
  const [modalVisible, setModalVisible] = useState(false);
  const [modalCaregiverIds, setModalCaregiverIds] = useState<number[]>([]);

  const handleEventPress = useCallback((evt: TimelineEventProps) => {
    const data = (evt as any)?.data as EventData | undefined;
    const ids = data?.caregiverIds ?? [];
    setModalCaregiverIds(ids);
    setModalVisible(true);
  }, []);

  const filterRef = useRef<any>(null);
  useFocusEffect(
    useCallback(() => {
      // Reset filters
      filterRef.current?.resetFilters?.();

      // Reset calendar-related state
      // setEventsByDate({});
      // setServiceIds([]);
      // setCaregiverFilterIds([]);
      // setDurationMinutes(null);
      // setShouldFetch(false);
      // weekCacheRef.current.clear();

      // Reset current date to today
      // setCurrentDate(getDate());
    }, []),
  );
  return (
    <SafeAreaView style={styles.flex1}>
      {/* IMPORTANT: useScroll={false} so no ScrollView wraps the list */}
      <KeyBoardAvoidWrapper useScroll={false} verticalOffset={90}>
        <FilterHeader
          ref={filterRef}
          onApply={({ serviceIds: sIds, caregiverIds: cIds, duration }) => {
            Keyboard.dismiss();

            setServiceIds(Array.isArray(sIds) ? sIds : sIds ? [sIds] : []);

            const idsOnly = Array.isArray(cIds) ? cIds : cIds ? [cIds] : [];
            setCaregiverFilterIds(idsOnly.map((id) => Number(id)));

            const parsedDuration =
              duration && !isNaN(Number(duration)) ? Math.max(1, Math.floor(Number(duration))) : null;
            setDurationMinutes(parsedDuration);

            // Reset
            weekCacheRef.current.clear();
            setEventsByDate({ [currentDate]: [] });
            setShouldFetch(true);
          }}
        />

        <View style={styles.calendarArea}>
          <CalendarProvider
            date={currentDate}
            onDateChanged={setCurrentDate}
            onMonthChange={() => {}}
            showTodayButton
            disabledOpacity={0.6}
          >
            <ExpandableCalendar
              firstDay={1}
              leftArrowImageSource={require('../assets/images/previous.png')}
              rightArrowImageSource={require('../assets/images/next.png')}
              markedDates={markedDates}
            />

            {isLoading && (
              <View style={styles.loaderWrap}>
                <ActivityIndicator />
              </View>
            )}

            <TimelineList
              key={`${cacheKey}|kb:${keyboardVisible}`}
              events={safeEventsByDate}
              showNowIndicator
              scrollToNow={hasEvents}
              scrollToFirst={hasEvents}
              initialTime={INITIAL_TIME}
              timelineProps={{
                ...baseTimelineProps,
                onEventPress: handleEventPress,
              }}
            />
          </CalendarProvider>
        </View>
      </KeyBoardAvoidWrapper>

      {/* Caregivers modal */}
      <Modal visible={modalVisible} transparent animationType="slide" onRequestClose={() => setModalVisible(false)}>
        <View style={styles.modalBackdrop}>
          <View style={styles.modalCard}>
            <Text style={styles.modalTitle}>{strings.caregiversListTitle}</Text>

            <FlatList
              data={modalCaregiverIds}
              keyExtractor={(id) => String(id)}
              renderItem={({ item: id }) => (
                <Text style={styles.modalItem}>{caregiversByIdRef.current.get(id) || `#${id}`}</Text>
              )}
              ItemSeparatorComponent={FlatListSeparator}
              style={{ maxHeight: 260 }}
            />

            <TouchableOpacity style={styles.modalCloseBtn} onPress={() => setModalVisible(false)}>
              <Text style={styles.modalCloseTxt}>{strings.close}</Text>
            </TouchableOpacity>
          </View>
        </View>
      </Modal>
    </SafeAreaView>
  );
}

const FlatListSeparator = () => <View style={{ height: 8 }} />;

const styles = StyleSheet.create({
  flex1: { flex: 1 },
  loaderWrap: { paddingVertical: 8 },
  calendarArea: { flex: 1 },

  modalBackdrop: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.32)',
    justifyContent: 'center',
    alignItems: 'center',
    padding: 24,
  },
  modalCard: {
    width: '100%',
    borderRadius: 12,
    backgroundColor: '#fff',
    padding: 16,
  },
  modalTitle: { fontSize: 18, fontWeight: '600', marginBottom: 12 },
  modalItem: { fontSize: 16 },
  modalCloseBtn: {
    marginTop: 16,
    alignSelf: 'flex-end',
    paddingVertical: 8,
    paddingHorizontal: 14,
    borderRadius: 8,
    backgroundColor: '#0a84ff',
  },
  modalCloseTxt: { color: '#fff', fontWeight: '600' },
});

valavanisleonidas avatar Aug 21 '25 20:08 valavanisleonidas

Hey we are also facing something similar, did you resolve this somehow?

AshishK0171 avatar Aug 28 '25 06:08 AshishK0171

Hey we are also facing something similar, did you resolve this somehow?

not really. i havent figured it out yet.

valavanisleonidas avatar Sep 02 '25 19:09 valavanisleonidas

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Dec 11 '25 19:12 stale[bot]