- Fix ShareButton silently swallowing errors from createShareLink; now shows inline error text so failures are visible to the user - Add getShareLinksForEntity server action and EntityShareLink type to _core/share.ts - Add ShareLinkList component — renders active share links per entity with per-row Revoke; renders nothing when empty - Wire ShareLinkList into plant and container detail pages (loaded server-side in parallel with the entity fetch) - Add images jsonb column to garden_containers schema + migration 0018 - Add addContainerImage / removeContainerImage / setContainerPrimaryImage server actions mirroring the plant image pattern (10-image cap, first upload auto-sets cover) - Update ContainerDetailDto, listContainers, getContainer to include images - Rewrite ContainerDetail with Info/Gallery tabs; Gallery tab mirrors plant gallery (3-col grid, star/X overlays, upload button, counter) - Update ContainerShareData and container renderSharedView to show cover image hero and secondary image grid on public share pages
625 lines
18 KiB
TypeScript
625 lines
18 KiB
TypeScript
import { and, desc, eq, lte, sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { getCurrentSession } from "@/lib/session";
|
|
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
|
|
|
|
export type ContainerDto = {
|
|
id: string;
|
|
householdId: string;
|
|
name: string;
|
|
type: string;
|
|
locationNotes: string | null;
|
|
coverImageUrl: string | null;
|
|
images: string[];
|
|
plantCount: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type ContainerDetailDto = ContainerDto & {
|
|
plants: PlantSummaryDto[];
|
|
};
|
|
|
|
export type PlantSummaryDto = {
|
|
id: string;
|
|
name: string;
|
|
scientificName: string | null;
|
|
healthStatus: string;
|
|
primaryImageUrl: string | null;
|
|
category: string;
|
|
};
|
|
|
|
export async function listContainers(): Promise<ContainerDto[]> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: gardenContainers.id,
|
|
householdId: gardenContainers.householdId,
|
|
name: gardenContainers.name,
|
|
type: gardenContainers.type,
|
|
locationNotes: gardenContainers.locationNotes,
|
|
coverImageUrl: gardenContainers.coverImageUrl,
|
|
images: gardenContainers.images,
|
|
createdAt: gardenContainers.createdAt,
|
|
updatedAt: gardenContainers.updatedAt,
|
|
plantCount: sql<number>`count(${gardenPlants.id})::int`,
|
|
})
|
|
.from(gardenContainers)
|
|
.leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id))
|
|
.where(eq(gardenContainers.householdId, household.id))
|
|
.groupBy(gardenContainers.id)
|
|
.orderBy(gardenContainers.name);
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
images: r.images ?? [],
|
|
plantCount: r.plantCount ?? 0,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
}));
|
|
}
|
|
|
|
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const [container] = await db
|
|
.select({
|
|
id: gardenContainers.id,
|
|
householdId: gardenContainers.householdId,
|
|
name: gardenContainers.name,
|
|
type: gardenContainers.type,
|
|
locationNotes: gardenContainers.locationNotes,
|
|
coverImageUrl: gardenContainers.coverImageUrl,
|
|
images: gardenContainers.images,
|
|
createdAt: gardenContainers.createdAt,
|
|
updatedAt: gardenContainers.updatedAt,
|
|
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
|
|
})
|
|
.from(gardenContainers)
|
|
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
|
|
.limit(1);
|
|
|
|
if (!container) return null;
|
|
|
|
const plants = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
name: gardenPlants.name,
|
|
scientificName: gardenPlants.scientificName,
|
|
healthStatus: gardenPlants.healthStatus,
|
|
primaryImageUrl: gardenPlants.primaryImageUrl,
|
|
category: gardenPlants.category,
|
|
})
|
|
.from(gardenPlants)
|
|
.where(eq(gardenPlants.containerId, id))
|
|
.orderBy(desc(gardenPlants.name));
|
|
|
|
return {
|
|
id: container.id,
|
|
householdId: container.householdId,
|
|
name: container.name,
|
|
type: container.type,
|
|
locationNotes: container.locationNotes,
|
|
coverImageUrl: container.coverImageUrl,
|
|
images: container.images ?? [],
|
|
plantCount: container.plantCount ?? 0,
|
|
createdAt: container.createdAt.toISOString(),
|
|
updatedAt: container.updatedAt.toISOString(),
|
|
plants,
|
|
};
|
|
}
|
|
|
|
export async function canAccessContainer(id: string, householdId: string): Promise<boolean> {
|
|
const [row] = await db
|
|
.select({ id: gardenContainers.id })
|
|
.from(gardenContainers)
|
|
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
|
|
.limit(1);
|
|
|
|
return !!row;
|
|
}
|
|
|
|
export async function searchContainers(query: string, householdId: string) {
|
|
const rows = await db
|
|
.select({ id: gardenContainers.id, name: gardenContainers.name })
|
|
.from(gardenContainers)
|
|
.where(
|
|
and(
|
|
eq(gardenContainers.householdId, householdId),
|
|
sql`${gardenContainers.name} ilike ${`%${query}%`}`,
|
|
),
|
|
)
|
|
.limit(10);
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.name,
|
|
url: `/garden/containers/${r.id}`,
|
|
}));
|
|
}
|
|
|
|
// ─── Plant queries ────────────────────────────────────────────────────────────
|
|
|
|
export type PlantListItemDto = {
|
|
id: string;
|
|
containerId: string | null;
|
|
containerName: string | null;
|
|
name: string;
|
|
healthStatus: string;
|
|
primaryImageUrl: string | null;
|
|
category: string;
|
|
lastWateredAt: string | null;
|
|
hasOverdueCare: boolean;
|
|
};
|
|
|
|
export type CareLogSummaryDto = {
|
|
id: string;
|
|
careType: string;
|
|
notes: string | null;
|
|
performedAt: string;
|
|
};
|
|
|
|
export type CareScheduleSummaryDto = {
|
|
id: string;
|
|
careType: string;
|
|
intervalDays: number;
|
|
nextDueAt: string | null;
|
|
enabled: boolean;
|
|
};
|
|
|
|
export type PlantDetailDto = {
|
|
id: string;
|
|
householdId: string;
|
|
containerId: string | null;
|
|
containerName: string | null;
|
|
name: string;
|
|
scientificName: string | null;
|
|
speciesId: string | null;
|
|
category: string;
|
|
notes: string | null;
|
|
acquisitionDate: string | null;
|
|
growthStage: string | null;
|
|
healthStatus: string;
|
|
sunlight: string | null;
|
|
wateringNotes: string | null;
|
|
fertilizingNotes: string | null;
|
|
primaryImageUrl: string | null;
|
|
images: string[];
|
|
recentCareLogs: CareLogSummaryDto[];
|
|
activeSchedules: CareScheduleSummaryDto[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
|
|
PlantListItemDto[]
|
|
> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const filter = containerId
|
|
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
|
|
: eq(gardenPlants.householdId, household.id);
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
containerId: gardenPlants.containerId,
|
|
containerName: gardenContainers.name,
|
|
name: gardenPlants.name,
|
|
healthStatus: gardenPlants.healthStatus,
|
|
primaryImageUrl: gardenPlants.primaryImageUrl,
|
|
category: gardenPlants.category,
|
|
lastWateredAt: sql<string | null>`(
|
|
select performed_at::text from garden_care_logs
|
|
where plant_id = ${gardenPlants.id}
|
|
and care_type = 'watering'
|
|
order by performed_at desc
|
|
limit 1
|
|
)`,
|
|
hasOverdueCare: sql<boolean>`exists(
|
|
select 1 from garden_care_schedules
|
|
where plant_id = ${gardenPlants.id}
|
|
and enabled = true
|
|
and next_due_at < now()
|
|
)`,
|
|
})
|
|
.from(gardenPlants)
|
|
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
|
|
.where(filter)
|
|
.orderBy(gardenContainers.name, gardenPlants.name);
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
containerName: r.containerName ?? null,
|
|
lastWateredAt: r.lastWateredAt ?? null,
|
|
hasOverdueCare: !!r.hasOverdueCare,
|
|
}));
|
|
}
|
|
|
|
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const [row] = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
householdId: gardenPlants.householdId,
|
|
containerId: gardenPlants.containerId,
|
|
containerName: gardenContainers.name,
|
|
name: gardenPlants.name,
|
|
scientificName: gardenPlants.scientificName,
|
|
speciesId: gardenPlants.speciesId,
|
|
category: gardenPlants.category,
|
|
notes: gardenPlants.notes,
|
|
acquisitionDate: gardenPlants.acquisitionDate,
|
|
growthStage: gardenPlants.growthStage,
|
|
healthStatus: gardenPlants.healthStatus,
|
|
sunlight: gardenPlants.sunlight,
|
|
wateringNotes: gardenPlants.wateringNotes,
|
|
fertilizingNotes: gardenPlants.fertilizingNotes,
|
|
primaryImageUrl: gardenPlants.primaryImageUrl,
|
|
images: gardenPlants.images,
|
|
createdAt: gardenPlants.createdAt,
|
|
updatedAt: gardenPlants.updatedAt,
|
|
})
|
|
.from(gardenPlants)
|
|
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
|
|
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
|
|
.limit(1);
|
|
|
|
if (!row) return null;
|
|
|
|
const careLogs = await db
|
|
.select({
|
|
id: gardenCareLogs.id,
|
|
careType: gardenCareLogs.careType,
|
|
notes: gardenCareLogs.notes,
|
|
performedAt: gardenCareLogs.performedAt,
|
|
})
|
|
.from(gardenCareLogs)
|
|
.where(eq(gardenCareLogs.plantId, id))
|
|
.orderBy(desc(gardenCareLogs.performedAt))
|
|
.limit(5);
|
|
|
|
const schedules = await db
|
|
.select({
|
|
id: gardenCareSchedules.id,
|
|
careType: gardenCareSchedules.careType,
|
|
intervalDays: gardenCareSchedules.intervalDays,
|
|
nextDueAt: gardenCareSchedules.nextDueAt,
|
|
enabled: gardenCareSchedules.enabled,
|
|
})
|
|
.from(gardenCareSchedules)
|
|
.where(and(eq(gardenCareSchedules.plantId, id), eq(gardenCareSchedules.enabled, true)));
|
|
|
|
return {
|
|
id: row.id,
|
|
householdId: row.householdId,
|
|
containerId: row.containerId,
|
|
containerName: row.containerName ?? null,
|
|
name: row.name,
|
|
scientificName: row.scientificName,
|
|
speciesId: row.speciesId,
|
|
category: row.category,
|
|
notes: row.notes,
|
|
acquisitionDate: row.acquisitionDate,
|
|
growthStage: row.growthStage,
|
|
healthStatus: row.healthStatus,
|
|
sunlight: row.sunlight,
|
|
wateringNotes: row.wateringNotes,
|
|
fertilizingNotes: row.fertilizingNotes,
|
|
primaryImageUrl: row.primaryImageUrl,
|
|
images: row.images,
|
|
recentCareLogs: careLogs.map((l) => ({
|
|
id: l.id,
|
|
careType: l.careType,
|
|
notes: l.notes,
|
|
performedAt: l.performedAt.toISOString(),
|
|
})),
|
|
activeSchedules: schedules.map((s) => ({
|
|
id: s.id,
|
|
careType: s.careType,
|
|
intervalDays: s.intervalDays,
|
|
nextDueAt: s.nextDueAt?.toISOString() ?? null,
|
|
enabled: s.enabled,
|
|
})),
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
export async function searchPlants(query: string, householdId: string) {
|
|
const rows = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
name: gardenPlants.name,
|
|
scientificName: gardenPlants.scientificName,
|
|
})
|
|
.from(gardenPlants)
|
|
.where(
|
|
and(
|
|
eq(gardenPlants.householdId, householdId),
|
|
sql`(${gardenPlants.name} || ' ' || coalesce(${gardenPlants.scientificName}, '')) ilike ${`%${query}%`}`,
|
|
),
|
|
)
|
|
.limit(10);
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.name + (r.scientificName ? ` (${r.scientificName})` : ""),
|
|
url: `/garden/plants/${r.id}`,
|
|
}));
|
|
}
|
|
|
|
// ─── Care queries ─────────────────────────────────────────────────────────────
|
|
|
|
export type CareLogDto = {
|
|
id: string;
|
|
careType: string;
|
|
notes: string | null;
|
|
performedAt: string;
|
|
performedBy: string | null;
|
|
};
|
|
|
|
export type CareScheduleDto = {
|
|
id: string;
|
|
careType: string;
|
|
intervalDays: number;
|
|
lastPerformedAt: string | null;
|
|
nextDueAt: string | null;
|
|
enabled: boolean;
|
|
daysUntilDue: number | null;
|
|
isOverdue: boolean;
|
|
};
|
|
|
|
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: gardenCareLogs.id,
|
|
careType: gardenCareLogs.careType,
|
|
notes: gardenCareLogs.notes,
|
|
performedAt: gardenCareLogs.performedAt,
|
|
performedBy: gardenCareLogs.performedBy,
|
|
})
|
|
.from(gardenCareLogs)
|
|
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id)))
|
|
.orderBy(desc(gardenCareLogs.performedAt))
|
|
.limit(limit);
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
careType: r.careType,
|
|
notes: r.notes,
|
|
performedAt: r.performedAt.toISOString(),
|
|
performedBy: r.performedBy,
|
|
}));
|
|
}
|
|
|
|
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(gardenCareSchedules)
|
|
.where(
|
|
and(
|
|
eq(gardenCareSchedules.plantId, plantId),
|
|
eq(gardenCareSchedules.householdId, household.id),
|
|
),
|
|
)
|
|
.orderBy(gardenCareSchedules.careType);
|
|
|
|
const now = new Date();
|
|
return rows.map((s) => {
|
|
const next = s.nextDueAt;
|
|
const daysUntilDue = next
|
|
? Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
: null;
|
|
return {
|
|
id: s.id,
|
|
careType: s.careType,
|
|
intervalDays: s.intervalDays,
|
|
lastPerformedAt: s.lastPerformedAt?.toISOString() ?? null,
|
|
nextDueAt: next?.toISOString() ?? null,
|
|
enabled: s.enabled,
|
|
daysUntilDue,
|
|
isOverdue: daysUntilDue !== null && daysUntilDue < 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
export type OverduePlantDto = {
|
|
id: string;
|
|
name: string;
|
|
primaryImageUrl: string | null;
|
|
mostOverdueAt: Date;
|
|
};
|
|
|
|
export async function getOverduePlants(householdId: string): Promise<OverduePlantDto[]> {
|
|
const rows = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
name: gardenPlants.name,
|
|
primaryImageUrl: gardenPlants.primaryImageUrl,
|
|
mostOverdueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
|
|
})
|
|
.from(gardenPlants)
|
|
.innerJoin(
|
|
gardenCareSchedules,
|
|
and(
|
|
eq(gardenCareSchedules.plantId, gardenPlants.id),
|
|
eq(gardenCareSchedules.enabled, true),
|
|
lte(gardenCareSchedules.nextDueAt, sql`now()`),
|
|
),
|
|
)
|
|
.where(eq(gardenPlants.householdId, householdId))
|
|
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
|
|
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
|
|
|
|
return rows;
|
|
}
|
|
|
|
export type CareDueSoonDto = {
|
|
id: string;
|
|
name: string;
|
|
primaryImageUrl: string | null;
|
|
nextDueAt: Date;
|
|
};
|
|
|
|
export async function getCareDueSoon(
|
|
householdId: string,
|
|
withinDays: number,
|
|
): Promise<CareDueSoonDto[]> {
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() + withinDays);
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: gardenPlants.id,
|
|
name: gardenPlants.name,
|
|
primaryImageUrl: gardenPlants.primaryImageUrl,
|
|
nextDueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
|
|
})
|
|
.from(gardenPlants)
|
|
.innerJoin(
|
|
gardenCareSchedules,
|
|
and(
|
|
eq(gardenCareSchedules.plantId, gardenPlants.id),
|
|
eq(gardenCareSchedules.enabled, true),
|
|
lte(gardenCareSchedules.nextDueAt, cutoff),
|
|
),
|
|
)
|
|
.where(eq(gardenPlants.householdId, householdId))
|
|
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
|
|
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
|
|
|
|
return rows;
|
|
}
|
|
|
|
// ─── Widget queries ───────────────────────────────────────────────────────────
|
|
|
|
export type CareDueWidgetRow = {
|
|
plantId: string;
|
|
plantName: string;
|
|
scheduleId: string;
|
|
careType: string;
|
|
nextDueAt: Date | null;
|
|
daysUntilDue: number;
|
|
};
|
|
|
|
export async function getCareDueWidgetRows(
|
|
householdId: string,
|
|
containerIds: "all" | string[],
|
|
daysAhead: number,
|
|
): Promise<CareDueWidgetRow[]> {
|
|
const cutoff = new Date();
|
|
cutoff.setDate(cutoff.getDate() + daysAhead);
|
|
|
|
const baseConditions = [
|
|
eq(gardenCareSchedules.householdId, householdId),
|
|
eq(gardenCareSchedules.enabled, true),
|
|
lte(gardenCareSchedules.nextDueAt, cutoff),
|
|
];
|
|
|
|
const containerFilter =
|
|
containerIds !== "all" && containerIds.length > 0
|
|
? sql`${gardenPlants.containerId} = ANY(ARRAY[${sql.join(
|
|
containerIds.map((id) => sql`${id}::uuid`),
|
|
sql`, `,
|
|
)}])`
|
|
: undefined;
|
|
|
|
const where =
|
|
containerFilter !== undefined
|
|
? and(...baseConditions, containerFilter)
|
|
: and(...baseConditions);
|
|
|
|
const rows = await db
|
|
.select({
|
|
plantId: gardenPlants.id,
|
|
plantName: gardenPlants.name,
|
|
scheduleId: gardenCareSchedules.id,
|
|
careType: gardenCareSchedules.careType,
|
|
nextDueAt: gardenCareSchedules.nextDueAt,
|
|
})
|
|
.from(gardenCareSchedules)
|
|
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
|
|
.where(where)
|
|
.orderBy(gardenCareSchedules.nextDueAt);
|
|
|
|
const now = Date.now();
|
|
return rows.map((r) => ({
|
|
plantId: r.plantId,
|
|
plantName: r.plantName,
|
|
scheduleId: r.scheduleId,
|
|
careType: r.careType,
|
|
nextDueAt: r.nextDueAt,
|
|
daysUntilDue: r.nextDueAt
|
|
? Math.ceil((r.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24))
|
|
: 0,
|
|
}));
|
|
}
|
|
|
|
export type GardenOverviewStats = {
|
|
plantCount: number;
|
|
containerCount: number;
|
|
overdueCount: number;
|
|
nextCare: { plantName: string; careType: string; daysUntilDue: number } | null;
|
|
};
|
|
|
|
export async function getGardenOverviewStats(householdId: string): Promise<GardenOverviewStats> {
|
|
const [plantRow, containerRow] = await Promise.all([
|
|
db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(gardenPlants)
|
|
.where(eq(gardenPlants.householdId, householdId)),
|
|
db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(gardenContainers)
|
|
.where(eq(gardenContainers.householdId, householdId)),
|
|
]);
|
|
|
|
const plantCount = plantRow[0]?.count ?? 0;
|
|
const containerCount = containerRow[0]?.count ?? 0;
|
|
|
|
const overdueRows = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(gardenCareSchedules)
|
|
.where(
|
|
and(
|
|
eq(gardenCareSchedules.householdId, householdId),
|
|
eq(gardenCareSchedules.enabled, true),
|
|
lte(gardenCareSchedules.nextDueAt, sql`now()`),
|
|
),
|
|
);
|
|
const overdueCount = overdueRows[0]?.count ?? 0;
|
|
|
|
const nextRows = await db
|
|
.select({
|
|
plantName: gardenPlants.name,
|
|
careType: gardenCareSchedules.careType,
|
|
nextDueAt: gardenCareSchedules.nextDueAt,
|
|
})
|
|
.from(gardenCareSchedules)
|
|
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
|
|
.where(
|
|
and(eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true)),
|
|
)
|
|
.orderBy(gardenCareSchedules.nextDueAt)
|
|
.limit(1);
|
|
|
|
const next = nextRows[0];
|
|
const now = Date.now();
|
|
const nextCare = next?.nextDueAt
|
|
? {
|
|
plantName: next.plantName,
|
|
careType: next.careType,
|
|
daysUntilDue: Math.ceil((next.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)),
|
|
}
|
|
: null;
|
|
|
|
return { plantCount, containerCount, overdueCount, nextCare };
|
|
}
|