Skip to content

Commit a3c536e

Browse files
committed
Merge branch 'worktree-agent-a6e381dc0b49bf0ee'
2 parents 0b264dc + 9a9cdfd commit a3c536e

19 files changed

Lines changed: 1407 additions & 83 deletions

‎src/app/(tabs)/index.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ export default function Today() {
8787
title="Aujourd’hui"
8888
subtitle={`${capitalize(formatLongDate(day))} · ${place.name}`}
8989
actions={
90-
<IconButton icon={icons.settings} label="Réglages" onPress={() => router.push('/settings')} />
90+
<>
91+
<IconButton icon={icons.calendar} label="Calendrier" onPress={() => router.push('/calendar')} />
92+
<IconButton icon={icons.settings} label="Réglages" onPress={() => router.push('/settings')} />
93+
</>
9194
}
9295
/>
9396

‎src/app/_layout.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ function App() {
9595
<Stack.Screen name="plant/[id]/edit" options={{ title: 'Modifier la plante', presentation: 'modal' }} />
9696
<Stack.Screen name="plant/[id]/diagnose" options={{ title: 'Diagnostic' }} />
9797
<Stack.Screen name="plant/[id]/ask" options={{ title: 'Demande à Plantule' }} />
98+
<Stack.Screen name="plant/[id]/photos" options={{ title: 'Photos' }} />
99+
<Stack.Screen
100+
name="plant/[id]/photo/[photoId]"
101+
options={{
102+
// Photos are shown on a dark background, whatever the phone's theme.
103+
title: '',
104+
animation: 'fade',
105+
headerStyle: { backgroundColor: palettes.dark.background },
106+
headerTintColor: palettes.dark.text,
107+
headerTitleStyle: { ...typography.heading, color: palettes.dark.text },
108+
contentStyle: { backgroundColor: palettes.dark.background },
109+
}}
110+
/>
98111
<Stack.Screen name="diagnosis/[id]" options={{ title: 'Diagnostic' }} />
99112
<Stack.Screen name="task/new" options={{ title: 'Nouvelle tâche', presentation: 'modal' }} />
100113
<Stack.Screen
@@ -112,6 +125,7 @@ function App() {
112125
<Stack.Screen name="place/new" options={{ title: 'Nouveau lieu', presentation: 'modal' }} />
113126
<Stack.Screen name="place/edit" options={{ title: 'Renommer le lieu', presentation: 'modal' }} />
114127
<Stack.Screen name="settings" options={{ title: 'Réglages' }} />
128+
<Stack.Screen name="calendar" options={{ title: 'Calendrier' }} />
115129
<Stack.Screen name="scan/identify" options={{ title: 'Identification' }} />
116130
<Stack.Screen name="scan/sheet" options={{ title: 'Fiche espèce' }} />
117131
</Stack>

‎src/app/calendar.tsx‎

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
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

Comments
 (0)