๐ Fix calendar skipping Saturday column
Changes
3 files changed, +135 -86
MODIFY
app/(tabs)/calendar.tsx
+71 -86
@@ -10,13 +10,14 @@
10
10
import { taskService } from '@/services';
11
11
import { Colors } from '@/constants/theme';
12
12
import { useColorScheme } from '@/hooks/use-color-scheme';
13
+import { buildCalendarWeeks } from '@/utils/calendar';
13
14
14
15
export default function CalendarScreen() {
15
16
const [selectedDate, setSelectedDate] = useState(new Date());
16
17
const [currentMonth, setCurrentMonth] = useState(new Date());
17
18
const [tasks, setTasks] = useState<Task[]>([]);
18
19
const [tasksByDate, setTasksByDate] = useState<Record<string, number>>({});
19
- const colorScheme = useColorScheme();
20
+ const colorScheme = useColorScheme() ?? 'light';
20
21
const colors = Colors[colorScheme];
21
22
22
23
useFocusEffect(
@@ -39,45 +40,33 @@
39
40
}
40
41
});
41
42
setTasksByDate(counts);
42
-
43
- // Filter tasks for selected date
43
+
44
+ // Filter tasks for selected date (reuse the tasks we already loaded)
44
45
filterTasksForDate(selectedDate, allTasks);
45
46
} catch {
46
47
Alert.alert('Error', 'Failed to load tasks');
47
48
}
48
49
};
49
50
50
- const filterTasksForDate = (date: Date, allTasks?: Task[]) => {
51
+ const filterTasksForDate = async (date: Date, allTasks?: Task[]) => {
51
52
const startOfDay = new Date(date);
52
53
startOfDay.setHours(0, 0, 0, 0);
53
54
const endOfDay = new Date(date);
54
55
endOfDay.setHours(23, 59, 59, 999);
55
56
56
- taskService.getAllTasks().then(tasks => {
57
- const filtered = tasks.filter(task => {
58
- if (!task.dueDate || task.completed) return false;
59
- const taskDate = new Date(task.dueDate);
60
- return taskDate >= startOfDay && taskDate <= endOfDay;
61
- });
62
- setTasks(filtered);
57
+ const source = allTasks ?? (await taskService.getAllTasks());
58
+ const filtered = source.filter(task => {
59
+ if (!task.dueDate || task.completed) return false;
60
+ const taskDate = new Date(task.dueDate);
61
+ return taskDate >= startOfDay && taskDate <= endOfDay;
63
62
});
63
+ setTasks(filtered);
64
64
};
65
65
66
66
const getDateKey = (date: Date) => {
67
67
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
68
68
};
69
69
70
- const getDaysInMonth = (date: Date) => {
71
- const year = date.getFullYear();
72
- const month = date.getMonth();
73
- const firstDay = new Date(year, month, 1);
74
- const lastDay = new Date(year, month + 1, 0);
75
- const daysInMonth = lastDay.getDate();
76
- const startingDayOfWeek = firstDay.getDay();
77
-
78
- return { daysInMonth, startingDayOfWeek };
79
- };
80
-
81
70
const handleDateSelect = (day: number) => {
82
71
const newDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
83
72
setSelectedDate(newDate);
@@ -128,67 +117,68 @@
128
117
};
129
118
130
119
const renderCalendar = () => {
131
- const { daysInMonth, startingDayOfWeek } = getDaysInMonth(currentMonth);
132
- const days = [];
133
120
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
134
-
135
- // Week day headers
136
- const headers = weekDays.map(day => (
137
- <ThemedView key={day} style={styles.dayHeader}>
138
- <ThemedText style={styles.dayHeaderText}>{day}</ThemedText>
139
- </ThemedView>
140
- ));
141
-
142
- // Empty cells before first day
143
- for (let i = 0; i < startingDayOfWeek; i++) {
144
- days.push(<View key={`empty-${i}`} style={styles.dayCell} />);
145
- }
146
-
147
- // Day cells
148
- for (let day = 1; day <= daysInMonth; day++) {
149
- const date = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
150
- const dateKey = getDateKey(date);
151
- const taskCount = tasksByDate[dateKey] || 0;
152
- const isSelected = selectedDate.getDate() === day &&
153
- selectedDate.getMonth() === currentMonth.getMonth() &&
154
- selectedDate.getFullYear() === currentMonth.getFullYear();
155
- const isToday = new Date().getDate() === day &&
156
- new Date().getMonth() === currentMonth.getMonth() &&
157
- new Date().getFullYear() === currentMonth.getFullYear();
158
-
159
- days.push(
160
- <TouchableOpacity
161
- key={day}
162
- style={[
163
- styles.dayCell,
164
- isToday && [styles.today, { borderColor: colors.tint }],
165
- isSelected && [styles.selectedDay, { backgroundColor: colors.tint }],
166
- ]}
167
- onPress={() => handleDateSelect(day)}
168
- >
169
- <View style={styles.dayCellContent}>
170
- <ThemedText style={[
171
- styles.dayText,
172
- isSelected && styles.selectedDayText,
173
- ]}>
174
- {day}
175
- </ThemedText>
176
- {taskCount > 0 && (
177
- <View style={[styles.taskBadge, { backgroundColor: isSelected ? '#fff' : colors.tint }]}>
178
- <ThemedText style={[styles.taskBadgeText, { color: isSelected ? colors.tint : '#fff' }]}>
179
- {taskCount}
180
- </ThemedText>
181
- </View>
182
- )}
183
- </View>
184
- </TouchableOpacity>
185
- );
186
- }
121
+ const weeks = buildCalendarWeeks(currentMonth.getFullYear(), currentMonth.getMonth());
122
+ const now = new Date();
187
123
188
124
return (
189
125
<View style={styles.calendar}>
190
- <View style={styles.weekDays}>{headers}</View>
191
- <View style={styles.daysGrid}>{days}</View>
126
+ <View style={styles.week}>
127
+ {weekDays.map(day => (
128
+ <ThemedView key={day} style={styles.dayHeader}>
129
+ <ThemedText style={styles.dayHeaderText}>{day}</ThemedText>
130
+ </ThemedView>
131
+ ))}
132
+ </View>
133
+
134
+ {weeks.map((week, weekIndex) => (
135
+ <View key={`week-${weekIndex}`} style={styles.week}>
136
+ {week.map((day, dayIndex) => {
137
+ if (day === null) {
138
+ return <View key={`empty-${weekIndex}-${dayIndex}`} style={styles.dayCell} />;
139
+ }
140
+
141
+ const dateKey = getDateKey(
142
+ new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day)
143
+ );
144
+ const taskCount = tasksByDate[dateKey] || 0;
145
+ const isSelected = selectedDate.getDate() === day &&
146
+ selectedDate.getMonth() === currentMonth.getMonth() &&
147
+ selectedDate.getFullYear() === currentMonth.getFullYear();
148
+ const isToday = now.getDate() === day &&
149
+ now.getMonth() === currentMonth.getMonth() &&
150
+ now.getFullYear() === currentMonth.getFullYear();
151
+
152
+ return (
153
+ <TouchableOpacity
154
+ key={day}
155
+ style={[
156
+ styles.dayCell,
157
+ isToday && [styles.today, { borderColor: colors.tint }],
158
+ isSelected && [styles.selectedDay, { backgroundColor: colors.tint }],
159
+ ]}
160
+ onPress={() => handleDateSelect(day)}
161
+ >
162
+ <View style={styles.dayCellContent}>
163
+ <ThemedText style={[
164
+ styles.dayText,
165
+ isSelected && styles.selectedDayText,
166
+ ]}>
167
+ {day}
168
+ </ThemedText>
169
+ {taskCount > 0 && (
170
+ <View style={[styles.taskBadge, { backgroundColor: isSelected ? '#fff' : colors.tint }]}>
171
+ <ThemedText style={[styles.taskBadgeText, { color: isSelected ? colors.tint : '#fff' }]}>
172
+ {taskCount}
173
+ </ThemedText>
174
+ </View>
175
+ )}
176
+ </View>
177
+ </TouchableOpacity>
178
+ );
179
+ })}
180
+ </View>
181
+ ))}
192
182
</View>
193
183
);
194
184
};
@@ -270,9 +260,8 @@
270
260
paddingHorizontal: 8,
271
261
paddingBottom: 16,
272
262
},
273
- weekDays: {
263
+ week: {
274
264
flexDirection: 'row',
275
- marginBottom: 8,
276
265
},
277
266
dayHeader: {
278
267
flex: 1,
@@ -284,12 +273,8 @@
284
273
fontWeight: '600',
285
274
opacity: 0.6,
286
275
},
287
- daysGrid: {
288
- flexDirection: 'row',
289
- flexWrap: 'wrap',
290
- },
291
276
dayCell: {
292
- width: `${100 / 7}%`,
277
+ flex: 1,
293
278
aspectRatio: 1,
294
279
padding: 4,
295
280
},
ADD
utils/__tests__/calendar.test.ts
+27 -0
@@ -0,0 +1,27 @@
1
+import { buildCalendarWeeks } from '../calendar';
2
+
3
+describe('buildCalendarWeeks', () => {
4
+ it('places each weekday in the correct column (June 2026 starts on Monday)', () => {
5
+ // Month is 0-indexed: 5 = June. June 1 2026 is a Monday, June 6 is a Saturday.
6
+ const weeks = buildCalendarWeeks(2026, 5);
7
+ // Sunday column is empty, Saturday column holds the 6th โ not skipped.
8
+ expect(weeks[0]).toEqual([null, 1, 2, 3, 4, 5, 6]);
9
+ });
10
+
11
+ it('always returns full 7-column weeks', () => {
12
+ const weeks = buildCalendarWeeks(2026, 5);
13
+ weeks.forEach((week) => expect(week).toHaveLength(7));
14
+ });
15
+
16
+ it('starts in the first column when the month begins on a Sunday (Feb 2026)', () => {
17
+ // Feb 1 2026 is a Sunday.
18
+ const weeks = buildCalendarWeeks(2026, 1);
19
+ expect(weeks[0][0]).toBe(1);
20
+ });
21
+
22
+ it('includes every day of the month exactly once and in order', () => {
23
+ const weeks = buildCalendarWeeks(2026, 5);
24
+ const days = weeks.flat().filter((d): d is number => d !== null);
25
+ expect(days).toEqual(Array.from({ length: 30 }, (_, i) => i + 1));
26
+ });
27
+});
ADD
utils/calendar.ts
+37 -0
@@ -0,0 +1,37 @@
1
+/**
2
+ * Calendar grid helpers
3
+ *
4
+ * Pure logic for laying out a month as weeks, so the calendar grid can be
5
+ * rendered as fixed 7-column rows (avoiding sub-pixel width rounding that
6
+ * would otherwise wrap the last column).
7
+ */
8
+
9
+/**
10
+ * Build the weeks of a month as rows of 7 cells. Each cell is the day number
11
+ * (1-based) or `null` for padding before the first / after the last day.
12
+ * Week starts on Sunday (index 0), matching the weekday headers.
13
+ *
14
+ * @param year Full year, e.g. 2026
15
+ * @param month Month index, 0 = January โฆ 11 = December
16
+ */
17
+export function buildCalendarWeeks(year: number, month: number): (number | null)[][] {
18
+ const startingDayOfWeek = new Date(year, month, 1).getDay(); // 0 = Sunday
19
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
20
+
21
+ const cells: (number | null)[] = [];
22
+ for (let i = 0; i < startingDayOfWeek; i++) {
23
+ cells.push(null);
24
+ }
25
+ for (let day = 1; day <= daysInMonth; day++) {
26
+ cells.push(day);
27
+ }
28
+ while (cells.length % 7 !== 0) {
29
+ cells.push(null);
30
+ }
31
+
32
+ const weeks: (number | null)[][] = [];
33
+ for (let i = 0; i < cells.length; i += 7) {
34
+ weeks.push(cells.slice(i, i + 7));
35
+ }
36
+ return weeks;
37
+}