From e74b8a987a3997dd320dab2a52eae3fbf094d572 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Sat, 24 Jan 2026 08:31:46 -1000 Subject: [PATCH 1/3] Min bars for BarChart. Closes #141 --- apps/mobile/src/components/BarChart.tsx | 33 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/BarChart.tsx b/apps/mobile/src/components/BarChart.tsx index 22c4ca1..07d7d17 100644 --- a/apps/mobile/src/components/BarChart.tsx +++ b/apps/mobile/src/components/BarChart.tsx @@ -173,8 +173,35 @@ export function BarChart({ } } else { // For week and month views, iterate day by day - const currentDate = new Date(oldestDate) - while (currentDate <= newestDate) { + const msInDay = 24 * 60 * 60 * 1000 + + const normalizedOldestDate = new Date(oldestDate) + normalizedOldestDate.setHours(0, 0, 0, 0) + + const normalizedNewestDate = new Date(newestDate) + normalizedNewestDate.setHours(0, 0, 0, 0) + + // Like Year view, always show up through "today" (unless there is future data/events). + const endDate = new Date(Math.max(today.getTime(), normalizedNewestDate.getTime())) + endDate.setHours(0, 0, 0, 0) + + const minBarsForRange = timeRange === 'Week' ? 7 : 30 + + // Clamp start so we don't get negative ranges (e.g. future-only data). + const naturalStartDate = new Date(Math.min(normalizedOldestDate.getTime(), endDate.getTime())) + naturalStartDate.setHours(0, 0, 0, 0) + + const totalDays = Math.floor((endDate.getTime() - naturalStartDate.getTime()) / msInDay) + 1 + + // Backfill empty days before the first day to ensure a minimum bar count. + const startDate = new Date(naturalStartDate) + if (totalDays < minBarsForRange) { + const daysToBackfill = minBarsForRange - totalDays + startDate.setDate(startDate.getDate() - daysToBackfill) + } + + const currentDate = new Date(startDate) + while (currentDate <= endDate) { const existingData = data.find(d => { const dDate = new Date(d.date) dDate.setHours(0, 0, 0, 0) @@ -375,7 +402,7 @@ export function BarChart({ } } - if (data.length === 0) { + if (data.length === 0 && events.length === 0) { return ( No data available From 252e2bfd8dc5f9dbbb56dc31502ff691097dfc3d Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Sat, 24 Jan 2026 08:36:37 -1000 Subject: [PATCH 2/3] Min bars for BarChart: component test --- apps/mobile/src/components/BarChart.tsx | 1 + .../components/__tests__/BarChart.test.tsx | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 apps/mobile/src/components/__tests__/BarChart.test.tsx diff --git a/apps/mobile/src/components/BarChart.tsx b/apps/mobile/src/components/BarChart.tsx index 07d7d17..d5b8a76 100644 --- a/apps/mobile/src/components/BarChart.tsx +++ b/apps/mobile/src/components/BarChart.tsx @@ -490,6 +490,7 @@ export function BarChart({ return ( { + const baseNow = new Date('2026-01-24T12:00:00') // local time (avoid timezone drift) + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(baseNow) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + const todayAtMidnight = () => { + const d = new Date(baseNow) + d.setHours(0, 0, 0, 0) + + return d + } + + it.each([ + { timeRange: 'Week' as const, expectedBars: 7 }, + { timeRange: 'Month' as const, expectedBars: 30 }, + { timeRange: 'Year' as const, expectedBars: 12 }, + ])('renders at least the minimum bars for $timeRange when only 1 datum exists today', ({ timeRange, expectedBars }) => { + const datum: BarChartInputDatum = { + date: todayAtMidnight(), + value: 1, + } + + render( + + ) + + expect(screen.getAllByTestId('bar-chart-bar')).toHaveLength(expectedBars) + }) + + it.each([ + { timeRange: 'Week' as const, expectedBars: 7 }, + { timeRange: 'Month' as const, expectedBars: 30 }, + { timeRange: 'Year' as const, expectedBars: 12 }, + ])('renders at least the minimum bars for $timeRange when only 1 event exists today', ({ timeRange, expectedBars }) => { + render( + + ) + + expect(screen.getAllByTestId('bar-chart-bar')).toHaveLength(expectedBars) + }) +}) From f3d724b58115da0c160a228478e66095ee85fada Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Sat, 24 Jan 2026 09:19:12 -1000 Subject: [PATCH 3/3] Todo list filters for plants and fertilizers. Closes #150 --- apps/api/src/services/chores/index.ts | 55 +++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/chores/index.ts b/apps/api/src/services/chores/index.ts index 6018eea..0a677d0 100644 --- a/apps/api/src/services/chores/index.ts +++ b/apps/api/src/services/chores/index.ts @@ -2,6 +2,7 @@ import mongoose, { type FilterQuery } from 'mongoose' import { Chore, + Fertilizer, Plant, PlantLifecycleEvent, type IChore, @@ -24,11 +25,57 @@ export const getChores = async ({ userId?: string, }) => { const query: FilterQuery = userId ? { user: userId } : {} - if (q) { - query.$or = [ - { description: { $regex: q, $options: 'i' } }, - { notes: { $regex: q, $options: 'i' } }, + + if (q?.trim()) { + const searchRegex = { $regex: q?.trim(), $options: 'i' } + + // Find chores with matching fields on the chore itself. + const orClauses: FilterQuery[] = [ + { description: searchRegex }, + { notes: searchRegex }, ] + + // Also include chores that belong to plants matching the query. + await Plant + .find({ + ...(userId ? { user: userId } : {}), + $or: [ + { name: searchRegex }, + { notes: searchRegex }, + ], + }) + .select({ chores: 1 }) + .lean() + .then((plants) => { + const plantChoreIds = Array.from(new Set( + plants.flatMap((p) => (p.chores ?? []).map((id) => id.toString())), + )).map((id) => new mongoose.Types.ObjectId(id)) + + if (plantChoreIds.length > 0) { + orClauses.push({ _id: { $in: plantChoreIds } }) + } + }) + + // Also include chores that have fertilizers matching the query. + await Fertilizer + .find({ + ...(userId ? { user: userId } : {}), + $or: [ + { name: searchRegex }, + { notes: searchRegex }, + ], + }) + .select({ _id: 1 }) + .lean() + .then((fertilizers) => { + const fertilizerIds = fertilizers.map((f) => new mongoose.Types.ObjectId(f._id)) + + if (fertilizerIds.length > 0) { + orClauses.push({ 'fertilizers.fertilizer': { $in: fertilizerIds } }) + } + }) + + query.$or = orClauses } const choresRaw = await Chore