diff --git a/.agent/skills/api-akomo/SKILL.md b/.agent/skills/api-akomo/SKILL.md
index af7c9f6..a2e71ec 100644
--- a/.agent/skills/api-akomo/SKILL.md
+++ b/.agent/skills/api-akomo/SKILL.md
@@ -1,26 +1,28 @@
---
name: api-akomo-docs
-description: provides detailed technical documentation and interaction guidelines for the akomo backend api (api.akomo.jodaz.xyz).
+description: provides detailed technical documentation and interaction guidelines for the akomo backend api (api.akomo.xyz).
---
# AKomo API Documentation Skill
## When to use this skill
- when a user or another agent needs to interact with the AKomo backend API.
- when exploring the available endpoints for exchange rates and mobile app builds.
-- when planning a new application that consumes data from `api.akomo.jodaz.xyz`.
+- when planning a new application that consumes data from `api.akomo.xyz`.
- when debugging issues related to fetching BCV, Binance rates, or app builds from the AKomo server.
## Overview
-The AKomo API serves as the centralized backend for the AKomo mobile application (market tracking / currency exchange). The root domain is `https://api.akomo.jodaz.xyz/api`. It mainly handles:
+The AKomo API serves as the centralized backend for the AKomo mobile application (market tracking / currency exchange). The root domain is `https://api.akomo.xyz/api`. It mainly handles:
1. **Exchange Rates**: Syncing and providing latest currency rates (USD, EUR, USDT) from sources like BCV and Binance P2P.
2. **Builds**: Managing the download URLs and versions of the mobile application across platforms.
+Interactive documentation is available via Swagger at: `https://api.akomo.xyz/api/docs`
+
## Necessary Inputs
-None. This skill provides documentation. If making requests, the HTTP client must respect the base URL and standard headers (e.g., `Content-Type: application/json` for POST requests). Standard REST conventions apply. Authentication/Authorization is not explicitly required for standard `GET` requests for rates and builds (they are public), but internal `POST` routes may be protected in deployment (if enforced at a higher level).
+None. This skill provides documentation. If making requests, the HTTP client must respect the base URL and standard headers (e.g., `Content-Type: application/json` for POST requests). Standard REST conventions apply. Authentication/Authorization currently relies on environment secrets for protected operations (e.g., Supabase service role), but public `GET` endpoints are open.
## Workflow
-1. **Identify the Data Required**: Determine if you need current rates, manual rates update, a specific Binance average, or mobile build information.
-2. **Choose the Corresponding Endpoint**: Reference the "Endpoints" section to find the exact path and method.
+1. **Identify the Data Required**: Determine if you need current rates, historical data, manual rates update, a specific Binance average, or mobile build information.
+2. **Choose the Corresponding Endpoint**: Reference the "Endpoints" section or the Swagger UI to find the exact path and method.
3. **Construct the Request**: Include the proper Query Parameters or JSON Body as defined.
4. **Parse the Response**: Ensure your application can parse the specific output formats provided by the API.
@@ -47,6 +49,12 @@ Returns the most recently recorded exchange rates for tracked currencies (USD, E
```
*(Note: The `value` is returned as a comma-separated string `36,25` for UI rendering).*
+#### `GET /api/exchange-rates/history`
+Returns historical exchange rates for the last N days.
+- **Query Parameters:**
+ - `days` (optional, default: `7`): Number of days of history to fetch.
+- **Output (JSON):** Returns an array of objects containing symbol, current price, percentage change, and history points.
+
#### `POST /api/exchange-rates/bcv`
Manually updates the historical BCV record for USD and EUR.
- **Body:**
@@ -77,13 +85,13 @@ Calculates and returns the average Binance P2P price for a specific asset/fiat p
}
```
-*(Note: Standard CRUD routes like `POST /`, `GET /:id`, `PATCH /:id`, and `DELETE /:id` exist under `/api/exchange-rates` but are currently stubbed implementations).*
+*(Note: Standard CRUD routes like `POST /`, `GET /:id`, `PATCH /:id`, and `DELETE /:id` exist but are mostly for internal use or administrative manual updates).*
### 2. Builds
Manages the mobile application build references (links to APKs/IPAs).
#### `GET /api/builds`
-Returns a list of available app builds, ordered by creation date (newest first).
+Returns a list of available app builds.
- **Output (JSON):**
```json
[
@@ -105,7 +113,6 @@ Registers a newly compiled mobile application build.
"platform": "ios"
}
```
-- **Output:** Returns the created database record (JSON).
## Error Handling
If requests fail, standard NestJS HTTP exceptions will be returned in this generic format:
@@ -116,7 +123,7 @@ If requests fail, standard NestJS HTTP exceptions will be returned in this gener
"error": "Bad Request"
}
```
-Validation errors for POST bodies return an array of user-friendly validation messages under the `message` property due to the NestJS global ValidationPipe.
+Validation errors for POST bodies return an array of user-friendly validation messages under the `message` property.
## Output Structure when Queried
-When another agent queries this skill to understand the API, you must serve these exact endpoint definitions, keeping the expected inputs (Query/Body) and outputs (Responses) explicitly clear.
+When another agent queries this skill to understand the API, you must serve these exact endpoint definitions, keeping the expected inputs (Query/Body) and outputs (Responses) explicitly clear. Also point them to the Swagger UI for real-time exploratory testing.
diff --git a/mobile/app/(tabs)/history.tsx b/mobile/app/(tabs)/history.tsx
index acd500d..f9a2090 100644
--- a/mobile/app/(tabs)/history.tsx
+++ b/mobile/app/(tabs)/history.tsx
@@ -12,11 +12,11 @@ import {
} from 'react-native';
import { Text } from '@/components/Themed';
import { useExchangeHistory } from '@/hooks/use-exchange-history';
-import { HistoryChart } from '@/components/HistoryChart';
+// import { HistoryChart } from '@/components/HistoryChart';
import { GradientBackground } from '@/components/GradientBackground';
import { TopHeader } from '@/components/TopHeader';
import { CreditsFooter } from '@/components/CreditsFooter';
-import { ArrowUpRight, ArrowDownRight } from 'lucide-react-native';
+import { ArrowUpRight, ArrowDownRight, TrendingUp } from 'lucide-react-native';
export default function HistoryScreen() {
const [days, setDays] = useState(7);
@@ -24,6 +24,14 @@ export default function HistoryScreen() {
const { width } = useWindowDimensions();
const isDesktop = Platform.OS === 'web' && width > 768;
+ // Calculate gaps between USDT and BCV rates
+ const usdtPrice = data?.find(i => i.symbol.toUpperCase().includes('USDT'))?.currentPrice;
+ const usdPrice = data?.find(i => i.symbol === 'USD')?.currentPrice;
+ const eurPrice = data?.find(i => i.symbol === 'EUR')?.currentPrice;
+
+ const usdGap = (usdtPrice && usdPrice) ? ((usdtPrice / usdPrice) - 1) * 100 : 0;
+ const eurGap = (usdtPrice && eurPrice) ? ((usdtPrice / eurPrice) - 1) * 100 : 0;
+
const renderTab = (value: number, label: string) => {
const isActive = days === value;
return (
@@ -97,12 +105,36 @@ export default function HistoryScreen() {
{data?.map((item) => renderRateCard(item.symbol, item.currentPrice, item.change))}
- {/* Chart section — card rules: borderRadius 20, padding 24, border */}
+ {/* Gap Section — Breach Analysis */}
+
+
+
+ Brecha Cambiaria
+
+
+
+ USDT / USD BCV
+ = 0 ? '#F1C40F' : '#14b8a6' }]}>
+ {usdGap >= 0 ? '+' : ''}{usdGap.toFixed(2)}%
+
+
+
+
+ USDT / EUR BCV
+ = 0 ? '#F1C40F' : '#14b8a6' }]}>
+ {eurGap >= 0 ? '+' : ''}{eurGap.toFixed(2)}%
+
+
+
+
+ {/* Chart section hidden per user request */}
+ {/*
Evolución últimos {days} días
{data && }
{days} días de datos
+ */}
@@ -214,7 +246,7 @@ const styles = StyleSheet.create({
rateChangeNegative: {
color: '#14b8a6', // teal-accent for negative change
},
- // Chart card: borderRadius 20, padding 24, border per guidelines
+ /*
chartSection: {
backgroundColor: '#1B6B3E',
borderRadius: 20,
@@ -235,6 +267,41 @@ const styles = StyleSheet.create({
fontSize: 12,
opacity: 0.8,
},
+ */
+ gapSection: {
+ backgroundColor: '#155230', // dark green for breach analysis
+ borderRadius: 20,
+ padding: 20,
+ marginBottom: 20,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ },
+ gapHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 16,
+ },
+ gapTitle: {
+ color: '#F1C40F',
+ fontSize: 16,
+ fontWeight: 'bold',
+ fontFamily: 'Zain_700Bold',
+ },
+ gapRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ gapLabel: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ opacity: 0.8,
+ },
+ gapValue: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
footer: {
alignItems: 'center',
marginTop: 40,
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 16cd686..b1e253f 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -74,6 +74,7 @@ function RootLayoutNav() {
case '/info': return 'Información';
case '/builds': return 'Descargas';
case '/history': return 'Historial';
+ case '/calculator': return 'Calculadora';
case '/modal': return 'Detalles';
case '/privacy-policy': return 'Privacidad';
default: return 'Tasas';
@@ -105,6 +106,7 @@ function RootLayoutNav() {
+
diff --git a/mobile/app/calculator.tsx b/mobile/app/calculator.tsx
new file mode 100644
index 0000000..e3308f3
--- /dev/null
+++ b/mobile/app/calculator.tsx
@@ -0,0 +1,303 @@
+import React, { useMemo, useState } from 'react';
+import { View, StyleSheet, SafeAreaView, StatusBar, Platform, useWindowDimensions, FlatList, TouchableOpacity, Image } from 'react-native';
+import { Text } from '@/components/Themed';
+import { GradientBackground } from '@/components/GradientBackground';
+import { TopHeader } from '@/components/TopHeader';
+import { useExchangeRates } from '@/hooks/use-exchange-rates';
+import { useCalculatorStore } from '@/stores/calculator-store';
+import { CalculatorItem } from '@/components/CalculatorItem';
+import { formatNumber } from '@/utils/format';
+import { Plus, Trash2, DollarSign, Euro, RotateCcw, Copy } from 'lucide-react-native';
+import * as Clipboard from 'expo-clipboard';
+
+export default function CalculatorScreen() {
+ const { data } = useExchangeRates();
+ const { items, addItem, clearAll } = useCalculatorStore();
+ const [selectedUnit, setSelectedUnit] = useState<'USD' | 'EUR' | 'USDT'>('USD');
+ const { width } = useWindowDimensions();
+ const isDesktop = Platform.OS === 'web' && width > 768;
+
+ // Calculate totals
+ const totalBase = useMemo(() => {
+ return items.reduce((sum, item) => {
+ const val = parseFloat(item.amount.replace(',', '.')) || 0;
+ return sum + val;
+ }, 0);
+ }, [items]);
+
+ const getRateValue = (symbol: string) => {
+ const rate = data?.rates.find(r => r.label === symbol);
+ if (!rate) return 0;
+ return parseFloat(rate.value.replace(',', '.')) || 0;
+ };
+
+ const rateUSD = getRateValue('USD');
+ const rateEUR = getRateValue('EUR');
+ const rateUSDT = getRateValue('USDT');
+
+ const totalBcvUSD = totalBase * rateUSD;
+ const totalBcvEUR = totalBase * rateEUR;
+ const totalBinanceUSDT = totalBase * rateUSDT;
+
+ const unitLabels = {
+ 'USDT': 'USDT',
+ 'USD': 'USD BCV',
+ 'EUR': 'EURO BCV'
+ };
+
+ const handleCopy = async () => {
+ const text = `${formatNumber(totalBase)} ${unitLabels[selectedUnit]}\nBs. ${formatNumber(currentVesTotal)}`;
+ await Clipboard.setStringAsync(text);
+ };
+
+ const currentVesTotal = useMemo(() => {
+ switch (selectedUnit) {
+ case 'USD': return totalBcvUSD;
+ case 'EUR': return totalBcvEUR;
+ case 'USDT': return totalBinanceUSDT;
+ default: return totalBcvUSD;
+ }
+ }, [selectedUnit, totalBcvUSD, totalBcvEUR, totalBinanceUSDT]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {(['USDT', 'USD', 'EUR'] as const).map((unit) => {
+ const isActive = selectedUnit === unit;
+ return (
+ setSelectedUnit(unit)}
+ activeOpacity={0.8}
+ >
+ {unit === 'USDT' ? (
+
+ ) : unit === 'USD' ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+ Calculadora
+
+
+
+
+
+ {selectedUnit === 'USDT' ? (
+
+ ) : selectedUnit === 'USD' ? (
+
+ ) : (
+
+ )}
+
+ {formatNumber(totalBase)}
+
+
+
+ Bs. {formatNumber(currentVesTotal)}
+
+
+
+
+
+
+
+
+
+
+ item.id}
+ renderItem={({ item }) => }
+ contentContainerStyle={styles.listContent}
+ showsVerticalScrollIndicator={false}
+ ListEmptyComponent={
+
+ No hay items. Agrega uno debajo.
+
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ },
+ content: {
+ flex: 1,
+ paddingHorizontal: 16,
+ paddingTop: 10,
+ width: '100%',
+ },
+ desktopContent: {
+ maxWidth: 600,
+ alignSelf: 'center',
+ },
+ summaryCard: {
+ backgroundColor: 'transparent',
+ borderRadius: 20,
+ padding: 24,
+ marginBottom: 24,
+ borderWidth: 1,
+ borderColor: '#1B6B3E',
+ },
+ summaryHeaderRow: {
+ flexDirection: 'row-reverse',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ headerActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ summaryTitle: {
+ color: '#fff',
+ fontSize: 20,
+ fontWeight: 'bold',
+ opacity: 0.8,
+ },
+ headerResetButton: {
+ backgroundColor: '#1B6B3E',
+ padding: 10,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ },
+ summaryFooterRow: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ marginTop: 12,
+ },
+ copyButton: {
+ backgroundColor: '#1B6B3E',
+ padding: 10,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ },
+ totalsStack: {
+ alignItems: 'center',
+ gap: 4,
+ },
+ totalBase: {
+ flexDirection: 'row',
+ alignItems: 'baseline',
+ gap: 6,
+ },
+ totalBaseAmount: {
+ color: '#fff',
+ fontSize: 56,
+ fontWeight: 'bold',
+ },
+ totalIconContainer: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ totalIconImage: {
+ width: 32,
+ height: 32,
+ },
+ totalVesSub: {
+ color: '#F1C40F',
+ fontSize: 24,
+ fontWeight: 'bold',
+ opacity: 0.9,
+ },
+ customToggleContainer: {
+ flexDirection: 'row',
+ backgroundColor: '#1B6B3E',
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ padding: 2,
+ },
+ toggleSection: {
+ paddingHorizontal: 10,
+ paddingVertical: 10,
+ borderRadius: 10,
+ },
+ activeToggleSection: {
+ backgroundColor: '#F1C40F',
+ },
+ toggleText: {
+ color: '#F1C40F',
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+ activeToggleText: {
+ color: '#1B6B3E',
+ },
+ clearButton: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: 'rgba(27, 107, 62, 0.4)',
+ padding: 10,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#1B6B3E',
+ },
+ listContent: {
+ paddingBottom: 20,
+ },
+ emptyContainer: {
+ padding: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.2)',
+ borderRadius: 16,
+ borderStyle: 'dashed',
+ },
+ emptyText: {
+ color: 'rgba(255,255,255,0.6)',
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ addButton: {
+ backgroundColor: '#1B6B3E',
+ padding: 16,
+ borderRadius: 50,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 40,
+ marginTop: 20,
+ alignSelf: 'center',
+ // Shadow for premium feel
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 5,
+ elevation: 8,
+ },
+});
diff --git a/mobile/assets/images/adaptive-icon.png b/mobile/assets/images/adaptive-icon.png
index a7ec7d5..e2ad59c 100644
Binary files a/mobile/assets/images/adaptive-icon.png and b/mobile/assets/images/adaptive-icon.png differ
diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png
index 44307dd..3fad646 100644
Binary files a/mobile/assets/images/favicon.png and b/mobile/assets/images/favicon.png differ
diff --git a/mobile/assets/images/ios-dark.png b/mobile/assets/images/ios-dark.png
deleted file mode 100644
index ceb6e65..0000000
Binary files a/mobile/assets/images/ios-dark.png and /dev/null differ
diff --git a/mobile/assets/images/logo.png b/mobile/assets/images/logo.png
index 44307dd..3fad646 100644
Binary files a/mobile/assets/images/logo.png and b/mobile/assets/images/logo.png differ
diff --git a/mobile/assets/images/logo_arepa.svg b/mobile/assets/images/logo_arepa.svg
deleted file mode 100644
index 6079b2e..0000000
--- a/mobile/assets/images/logo_arepa.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
diff --git a/mobile/assets/images/splash-icon-dark.png b/mobile/assets/images/splash-icon-dark.png
index 97a20dd..3fad646 100644
Binary files a/mobile/assets/images/splash-icon-dark.png and b/mobile/assets/images/splash-icon-dark.png differ
diff --git a/mobile/assets/images/splash-icon-light.png b/mobile/assets/images/splash-icon-light.png
index f9c34d5..2fafd98 100644
Binary files a/mobile/assets/images/splash-icon-light.png and b/mobile/assets/images/splash-icon-light.png differ
diff --git a/mobile/assets/images/splash.png b/mobile/assets/images/splash.png
index 42245a6..3fad646 100644
Binary files a/mobile/assets/images/splash.png and b/mobile/assets/images/splash.png differ
diff --git a/mobile/components/CalculatorItem.tsx b/mobile/components/CalculatorItem.tsx
new file mode 100644
index 0000000..5ce7daf
--- /dev/null
+++ b/mobile/components/CalculatorItem.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { View, StyleSheet, TouchableOpacity } from 'react-native';
+import { TextInput } from '@/components/Themed';
+import { Trash2 } from 'lucide-react-native';
+import { CalculatorItem as CalculatorItemType, useCalculatorStore } from '../stores/calculator-store';
+import { formatInputDisplay } from '../utils/format';
+
+interface Props {
+ item: CalculatorItemType;
+}
+
+export function CalculatorItem({ item }: Props) {
+ const updateItem = useCalculatorStore((state) => state.updateItem);
+ const removeItem = useCalculatorStore((state) => state.removeItem);
+
+ const handleAmountChange = (text: string) => {
+ updateItem(item.id, { amount: formatInputDisplay(text) });
+ };
+
+ return (
+
+
+ updateItem(item.id, { description: text })}
+ placeholder="Descripción"
+ placeholderTextColor="rgba(255,255,255,0.4)"
+ />
+
+
+ removeItem(item.id)}>
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 12,
+ gap: 16,
+ },
+ inputContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ gap: 10,
+ },
+ descriptionInput: {
+ flex: 2,
+ backgroundColor: '#1B6B3E',
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ color: '#fff',
+ fontSize: 16,
+ },
+ amountInput: {
+ flex: 1.2,
+ backgroundColor: '#1B6B3E',
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: '#448A44',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ color: '#F1C40F',
+ fontSize: 16,
+ fontWeight: 'bold',
+ textAlign: 'right',
+ },
+ deleteButton: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ }
+});
diff --git a/mobile/components/TopHeader.tsx b/mobile/components/TopHeader.tsx
index 092b01b..107ff0a 100644
--- a/mobile/components/TopHeader.tsx
+++ b/mobile/components/TopHeader.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { View, StyleSheet, TouchableOpacity, Image, Platform, useWindowDimensions } from 'react-native';
import { Text } from '@/components/Themed';
import { useRouter, usePathname } from 'expo-router';
-import { Home, HelpCircle, BarChart3 } from 'lucide-react-native';
+import { Home, HelpCircle, BarChart3, Calculator } from 'lucide-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export function TopHeader() {
@@ -23,6 +23,7 @@ export function TopHeader() {
const isHomeActive = pathname === '/' || pathname === '/index';
const isHistoryActive = pathname === '/history' || pathname.includes('history');
+ const isCalculatorActive = pathname === '/calculator';
const isInfoActive = pathname === '/info';
return (
@@ -40,6 +41,10 @@ export function TopHeader() {
+ navigateTo('calculator')} style={styles.iconBtn}>
+
+
+
{/* History button */}
navigateTo('(tabs)/history')} style={styles.iconBtn}>
diff --git a/mobile/stores/calculator-store.ts b/mobile/stores/calculator-store.ts
new file mode 100644
index 0000000..a74c422
--- /dev/null
+++ b/mobile/stores/calculator-store.ts
@@ -0,0 +1,41 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import { storage } from '../utils/storage';
+
+export interface CalculatorItem {
+ id: string;
+ description: string;
+ amount: string;
+}
+
+interface CalculatorState {
+ items: CalculatorItem[];
+ addItem: () => void;
+ removeItem: (id: string) => void;
+ updateItem: (id: string, data: Partial) => void;
+ clearAll: () => void;
+}
+
+const generateId = () => Math.random().toString(36).substring(2, 9);
+
+export const useCalculatorStore = create()(
+ persist(
+ (set) => ({
+ items: [],
+ addItem: () => set((state) => ({
+ items: [...state.items, { id: generateId(), description: `Item ${state.items.length + 1}`, amount: '' }]
+ })),
+ removeItem: (id) => set((state) => ({
+ items: state.items.filter(item => item.id !== id)
+ })),
+ updateItem: (id, data) => set((state) => ({
+ items: state.items.map(item => item.id === id ? { ...item, ...data } : item)
+ })),
+ clearAll: () => set({ items: [] }),
+ }),
+ {
+ name: 'calculator-storage',
+ storage: createJSONStorage(() => storage),
+ }
+ )
+);