421 lines
13 KiB
Markdown
421 lines
13 KiB
Markdown
# Battery Tracker - Technical Specification Document
|
|
|
|
## 1. Tech Stack Overview
|
|
|
|
### Recommended Stack
|
|
|
|
| Layer | Technology | Rationale |
|
|
|-------|------------|-----------|
|
|
| **Framework** | Next.js 14 (App Router) | Full-stack React, simple deployment, API routes built-in |
|
|
| **UI Library** | React 18 | Component-based, excellent ecosystem |
|
|
| **Styling** | Tailwind CSS | Utility-first, rapid development, beautiful defaults |
|
|
| **Database** | SQLite (via better-sqlite3) | Zero-config, file-based, perfect for personal use |
|
|
| **ORM** | Drizzle ORM | Type-safe, lightweight, great SQLite support |
|
|
| **State** | React hooks + Server Components | Minimal client state needed |
|
|
| **Icons** | Lucide React | Clean, consistent icon set |
|
|
|
|
### Why This Stack?
|
|
|
|
1. **Next.js**: Single framework handles both frontend and backend. No separate API server needed.
|
|
2. **SQLite**: No database server to manage. Data stored in a single file. Easy to backup.
|
|
3. **Tailwind**: Beautiful UI without writing custom CSS. Built-in dark mode support.
|
|
4. **Drizzle**: Modern, type-safe ORM that's lighter than Prisma.
|
|
|
|
## 2. Project Structure
|
|
|
|
```
|
|
battery-tracker/
|
|
├── src/
|
|
│ ├── app/ # Next.js App Router
|
|
│ │ ├── layout.tsx # Root layout
|
|
│ │ ├── page.tsx # Dashboard
|
|
│ │ ├── batteries/
|
|
│ │ │ ├── page.tsx # Battery list
|
|
│ │ │ └── [id]/page.tsx # Battery detail/edit
|
|
│ │ ├── devices/
|
|
│ │ │ ├── page.tsx # Device list
|
|
│ │ │ └── [id]/page.tsx # Device detail/edit
|
|
│ │ └── api/ # API routes
|
|
│ │ ├── batteries/
|
|
│ │ ├── devices/
|
|
│ │ └── types/
|
|
│ ├── components/
|
|
│ │ ├── ui/ # Reusable UI components
|
|
│ │ ├── battery/ # Battery-specific components
|
|
│ │ └── device/ # Device-specific components
|
|
│ ├── lib/
|
|
│ │ ├── db/
|
|
│ │ │ ├── index.ts # Database connection
|
|
│ │ │ ├── schema.ts # Drizzle schema
|
|
│ │ │ └── migrations/ # Database migrations
|
|
│ │ └── utils.ts # Utility functions
|
|
│ └── types/
|
|
│ └── index.ts # TypeScript types
|
|
├── public/
|
|
├── data/ # SQLite database file
|
|
├── package.json
|
|
├── tailwind.config.ts
|
|
├── drizzle.config.ts
|
|
└── tsconfig.json
|
|
```
|
|
|
|
## 3. Database Schema
|
|
|
|
```sql
|
|
-- Battery types / form factors (AA, AAA, 18650, etc.)
|
|
CREATE TABLE battery_types (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
is_custom BOOLEAN DEFAULT FALSE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Battery chemistries (NiMH, Li-ion, LiFePO4, etc.)
|
|
CREATE TABLE chemistries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
is_custom BOOLEAN DEFAULT FALSE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Brands (Panasonic, Eneloop, etc.)
|
|
CREATE TABLE brands (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Battery groups (Brand + Type + Chemistry combination with counts)
|
|
CREATE TABLE battery_groups (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
brand_id INTEGER NOT NULL REFERENCES brands(id),
|
|
type_id INTEGER NOT NULL REFERENCES battery_types(id),
|
|
chemistry_id INTEGER NOT NULL REFERENCES chemistries(id),
|
|
available_count INTEGER DEFAULT 0,
|
|
charging_count INTEGER DEFAULT 0,
|
|
notes TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(brand_id, type_id, chemistry_id)
|
|
);
|
|
|
|
-- Devices
|
|
CREATE TABLE devices (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Device-Battery assignments
|
|
CREATE TABLE device_batteries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
battery_group_id INTEGER NOT NULL REFERENCES battery_groups(id),
|
|
quantity INTEGER NOT NULL DEFAULT 1,
|
|
assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(device_id, battery_group_id)
|
|
);
|
|
```
|
|
|
|
### Drizzle Schema (TypeScript)
|
|
|
|
```typescript
|
|
// src/lib/db/schema.ts
|
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
|
|
|
export const batteryTypes = sqliteTable('battery_types', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
name: text('name').notNull().unique(),
|
|
isCustom: integer('is_custom', { mode: 'boolean' }).default(false),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
|
|
export const chemistries = sqliteTable('chemistries', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
name: text('name').notNull().unique(),
|
|
isCustom: integer('is_custom', { mode: 'boolean' }).default(false),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
|
|
export const brands = sqliteTable('brands', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
name: text('name').notNull().unique(),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
|
|
export const batteryGroups = sqliteTable('battery_groups', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
brandId: integer('brand_id').notNull().references(() => brands.id),
|
|
typeId: integer('type_id').notNull().references(() => batteryTypes.id),
|
|
chemistryId: integer('chemistry_id').notNull().references(() => chemistries.id),
|
|
availableCount: integer('available_count').default(0),
|
|
chargingCount: integer('charging_count').default(0),
|
|
notes: text('notes'),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
|
|
export const devices = sqliteTable('devices', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).defaultNow(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
|
|
export const deviceBatteries = sqliteTable('device_batteries', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
deviceId: integer('device_id').notNull().references(() => devices.id),
|
|
batteryGroupId: integer('battery_group_id').notNull().references(() => batteryGroups.id),
|
|
quantity: integer('quantity').notNull().default(1),
|
|
assignedAt: integer('assigned_at', { mode: 'timestamp' }).defaultNow(),
|
|
});
|
|
```
|
|
|
|
## 4. API Design
|
|
|
|
### RESTful Endpoints
|
|
|
|
```
|
|
# Battery Types (Form Factors)
|
|
GET /api/types # List all battery types
|
|
POST /api/types # Create custom type
|
|
|
|
# Chemistries
|
|
GET /api/chemistries # List all chemistries
|
|
POST /api/chemistries # Create custom chemistry
|
|
|
|
# Brands
|
|
GET /api/brands # List all brands
|
|
POST /api/brands # Create brand
|
|
|
|
# Battery Groups
|
|
GET /api/batteries # List all battery groups (with brand/type/chemistry info)
|
|
POST /api/batteries # Create or add to battery group (upsert)
|
|
GET /api/batteries/:id # Get single battery group
|
|
PATCH /api/batteries/:id # Update battery group
|
|
DELETE /api/batteries/:id # Delete battery group
|
|
|
|
# Quick Actions
|
|
POST /api/batteries/:id/charge # Move X batteries to charging
|
|
POST /api/batteries/:id/available # Move X batteries to available
|
|
POST /api/batteries/:id/assign # Assign to device
|
|
|
|
# Devices
|
|
GET /api/devices # List all devices (with battery assignments)
|
|
POST /api/devices # Create device
|
|
GET /api/devices/:id # Get single device
|
|
PATCH /api/devices/:id # Update device
|
|
DELETE /api/devices/:id # Delete device
|
|
|
|
# Device Battery Management
|
|
POST /api/devices/:id/batteries # Assign batteries to device
|
|
DELETE /api/devices/:id/batteries/:gid # Remove batteries from device
|
|
```
|
|
|
|
## 5. Component Architecture
|
|
|
|
### Key Components
|
|
|
|
```
|
|
<RootLayout>
|
|
├── <Navigation />
|
|
├── <main>
|
|
│ └── {children}
|
|
└── <Toaster /> (for notifications)
|
|
|
|
<Dashboard>
|
|
├── <StatsCards /> # Available, In Use, Charging totals
|
|
├── <QuickActions /> # Common actions
|
|
└── <RecentActivity /> # Recent changes (optional)
|
|
|
|
<BatteryList>
|
|
├── <FilterBar /> # Filter by type, brand, chemistry, status
|
|
├── <BatteryCard />[] # Grid of battery groups
|
|
│ ├── <StatusBadges /> # Available/InUse/Charging counts
|
|
│ └── <QuickActionButtons />
|
|
└── <AddBatteryButton />
|
|
|
|
<DeviceList>
|
|
├── <DeviceCard />[]
|
|
│ ├── <DeviceInfo />
|
|
│ └── <AssignedBatteries />
|
|
└── <AddDeviceButton />
|
|
```
|
|
|
|
### Shared UI Components
|
|
|
|
- `Button` - Primary, secondary, danger variants
|
|
- `Card` - Container with shadow
|
|
- `Badge` - Status indicators
|
|
- `Input` / `Select` - Form elements
|
|
- `Modal` - For forms and confirmations
|
|
- `Toast` - Non-intrusive notifications
|
|
|
|
## 6. Key Features Implementation
|
|
|
|
### 6.1 Dashboard Stats Query
|
|
|
|
```typescript
|
|
// Get aggregated stats
|
|
const stats = await db
|
|
.select({
|
|
available: sql<number>`SUM(available_count)`,
|
|
charging: sql<number>`SUM(charging_count)`,
|
|
inUse: sql<number>`SUM(quantity)`,
|
|
})
|
|
.from(batteryGroups)
|
|
.leftJoin(deviceBatteries, eq(batteryGroups.id, deviceBatteries.batteryGroupId));
|
|
```
|
|
|
|
### 6.2 Move Batteries Action
|
|
|
|
```typescript
|
|
// Move batteries from available to charging
|
|
async function startCharging(groupId: number, count: number) {
|
|
await db
|
|
.update(batteryGroups)
|
|
.set({
|
|
availableCount: sql`available_count - ${count}`,
|
|
chargingCount: sql`charging_count + ${count}`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(batteryGroups.id, groupId));
|
|
}
|
|
```
|
|
|
|
### 6.3 Assign to Device
|
|
|
|
```typescript
|
|
async function assignToDevice(groupId: number, deviceId: number, quantity: number) {
|
|
await db.transaction(async (tx) => {
|
|
// Decrement available count
|
|
await tx
|
|
.update(batteryGroups)
|
|
.set({ availableCount: sql`available_count - ${quantity}` })
|
|
.where(eq(batteryGroups.id, groupId));
|
|
|
|
// Add/update device assignment
|
|
await tx
|
|
.insert(deviceBatteries)
|
|
.values({ deviceId, batteryGroupId: groupId, quantity })
|
|
.onConflictDoUpdate({
|
|
target: [deviceBatteries.deviceId, deviceBatteries.batteryGroupId],
|
|
set: { quantity: sql`quantity + ${quantity}` },
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
### 6.4 Add Batteries (Upsert Behavior)
|
|
|
|
When adding batteries, the system checks if a group with the same brand-type-chemistry combination exists:
|
|
- **If exists**: Adds the quantity to the existing group's available count
|
|
- **If not exists**: Creates a new battery group
|
|
|
|
```typescript
|
|
// POST /api/batteries - Upsert behavior
|
|
async function addBatteries(brandId: number, typeId: number, chemistryId: number, quantity: number) {
|
|
// Check for existing group
|
|
const existing = await db
|
|
.select()
|
|
.from(batteryGroups)
|
|
.where(and(
|
|
eq(batteryGroups.brandId, brandId),
|
|
eq(batteryGroups.typeId, typeId),
|
|
eq(batteryGroups.chemistryId, chemistryId)
|
|
))
|
|
.get();
|
|
|
|
if (existing) {
|
|
// Add to existing group
|
|
await db
|
|
.update(batteryGroups)
|
|
.set({
|
|
availableCount: sql`available_count + ${quantity}`,
|
|
updatedAt: new Date()
|
|
})
|
|
.where(eq(batteryGroups.id, existing.id));
|
|
return { ...existing, isNew: false };
|
|
} else {
|
|
// Create new group
|
|
const result = await db
|
|
.insert(batteryGroups)
|
|
.values({ brandId, typeId, chemistryId, availableCount: quantity })
|
|
.returning();
|
|
return { ...result[0], isNew: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
Response includes `isNew` flag to allow UI to show appropriate feedback message.
|
|
|
|
## 7. UI/UX Design Details
|
|
|
|
### Color Scheme
|
|
|
|
```css
|
|
/* Status Colors */
|
|
--available: #22c55e; /* Green 500 */
|
|
--in-use: #3b82f6; /* Blue 500 */
|
|
--charging: #f59e0b; /* Amber 500 */
|
|
|
|
/* Neutral Palette */
|
|
--background: #f8fafc; /* Slate 50 */
|
|
--card: #ffffff;
|
|
--text: #0f172a; /* Slate 900 */
|
|
--muted: #64748b; /* Slate 500 */
|
|
```
|
|
|
|
### Responsive Breakpoints
|
|
|
|
- Mobile: < 640px (single column)
|
|
- Tablet: 640px - 1024px (2 columns)
|
|
- Desktop: > 1024px (3-4 columns)
|
|
|
|
## 8. Development Setup
|
|
|
|
```bash
|
|
# Create project
|
|
npx create-next-app@latest battery-tracker --typescript --tailwind --app
|
|
|
|
# Install dependencies
|
|
npm install drizzle-orm better-sqlite3
|
|
npm install -D drizzle-kit @types/better-sqlite3
|
|
|
|
# Run development
|
|
npm run dev
|
|
|
|
# Database migrations
|
|
npx drizzle-kit generate
|
|
npx drizzle-kit migrate
|
|
```
|
|
|
|
## 9. Deployment Options
|
|
|
|
1. **Self-hosted (Docker)**: Single container with SQLite volume
|
|
2. **Vercel + Turso**: If cloud SQLite needed
|
|
3. **Local only**: Run on home server/NAS
|
|
|
|
### Docker Compose (Simple)
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
services:
|
|
app:
|
|
build: .
|
|
ports:
|
|
- "3000:3000"
|
|
volumes:
|
|
- ./data:/app/data # Persist SQLite
|
|
```
|
|
|
|
## 10. Future Considerations
|
|
|
|
- **PWA Support**: Add service worker for offline access
|
|
- **Import/Export**: JSON backup/restore
|
|
- **Dark Mode**: Tailwind dark mode utilities
|
|
- **Search**: Full-text search across batteries and devices
|