|
| 1 | +import { router, Stack } from 'expo-router'; |
| 2 | +import { useMemo, useState } from 'react'; |
| 3 | +import { Pressable, View } from 'react-native'; |
| 4 | + |
| 5 | +import { describeEvent } from '@/components/event-row'; |
| 6 | +import { PlantThumb } from '@/components/plant-thumb'; |
| 7 | +import { TaskKindBadge, TaskRow } from '@/components/task-row'; |
| 8 | +import { HeaderButton, IconButton, icons, ListRow, ListSection, Screen, Text } from '@/components/ui'; |
| 9 | +import { useCurrentPlace, usePlaceEvents, usePlants, useRooms, useTasks } from '@/db/hooks'; |
| 10 | +import type { CareEvent, Plant, Task, TaskKind } from '@/db/types'; |
| 11 | +import { addMonths, monthGrid, monthOf, projectTasks, WEEKDAYS, type Occurrence } from '@/lib/calendar'; |
| 12 | +import { careActions } from '@/lib/care-actions'; |
| 13 | +import { formatDayMonth, formatLongDate, formatMonth, formatTime, parseDate, toDateString, today } from '@/lib/dates'; |
| 14 | +import { plural, taskTitle } from '@/lib/labels'; |
| 15 | +import { capitalize } from '@/lib/text'; |
| 16 | +import { radius, spacing, touchTarget, useTheme } from '@/theme'; |
| 17 | + |
| 18 | +/** Done care from the journal, by local day. */ |
| 19 | +function doneByDay(events: CareEvent[]): Map<string, CareEvent[]> { |
| 20 | + const byDay = new Map<string, CareEvent[]>(); |
| 21 | + for (const event of events) { |
| 22 | + if (event.kind !== 'done') continue; |
| 23 | + const day = toDateString(new Date(event.occurred_at)); |
| 24 | + byDay.set(day, [...(byDay.get(day) ?? []), event]); |
| 25 | + } |
| 26 | + return byDay; |
| 27 | +} |
| 28 | + |
| 29 | +function openTask(taskId: string) { |
| 30 | + router.push({ pathname: '/task/[id]', params: { id: taskId } }); |
| 31 | +} |
| 32 | + |
| 33 | +/** The month at a glance: care planned from today on, and what the journal says for past days. */ |
| 34 | +export default function Calendar() { |
| 35 | + const place = useCurrentPlace(); |
| 36 | + const tasks = useTasks(place.id); |
| 37 | + const plants = usePlants(place.id); |
| 38 | + const rooms = useRooms(place.id); |
| 39 | + const now = today(); |
| 40 | + const [month, setMonth] = useState(() => monthOf(now)); |
| 41 | + const [selected, setSelected] = useState(now); |
| 42 | + |
| 43 | + const weeks = useMemo(() => monthGrid(month), [month]); |
| 44 | + const from = weeks[0][0].day; |
| 45 | + const to = weeks[weeks.length - 1][6].day; |
| 46 | + const events = usePlaceEvents(place.id, from, to); |
| 47 | + const planned = useMemo(() => projectTasks(tasks, from, to, now), [tasks, from, to, now]); |
| 48 | + const done = useMemo(() => doneByDay(events), [events]); |
| 49 | + const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants]); |
| 50 | + const roomNames = useMemo(() => new Map(rooms.map((r) => [r.id, r.name])), [rooms]); |
| 51 | + |
| 52 | + // Past days show what was done, today and later what is planned. |
| 53 | + const careOn = (day: string): { kind: TaskKind; late: boolean }[] => |
| 54 | + day < now |
| 55 | + ? (done.get(day) ?? []).map((e) => ({ kind: e.task_kind, late: false })) |
| 56 | + : (planned.get(day) ?? []).map((o) => ({ kind: o.task.kind, late: o.late })); |
| 57 | + |
| 58 | + const select = (day: string) => { |
| 59 | + setMonth(monthOf(day)); |
| 60 | + setSelected(day); |
| 61 | + }; |
| 62 | + const showMonth = (count: number) => { |
| 63 | + const next = addMonths(month, count); |
| 64 | + select(next === monthOf(now) ? now : next); |
| 65 | + }; |
| 66 | + |
| 67 | + return ( |
| 68 | + <> |
| 69 | + <Stack.Screen |
| 70 | + options={{ |
| 71 | + headerRight: () => ( |
| 72 | + <HeaderButton title="Aujourd’hui" onPress={() => select(now)} disabled={selected === now} /> |
| 73 | + ), |
| 74 | + }} |
| 75 | + /> |
| 76 | + <Screen> |
| 77 | + <View style={{ gap: spacing.sm }}> |
| 78 | + <View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.xs }}> |
| 79 | + <IconButton icon={icons.chevronLeft} label="Mois précédent" onPress={() => showMonth(-1)} /> |
| 80 | + <Text |
| 81 | + variant="title" |
| 82 | + accessibilityRole="header" |
| 83 | + accessibilityLiveRegion="polite" |
| 84 | + style={{ flex: 1, textAlign: 'center' }}> |
| 85 | + {capitalize(formatMonth(month))} |
| 86 | + </Text> |
| 87 | + <IconButton icon={icons.chevronRight} label="Mois suivant" onPress={() => showMonth(1)} /> |
| 88 | + </View> |
| 89 | + <Text variant="subhead" tone="secondary" style={{ textAlign: 'center' }}> |
| 90 | + {place.name} |
| 91 | + </Text> |
| 92 | + </View> |
| 93 | + |
| 94 | + <MonthGrid |
| 95 | + weeks={weeks} |
| 96 | + today={now} |
| 97 | + selected={selected} |
| 98 | + careOn={careOn} |
| 99 | + onSelect={select} |
| 100 | + /> |
| 101 | + |
| 102 | + <DayDetails |
| 103 | + day={selected} |
| 104 | + today={now} |
| 105 | + planned={planned.get(selected) ?? []} |
| 106 | + done={done.get(selected) ?? []} |
| 107 | + plantsById={plantsById} |
| 108 | + roomNames={roomNames} |
| 109 | + /> |
| 110 | + </Screen> |
| 111 | + </> |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +type MonthGridProps = { |
| 116 | + weeks: ReturnType<typeof monthGrid>; |
| 117 | + today: string; |
| 118 | + selected: string; |
| 119 | + careOn: (day: string) => { kind: TaskKind; late: boolean }[]; |
| 120 | + onSelect: (day: string) => void; |
| 121 | +}; |
| 122 | + |
| 123 | +function MonthGrid({ weeks, today, selected, careOn, onSelect }: MonthGridProps) { |
| 124 | + const theme = useTheme(); |
| 125 | + return ( |
| 126 | + <View |
| 127 | + style={{ |
| 128 | + paddingHorizontal: spacing.xs, |
| 129 | + paddingVertical: spacing.sm, |
| 130 | + borderRadius: radius.lg, |
| 131 | + borderCurve: 'continuous', |
| 132 | + backgroundColor: theme.surfaceContainerLow, |
| 133 | + }}> |
| 134 | + {/* Every day says its full date to screen readers: the initials are for the eyes. */} |
| 135 | + <View |
| 136 | + style={{ flexDirection: 'row', paddingBottom: spacing.xs }} |
| 137 | + importantForAccessibility="no-hide-descendants" |
| 138 | + accessibilityElementsHidden> |
| 139 | + {WEEKDAYS.map((name) => ( |
| 140 | + <Text key={name} variant="caption" tone="secondary" style={{ flex: 1, textAlign: 'center' }}> |
| 141 | + {name.charAt(0).toUpperCase()} |
| 142 | + </Text> |
| 143 | + ))} |
| 144 | + </View> |
| 145 | + {weeks.map((week) => ( |
| 146 | + <View key={week[0].day} style={{ flexDirection: 'row' }}> |
| 147 | + {week.map(({ day, inMonth }) => ( |
| 148 | + <DayCell |
| 149 | + key={day} |
| 150 | + day={day} |
| 151 | + inMonth={inMonth} |
| 152 | + isToday={day === today} |
| 153 | + past={day < today} |
| 154 | + selected={day === selected} |
| 155 | + care={careOn(day)} |
| 156 | + onPress={() => onSelect(day)} |
| 157 | + /> |
| 158 | + ))} |
| 159 | + </View> |
| 160 | + ))} |
| 161 | + </View> |
| 162 | + ); |
| 163 | +} |
| 164 | + |
| 165 | +type DayCellProps = { |
| 166 | + day: string; |
| 167 | + inMonth: boolean; |
| 168 | + isToday: boolean; |
| 169 | + past: boolean; |
| 170 | + selected: boolean; |
| 171 | + care: { kind: TaskKind; late: boolean }[]; |
| 172 | + onPress: () => void; |
| 173 | +}; |
| 174 | + |
| 175 | +/** A day and up to 3 dots for its care: late in red, done in grey. */ |
| 176 | +function DayCell({ day, inMonth, isToday, past, selected, care, onPress }: DayCellProps) { |
| 177 | + const theme = useTheme(); |
| 178 | + const count = care.length; |
| 179 | + const label = [ |
| 180 | + formatDayMonth(day), |
| 181 | + isToday && 'aujourd’hui', |
| 182 | + count > 0 && (past ? `${plural(count, 'soin')} ${count > 1 ? 'faits' : 'fait'}` : plural(count, 'soin')), |
| 183 | + ] |
| 184 | + .filter(Boolean) |
| 185 | + .join(', '); |
| 186 | + const numberColor = selected |
| 187 | + ? theme.onPrimary |
| 188 | + : isToday |
| 189 | + ? theme.primary |
| 190 | + : inMonth |
| 191 | + ? theme.text |
| 192 | + : theme.textTertiary; |
| 193 | + |
| 194 | + return ( |
| 195 | + <Pressable |
| 196 | + accessibilityRole="button" |
| 197 | + accessibilityLabel={label} |
| 198 | + accessibilityState={{ selected }} |
| 199 | + onPress={onPress} |
| 200 | + style={{ flex: 1, minHeight: touchTarget + 4, alignItems: 'center', gap: 3, paddingVertical: 2 }}> |
| 201 | + <View |
| 202 | + style={{ |
| 203 | + width: 36, |
| 204 | + height: 36, |
| 205 | + borderRadius: radius.full, |
| 206 | + alignItems: 'center', |
| 207 | + justifyContent: 'center', |
| 208 | + borderWidth: isToday && !selected ? 1.5 : 0, |
| 209 | + borderColor: theme.primary, |
| 210 | + backgroundColor: selected ? theme.primary : undefined, |
| 211 | + }}> |
| 212 | + <Text variant={selected || isToday ? 'bodyStrong' : 'body'} color={numberColor}> |
| 213 | + {parseDate(day).getDate()} |
| 214 | + </Text> |
| 215 | + </View> |
| 216 | + <View style={{ flexDirection: 'row', gap: 3, height: 6, opacity: inMonth ? 1 : 0.5 }}> |
| 217 | + {care.slice(0, 3).map((item, index) => ( |
| 218 | + <View |
| 219 | + key={index} |
| 220 | + style={{ |
| 221 | + width: 6, |
| 222 | + height: 6, |
| 223 | + borderRadius: radius.full, |
| 224 | + backgroundColor: item.late ? theme.error : past ? theme.outline : theme.primary, |
| 225 | + }} |
| 226 | + /> |
| 227 | + ))} |
| 228 | + </View> |
| 229 | + </Pressable> |
| 230 | + ); |
| 231 | +} |
| 232 | + |
| 233 | +type DayDetailsProps = { |
| 234 | + day: string; |
| 235 | + today: string; |
| 236 | + planned: Occurrence[]; |
| 237 | + done: CareEvent[]; |
| 238 | + plantsById: Map<string, Plant>; |
| 239 | + roomNames: Map<string, string>; |
| 240 | +}; |
| 241 | + |
| 242 | +/** The selected day: its care to do (today and later), or what was done (today and before). */ |
| 243 | +function DayDetails({ day, today, planned, done, plantsById, roomNames }: DayDetailsProps) { |
| 244 | + const isToday = day === today; |
| 245 | + const past = day < today; |
| 246 | + const late = planned.filter((o) => o.late); |
| 247 | + const due = planned.filter((o) => !o.late); |
| 248 | + const title = isToday ? `Aujourd’hui, ${formatLongDate(day)}` : capitalize(formatLongDate(day)); |
| 249 | + const roomOf = (plant: Plant) => (plant.room_id ? roomNames.get(plant.room_id) : null); |
| 250 | + |
| 251 | + // Today, rows act like on the Today screen: tap for the actions, tick when done. |
| 252 | + const todayRows = (list: Occurrence[]) => |
| 253 | + list.map(({ task }) => { |
| 254 | + const plant = plantsById.get(task.plant_id); |
| 255 | + if (!plant) return null; |
| 256 | + return ( |
| 257 | + <TaskRow |
| 258 | + key={task.id} |
| 259 | + task={task} |
| 260 | + plant={plant} |
| 261 | + roomName={roomOf(plant)} |
| 262 | + onPress={() => openTask(task.id)} |
| 263 | + onComplete={() => careActions.complete(task.id)} |
| 264 | + /> |
| 265 | + ); |
| 266 | + }); |
| 267 | + |
| 268 | + const empty = past |
| 269 | + ? 'Rien de noté dans le journal ce jour-là.' |
| 270 | + : isToday |
| 271 | + ? 'Rien à faire aujourd’hui.' |
| 272 | + : 'Aucun soin prévu ce jour-là.'; |
| 273 | + const nothing = planned.length === 0 && (past || isToday ? done.length === 0 : true); |
| 274 | + |
| 275 | + return ( |
| 276 | + <View style={{ gap: spacing.lg }}> |
| 277 | + <Text variant="heading" accessibilityRole="header" accessibilityLiveRegion="polite"> |
| 278 | + {title} |
| 279 | + </Text> |
| 280 | + {nothing && ( |
| 281 | + <Text variant="subhead" tone="secondary" style={{ paddingHorizontal: spacing.xs }}> |
| 282 | + {empty} |
| 283 | + </Text> |
| 284 | + )} |
| 285 | + {!past && late.length > 0 && <ListSection title="En retard">{todayRows(late)}</ListSection>} |
| 286 | + {!past && due.length > 0 && ( |
| 287 | + <ListSection title={isToday ? 'À faire' : 'Prévu'}> |
| 288 | + {isToday |
| 289 | + ? todayRows(due) |
| 290 | + : due.map(({ task }) => { |
| 291 | + const plant = plantsById.get(task.plant_id); |
| 292 | + if (!plant) return null; |
| 293 | + return <PlannedRow key={task.id} task={task} plant={plant} roomName={roomOf(plant)} />; |
| 294 | + })} |
| 295 | + </ListSection> |
| 296 | + )} |
| 297 | + {(past || isToday) && done.length > 0 && ( |
| 298 | + <ListSection title="Fait"> |
| 299 | + {done.map((event) => { |
| 300 | + const plant = plantsById.get(event.plant_id); |
| 301 | + if (!plant) return null; |
| 302 | + return <DoneRow key={event.id} event={event} plant={plant} />; |
| 303 | + })} |
| 304 | + </ListSection> |
| 305 | + )} |
| 306 | + </View> |
| 307 | + ); |
| 308 | +} |
| 309 | + |
| 310 | +/** The plant's photo with the care's badge, as on the Today screen. */ |
| 311 | +function CareThumb({ plant, kind }: { plant: Plant; kind: TaskKind }) { |
| 312 | + const theme = useTheme(); |
| 313 | + return ( |
| 314 | + <View> |
| 315 | + <PlantThumb uri={plant.main_photo_uri} size={48} /> |
| 316 | + <View style={{ position: 'absolute', right: -6, bottom: -6 }}> |
| 317 | + <View style={{ borderRadius: radius.full, borderWidth: 2, borderColor: theme.surfaceContainerLow }}> |
| 318 | + <TaskKindBadge task={{ kind }} size={24} /> |
| 319 | + </View> |
| 320 | + </View> |
| 321 | + </View> |
| 322 | + ); |
| 323 | +} |
| 324 | + |
| 325 | +/** Care planned on a later day. */ |
| 326 | +function PlannedRow({ task, plant, roomName }: { task: Task; plant: Plant; roomName?: string | null }) { |
| 327 | + return ( |
| 328 | + <ListRow |
| 329 | + leading={<CareThumb plant={plant} kind={task.kind} />} |
| 330 | + title={plant.nickname} |
| 331 | + subtitle={[taskTitle(task), roomName].filter(Boolean).join(' · ')} |
| 332 | + onPress={() => openTask(task.id)} |
| 333 | + accessibilityHint="Ouvre les actions de la tâche" |
| 334 | + /> |
| 335 | + ); |
| 336 | +} |
| 337 | + |
| 338 | +/** Care written in the journal that day. */ |
| 339 | +function DoneRow({ event, plant }: { event: CareEvent; plant: Plant }) { |
| 340 | + const text = `${describeEvent(event)} à ${formatTime(event.occurred_at)}`; |
| 341 | + return ( |
| 342 | + <ListRow |
| 343 | + leading={<CareThumb plant={plant} kind={event.task_kind} />} |
| 344 | + title={plant.nickname} |
| 345 | + subtitle={event.note ? `${text}\n« ${event.note} »` : text} |
| 346 | + /> |
| 347 | + ); |
| 348 | +} |
0 commit comments