Compare commits

...
5 Commits
Author SHA1 Message Date
ginnoir 7d0f02996f chore: release v0.4.3
Release / build-and-push (push) Has been cancelled
2026-06-02 03:25:03 -05:00
ginnoir 32e97b0677 fix(garden): replace perenual with openplantbook for species lookup
Perenual's free tier does not serve care instructions. OpenPlantBook
provides watering, sunlight, and other care data at no cost.

- OAuth2 client_credentials token fetch with module-level cache
- Search via /api/v1/plant/search/, detail via /api/v1/plant/detail/{pid}/?include=care
- SpeciesSuggestion shape unchanged; no frontend or schema changes required
- Env vars: OPENPLANTBOOK_CLIENT_ID + OPENPLANTBOOK_CLIENT_SECRET (replaces PERENUAL_API_KEY)
2026-06-02 03:24:20 -05:00
ginnoir e0b423aad5 chore: remove dead code and unused dependencies
- Delete src/placeholder.ts (empty stub with no references)
- Remove zod-to-json-schema dep (not imported anywhere in src/)
- Remove @eslint/eslintrc devDep (project uses flat config with @eslint/js)
- Remove @types/react-grid-layout devDep (react-grid-layout v2 ships own types)
2026-06-02 02:37:56 -05:00
ginnoir 389ff6040b ci: cache next.js build output and parallelize checks with build 2026-06-02 02:05:34 -05:00
ginnoir 65d36bab4e fix: drive bottom nav from module registry so all modules appear on mobile
Release / build-and-push (push) Has been cancelled
The bottom nav was hardcoded; the sidebar already read from the module
registry. Converted BottomNav to a server component that calls
getRegistry() using the same pattern as Sidebar, so any module with a
nav field automatically populates both nav surfaces.

Bumps version to 0.4.2.
2026-06-02 01:55:36 -05:00
9 changed files with 125 additions and 91 deletions
+3 -2
View File
@@ -40,5 +40,6 @@ MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
# Perenual plant species API (https://perenual.com — free tier available)
PERENUAL_API_KEY=
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET=
+25 -3
View File
@@ -6,7 +6,7 @@ on:
pull_request:
jobs:
verify:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -28,10 +28,32 @@ jobs:
- run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.3
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-nextjs-
- run: pnpm build
env:
# Build only — no DB connection. Provide a placeholder so any
# module-level reads of DATABASE_URL don't crash the build.
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
AUTH_SECRET: ci-placeholder-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
### Bug Fixes
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
All notable changes to famapp are documented here.
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
+2 -1
View File
@@ -16,7 +16,8 @@ ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm install --offline --frozen-lockfile
RUN pnpm build
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
pnpm build
# ── Stage 3: production runtime ───────────────────────────────────────────────
FROM node:22-alpine AS runner
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.4.0",
"version": "0.4.3",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -48,7 +48,6 @@
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@release-it/conventional-changelog": "^11.0.1",
@@ -56,7 +55,6 @@
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
@@ -98,7 +96,6 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
"zod": "^4.4.3"
}
}
-24
View File
@@ -83,9 +83,6 @@ importers:
zod:
specifier: ^4.4.3
version: 4.4.3
zod-to-json-schema:
specifier: ^3.25.2
version: 3.25.2(zod@4.4.3)
devDependencies:
'@commitlint/cli':
specifier: ^21.0.2
@@ -93,9 +90,6 @@ importers:
'@commitlint/config-conventional':
specifier: ^21.0.2
version: 21.0.2
'@eslint/eslintrc':
specifier: ^3.3.5
version: 3.3.5
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
@@ -117,9 +111,6 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/react-grid-layout':
specifier: ^2.1.0
version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
@@ -1877,10 +1868,6 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-grid-layout@2.1.0':
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
@@ -6564,13 +6551,6 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-grid-layout@2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
react-grid-layout: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
transitivePeerDependencies:
- react
- react-dom
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
@@ -10093,10 +10073,6 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
+11 -11
View File
@@ -1,19 +1,19 @@
"use client";
import { getRegistry } from "@/modules/_core/registry";
import { NavLink } from "@/components/nav-link";
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
{ href: "/", label: "Dashboard", icon: "home" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/lists", label: "Lists", icon: "list" },
{ href: "/notes", label: "Notes", icon: "note" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export async function BottomNav() {
const { modules } = getRegistry();
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
const items = [
{ href: "/", label: "Dashboard", icon: "home" },
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
{ href: "/settings", label: "Settings", icon: "settings" },
];
export function BottomNav() {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{ITEMS.map((it) => (
{items.map((it) => (
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
))}
</nav>
+76 -44
View File
@@ -13,60 +13,89 @@ export type SpeciesSuggestion = {
};
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const BASE_URL = "https://perenual.com";
const BASE_URL = "https://open.plantbook.io/api/v1";
type TokenCache = { access_token: string; expiresAt: number } | null;
let tokenCache: TokenCache = null;
async function getToken(): Promise<string | null> {
const clientId = process.env.OPENPLANTBOOK_CLIENT_ID;
const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.access_token;
}
try {
const res = await fetch(`${BASE_URL}/token/`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
cache: "no-store",
});
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
const json = (await res.json()) as { access_token: string; expires_in: number };
tokenCache = {
access_token: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
return tokenCache.access_token;
} catch (err) {
console.error("[species-lookup] getToken failed", err);
return null;
}
}
function parseSunlight(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value === "string" && value) return value.split(", ");
return [];
}
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
const rawNames = item.scientific_name;
const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : "";
const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : [];
const defaultImage = item.default_image as Record<string, unknown> | null | undefined;
const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null;
return {
id: String(item.id),
common_name: String(item.common_name ?? ""),
scientific_name: scientificName,
id: String(item.pid ?? ""),
common_name: String(item.alias ?? ""),
scientific_name: String(item.display_pid ?? ""),
watering: String(item.watering ?? ""),
sunlight,
cycle: String(item.cycle ?? ""),
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
sunlight: parseSunlight(item.sunlight),
cycle: "",
default_image_url: item.image_url ? String(item.image_url) : null,
};
}
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return [];
if (!query.trim()) return [];
const token = await getToken();
if (!token) return [];
try {
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
const url = `${BASE_URL}/plant/search/?alias=${encodeURIComponent(query)}&limit=20`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status}`);
const json = (await res.json()) as { data?: unknown[] };
if (!Array.isArray(json.data)) return [];
const json = (await res.json()) as { results?: unknown[] };
if (!Array.isArray(json.results)) return [];
const results = json.data.map((item) => parseItem(item as Record<string, unknown>));
const pids = json.results
.map((r) => String((r as Record<string, unknown>).pid ?? ""))
.filter(Boolean);
await Promise.allSettled(
results.map((r) =>
db
.insert(gardenSpeciesCache)
.values({
speciesId: r.id,
data: r as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
}),
),
);
return results;
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
return details
.filter(
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
r.status === "fulfilled" && r.value !== null,
)
.map((r) => r.value);
} catch (err) {
console.error("[species-lookup] searchSpecies failed", err);
return [];
@@ -83,12 +112,15 @@ export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | nu
if (cached) return cached.data as unknown as SpeciesSuggestion;
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return null;
const token = await getToken();
if (!token) return null;
try {
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, { cache: "no-store" });
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}/?include=care`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
const item = (await res.json()) as Record<string, unknown>;
-1
View File
@@ -1 +0,0 @@
export {};