first commit

This commit is contained in:
2026-04-30 18:22:10 +02:00
commit c3e98af5b6
36 changed files with 4779 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
data/*.db
+399
View File
@@ -0,0 +1,399 @@
# AGENTS.md
## Project Context
This repository contains a web application for creating, editing, calculating, and documenting electrical power balances for building-services electrical planning.
The application is intended for small internal use by approximately 23 concurrent users. It should support practical planning workflows, not an over-engineered enterprise architecture.
The domain is electrical building planning, especially German TGA / ELT planning. Important concepts include:
- Power balance / Leistungsbilanz
- Distribution boards / Verteilungen
- Electrical consumers / Verbraucher
- Installed power / installierte Leistung
- Demand factor / Gleichzeitigkeitsfaktor
- Calculated demand power / berechnete Leistung
- Voltage, current, phases, cos phi
- Device groups and individual devices
- Project-based calculation and documentation
Use English identifiers in code, database fields, interfaces, classes, and file names. German wording is allowed and preferred in UI labels, reports, and domain-facing text.
## Main Goal
Build a maintainable web application that allows users to:
- Create and manage projects
- Define electrical distributions or calculation areas
- Add individual electrical consumers
- Add grouped consumers with quantity
- Assign technical parameters to consumers
- Calculate installed and demand power
- Structure consumers by project, distribution, area, system, or category
- Export or print a usable power-balance report later
The application should be practical for electrical planners and should keep the data model understandable.
## Technology Direction
Preferred stack:
- TypeScript
- Node.js backend
- SQLite database for the initial version
- ORM is allowed and preferred if it improves type safety and maintainability
- Drizzle ORM is preferred for a more explicit SQL-oriented approach
- Prisma is acceptable if the project already uses it
- Frontend may be React-based if a UI is implemented
- Docker support for local development is desired
Do not introduce unnecessary infrastructure such as PostgreSQL, Redis, Kubernetes, microservices, message queues, or complex event systems unless explicitly requested.
SQLite is sufficient for the expected initial workload.
## Architecture Principles
Keep the application modular, but avoid excessive abstraction.
Preferred structure:
```text
src/
├─ domain/
│ ├─ models/
│ ├─ services/
│ └─ calculations/
├─ db/
│ ├─ schema/
│ ├─ migrations/
│ └─ repositories/
├─ server/
│ ├─ routes/
│ ├─ controllers/
│ └─ middleware/
├─ frontend/
│ ├─ components/
│ ├─ pages/
│ └─ utils/
└─ shared/
├─ types/
└─ validation/
```
The exact structure may be simplified if the project is still small.
Use object-oriented design where it is useful for domain behavior, but do not force everything into classes.
Good candidates for classes:
- Project
- PowerBalance
- DistributionBoard
- Consumer
- ConsumerGroup
- CalculationService
- ReportGenerator
Good candidates for interfaces/types:
- DTOs
- API request/response shapes
- ORM result types
- Configuration objects
- Form data
- Validation schemas
Avoid duplicating the same model excessively across domain, database, API, and frontend. Some separation is acceptable, but keep mappings simple and explicit.
## Naming Conventions
Use English names in code.
Examples:
- `Project`
- `PowerBalance`
- `DistributionBoard`
- `Consumer`
- `ConsumerGroup`
- `installedPower`
- `demandFactor`
- `demandPower`
- `ratedCurrent`
- `powerFactor`
- `quantity`
- `voltage`
- `phaseCount`
Use PascalCase for classes, interfaces, React components, and types.
Use camelCase for variables, properties, functions, and methods.
Use kebab-case for file names unless the local framework convention requires otherwise.
Examples:
```text
power-balance.service.ts
consumer.model.ts
distribution-board.repository.ts
calculation-utils.ts
```
## Domain Model Guidelines
A consumer must still have a quantity. This allows the same structure to represent either a single device or a grouped device entry.
Example:
```ts
type Consumer = {
id: string;
projectId: string;
distributionBoardId?: string;
name: string;
category?: string;
quantity: number;
installedPowerPerUnit: number;
installedPowerTotal: number;
demandFactor: number;
demandPower: number;
voltage?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
};
```
The calculated fields may either be persisted or calculated dynamically. Prefer dynamic calculation first unless persistence is needed for reporting, auditability, or performance.
## Calculation Rules
Keep calculation logic centralized.
Do not spread electrical formulas across route handlers, UI components, or database repositories.
Use dedicated calculation services or pure functions.
Examples:
```text
src/domain/calculations/power-calculation.ts
src/domain/services/power-balance.service.ts
```
Typical calculation concepts:
- Installed power total = quantity × installed power per unit
- Demand power = installed power total × demand factor
- Current calculation depends on voltage, phase count, and power factor
When implementing formulas, add comments for the electrical meaning, especially where three-phase and single-phase calculations differ.
Always be careful with units.
Preferred base units:
- Power: kW
- Current: A
- Voltage: V
- Demand factor: decimal number from 0 to 1
If other units are added later, implement explicit conversion helpers.
## Database Guidelines
Use SQLite initially.
Keep schema clear and readable.
Avoid premature normalization. Normalize where it prevents real inconsistency, not just for theoretical purity.
Recommended core tables:
- projects
- power_balances
- distribution_boards
- consumers
- consumer_categories or system_types, if needed later
Use migrations. Do not manually modify the database schema without creating a migration.
Use repositories or database access modules. Do not place raw database queries directly in UI components or high-level route handlers.
## API Guidelines
Keep API routes resource-oriented.
Example routes:
```text
GET /api/projects
POST /api/projects
GET /api/projects/:projectId
PUT /api/projects/:projectId
DELETE /api/projects/:projectId
GET /api/projects/:projectId/power-balances
POST /api/projects/:projectId/power-balances
GET /api/power-balances/:powerBalanceId/consumers
POST /api/power-balances/:powerBalanceId/consumers
PUT /api/consumers/:consumerId
DELETE /api/consumers/:consumerId
```
Validate all incoming API data.
Use shared validation schemas if possible.
## UI Guidelines
The UI should be practical and data-entry friendly.
Prioritize:
- Tables for consumers
- Inline editing where useful
- Clear grouping by distribution board or area
- Totals at group and project level
- German UI labels
- Consistent units in column headers
- Minimal clicks for adding multiple consumers
Use German labels such as:
- Projekt
- Leistungsbilanz
- Verteilung
- Verbraucher
- Anzahl
- Leistung je Stück
- Installierte Leistung
- Gleichzeitigkeitsfaktor
- Berechnete Leistung
- Bemerkung
Code identifiers must remain English.
## Testing Guidelines
Add tests for calculation logic.
Calculation tests are more important than superficial UI tests.
At minimum, test:
- Total installed power calculation
- Demand power calculation
- Quantity handling
- Single-phase current calculation, if implemented
- Three-phase current calculation, if implemented
- Edge cases such as quantity 0, demand factor 0, and missing optional values
Do not change formulas without updating or adding tests.
## Docker and Development
The project should be runnable in a local development environment, including on Windows with Docker Desktop.
If Docker is used, provide:
- `Dockerfile`
- `docker-compose.yml`
- clear volume handling for SQLite database files
- documented development commands
Avoid configurations that only work on Linux unless explicitly documented.
## Documentation Expectations
Keep documentation short but useful.
Document:
- How to install dependencies
- How to start the dev server
- How to run migrations
- How to run tests
- Basic domain assumptions
- Important formulas
Prefer concise Markdown documentation.
## Coding Style
Write clear, explicit TypeScript.
Prefer readability over cleverness.
Avoid:
- unnecessary generic abstractions
- magic numbers
- hidden side effects
- large files with unrelated responsibilities
- mixing UI, database, and calculation logic
Use meaningful names even if they are longer.
## Safety and Data Integrity
Do not delete user data without explicit confirmation in the UI or API design.
Use soft-delete only if the project introduces audit or recovery requirements.
Validate numeric values carefully:
- quantity must not be negative
- demand factor should normally be between 0 and 1
- installed power must not be negative
- voltage must be positive
- phase count should be limited to valid values
## Agent Behavior
When modifying this repository:
1. Inspect the existing structure before adding new files.
2. Reuse existing patterns unless they are clearly wrong.
3. Keep changes small and focused.
4. Do not introduce large framework changes without explicit instruction.
5. Do not silently change the database technology.
6. Do not silently change the ORM.
7. Keep domain terms consistent.
8. Add or update tests when changing calculation logic.
9. Update this `AGENTS.md` if a new recurring project rule is established.
10. Prefer practical implementation over theoretical perfection.
## Out of Scope Unless Explicitly Requested
Do not implement the following unless explicitly requested:
- Multi-tenant user management
- Role-based permissions
- Cloud deployment
- PostgreSQL migration
- Realtime collaboration
- Complex report designer
- Full BIM integration
- GAEB integration
- Revit integration
- Authentication providers
- Enterprise audit logging
## Preferred First Milestone
The first useful milestone should be:
- Create a project
- Create one power balance
- Add distribution boards
- Add consumers with quantity
- Calculate installed power and demand power
- Show totals in the UI
- Persist data in SQLite
- Provide basic tests for calculation logic
Do not start with advanced reporting before the core data and calculation workflow works.
+27
View File
@@ -0,0 +1,27 @@
# Leistungsbilanz
TypeScript backend for electrical power-balance planning.
## Setup
1. `npm install`
2. `npm run db:generate`
3. `npm run db:migrate`
4. `npm run dev`
## Commands
- `npm run dev`: Start API server
- `npm run build`: TypeScript build
- `npm run test`: Calculation tests
- `npm run db:generate`: Generate migrations
- `npm run db:migrate`: Apply migrations
## Current API
- `GET /health`
- `GET /api/projects`
- `POST /api/projects`
- `GET /api/consumers/projects/:projectId`
- `POST /api/consumers`
@@ -0,0 +1,666 @@
# Context Dump Requirements for Electrical Load Balance and Circuit List Web App
## 1. Purpose
The application shall be a web app for creating electrical load balances and combined load balance / circuit lists for electrical planning.
The application shall primarily work in a tabular way.
## 2. Basic Structure
The application manages projects.
A project contains:
- Multiple distribution boards
- One project-specific device list
Each distribution board contains:
- Exactly one circuit list
Additionally, there is:
- One global device list
- One project-specific device list per project
Basic hierarchy:
```text
Project
├── ProjectDeviceList
└── DistributionBoard
└── CircuitList
└── CircuitEntries
Global
└── GlobalDeviceList
```
## 3. Projects
A project is the top-level domain object.
A project can contain multiple distribution boards.
A project has one project-specific device list.
Devices from the project-specific device list can be used in the circuit lists of the distribution boards within that project.
### Project Attributes
```text
Project
- id
- name
- distributionBoards: List<DistributionBoard>
- projectDeviceList: DeviceList
```
## 4. Distribution Boards
A distribution board belongs to exactly one project.
A distribution board has exactly one circuit list.
A project can contain multiple distribution boards.
Each circuit list is therefore assigned to one distribution board.
### DistributionBoard Attributes
```text
DistributionBoard
- id
- name
- circuitList: CircuitList
```
## 5. Circuit Lists
A circuit list belongs to one distribution board.
The circuit list is table-based.
One row in the table is one circuit entry.
Multiple rows may reference the same circuit number.
This is required so that multiple different devices can be assigned to the same circuit.
Example:
```text
Circuit 1 | Sockets | 6 pcs
Circuit 1 | Lights | 3 pcs
Circuit 1 | Fixed load | 1 pc
```
This means that one circuit can be represented by multiple table rows.
### CircuitList Attributes
```text
CircuitList
- id
- name
- entries: List<CircuitEntry>
```
## 6. Circuit Entries
A circuit entry is one row in the circuit list.
A circuit entry can:
- Be linked to a device from the project-specific device list
- Exist without a device
- Exist without a device reference
- Be detached from the originally linked device
- Have its own description
- Have its own values
A circuit entry may be incomplete.
In principle, there are no required fields.
### CircuitEntry Attributes
```text
CircuitEntry
- id
- circuitNumber
- description
- roomNumber
- roomName
- device
- isLinkedToDevice
- quantity
- unitPower
- simultaneityFactor
- cosPhi
- totalPower
- deviceType
- phaseType
- tradeOrCostGroup
- group
- protectionType
- protectionRatedCurrent
- protectionCharacteristic
- cableType
- cableCrossSection
- comment
```
## 7. Circuit Entry Description
The `description` of a circuit entry is independent from:
- The device name
- The device display name
- The device link
Even if a circuit entry is linked to a device, the circuit entry description can always be overwritten manually.
There are therefore three different naming fields:
```text
Device.name
Device.displayName
CircuitEntry.description
```
### Meaning
```text
Device.name
```
Internal or unique name of the device.
```text
Device.displayName
```
Default visible name of the device.
```text
CircuitEntry.description
```
Concrete description in the circuit list. This can be manually overwritten.
## 8. Devices
The application shall support configurable devices.
A device can represent either:
- A single device
- A collection/group of devices
Examples:
- Single pendant light
- Socket group with six sockets
- Workplace connection group
- Fixed load
### Device Attributes
```text
Device
- id
- name
- displayName
- deviceType
- phaseType
- quantity
- unitPower
- cosPhi
- tradeOrCostGroup
```
### Attribute Meaning
#### `name`
Internal or unique name.
Used to distinguish similar devices.
#### `displayName`
Default visible name.
Multiple devices may have the same display name.
#### `deviceType`
The type of device.
Examples:
- Light
- Socket
- Fixed connection
- Generic
- Other values to be defined later
#### `phaseType`
Defines whether the device is single-phase or three-phase.
Examples:
- Single-phase
- Three-phase
#### `quantity`
Default quantity of the device.
This allows a device to be used either as a single device or as a collection.
Example:
```text
Device: Socket group 6x
quantity: 6
unitPower: 100 W
```
#### `unitPower`
Power of one individual device or element.
#### `cosPhi`
Power factor.
#### `tradeOrCostGroup`
Assignment to a trade or cost group.
## 9. Device Name and Display Name
A device needs both a `name` and a `displayName`.
Reason:
Two devices may look the same in the circuit list but still be internally different.
Example:
```text
Device 1:
name: Pendant light high occupancy
displayName: Pendant light
Device 2:
name: Pendant light low occupancy
displayName: Pendant light
```
This allows internally different devices to be displayed with the same name in the circuit list.
## 10. Device Lists
There are two types of device lists:
1. Global device list
2. Project-specific device list
### Global Device List
The global device list contains generally reusable devices.
It is intended to be used across projects.
### Project-Specific Device List
The project-specific device list belongs to a specific project.
It contains devices that can be used within that project.
Devices from this list can be selected in the circuit lists of the project's distribution boards.
### DeviceList Attributes
```text
DeviceList
- id
- name
- type
- devices: List<Device>
```
Possible values for `type`:
```text
global
project
```
## 11. Copying Devices Between Device Lists
Devices shall be copyable between the global device list and project-specific device lists.
A device can be copied:
- From the global device list to a project-specific device list
- From a project-specific device list to the global device list
Copying means:
- A separate device is created in the target list
- Permanent synchronization between the original device and the copied device is not defined as a requirement
## 12. Device Link Between Device and Circuit Entry
A circuit entry can be linked to a device from the project-specific device list.
When the link is active, device values can be used in the circuit entry.
If the underlying device is changed, all still-linked circuit entries shall be updated.
The link can be enabled or disabled per circuit entry.
If a circuit entry is detached from the device:
- The circuit entry remains in place
- The circuit entry keeps its own values
- Future changes to the device no longer affect that circuit entry
A circuit entry may also exist without a device and without a device reference.
## 13. Quantity
There is a quantity on the device and a quantity on the circuit entry.
### Device Quantity
The device quantity describes the default scope of a device.
It allows devices to represent either single devices or collections.
Example:
```text
Device: Socket group 6x
quantity: 6
unitPower: 100 W
```
### Circuit Entry Quantity
The circuit entry quantity describes the concrete quantity used in the individual circuit entry.
It can be prefilled from the device quantity when a circuit entry is created from a device.
It can be changed in the circuit entry.
### Add Count
When adding a device to a circuit list, there shall be a separate value defining how many circuit entries shall be created.
Important distinction:
```text
addCount != quantity
```
Example:
```text
Device: Workplace socket
Add count: 5
Quantity per entry: 6
```
Result:
```text
5 circuit entries
Each circuit entry has quantity = 6
```
## 14. Total Power
The total power shall be calculated automatically.
The total power is an attribute of the circuit entry.
The calculation is based at least on:
- quantity
- unitPower
- simultaneityFactor
The exact calculation logic can be specified later.
## 15. Rooms
Rooms are represented by two fields:
```text
roomNumber
roomName
```
## 16. Trade / Cost Group and Group
Entries shall be groupable by domain-specific criteria.
The planned fields are:
```text
tradeOrCostGroup
group
```
These fields shall help treat similar elements together.
## 17. Table Functions
The circuit list shall support common table operations.
Defined operations are:
- Create entry
- Edit entry
- Delete entry
- Duplicate entry
- Copy entry to another circuit list
- Add device as entry
- Add device multiple times as entries
- Set quantity within an entry
General requirement:
- All common table manipulations shall be possible
Additional table functions may be specified later, for example:
- Sorting
- Filtering
- Reordering
- Multi-selection
- Bulk editing
## 18. Required Fields
In principle, there shall be no required fields.
This means:
- Entries may be incomplete
- A circuit entry may exist without a device
- A circuit entry may exist without a device reference
- Values can be completed later
## 19. Selection Lists
The application shall support fixed selection lists.
The exact selection lists will be defined later.
Current possible candidates based on the requirements are:
- deviceType
- phaseType
- tradeOrCostGroup
- group
- protectionType
- protectionCharacteristic
- cableType
- cableCrossSection
These lists are not yet finally specified.
## 20. Copying and Duplicating
Circuit entries shall be duplicatable.
Circuit entries shall be copyable to other circuit lists.
Devices shall be copyable between the global device list and project-specific device lists.
When adding a device to a circuit list, the user shall be able to define how many circuit entries shall be created from it.
Open point:
- Whether a device link is kept or detached when copying a circuit entry to another circuit list is not yet specified.
## 21. Simplified Class Model
The model shall use multiple classes but shall not be overly fragmented.
Current final class model:
```text
Project
- id
- name
- distributionBoards: List<DistributionBoard>
- projectDeviceList: DeviceList
DistributionBoard
- id
- name
- circuitList: CircuitList
CircuitList
- id
- name
- entries: List<CircuitEntry>
CircuitEntry
- id
- circuitNumber
- description
- roomNumber
- roomName
- device: Device
- isLinkedToDevice
- quantity
- unitPower
- simultaneityFactor
- cosPhi
- totalPower
- deviceType
- phaseType
- tradeOrCostGroup
- group
- protectionType
- protectionRatedCurrent
- protectionCharacteristic
- cableType
- cableCrossSection
- comment
Device
- id
- name
- displayName
- deviceType
- phaseType
- quantity
- unitPower
- cosPhi
- tradeOrCostGroup
DeviceList
- id
- name
- type
- devices: List<Device>
```
## 22. Central Domain Rules
1. A project contains multiple distribution boards.
2. Each distribution board contains exactly one circuit list.
3. A circuit list consists of circuit entries.
4. One row in the circuit list is one circuit entry.
5. Multiple circuit entries may have the same circuit number.
6. This allows multiple different devices to be assigned to one circuit.
7. A circuit entry can be linked to a device.
8. A circuit entry can exist without a device.
9. A circuit entry can exist without a device reference.
10. The link between a circuit entry and a device can be detached per entry.
11. If a device is changed, all still-linked circuit entries are updated.
12. Detached circuit entries are no longer changed by later device changes.
13. A device has a default quantity.
14. A circuit entry has its own quantity.
15. The quantity on the device can be used as a default value for new circuit entries.
16. The description of a circuit entry can be overwritten independently from the linked device.
17. The total power of a circuit entry is calculated automatically.
18. A project has a project-specific device list.
19. Additionally, there is a global device list.
20. Devices can be copied between the global device list and project-specific device lists.
21. Circuit entries can be duplicated.
22. Circuit entries can be copied to other circuit lists.
23. When adding a device, the user can define how many circuit entries shall be created.
24. In principle, there are no required fields.
25. The application shall support fixed selection lists. Details will be specified later.
## 23. Open Points
The following points are not yet finally specified:
1. Exact formula for calculating `totalPower`.
2. Exact list of fields synchronized while a circuit entry is linked to a device.
3. Whether the device link is kept or detached when copying a circuit entry to another circuit list.
4. Final selection lists and their values.
5. Exact behavior of table functions such as sorting, filtering, multi-selection, and bulk editing.
6. Whether global devices and project devices remain permanently independent after copying, or whether optional synchronization should be supported later.
+11
View File
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const drizzle_kit_1 = require("drizzle-kit");
exports.default = (0, drizzle_kit_1.defineConfig)({
dialect: "sqlite",
schema: "./src/db/schema/*.ts",
out: "./src/db/migrations",
dbCredentials: {
url: "./data/leistungsbilanz.db",
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema/*.ts",
out: "./src/db/migrations",
dbCredentials: {
url: "./data/leistungsbilanz.db",
},
});
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leistungsbilanz API</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="info">
<h1>Leistungsbilanz Backend</h1>
<p>Der aktuelle Stand laeuft als TypeScript Node/SQLite API.</p>
<p>Serverstart: <code>npm run dev</code></p>
<p>Healthcheck: <code>GET /health</code></p>
</main>
</body>
</html>
+2842
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "leistungsbilanz",
"version": "1.0.0",
"description": "",
"main": "dist/server/index.js",
"scripts": {
"dev": "tsx watch src/server/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"zod": "^4.4.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
const dataDir = path.resolve("data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(path.resolve(dataDir, "leistungsbilanz.db"));
export const db = drizzle(sqlite);
@@ -0,0 +1,28 @@
CREATE TABLE `consumers` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`distribution_board_id` text,
`name` text NOT NULL,
`category` text,
`quantity` integer NOT NULL,
`installed_power_per_unit_kw` real NOT NULL,
`demand_factor` real NOT NULL,
`voltage_v` real,
`phase_count` integer,
`power_factor` real,
`note` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`distribution_board_id`) REFERENCES `distribution_boards`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `distribution_boards` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL
);
+208
View File
@@ -0,0 +1,208 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d219f155-9dd0-48e3-8fd2-8278f1f788ca",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"consumers": {
"name": "consumers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distribution_board_id": {
"name": "distribution_board_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"installed_power_per_unit_kw": {
"name": "installed_power_per_unit_kw",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"demand_factor": {
"name": "demand_factor",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"voltage_v": {
"name": "voltage_v",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phase_count": {
"name": "phase_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"power_factor": {
"name": "power_factor",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"consumers_project_id_projects_id_fk": {
"name": "consumers_project_id_projects_id_fk",
"tableFrom": "consumers",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"consumers_distribution_board_id_distribution_boards_id_fk": {
"name": "consumers_distribution_board_id_distribution_boards_id_fk",
"tableFrom": "consumers",
"tableTo": "distribution_boards",
"columnsFrom": [
"distribution_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"distribution_boards": {
"name": "distribution_boards",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"distribution_boards_project_id_projects_id_fk": {
"name": "distribution_boards_project_id_projects_id_fk",
"tableFrom": "distribution_boards",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777565414148,
"tag": "0000_bizarre_colossus",
"breakpoints": true
}
]
}
@@ -0,0 +1,31 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import { consumers } from "../schema/consumers.js";
import type { CreateConsumerInput } from "../../shared/validation/consumer.schemas.js";
export class ConsumerRepository {
async listByProject(projectId: string) {
return db.select().from(consumers).where(eq(consumers.projectId, projectId));
}
async create(input: CreateConsumerInput) {
const id = crypto.randomUUID();
await db.insert(consumers).values({
id,
projectId: input.projectId,
distributionBoardId: input.distributionBoardId ?? null,
name: input.name,
category: input.category ?? null,
quantity: input.quantity,
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
demandFactor: input.demandFactor,
voltageV: input.voltageV ?? null,
phaseCount: input.phaseCount ?? null,
powerFactor: input.powerFactor ?? null,
note: input.note ?? null,
});
return { id, ...input };
}
}
+21
View File
@@ -0,0 +1,21 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "../client.js";
import { projects } from "../schema/projects.js";
export class ProjectRepository {
async list() {
return db.select().from(projects);
}
async create(name: string) {
const id = crypto.randomUUID();
await db.insert(projects).values({ id, name });
return { id, name };
}
async delete(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId));
}
}
+23
View File
@@ -0,0 +1,23 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { distributionBoards } from "./distribution-boards.js";
import { projects } from "./projects.js";
export const consumers = sqliteTable("consumers", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
distributionBoardId: text("distribution_board_id").references(() => distributionBoards.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
category: text("category"),
quantity: integer("quantity").notNull(),
installedPowerPerUnitKw: real("installed_power_per_unit_kw").notNull(),
demandFactor: real("demand_factor").notNull(),
voltageV: real("voltage_v"),
phaseCount: integer("phase_count"),
powerFactor: real("power_factor"),
note: text("note"),
});
+11
View File
@@ -0,0 +1,11 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const distributionBoards = sqliteTable("distribution_boards", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
});
+7
View File
@@ -0,0 +1,7 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
});
@@ -0,0 +1,29 @@
export interface PowerInput {
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
}
export interface CurrentInput {
demandPowerKw: number;
voltageV: number;
phaseCount: 1 | 3;
powerFactor: number;
}
export function calculateInstalledPowerKw(input: PowerInput): number {
return input.quantity * input.installedPowerPerUnitKw;
}
export function calculateDemandPowerKw(input: PowerInput): number {
return calculateInstalledPowerKw(input) * input.demandFactor;
}
export function calculateCurrentA(input: CurrentInput): number {
const powerW = input.demandPowerKw * 1000;
if (input.phaseCount === 1) {
return powerW / (input.voltageV * input.powerFactor);
}
return powerW / (Math.sqrt(3) * input.voltageV * input.powerFactor);
}
+15
View File
@@ -0,0 +1,15 @@
export interface Consumer {
id: string;
projectId: string;
distributionBoardId?: string;
name: string;
category?: string;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV?: number;
phaseCount?: 1 | 3;
powerFactor?: number;
note?: string;
}
@@ -0,0 +1,45 @@
import {
calculateCurrentA,
calculateDemandPowerKw,
calculateInstalledPowerKw,
} from "../calculations/power-calculation.js";
import type { Consumer } from "../models/consumer.model.js";
export interface ConsumerWithCalculatedValues extends Consumer {
installedPowerKw: number;
demandPowerKw: number;
currentA?: number;
}
export class PowerBalanceService {
enrichConsumer(consumer: Consumer): ConsumerWithCalculatedValues {
const installedPowerKw = calculateInstalledPowerKw({
quantity: consumer.quantity,
installedPowerPerUnitKw: consumer.installedPowerPerUnitKw,
demandFactor: consumer.demandFactor,
});
const demandPowerKw = calculateDemandPowerKw({
quantity: consumer.quantity,
installedPowerPerUnitKw: consumer.installedPowerPerUnitKw,
demandFactor: consumer.demandFactor,
});
let currentA: number | undefined;
if (consumer.voltageV && consumer.phaseCount && consumer.powerFactor) {
currentA = calculateCurrentA({
demandPowerKw,
voltageV: consumer.voltageV,
phaseCount: consumer.phaseCount,
powerFactor: consumer.powerFactor,
});
}
return {
...consumer,
installedPowerKw,
demandPowerKw,
currentA,
};
}
}
+4
View File
@@ -0,0 +1,4 @@
# Components Placeholder
Reusable UI components for project, distribution board, and consumer tables.
+5
View File
@@ -0,0 +1,5 @@
# Frontend Placeholder
Frontend implementation will move here in the next step (React/Next.js).
The backend API, domain logic, and SQLite persistence are now prepared.
+4
View File
@@ -0,0 +1,4 @@
# Frontend Utils Placeholder
Shared UI utility helpers will be implemented during the Next.js migration step.
@@ -0,0 +1,42 @@
import type { Request, Response } from "express";
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
import { PowerBalanceService } from "../../domain/services/power-balance.service.js";
import { createConsumerSchema } from "../../shared/validation/consumer.schemas.js";
const consumerRepository = new ConsumerRepository();
const powerBalanceService = new PowerBalanceService();
export async function listConsumersByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const rows = await consumerRepository.listByProject(projectId);
const enriched = rows.map((row) =>
powerBalanceService.enrichConsumer({
id: row.id,
projectId: row.projectId,
distributionBoardId: row.distributionBoardId ?? undefined,
name: row.name,
category: row.category ?? undefined,
quantity: row.quantity,
installedPowerPerUnitKw: row.installedPowerPerUnitKw,
demandFactor: row.demandFactor,
voltageV: row.voltageV ?? undefined,
phaseCount: row.phaseCount === 1 || row.phaseCount === 3 ? row.phaseCount : undefined,
powerFactor: row.powerFactor ?? undefined,
note: row.note ?? undefined,
})
);
res.json(enriched);
}
export async function createConsumer(req: Request, res: Response) {
const parsed = createConsumerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const created = await consumerRepository.create(parsed.data);
const enriched = powerBalanceService.enrichConsumer(created);
return res.status(201).json(enriched);
}
@@ -0,0 +1,20 @@
import type { Request, Response } from "express";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { createProjectSchema } from "../../shared/validation/consumer.schemas.js";
const projectRepository = new ProjectRepository();
export async function listProjects(_req: Request, res: Response) {
const result = await projectRepository.list();
res.json(result);
}
export async function createProject(req: Request, res: Response) {
const parsed = createProjectSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const project = await projectRepository.create(parsed.data.name);
return res.status(201).json(project);
}
+23
View File
@@ -0,0 +1,23 @@
import express from "express";
import { consumerRouter } from "./routes/consumer.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
const app = express();
const port = Number(process.env.PORT || 3000);
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/projects", projectRouter);
app.use("/api/consumers", consumerRouter);
app.use(errorMiddleware);
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
+12
View File
@@ -0,0 +1,12 @@
import type { NextFunction, Request, Response } from "express";
export function errorMiddleware(
error: unknown,
_req: Request,
res: Response,
_next: NextFunction
) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
}
+8
View File
@@ -0,0 +1,8 @@
import { Router } from "express";
import { createConsumer, listConsumersByProject } from "../controllers/consumer.controller.js";
export const consumerRouter = Router();
consumerRouter.get("/projects/:projectId", listConsumersByProject);
consumerRouter.post("/", createConsumer);
+8
View File
@@ -0,0 +1,8 @@
import { Router } from "express";
import { createProject, listProjects } from "../controllers/project.controller.js";
export const projectRouter = Router();
projectRouter.get("/", listProjects);
projectRouter.post("/", createProject);
+26
View File
@@ -0,0 +1,26 @@
export interface ProjectDto {
id: string;
name: string;
}
export interface DistributionBoardDto {
id: string;
projectId: string;
name: string;
}
export interface ConsumerDto {
id: string;
projectId: string;
distributionBoardId: string | null;
name: string;
category: string | null;
quantity: number;
installedPowerPerUnitKw: number;
demandFactor: number;
voltageV: number | null;
phaseCount: 1 | 3 | null;
powerFactor: number | null;
note: string | null;
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
export const createConsumerSchema = z.object({
projectId: z.string().min(1),
distributionBoardId: z.string().min(1).optional(),
name: z.string().min(1),
category: z.string().optional(),
quantity: z.number().min(0),
installedPowerPerUnitKw: z.number().min(0),
demandFactor: z.number().min(0).max(1),
voltageV: z.number().positive().optional(),
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
powerFactor: z.number().min(0).max(1).optional(),
note: z.string().optional(),
});
export const createProjectSchema = z.object({
name: z.string().min(1),
});
export type CreateConsumerInput = z.infer<typeof createConsumerSchema>;
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
+15
View File
@@ -0,0 +1,15 @@
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f5f6f8;
color: #1f2937;
}
.info {
max-width: 760px;
margin: 40px auto;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 24px;
}
+56
View File
@@ -0,0 +1,56 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const power_calculation_js_1 = require("../src/domain/calculations/power-calculation.js");
(0, vitest_1.describe)("power calculation", () => {
(0, vitest_1.it)("calculates installed power", () => {
const result = (0, power_calculation_js_1.calculateInstalledPowerKw)({
quantity: 4,
installedPowerPerUnitKw: 1.2,
demandFactor: 0.8,
});
(0, vitest_1.expect)(result).toBeCloseTo(4.8);
});
(0, vitest_1.it)("calculates demand power", () => {
const result = (0, power_calculation_js_1.calculateDemandPowerKw)({
quantity: 4,
installedPowerPerUnitKw: 1.2,
demandFactor: 0.8,
});
(0, vitest_1.expect)(result).toBeCloseTo(3.84);
});
(0, vitest_1.it)("handles zero quantity", () => {
const result = (0, power_calculation_js_1.calculateInstalledPowerKw)({
quantity: 0,
installedPowerPerUnitKw: 3,
demandFactor: 0.9,
});
(0, vitest_1.expect)(result).toBe(0);
});
(0, vitest_1.it)("handles zero demand factor", () => {
const result = (0, power_calculation_js_1.calculateDemandPowerKw)({
quantity: 5,
installedPowerPerUnitKw: 2,
demandFactor: 0,
});
(0, vitest_1.expect)(result).toBe(0);
});
(0, vitest_1.it)("calculates single-phase current", () => {
const current = (0, power_calculation_js_1.calculateCurrentA)({
demandPowerKw: 2.3,
voltageV: 230,
phaseCount: 1,
powerFactor: 0.95,
});
(0, vitest_1.expect)(current).toBeCloseTo(10.53, 2);
});
(0, vitest_1.it)("calculates three-phase current", () => {
const current = (0, power_calculation_js_1.calculateCurrentA)({
demandPowerKw: 9.5,
voltageV: 400,
phaseCount: 3,
powerFactor: 0.9,
});
(0, vitest_1.expect)(current).toBeCloseTo(15.23, 2);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
calculateCurrentA,
calculateDemandPowerKw,
calculateInstalledPowerKw,
} from "../src/domain/calculations/power-calculation.js";
describe("power calculation", () => {
it("calculates installed power", () => {
const result = calculateInstalledPowerKw({
quantity: 4,
installedPowerPerUnitKw: 1.2,
demandFactor: 0.8,
});
assert.ok(Math.abs(result - 4.8) < 0.00001);
});
it("calculates demand power", () => {
const result = calculateDemandPowerKw({
quantity: 4,
installedPowerPerUnitKw: 1.2,
demandFactor: 0.8,
});
assert.ok(Math.abs(result - 3.84) < 0.00001);
});
it("handles zero quantity", () => {
const result = calculateInstalledPowerKw({
quantity: 0,
installedPowerPerUnitKw: 3,
demandFactor: 0.9,
});
assert.equal(result, 0);
});
it("handles zero demand factor", () => {
const result = calculateDemandPowerKw({
quantity: 5,
installedPowerPerUnitKw: 2,
demandFactor: 0,
});
assert.equal(result, 0);
});
it("calculates single-phase current", () => {
const current = calculateCurrentA({
demandPowerKw: 2.3,
voltageV: 230,
phaseCount: 1,
powerFactor: 0.95,
});
assert.ok(Math.abs(current - 10.53) < 0.02);
});
it("calculates three-phase current", () => {
const current = calculateCurrentA({
demandPowerKw: 9.5,
voltageV: 400,
phaseCount: 3,
powerFactor: 0.9,
});
assert.ok(Math.abs(current - 15.23) < 0.02);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}