Initial commit - Stashpile v0.1

Smart household management frontend for Grocy servers.

Features:
- Dashboard with PulseRing status indicator
- Shopping list with checkable items
- Expiring soon / low stock alerts
- Chores summary
- Quick add with create product flow
- PWA installable

Tech: React 19, TypeScript, Vite, TanStack Router, Zustand, Tailwind CSS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 21:14:12 -06:00
commit cbca17a682
47 changed files with 13973 additions and 0 deletions

206
src/api/client.ts Normal file
View File

@@ -0,0 +1,206 @@
import type { EntityName, ApiError, StockProduct, Product, SystemInfo, User } from '../types/grocy';
const STORAGE_KEY_SERVER = 'grocy_server_url';
const STORAGE_KEY_API = 'grocy_api_key';
class GrocyApiClient {
private serverUrl: string | null = null;
private apiKey: string | null = null;
constructor() {
// Load from localStorage on init
this.serverUrl = localStorage.getItem(STORAGE_KEY_SERVER);
this.apiKey = localStorage.getItem(STORAGE_KEY_API);
}
isConfigured(): boolean {
return !!(this.serverUrl && this.apiKey);
}
getServerUrl(): string | null {
return this.serverUrl;
}
configure(serverUrl: string, apiKey: string): void {
// Normalize URL - remove trailing slash
const normalizedUrl = serverUrl.replace(/\/+$/, '');
localStorage.setItem(STORAGE_KEY_SERVER, normalizedUrl);
localStorage.setItem(STORAGE_KEY_API, apiKey);
this.serverUrl = normalizedUrl;
this.apiKey = apiKey;
}
clearConfig(): void {
localStorage.removeItem(STORAGE_KEY_SERVER);
localStorage.removeItem(STORAGE_KEY_API);
this.serverUrl = null;
this.apiKey = null;
}
private getBaseUrl(): string {
// In development, use Vite proxy (relative URL)
// In production or when configured, use absolute URL
if (import.meta.env.DEV && !this.serverUrl) {
return '';
}
return this.serverUrl || '';
}
private async request<T>(method: string, endpoint: string, body?: unknown): Promise<T> {
const baseUrl = this.getBaseUrl();
const url = `${baseUrl}/api${endpoint}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
// Only add API key header if configured
if (this.apiKey) {
headers['GROCY-API-KEY'] = this.apiKey;
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error: ApiError = await response.json().catch(() => ({
error_message: `HTTP ${response.status}: ${response.statusText}`,
}));
throw new Error(error.error_message);
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
// Generic CRUD
async getObjects<T>(entity: EntityName, query?: string): Promise<T[]> {
const endpoint = `/objects/${entity}${query ? `?${query}` : ''}`;
return this.request<T[]>('GET', endpoint);
}
async getObject<T>(entity: EntityName, id: number): Promise<T> {
return this.request<T>('GET', `/objects/${entity}/${id}`);
}
async createObject<T>(entity: EntityName, data: Partial<T>): Promise<{ created_object_id: number }> {
return this.request('POST', `/objects/${entity}`, data);
}
async updateObject<T>(entity: EntityName, id: number, data: Partial<T>): Promise<void> {
return this.request('PUT', `/objects/${entity}/${id}`, data);
}
async deleteObject(entity: EntityName, id: number): Promise<void> {
return this.request('DELETE', `/objects/${entity}/${id}`);
}
// Stock
async getStock(): Promise<StockProduct[]> {
return this.request<StockProduct[]>('GET', '/stock');
}
async getStockProduct(productId: number): Promise<StockProduct> {
return this.request<StockProduct>('GET', `/stock/products/${productId}`);
}
async getProductByBarcode(barcode: string): Promise<{ product: Product }> {
return this.request('GET', `/stock/products/by-barcode/${barcode}`);
}
async addProduct(
productId: number,
amount: number,
options?: {
best_before_date?: string;
price?: number;
location_id?: number;
}
) {
return this.request('POST', `/stock/products/${productId}/add`, {
amount,
...options,
});
}
async consumeProduct(
productId: number,
amount: number,
options?: {
spoiled?: boolean;
stock_entry_id?: string;
location_id?: number;
}
) {
return this.request('POST', `/stock/products/${productId}/consume`, {
amount,
...options,
});
}
async openProduct(productId: number, amount: number, stockEntryId?: string) {
return this.request('POST', `/stock/products/${productId}/open`, {
amount,
stock_entry_id: stockEntryId,
});
}
// Shopping list
async addToShoppingList(productId: number, amount: number, listId = 1, note?: string) {
return this.createObject('shopping_list', {
product_id: productId,
amount,
shopping_list_id: listId,
note,
});
}
async clearShoppingList(listId = 1) {
return this.request('POST', `/stock/shoppinglist/clear`, { list_id: listId });
}
async addMissingProductsToShoppingList(listId = 1) {
return this.request('POST', `/stock/shoppinglist/add-missing-products`, { list_id: listId });
}
// Chores
async getChores(): Promise<Array<{ chore_id: number; chore_name: string; next_estimated_execution_time: string | null; next_execution_assigned_to_user_id: number | null; last_tracked_time: string | null; track_count: number }>> {
return this.request('GET', '/chores');
}
async executeChore(choreId: number, trackedTime?: string): Promise<void> {
return this.request('POST', `/chores/${choreId}/execute`, {
tracked_time: trackedTime || new Date().toISOString(),
});
}
// System
async getSystemInfo(): Promise<SystemInfo> {
return this.request<SystemInfo>('GET', '/system/info');
}
async getDbChangedTime(): Promise<{ changed_time: string }> {
return this.request('GET', '/system/db-changed-time');
}
// User
async getCurrentUser(): Promise<User> {
return this.request<User>('GET', '/user');
}
async getUserSettings(): Promise<Record<string, unknown>> {
return this.request('GET', '/user/settings');
}
}
export const grocyApi = new GrocyApiClient();
export default grocyApi;