Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions apps/api/src/services/chores/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import mongoose, { type FilterQuery } from 'mongoose'

import {
Chore,
Fertilizer,
Plant,
PlantLifecycleEvent,
type IChore,
Expand All @@ -24,11 +25,57 @@ export const getChores = async ({
userId?: string,
}) => {
const query: FilterQuery<IChore> = 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<IChore>[] = [
{ 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
Expand Down
34 changes: 31 additions & 3 deletions apps/mobile/src/components/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -375,7 +402,7 @@ export function BarChart({
}
}

if (data.length === 0) {
if (data.length === 0 && events.length === 0) {
return (
<View style={[barChartStyles.container, { height, justifyContent: 'center', alignItems: 'center' }]}>
<Text style={barChartStyles.noDataText}>No data available</Text>
Expand Down Expand Up @@ -463,6 +490,7 @@ export function BarChart({
return (
<TouchableOpacity
key={index}
testID='bar-chart-bar'
style={[
barChartStyles.barWrapper,
{
Expand Down
66 changes: 66 additions & 0 deletions apps/mobile/src/components/__tests__/BarChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react'
import { render, screen } from '@testing-library/react-native'

import { BarChart, type BarChartInputDatum } from '../BarChart'

describe('BarChart minimum bars', () => {
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(
<BarChart
timeRange={timeRange}
data={[datum]}
events={[]}
/>
)

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(
<BarChart
timeRange={timeRange}
data={[]}
events={[
{
date: todayAtMidnight(),
label: 'event',
},
]}
/>
)

expect(screen.getAllByTestId('bar-chart-bar')).toHaveLength(expectedBars)
})
})