- {filtered.map((item) => (
-
-
-
{item.name}
-
-
- {item.category}
-
- {item.brand}
-
-
-
-
-
-
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, i) => (
+
+ ))
+ ) : filtered.length === 0 ? (
+
+
No items found
+
+ Add items to your packs to see them here
+
- ))}
+ ) : (
+ filtered.map((item) => {
+ const itemWeightG = toGrams(item.weight, item.weightUnit);
+ return (
+
+
+
{item.name}
+
+ {item.category && (
+
+ {formatCategory(item.category)}
+
+ )}
+ {item.quantity > 1 && (
+ Γ{item.quantity}
+ )}
+
+
+
+
+ {fw(itemWeightG * item.quantity)}
+
+
{fw(itemWeightG)} each
+
+
+ );
+ })
+ )}
-
-
-
Total Weight
-
{fw(totalWeight)}
+ {!isLoading && filtered.length > 0 && (
+
+
+
Total Weight
+
{fw(totalWeight)}
+
-
-
+ )}
);
}
diff --git a/apps/web/components/screens/profile-screen.tsx b/apps/web/components/screens/profile-screen.tsx
index e5169dd393..c0346403ad 100644
--- a/apps/web/components/screens/profile-screen.tsx
+++ b/apps/web/components/screens/profile-screen.tsx
@@ -32,7 +32,7 @@ import type { Screen } from 'web-app/components/app-shell';
import { BetaBadge } from 'web-app/components/beta-badge';
import { ConfirmDialog, Modal, NotificationsPanel } from 'web-app/components/modals';
import { SettingsScreen } from 'web-app/components/screens/settings-screen';
-import { clearTokens } from 'web-app/lib/auth';
+import { authClient } from 'web-app/lib/auth-client';
import type { PackWithWeights } from 'web-app/lib/types';
import { cn } from 'web-app/lib/utils';
import { useWeight } from 'web-app/lib/weight-context';
@@ -250,8 +250,7 @@ export function ProfileScreen({ navigate }: ProfileScreenProps) {
open={showLogoutConfirm}
onClose={() => setShowLogoutConfirm(false)}
onConfirm={() => {
- clearTokens();
- router.push('/auth');
+ void authClient.signOut().then(() => router.push('/auth'));
}}
title="Sign Out?"
description="Are you sure you want to sign out of your account?"
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts
index 1b36940c86..437bfa6799 100644
--- a/apps/web/lib/api.ts
+++ b/apps/web/lib/api.ts
@@ -1,16 +1,16 @@
import { createApiClient } from '@packrat/api-client';
import { webEnv } from '@packrat/env/web';
-import Cookies from 'js-cookie';
+import { authClient } from 'web-app/lib/auth-client';
export const apiClient = createApiClient({
baseUrl: webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787',
auth: {
- getAccessToken: () => Cookies.get('access_token') ?? null,
- getRefreshToken: () => Cookies.get('refresh_token') ?? null,
- onAccessTokenRefreshed: (token) =>
- void Cookies.set('access_token', token, { expires: 1, sameSite: 'strict' }),
- onRefreshTokenRefreshed: (token) =>
- void Cookies.set('refresh_token', token, { expires: 30, sameSite: 'strict' }),
+ getAccessToken: async () => {
+ const { data } = await authClient.getSession();
+ return data?.session?.token ?? null;
+ },
+ getRefreshToken: async () => null,
+ onAccessTokenRefreshed: () => {},
onNeedsReauth: () => {
if (typeof window !== 'undefined') {
window.location.href = '/auth';
diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts
new file mode 100644
index 0000000000..0b46ceecf2
--- /dev/null
+++ b/apps/web/lib/auth-client.ts
@@ -0,0 +1,10 @@
+'use client';
+
+import { webEnv } from '@packrat/env/web';
+import { nextCookies } from 'better-auth/next-js';
+import { createAuthClient } from 'better-auth/react';
+
+export const authClient = createAuthClient({
+ baseURL: webEnv.NEXT_PUBLIC_API_URL ?? 'http://localhost:8787',
+ plugins: [nextCookies()],
+});
diff --git a/apps/web/lib/data.ts b/apps/web/lib/data.ts
index 9948e024bf..eb0f05e6c6 100644
--- a/apps/web/lib/data.ts
+++ b/apps/web/lib/data.ts
@@ -69,7 +69,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// ββ Mock Current User ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export const currentUser: User = {
- id: 1,
+ id: '1',
email: 'alex@packrat.app',
firstName: 'Alex',
lastName: 'Rivera',
@@ -94,7 +94,7 @@ function createPackItem(
image: null,
notes: null,
catalogItemId: null,
- userId: 1,
+ userId: '1',
deleted: false,
isAIGenerated: false,
createdAt: now,
@@ -385,7 +385,7 @@ const alpineWeights = calcWeights(alpineItems);
export const mockPacks: PackWithWeights[] = [
{
id: 'pack-1',
- userId: 1,
+ userId: '1',
name: '3-Season PCT Thru-Hike',
description:
'Optimized for the Pacific Crest Trail, spring through fall. Sub-10lb base weight.',
@@ -402,7 +402,7 @@ export const mockPacks: PackWithWeights[] = [
},
{
id: 'pack-2',
- userId: 1,
+ userId: '1',
name: 'Weekend Backpacking',
description: 'Comfortable 3-day base for Sierra trips. Prioritizes comfort over weight.',
category: 'backpacking',
@@ -418,7 +418,7 @@ export const mockPacks: PackWithWeights[] = [
},
{
id: 'pack-3',
- userId: 1,
+ userId: '1',
name: 'Alpine Summit Push',
description: 'Fast and light mountaineering kit for technical approaches and summit pushes.',
category: 'climbing',
@@ -439,7 +439,7 @@ export const mockPacks: PackWithWeights[] = [
export const mockTemplates: PackWithWeights[] = [
{
id: 'tmpl-1',
- userId: 0,
+ userId: '0',
name: 'Desert Thru-Hike Starter',
description:
'Minimal kit optimized for hot, dry conditions. Focus on sun protection and water capacity.',
@@ -457,7 +457,7 @@ export const mockTemplates: PackWithWeights[] = [
},
{
id: 'tmpl-2',
- userId: 0,
+ userId: '0',
name: 'Winter Mountaineering',
description:
'Cold weather alpine setup for sub-freezing conditions. Includes insulation layers.',
@@ -475,7 +475,7 @@ export const mockTemplates: PackWithWeights[] = [
},
{
id: 'tmpl-3',
- userId: 0,
+ userId: '0',
name: 'Ultralight Day Hike',
description: 'Minimal essentials for a fast day on the trail. 10 essentials coverage.',
category: 'hiking',
@@ -508,7 +508,7 @@ export const mockTrips: Trip[] = [
},
startDate: '2024-08-10',
endDate: '2024-08-17',
- userId: 1,
+ userId: '1',
packId: 'pack-1',
deleted: false,
createdAt: '2024-06-15T09:00:00Z',
@@ -526,7 +526,7 @@ export const mockTrips: Trip[] = [
},
startDate: '2024-09-22',
endDate: '2024-09-25',
- userId: 1,
+ userId: '1',
packId: 'pack-3',
deleted: false,
createdAt: '2024-07-01T14:00:00Z',
@@ -544,7 +544,7 @@ export const mockTrips: Trip[] = [
},
startDate: '2024-10-05',
endDate: '2024-10-08',
- userId: 1,
+ userId: '1',
packId: 'pack-2',
deleted: false,
createdAt: '2024-08-01T11:30:00Z',
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index cc008a5872..5cc46ffdaa 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -1,7 +1,7 @@
// ββ Auth / User ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export type User = {
- id: number;
+ id: string;
email: string;
firstName: string | null;
lastName: string | null;
@@ -54,7 +54,7 @@ export type PackItem = {
notes: string | null;
packId: string;
catalogItemId: number | null;
- userId: number;
+ userId: string;
deleted: boolean;
isAIGenerated: boolean;
createdAt: string; // ISO datetime
@@ -63,7 +63,7 @@ export type PackItem = {
export type PackWithWeights = {
id: string;
- userId: number;
+ userId: string;
name: string;
description: string | null;
category: PackCategory | null;
@@ -103,7 +103,7 @@ export type Trip = {
location?: TripLocation | null;
startDate?: string | null; // ISO date string
endDate?: string | null;
- userId?: number;
+ userId?: string;
packId?: string | null; // linked pack (optional)
deleted: boolean;
createdAt?: string;
diff --git a/apps/web/package.json b/apps/web/package.json
index 05da19afb8..176f40a825 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,6 +9,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
+ "@ai-sdk/react": "^3.0.170",
"@packrat/api-client": "workspace:*",
"@packrat/app": "workspace:*",
"@packrat/env": "workspace:*",
diff --git a/biome.json b/biome.json
index 0d84b85940..e4bce1e6dc 100644
--- a/biome.json
+++ b/biome.json
@@ -72,7 +72,6 @@
"packages/api/test/setup.ts",
"apps/expo/utils/weight.ts",
"packages/api/src/utils/weight.ts",
- "packages/units/src/index.ts",
"packages/mcp/src/index.ts",
"scripts/lint/**",
"scripts/format/**",
diff --git a/package.json b/package.json
index cd935755f1..2a7a19b5bd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "packrat-monorepo",
- "version": "2.0.25",
+ "version": "2.0.26",
"workspaces": [
"apps/*",
"packages/*"
@@ -170,6 +170,9 @@
"ws": "^8.18.1",
"zod": "^3.24.2"
},
+ "patchedDependencies": {
+ "@packrat-ai/nativewindui@2.0.3": "patches/@packrat-ai%2Fnativewindui@2.0.3.patch"
+ },
"trustedDependencies": [
"@sentry/cli"
]
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
index 4571bbf9da..65ec68b0d9 100644
--- a/packages/analytics/package.json
+++ b/packages/analytics/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/analytics",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/api-client/package.json b/packages/api-client/package.json
index 68a3306247..0a9d962340 100644
--- a/packages/api-client/package.json
+++ b/packages/api-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/api-client",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/api/container_src/package.json b/packages/api/container_src/package.json
index 4da61eb864..0e066901c2 100644
--- a/packages/api/container_src/package.json
+++ b/packages/api/container_src/package.json
@@ -1,6 +1,6 @@
{
"name": "container",
- "version": "2.0.25",
+ "version": "2.0.26",
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.0.0",
diff --git a/packages/api/package.json b/packages/api/package.json
index 84fa5821e5..763f94225b 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/api",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts
index db72d1d13f..ed9ffdac47 100644
--- a/packages/api/src/routes/index.ts
+++ b/packages/api/src/routes/index.ts
@@ -9,6 +9,7 @@ import { guidesRoutes } from './guides';
import { knowledgeBaseRoutes } from './knowledgeBase';
import { packsRoutes } from './packs';
import { packTemplatesRoutes } from './packTemplates';
+import { passwordResetRoutes } from './passwordReset';
import { seasonSuggestionsRoutes } from './seasonSuggestions';
import { trailConditionsRoutes } from './trailConditions';
import { trailsRoutes } from './trails';
@@ -35,6 +36,7 @@ export const routes = new Elysia({ prefix: '/api' })
.use(weatherRoutes)
.use(packTemplatesRoutes)
.use(seasonSuggestionsRoutes)
+ .use(passwordResetRoutes)
.use(userRoutes)
.use(uploadRoutes)
.use(trailConditionsRoutes)
diff --git a/packages/api/src/routes/passwordReset.ts b/packages/api/src/routes/passwordReset.ts
new file mode 100644
index 0000000000..3144533cb2
--- /dev/null
+++ b/packages/api/src/routes/passwordReset.ts
@@ -0,0 +1,47 @@
+import { ForgotPasswordRequestSchema, ResetPasswordRequestSchema } from '@packrat/api/schemas/auth';
+import {
+ requestPasswordReset,
+ verifyOtpAndResetPassword,
+} from '@packrat/api/services/passwordResetService';
+import { Elysia, status } from 'elysia';
+
+export const passwordResetRoutes = new Elysia({ prefix: '/password-reset' })
+ // public-route: unauthenticated users need this to initiate a password reset
+ .post(
+ '/request',
+ async ({ body }) => {
+ await requestPasswordReset(body.email);
+ return { success: true, message: 'If an account exists, a reset code has been sent.' };
+ },
+ {
+ body: ForgotPasswordRequestSchema,
+ detail: {
+ tags: ['Auth'],
+ summary: 'Request password reset',
+ description:
+ 'Send a 6-digit OTP to the user email. Always returns success to prevent email enumeration.',
+ },
+ },
+ )
+ // public-route: unauthenticated users need this to verify OTP and set a new password
+ .post(
+ '/verify',
+ async ({ body }) => {
+ try {
+ await verifyOtpAndResetPassword(body);
+ return { success: true, message: 'Password reset successfully.' };
+ } catch (error) {
+ return status(400, {
+ error: error instanceof Error ? error.message : 'Password reset failed',
+ });
+ }
+ },
+ {
+ body: ResetPasswordRequestSchema,
+ detail: {
+ tags: ['Auth'],
+ summary: 'Verify OTP and reset password',
+ description: 'Validate the 6-digit OTP and set a new password.',
+ },
+ },
+ );
diff --git a/packages/api/src/schemas/auth.ts b/packages/api/src/schemas/auth.ts
index b6784b07fb..69fb82e5c8 100644
--- a/packages/api/src/schemas/auth.ts
+++ b/packages/api/src/schemas/auth.ts
@@ -79,7 +79,7 @@ export const ForgotPasswordResponseSchema = z.object({
export const ResetPasswordRequestSchema = z.object({
email: z.string().email(),
- code: z.string().length(5),
+ code: z.string().length(6),
newPassword: z.string().min(8),
});
diff --git a/packages/api/src/services/passwordResetService.ts b/packages/api/src/services/passwordResetService.ts
new file mode 100644
index 0000000000..0f7dbe0a03
--- /dev/null
+++ b/packages/api/src/services/passwordResetService.ts
@@ -0,0 +1,82 @@
+import { hashPassword } from '@better-auth/utils/password';
+import { createDb } from '@packrat/api/db';
+import { account, users, verification } from '@packrat/api/db/schema';
+import { timingSafeEqual } from '@packrat/api/utils/auth';
+import { sendPasswordResetEmail } from '@packrat/api/utils/email';
+import { and, eq, gt } from 'drizzle-orm';
+
+const OTP_LENGTH = 6;
+const OTP_TTL_MS = 15 * 60 * 1000; // 15 minutes
+const IDENTIFIER_PREFIX = 'password-reset:';
+
+function generateOtp(): string {
+ return Array.from({ length: OTP_LENGTH }, () => Math.floor(Math.random() * 10)).join('');
+}
+
+export async function requestPasswordReset(email: string): Promise
{
+ const db = createDb();
+
+ const user = await db.query.users.findFirst({ where: eq(users.email, email) });
+ if (!user) return; // Don't reveal whether the email is registered
+
+ const code = generateOtp();
+ const identifier = `${IDENTIFIER_PREFIX}${email}`;
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + OTP_TTL_MS);
+
+ await db.delete(verification).where(eq(verification.identifier, identifier));
+ await db.insert(verification).values({
+ id: crypto.randomUUID(),
+ identifier,
+ value: code,
+ expiresAt,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ await sendPasswordResetEmail({ to: email, code });
+}
+
+export async function verifyOtpAndResetPassword({
+ email,
+ code,
+ newPassword,
+}: {
+ email: string;
+ code: string;
+ newPassword: string;
+}): Promise {
+ const db = createDb();
+ const identifier = `${IDENTIFIER_PREFIX}${email}`;
+
+ const record = await db.query.verification.findFirst({
+ where: and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date())),
+ });
+
+ if (!record || !timingSafeEqual(record.value, code)) {
+ throw new Error('Invalid or expired reset code');
+ }
+
+ const user = await db.query.users.findFirst({ where: eq(users.email, email) });
+ if (!user) throw new Error('User not found');
+
+ const hashedPassword = await hashPassword(newPassword);
+ const now = new Date();
+
+ // Update the credential account record (Better Auth email/password users)
+ const updated = await db
+ .update(account)
+ .set({ password: hashedPassword, updatedAt: now })
+ .where(and(eq(account.userId, user.id), eq(account.providerId, 'credential')))
+ .returning();
+
+ // Fallback for legacy users whose password lives on the users row
+ if (updated.length === 0) {
+ await db
+ .update(users)
+ .set({ passwordHash: hashedPassword, updatedAt: now })
+ .where(eq(users.id, user.id));
+ }
+
+ await db.delete(verification).where(eq(verification.identifier, identifier));
+}
diff --git a/packages/api/src/utils/__tests__/weight.test.ts b/packages/api/src/utils/__tests__/weight.test.ts
index 624e055ecd..d89067fb10 100644
--- a/packages/api/src/utils/__tests__/weight.test.ts
+++ b/packages/api/src/utils/__tests__/weight.test.ts
@@ -35,34 +35,34 @@ function makeItem(
// ---------------------------------------------------------------------------
describe('convertWeight', () => {
it('returns the same value when from === to', () => {
- expect(convertWeight(100, 'g', 'g')).toBe(100);
- expect(convertWeight(5, 'oz', 'oz')).toBe(5);
- expect(convertWeight(2, 'kg', 'kg')).toBe(2);
- expect(convertWeight(1, 'lb', 'lb')).toBe(1);
+ expect(convertWeight(100, { from: 'g', to: 'g' })).toBe(100);
+ expect(convertWeight(5, { from: 'oz', to: 'oz' })).toBe(5);
+ expect(convertWeight(2, { from: 'kg', to: 'kg' })).toBe(2);
+ expect(convertWeight(1, { from: 'lb', to: 'lb' })).toBe(1);
});
it('converts grams to ounces', () => {
- expect(convertWeight(100, 'g', 'oz')).toBeCloseTo(3.53, 1);
+ expect(convertWeight(100, { from: 'g', to: 'oz' })).toBeCloseTo(3.53, 1);
});
it('converts ounces to grams', () => {
- expect(convertWeight(1, 'oz', 'g')).toBeCloseTo(28.349523125, 8);
+ expect(convertWeight(1, { from: 'oz', to: 'g' })).toBeCloseTo(28.349523125, 8);
});
it('converts grams to kilograms', () => {
- expect(convertWeight(1000, 'g', 'kg')).toBe(1);
+ expect(convertWeight(1000, { from: 'g', to: 'kg' })).toBe(1);
});
it('converts kilograms to grams', () => {
- expect(convertWeight(1, 'kg', 'g')).toBe(1000);
+ expect(convertWeight(1, { from: 'kg', to: 'g' })).toBe(1000);
});
it('converts grams to pounds', () => {
- expect(convertWeight(453.59, 'g', 'lb')).toBeCloseTo(1, 1);
+ expect(convertWeight(453.59, { from: 'g', to: 'lb' })).toBeCloseTo(1, 1);
});
it('converts pounds to grams', () => {
- expect(convertWeight(1, 'lb', 'g')).toBeCloseTo(453.59237, 4);
+ expect(convertWeight(1, { from: 'lb', to: 'g' })).toBeCloseTo(453.59237, 4);
});
});
diff --git a/packages/app/package.json b/packages/app/package.json
index 5c840233ac..f362249b89 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/app",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/checks/package.json b/packages/checks/package.json
index 10c105548f..0d70bd90f2 100644
--- a/packages/checks/package.json
+++ b/packages/checks/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/checks",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c866c3eac6..c711ab0f3f 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/cli",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"bin": {
diff --git a/packages/config/package.json b/packages/config/package.json
index 5ee821bf82..61b6d02575 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/config",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/env/package.json b/packages/env/package.json
index c90ede46a5..413e55ac22 100644
--- a/packages/env/package.json
+++ b/packages/env/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/env",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/guards/package.json b/packages/guards/package.json
index a6a92314d8..eb3e79f325 100644
--- a/packages/guards/package.json
+++ b/packages/guards/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/guards",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 3f79d17662..b631f3c7b9 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/mcp",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"description": "PackRat MCP Server β outdoor adventure planning via Model Context Protocol",
"scripts": {
diff --git a/packages/osm-db/package.json b/packages/osm-db/package.json
index 46dc95b72f..065e8206b2 100644
--- a/packages/osm-db/package.json
+++ b/packages/osm-db/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/osm-db",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/osm-import/package.json b/packages/osm-import/package.json
index a4371619e8..58b8e16c4f 100644
--- a/packages/osm-import/package.json
+++ b/packages/osm-import/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/osm-import",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"description": "osm2pgsql flex config and import tooling for PackRat outdoor routes",
"type": "module",
diff --git a/packages/overpass/package.json b/packages/overpass/package.json
index f4ad457aef..1a08ecac06 100644
--- a/packages/overpass/package.json
+++ b/packages/overpass/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/overpass",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 16332aef81..66e7e3d18e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/ui",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"dependencies": {
"@packrat-ai/nativewindui": "2.0.3-2"
diff --git a/packages/units/package.json b/packages/units/package.json
index d2436dac73..ce136bf5ee 100644
--- a/packages/units/package.json
+++ b/packages/units/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/units",
- "version": "0.1.0",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/units/src/index.test.ts b/packages/units/src/index.test.ts
index 85654b2784..e2c0fcd247 100644
--- a/packages/units/src/index.test.ts
+++ b/packages/units/src/index.test.ts
@@ -222,62 +222,62 @@ describe('normalize / fromGrams round-trips', () => {
describe('convert', () => {
it('same unit returns input unchanged (no float ops)', () => {
- expect(packratConvert(5, 'g', 'g')).toBe(5);
- expect(packratConvert(5, 'oz', 'oz')).toBe(5);
- expect(packratConvert(5, 'lb', 'lb')).toBe(5);
- expect(packratConvert(5, 'kg', 'kg')).toBe(5);
- expect(packratConvert(0, 'oz', 'oz')).toBe(0);
+ expect(packratConvert(5, { from: 'g', to: 'g' })).toBe(5);
+ expect(packratConvert(5, { from: 'oz', to: 'oz' })).toBe(5);
+ expect(packratConvert(5, { from: 'lb', to: 'lb' })).toBe(5);
+ expect(packratConvert(5, { from: 'kg', to: 'kg' })).toBe(5);
+ expect(packratConvert(0, { from: 'oz', to: 'oz' })).toBe(0);
});
it('oz β lb: 16 oz = 1 lb', () => {
- expect(packratConvert(16, 'oz', 'lb')).toBeCloseTo(1, 10);
- expect(packratConvert(8, 'oz', 'lb')).toBeCloseTo(0.5, 10);
- expect(packratConvert(32, 'oz', 'lb')).toBeCloseTo(2, 10);
+ expect(packratConvert(16, { from: 'oz', to: 'lb' })).toBeCloseTo(1, 10);
+ expect(packratConvert(8, { from: 'oz', to: 'lb' })).toBeCloseTo(0.5, 10);
+ expect(packratConvert(32, { from: 'oz', to: 'lb' })).toBeCloseTo(2, 10);
});
it('lb β oz: 1 lb = 16 oz', () => {
- expect(packratConvert(1, 'lb', 'oz')).toBeCloseTo(16, 10);
- expect(packratConvert(0.5, 'lb', 'oz')).toBeCloseTo(8, 10);
- expect(packratConvert(2, 'lb', 'oz')).toBeCloseTo(32, 10);
+ expect(packratConvert(1, { from: 'lb', to: 'oz' })).toBeCloseTo(16, 10);
+ expect(packratConvert(0.5, { from: 'lb', to: 'oz' })).toBeCloseTo(8, 10);
+ expect(packratConvert(2, { from: 'lb', to: 'oz' })).toBeCloseTo(32, 10);
});
it('kg β lb: 1 kg β 2.20462 lb', () => {
- expect(packratConvert(1, 'kg', 'lb')).toBeCloseTo(2.20462, 4);
- expect(packratConvert(2, 'kg', 'lb')).toBeCloseTo(4.40924, 4);
- expect(packratConvert(0.5, 'kg', 'lb')).toBeCloseTo(1.10231, 4);
+ expect(packratConvert(1, { from: 'kg', to: 'lb' })).toBeCloseTo(2.20462, 4);
+ expect(packratConvert(2, { from: 'kg', to: 'lb' })).toBeCloseTo(4.40924, 4);
+ expect(packratConvert(0.5, { from: 'kg', to: 'lb' })).toBeCloseTo(1.10231, 4);
});
it('lb β kg: 1 lb β 0.453592 kg', () => {
- expect(packratConvert(1, 'lb', 'kg')).toBeCloseTo(0.453592, 5);
- expect(packratConvert(2.2046, 'lb', 'kg')).toBeCloseTo(1, 3);
- expect(packratConvert(10, 'lb', 'kg')).toBeCloseTo(4.53592, 4);
+ expect(packratConvert(1, { from: 'lb', to: 'kg' })).toBeCloseTo(0.453592, 5);
+ expect(packratConvert(2.2046, { from: 'lb', to: 'kg' })).toBeCloseTo(1, 3);
+ expect(packratConvert(10, { from: 'lb', to: 'kg' })).toBeCloseTo(4.53592, 4);
});
it('g β oz and back', () => {
- expect(packratConvert(OZ_TO_G, 'g', 'oz')).toBeCloseTo(1, 10);
- expect(packratConvert(100, 'g', 'oz')).toBeCloseTo(100 / OZ_TO_G, 8);
- expect(packratConvert(1000, 'g', 'oz')).toBeCloseTo(1000 / OZ_TO_G, 6);
+ expect(packratConvert(OZ_TO_G, { from: 'g', to: 'oz' })).toBeCloseTo(1, 10);
+ expect(packratConvert(100, { from: 'g', to: 'oz' })).toBeCloseTo(100 / OZ_TO_G, 8);
+ expect(packratConvert(1000, { from: 'g', to: 'oz' })).toBeCloseTo(1000 / OZ_TO_G, 6);
});
it('g β kg and back', () => {
- expect(packratConvert(1000, 'g', 'kg')).toBe(1);
- expect(packratConvert(500, 'g', 'kg')).toBe(0.5);
- expect(packratConvert(1, 'kg', 'g')).toBe(1000);
+ expect(packratConvert(1000, { from: 'g', to: 'kg' })).toBe(1);
+ expect(packratConvert(500, { from: 'g', to: 'kg' })).toBe(0.5);
+ expect(packratConvert(1, { from: 'kg', to: 'g' })).toBe(1000);
});
it('g β lb and back', () => {
- expect(packratConvert(LB_TO_G, 'g', 'lb')).toBeCloseTo(1, 10);
- expect(packratConvert(100, 'g', 'lb')).toBeCloseTo(100 / LB_TO_G, 8);
+ expect(packratConvert(LB_TO_G, { from: 'g', to: 'lb' })).toBeCloseTo(1, 10);
+ expect(packratConvert(100, { from: 'g', to: 'lb' })).toBeCloseTo(100 / LB_TO_G, 8);
});
it('kg β oz', () => {
- expect(packratConvert(1, 'kg', 'oz')).toBeCloseTo(1000 / OZ_TO_G, 5);
- expect(packratConvert(0.5, 'kg', 'oz')).toBeCloseTo(500 / OZ_TO_G, 5);
+ expect(packratConvert(1, { from: 'kg', to: 'oz' })).toBeCloseTo(1000 / OZ_TO_G, 5);
+ expect(packratConvert(0.5, { from: 'kg', to: 'oz' })).toBeCloseTo(500 / OZ_TO_G, 5);
});
it('oz β kg', () => {
- expect(packratConvert(1, 'oz', 'kg')).toBeCloseTo(OZ_TO_G / 1000, 8);
- expect(packratConvert(35.274, 'oz', 'kg')).toBeCloseTo(1, 2);
+ expect(packratConvert(1, { from: 'oz', to: 'kg' })).toBeCloseTo(OZ_TO_G / 1000, 8);
+ expect(packratConvert(35.274, { from: 'oz', to: 'kg' })).toBeCloseTo(1, 2);
});
it('all 12 unit pairs are round-trip exact at weight = 42', () => {
@@ -296,16 +296,16 @@ describe('convert', () => {
['kg', 'oz'],
];
for (const [a, b] of pairs) {
- const converted = packratConvert(42, a, b);
- const back = packratConvert(converted, b, a);
+ const converted = packratConvert(42, { from: a, to: b });
+ const back = packratConvert(converted, { from: b, to: a });
expect(back).toBeCloseTo(42, 10);
}
});
it('round-trips multiple weights for ozβlb', () => {
for (const oz of [0.5, 1, 2, 4, 8, 16, 32, 64]) {
- const lb = packratConvert(oz, 'oz', 'lb');
- const back = packratConvert(lb, 'lb', 'oz');
+ const lb = packratConvert(oz, { from: 'oz', to: 'lb' });
+ const back = packratConvert(lb, { from: 'lb', to: 'oz' });
expect(back).toBeCloseTo(oz, 10);
}
});
@@ -328,21 +328,6 @@ describe('displayWeight', () => {
expect(displayWeight(500, 'kg')).toBe(0.5); // not 0.50
});
- it('respects precision = 0', () => {
- expect(displayWeight(100, 'g', 0)).toBe(100);
- expect(displayWeight(28.7, 'g', 0)).toBe(29);
- });
-
- it('respects precision = 1', () => {
- expect(displayWeight(1234.56, 'g', 1)).toBe(1234.6);
- expect(displayWeight(normalize(1.23, 'oz'), 'oz', 1)).toBe(1.2);
- });
-
- it('respects precision = 4', () => {
- expect(displayWeight(OZ_TO_G, 'oz', 4)).toBe(1);
- expect(displayWeight(100, 'g', 4)).toBe(100);
- });
-
it('handles typical backpacking display values', () => {
// 3.2 oz water filter
const grams = normalize(3.2, 'oz');
@@ -443,56 +428,56 @@ describe('cross-validation against convert-units library', () => {
it('packratConvert ozβlb matches convert-units', () => {
for (const oz of [1, 4, 8, 12, 16, 32]) {
const expected = convert(oz).from('oz').to('lb') as number;
- expect(packratConvert(oz, 'oz', 'lb')).toBeCloseTo(expected, 4);
+ expect(packratConvert(oz, { from: 'oz', to: 'lb' })).toBeCloseTo(expected, 4);
}
});
it('packratConvert lbβoz matches convert-units', () => {
for (const lb of [0.5, 1, 1.5, 2, 3, 5]) {
const expected = convert(lb).from('lb').to('oz') as number;
- expect(packratConvert(lb, 'lb', 'oz')).toBeCloseTo(expected, 4);
+ expect(packratConvert(lb, { from: 'lb', to: 'oz' })).toBeCloseTo(expected, 4);
}
});
it('packratConvert kgβlb matches convert-units', () => {
for (const kg of [0.5, 1, 1.5, 2, 5, 10]) {
const expected = convert(kg).from('kg').to('lb') as number;
- expect(packratConvert(kg, 'kg', 'lb')).toBeCloseTo(expected, 3);
+ expect(packratConvert(kg, { from: 'kg', to: 'lb' })).toBeCloseTo(expected, 3);
}
});
it('packratConvert lbβkg matches convert-units', () => {
for (const lb of [1, 2.2046, 5, 10, 22.046]) {
const expected = convert(lb).from('lb').to('kg') as number;
- expect(packratConvert(lb, 'lb', 'kg')).toBeCloseTo(expected, 3);
+ expect(packratConvert(lb, { from: 'lb', to: 'kg' })).toBeCloseTo(expected, 3);
}
});
it('packratConvert kgβoz matches convert-units', () => {
for (const kg of [0.5, 1, 2, 5]) {
const expected = convert(kg).from('kg').to('oz') as number;
- expect(packratConvert(kg, 'kg', 'oz')).toBeCloseTo(expected, 2);
+ expect(packratConvert(kg, { from: 'kg', to: 'oz' })).toBeCloseTo(expected, 2);
}
});
it('packratConvert ozβkg matches convert-units', () => {
for (const oz of [1, 8, 16, 35.274]) {
const expected = convert(oz).from('oz').to('kg') as number;
- expect(packratConvert(oz, 'oz', 'kg')).toBeCloseTo(expected, 4);
+ expect(packratConvert(oz, { from: 'oz', to: 'kg' })).toBeCloseTo(expected, 4);
}
});
it('packratConvert gβoz matches convert-units', () => {
for (const g of [28.35, 100, 226.8, 453.6, 1000]) {
const expected = convert(g).from('g').to('oz') as number;
- expect(packratConvert(g, 'g', 'oz')).toBeCloseTo(expected, 2);
+ expect(packratConvert(g, { from: 'g', to: 'oz' })).toBeCloseTo(expected, 2);
}
});
it('packratConvert gβlb matches convert-units', () => {
for (const g of [100, 227, 454, 907, 2268]) {
const expected = convert(g).from('g').to('lb') as number;
- expect(packratConvert(g, 'g', 'lb')).toBeCloseTo(expected, 4);
+ expect(packratConvert(g, { from: 'g', to: 'lb' })).toBeCloseTo(expected, 4);
}
});
});
@@ -662,16 +647,16 @@ describe('numeric edge cases', () => {
it('convert same-unit short-circuits (no floating point ops)', () => {
// IEEE 754 exact: if from===to we return the input directly
const w = 1 / 3; // irrational in float
- expect(packratConvert(w, 'oz', 'oz')).toBe(w); // toBe = same reference value
- expect(packratConvert(w, 'lb', 'lb')).toBe(w);
- expect(packratConvert(w, 'kg', 'kg')).toBe(w);
- expect(packratConvert(w, 'g', 'g')).toBe(w);
+ expect(packratConvert(w, { from: 'oz', to: 'oz' })).toBe(w); // toBe = same reference value
+ expect(packratConvert(w, { from: 'lb', to: 'lb' })).toBe(w);
+ expect(packratConvert(w, { from: 'kg', to: 'kg' })).toBe(w);
+ expect(packratConvert(w, { from: 'g', to: 'g' })).toBe(w);
});
it('16 oz equals 1 lb through convert', () => {
// 16 oz β lb must equal exactly 1 lb β oz β lb
- const via_oz = packratConvert(16, 'oz', 'lb');
- const direct = packratConvert(1, 'lb', 'lb');
+ const via_oz = packratConvert(16, { from: 'oz', to: 'lb' });
+ const direct = packratConvert(1, { from: 'lb', to: 'lb' });
expect(via_oz).toBeCloseTo(direct, 10);
});
});
diff --git a/packages/units/src/index.ts b/packages/units/src/index.ts
index 1a21806cde..375a9cddce 100644
--- a/packages/units/src/index.ts
+++ b/packages/units/src/index.ts
@@ -31,18 +31,17 @@ export function fromGrams(grams: number, unit: WeightUnit): number {
/**
* Convert directly between any two weight units.
*/
-export function convert(weight: number, from: WeightUnit, to: WeightUnit): number {
- if (from === to) return weight;
- return (weight * TO_GRAMS[from]) / TO_GRAMS[to];
+export function convert(weight: number, units: { from: WeightUnit; to: WeightUnit }): number {
+ if (units.from === units.to) return weight;
+ return (weight * TO_GRAMS[units.from]) / TO_GRAMS[units.to];
}
/**
- * Format a gram value for display in the given unit.
- * Returns a number rounded to `precision` decimal places (default 2).
+ * Format a gram value for display in the given unit, rounded to 2 decimal places.
* Use this for all weight display β never roll your own toFixed.
*/
-export function displayWeight(grams: number, unit: WeightUnit, precision = 2): number {
- return parseFloat(fromGrams(grams, unit).toFixed(precision));
+export function displayWeight(grams: number, unit: WeightUnit): number {
+ return parseFloat(fromGrams(grams, unit).toFixed(2));
}
/**
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index 6662fa5d0e..9d89487890 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@packrat/web-ui",
- "version": "2.0.25",
+ "version": "2.0.26",
"private": true,
"type": "module",
"exports": {
diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch
new file mode 100644
index 0000000000..d4837e9502
--- /dev/null
+++ b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch
@@ -0,0 +1,27 @@
+diff --git a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
+index 86091777372a04b76c9e866d2951bcfde9f34e05..b4e2b4903d8a5356fe3f2eba46b2e3cb1c691ba7 100644
+--- a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
++++ b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
+@@ -1,3 +1,4 @@
++// @ts-nocheck
+ import { useAugmentedRef } from '@rn-primitives/hooks';
+ import { Portal } from '@rn-primitives/portal';
+ import { Stack, useNavigation } from 'expo-router';
+diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts
+index 786b62c4a216c360beb193b96092186319a634cb..741aefba1ddf080c103cfca27b14b10c95cd5cdd 100644
+--- a/src/components/Icon/types.ts
++++ b/src/components/Icon/types.ts
+@@ -1,3 +1,4 @@
++// @ts-nocheck
+ import type MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
+ import type MaterialIcons from '@expo/vector-icons/MaterialIcons';
+ import type { SymbolViewProps } from 'expo-symbols';
+diff --git a/src/components/LargeTitleHeader/LargeTitleHeader.tsx b/src/components/LargeTitleHeader/LargeTitleHeader.tsx
+index 95c66bbdece307542084c2e510c847543134f63e..d9bf7337f45a9b49f69863a16a24672363a3b8af 100644
+--- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx
++++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx
+@@ -1,3 +1,4 @@
++// @ts-nocheck
+ import { useRoute } from '@react-navigation/native';
+ import { useAugmentedRef } from '@rn-primitives/hooks';
+ import { Portal } from '@rn-primitives/portal';