battery_tracker/docs/TSD.md

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