Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
a042457
fix(etl): flush batches incrementally to prevent Worker OOM on large …
andrew-bierman May 7, 2026
5fc3c5d
fix(etl): remove weight/weightUnit from required field validation
andrew-bierman May 7, 2026
6a66809
fix(etl): upsert overwrites stale data instead of preserving old values
andrew-bierman May 7, 2026
e871f64
chore(etl): add SQL script to reset zombie ETL jobs stuck in running …
andrew-bierman May 7, 2026
53ffa42
fix(etl): use best-value-wins merge for weight on upsert conflict
andrew-bierman May 7, 2026
132c2a4
fix(types): remove baseUrl, fix ignoreDeprecations, cast TFunction fo…
andrew-bierman May 7, 2026
2f980dc
refactor(etl): replace sql.raw with sql template and sql.identifier i…
andrew-bierman May 7, 2026
59fb5b2
feat(units): add @packrat/units package and migrate all weight math
andrew-bierman May 7, 2026
4007975
fix(etl): increase stuck-job threshold from 30 min to 3 hours
andrew-bierman May 7, 2026
c1f2b9a
Merge pull request #2383 from PackRat-AI/fix/etl-memory-and-weight-va…
andrew-bierman May 7, 2026
779c3a7
fix(checks): add prefers-reduced-motion CSS and bump expo patch versions
andrew-bierman May 7, 2026
2b2e3b3
fix(units): replace raw typeof with isString from @packrat/guards
andrew-bierman May 7, 2026
4e62ae5
feat(admin): pagination, search debounce, reset-stuck endpoint, error…
andrew-bierman May 7, 2026
2996849
refactor(admin): replace hand-rolled URL state with nuqs; fix query k…
andrew-bierman May 7, 2026
354b814
Merge branch 'main' into feat/weight-audit
andrew-bierman May 7, 2026
3124658
refactor(admin): make all queryKeys entries functions for consistent …
andrew-bierman May 7, 2026
480739a
refactor(admin): queryKeys self-referencing factory (tkdodo pattern)
andrew-bierman May 7, 2026
d995526
fix(admin): address CodeRabbit review comments on PR #2386
andrew-bierman May 7, 2026
afc353f
feat(admin): rich data, raw object viewer, react-error-boundary
andrew-bierman May 7, 2026
d25fca5
fix(admin): use next/image for avatars, fix string concat lint warning
andrew-bierman May 7, 2026
3289309
fix(units): avoid noUncheckedIndexedAccess TS errors in percentage test
andrew-bierman May 7, 2026
cfbb19f
fix(units): align WEIGHT_UNITS order with @packrat/api/types ('g','oz…
andrew-bierman May 7, 2026
123e4ce
fix(admin): address CodeRabbit/Copilot review comments round 2
andrew-bierman May 7, 2026
8eda5a0
Merge pull request #2388 from PackRat-AI/feat/weight-audit
andrew-bierman May 7, 2026
585bfdd
Merge pull request #2386 from PackRat-AI/fix/etl-memory-and-weight-va…
andrew-bierman May 7, 2026
2a6c4a3
feat(trails): add trail search micro frontend acquisition surface
andrew-bierman May 7, 2026
2419785
fix(trails): use isString guard instead of raw typeof in parseToken
andrew-bierman May 7, 2026
41a02cc
fix(trails): align devDependency versions with monorepo
andrew-bierman May 7, 2026
dfe3f57
chore: sort root package.json keys
andrew-bierman May 7, 2026
ff7a1df
fix(trails): replace unsafe as-casts with fromZod + makeEnumGuard
andrew-bierman May 7, 2026
f2c147f
refactor(trails): swap manual fetch wrappers for @packrat/api-client
andrew-bierman May 7, 2026
8825db0
fix(trails): safe-cast annotation on Treaty→ApiTrail narrowing
andrew-bierman May 7, 2026
1a3c900
fix(catalog): use double-cast to satisfy TS2352 in treaty response casts
andrew-bierman May 7, 2026
74e5114
refactor(catalog): derive CatalogItem from CatalogItemSchema
andrew-bierman May 7, 2026
29294c6
refactor(auth): derive User type from UserSchema and parse at auth bo…
andrew-bierman May 7, 2026
57bbf09
fix(trails): align @types/react version with monorepo (~19.1.10)
andrew-bierman May 7, 2026
c6256d7
chore: update bun.lock for apps/trails deps (@types/leaflet)
andrew-bierman May 7, 2026
e49810b
fix(trails): address Copilot review findings
andrew-bierman May 7, 2026
ba32482
fix(trails): replace raw process.env with typed env shim
andrew-bierman May 7, 2026
347d82e
fix(trails): replace unsafe cast with fromZod schema validation
andrew-bierman May 7, 2026
8a55893
fix(trails): correct sport/offset types and pin nativewindui to 2.0.3
andrew-bierman May 7, 2026
17ad75c
chore: sort package.json overrides
andrew-bierman May 7, 2026
f9e84c3
feat(admin): ETL failure drill-down and global error summary
andrew-bierman May 7, 2026
51939ec
fix(api): replace unsafe ValidationError casts with fromZod parser
andrew-bierman May 7, 2026
ebccf58
feat(admin): ETL table load-more pagination
andrew-bierman May 8, 2026
fefc42d
fix(trails): address review comments — worker security, auth guards, …
andrew-bierman May 8, 2026
b0d4b22
feat(app): introduce @packrat/app with shared browser/storage utils
andrew-bierman May 8, 2026
6a5edba
fix(admin): address Copilot review comments on PR #2391
andrew-bierman May 8, 2026
ce503a3
chore(trails): remove CF Worker proxy — api-client talks directly to …
andrew-bierman May 8, 2026
5d75348
chore(trails): align API URL env var with Expo — NEXT_PUBLIC_API_URL
andrew-bierman May 8, 2026
bd8d1d2
Merge pull request #2391 from PackRat-AI/feat/admin-etl-pagination
andrew-bierman May 8, 2026
180be97
feat(admin): surface hidden data across all screens
andrew-bierman May 8, 2026
475c220
fix(admin): use 2-decimal formatting for price range min/max
andrew-bierman May 8, 2026
5ad45bb
Merge pull request #2389 from PackRat-AI/feat/trail-search-micro-fron…
andrew-bierman May 8, 2026
1129246
fix(etl): mark jobs completed explicitly + add retry for failed jobs
andrew-bierman May 8, 2026
103e5d8
Merge pull request #2394 from PackRat-AI/fix/etl-completion-and-retry
andrew-bierman May 8, 2026
dccdef3
Merge pull request #2392 from PackRat-AI/feat/admin-rich-display
andrew-bierman May 8, 2026
1e1fe84
chore: bump version to v2.0.25
mikib0 May 8, 2026
e9d59fc
Merge remote-tracking branch 'origin/main' into release/2.0.25
mikib0 May 9, 2026
5da8152
fix: align @types/react version to ~19.2.10 in apps/trails
mikib0 May 9, 2026
3abd7a2
chore: update @types/react version to ~19.2.10 in bun.lock
mikib0 May 9, 2026
7f53fff
chore: sort root package.json keys
mikib0 May 9, 2026
aec5266
chore: resolve all check:all failures
mikib0 May 9, 2026
3535b94
Merge pull request #2397 from PackRat-AI/release/2.0.25
mikib0 May 9, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# Git worktrees
.worktrees/
.worktrees
29 changes: 28 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,39 @@ features/{name}/
- **Feature flags**: `apps/expo/config.ts` — `featureFlags` object, default new flags to `false`
- **Animations**: React Native Reanimated 4

### Web Apps (apps/guides, apps/landing)
### Web Apps (apps/guides, apps/landing, apps/trails)

- Radix UI + Shadcn components, Tailwind CSS
- TanStack React Query for data fetching
- Zod for form validation

### API Client (`@packrat/api-client`)

Use `createApiClient` from `@packrat/api-client` for all PackRat API calls in web apps. **Never write manual fetch wrappers for PackRat API endpoints.**

```ts
// apps/<name>/lib/apiClient.ts
import { createApiClient } from '@packrat/api-client';
import { clearTokens, clearUser, getAccessToken, getRefreshToken, setTokens } from './auth';

export const apiClient = createApiClient({
baseUrl: typeof window !== 'undefined' ? window.location.origin : '',
auth: {
getAccessToken,
getRefreshToken,
onAccessTokenRefreshed: (token) => { /* persist new access token */ },
onRefreshTokenRefreshed: (token) => { /* persist new refresh token */ },
onNeedsReauth: () => { clearTokens(); clearUser(); },
},
});
```

- `baseUrl` should be the same origin when routing through a CF Worker proxy (so rate limiting applies); use `EXPO_PUBLIC_API_URL` for the Expo app
- `AuthHooks` wires your platform's token storage — the package is transport-only
- The client handles 401 → refresh → retry automatically; `onNeedsReauth` fires only when refresh itself fails
- Call via Treaty path syntax: `apiClient.auth.login.post(...)`, `apiClient.trails.search.get({ query: { q } })`
- Responses are `{ data, error, status }` — check `if (error || !data)` before using `data`

## Private Package Auth

`@packrat-ai/nativewindui` is hosted on GitHub Packages. `bunfig.toml` resolves the scope using `$PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN`. Bun auto-loads `.env.local` before running `install`, so the simplest setup is to put the token there alongside your other secrets.
Expand Down
133 changes: 114 additions & 19 deletions apps/admin/app/dashboard/catalog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { Badge } from '@packrat/web-ui/components/badge';
import { Button } from '@packrat/web-ui/components/button';
import { Skeleton } from '@packrat/web-ui/components/skeleton';
import {
Table,
Expand All @@ -13,11 +14,16 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { DeleteButton } from 'admin-app/components/delete-button';
import { EditCatalogDialog } from 'admin-app/components/edit-catalog-dialog';
import { RawObjectDialog } from 'admin-app/components/raw-object-dialog';
import { SearchInput } from 'admin-app/components/search-input';
import { usePaginatedSearch } from 'admin-app/hooks/use-paginated-search';
import { type AdminCatalogItem, deleteCatalogItem, getCatalogItems } from 'admin-app/lib/api';
import { formatDate } from 'admin-app/lib/date';
import { queryKeys } from 'admin-app/lib/queryKeys';
import { useSearchParams } from 'next/navigation';
import { ChevronLeft, ChevronRight, ExternalLink, Star } from 'lucide-react';
import Image from 'next/image';

const PAGE_SIZE = 50;

function TableSkeleton() {
return (
Expand All @@ -32,30 +38,72 @@ function TableSkeleton() {
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
);
}

function availabilityColor(availability: string | null) {
if (availability === 'InStock') return 'text-green-500';
if (availability === 'OutOfStock') return 'text-destructive';
return 'text-muted-foreground';
}

function CatalogRow({ item }: { item: AdminCatalogItem }) {
const queryClient = useQueryClient();

const { mutateAsync: handleDelete } = useMutation({
mutationFn: () => deleteCatalogItem(item.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog() });
queryClient.invalidateQueries({ queryKey: queryKeys.admin.catalog.all() });
},
});

const thumbUrl = item.images?.[0] ?? null;

return (
<TableRow className="hover:bg-muted/20">
<TableCell>
<div>
<p className="text-sm font-medium">{item.name}</p>
{item.brand && <p className="text-xs text-muted-foreground">{item.brand}</p>}
<div className="flex items-start gap-2.5">
{thumbUrl ? (
<Image
src={thumbUrl}
alt=""
width={40}
height={40}
className="rounded object-cover shrink-0 bg-muted"
/>
) : (
<div className="h-10 w-10 rounded bg-muted shrink-0" />
)}
<div>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{item.name}</p>
{item.productUrl && (
<a
href={item.productUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{item.brand && <span className="text-xs text-muted-foreground">{item.brand}</span>}
{item.model && <span className="text-xs text-muted-foreground/60">{item.model}</span>}
</div>
{item.description && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{item.description}
</p>
)}
</div>
</div>
</TableCell>
<TableCell>
Expand Down Expand Up @@ -83,16 +131,35 @@ function CatalogRow({ item }: { item: AdminCatalogItem }) {
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{item.price != null ? `$${item.price.toFixed(2)}` : '—'}
{item.price != null
? `${item.currency && item.currency !== 'USD' ? `${item.currency} ` : '$'}${item.price.toFixed(2)}`
: '—'}
</span>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<span className={`text-xs font-medium ${availabilityColor(item.availability)}`}>
{item.availability ?? '—'}
</span>
{item.ratingValue != null && (
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
<span className="text-xs text-muted-foreground">
{item.ratingValue.toFixed(1)}
{item.reviewCount != null && ` (${item.reviewCount})`}
</span>
</div>
)}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{item.createdAt ? formatDate(new Date(item.createdAt)) : '—'}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<RawObjectDialog label={`item:${item.id}`} data={item} />
<EditCatalogDialog item={item} />
<DeleteButton
label={item.name}
Expand All @@ -108,20 +175,22 @@ function CatalogRow({ item }: { item: AdminCatalogItem }) {
}

export default function CatalogPage() {
const searchParams = useSearchParams();
const q = searchParams?.get('q') ?? undefined;
const { q, setSearch, page, setPage } = usePaginatedSearch();
const offset = page * PAGE_SIZE;

const {
data: result,
isLoading,
isError,
} = useQuery({
queryKey: queryKeys.admin.catalog(q),
queryFn: () => getCatalogItems({ q }),
queryKey: queryKeys.admin.catalog.list({ q: q || undefined, page }),
queryFn: () => getCatalogItems({ q: q || undefined, limit: PAGE_SIZE, offset }),
});

const items = result?.data ?? [];
const total = result?.total ?? 0;
const hasPrev = page > 0;
const hasNext = items.length === PAGE_SIZE;

return (
<div>
Expand All @@ -132,7 +201,7 @@ export default function CatalogPage() {
</p>
</div>
<div className="space-y-4">
<SearchInput placeholder="Search by name, brand, or category…" />
<SearchInput placeholder="Search by name, brand, or category…" onSearch={setSearch} />
{isError ? (
<p className="text-sm text-destructive py-4">
Failed to load catalog. Check that the API is reachable.
Expand All @@ -157,16 +226,19 @@ export default function CatalogPage() {
<TableHead className="font-medium text-xs uppercase tracking-wide">
Price
</TableHead>
<TableHead className="font-medium text-xs uppercase tracking-wide">
Status
</TableHead>
<TableHead className="font-medium text-xs uppercase tracking-wide">
Added
</TableHead>
<TableHead className="font-medium text-xs uppercase tracking-wide w-20" />
<TableHead className="font-medium text-xs uppercase tracking-wide w-24" />
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No catalog items found{q ? ` matching "${q}"` : ''}.
</TableCell>
</TableRow>
Expand All @@ -176,11 +248,34 @@ export default function CatalogPage() {
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground">
{items.length.toLocaleString()} of {total.toLocaleString()} item
{total !== 1 ? 's' : ''}
{q ? ` matching "${q}"` : ''}
</p>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{items.length === 0
? `No items${q ? ` matching "${q}"` : ''}`
: `${(offset + 1).toLocaleString()}–${(offset + items.length).toLocaleString()} of ${total.toLocaleString()} items${q ? ` matching "${q}"` : ''}`}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={!hasPrev}
>
<ChevronLeft className="h-4 w-4" />
Prev
</Button>
<span className="text-xs text-muted-foreground">Page {page + 1}</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={!hasNext}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
)}
</div>
Expand Down
28 changes: 28 additions & 0 deletions apps/admin/app/dashboard/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import { Button } from '@packrat/web-ui/components/button';
import { useEffect } from 'react';

export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Dashboard error:', error);
}, [error]);

return (
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center">
<h2 className="text-lg font-semibold">Failed to load</h2>
<p className="text-sm text-muted-foreground max-w-sm">
{error.message || 'Something went wrong loading this page.'}
</p>
<Button onClick={reset} variant="outline" size="sm">
Try again
</Button>
</div>
);
}
13 changes: 12 additions & 1 deletion apps/admin/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
'use client';

import { SidebarInset, SidebarProvider } from '@packrat/web-ui/components/sidebar';
import { AppSidebar } from 'admin-app/components/app-sidebar';
import { AuthGuard } from 'admin-app/components/auth-guard';
import { DashboardHeader } from 'admin-app/components/dashboard-header';
import { ErrorFallback } from 'admin-app/components/error-fallback';
import { usePathname } from 'next/navigation';
import type React from 'react';
import { ErrorBoundary } from 'react-error-boundary';

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();

return (
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<DashboardHeader />
<main className="flex-1 overflow-auto p-6">{children}</main>
<main className="flex-1 overflow-auto p-6">
<ErrorBoundary FallbackComponent={ErrorFallback} resetKeys={[pathname]}>
{children}
</ErrorBoundary>
</main>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
Expand Down
Loading
Loading