13 KiB
13 KiB
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?
- Next.js: Single framework handles both frontend and backend. No separate API server needed.
- SQLite: No database server to manage. Data stored in a single file. Easy to backup.
- Tailwind: Beautiful UI without writing custom CSS. Built-in dark mode support.
- 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
-- 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)
// 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 variantsCard- Container with shadowBadge- Status indicatorsInput/Select- Form elementsModal- For forms and confirmationsToast- Non-intrusive notifications
6. Key Features Implementation
6.1 Dashboard Stats Query
// 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
// 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
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
// 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
/* 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
# 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
- Self-hosted (Docker): Single container with SQLite volume
- Vercel + Turso: If cloud SQLite needed
- Local only: Run on home server/NAS
Docker Compose (Simple)
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