27 Commits

Author SHA1 Message Date
57766c990e feat: Initial csv parsing for uploading rooms for a project. Need to style the upload form.
All checks were successful
CI / Linux Tests (push) Successful in 5m41s
2026-02-05 16:39:40 -05:00
b2b5e32535 feat: Experiments with csv parsing / printing, currently implemented in RoomTests only. 2026-02-05 11:43:48 -05:00
881737978d feat: Experiments with csv parsing / printing, currently implemented in RoomTests only. 2026-02-05 11:39:57 -05:00
6a764ade2b feat: Update room cooling load to accept either total or sensible loads, instead of requiring a total load. 2026-02-05 09:44:37 -05:00
5f03056534 feat: Adds createMany for rooms, in prep for parsing / uploading a csv file of room loads.
All checks were successful
CI / Linux Tests (push) Successful in 7m0s
2026-02-04 21:06:05 -05:00
10dd0dac82 feat: Updates todos.
All checks were successful
CI / Linux Tests (push) Successful in 6m38s
2026-02-04 16:59:27 -05:00
3cc7fe9926 feat: Updates environment variables and datbase to allow postgres configuration for production environments.
All checks were successful
CI / Linux Tests (push) Successful in 6m39s
2026-02-03 09:41:31 -05:00
bad4a49f41 feat: Adds devcontainer
All checks were successful
CI / Linux Tests (push) Successful in 6m36s
2026-02-01 22:01:23 -05:00
9276f88426 feat: Updates to use swift-validations for database.
All checks were successful
CI / Linux Tests (push) Successful in 6m28s
2026-02-01 00:55:44 -05:00
a3fb87f86e feat: Removes api routes and controller as they're currently not used.
All checks were successful
CI / Linux Tests (push) Successful in 5m30s
2026-01-30 17:10:14 -05:00
b359a3317f feat: Adds trunk size database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m33s
2026-01-30 16:52:12 -05:00
e0ec15b91e feat: Adds rooms database tests. 2026-01-30 15:45:54 -05:00
44a0964181 feat: Adds equivalent length database tests. 2026-01-30 15:28:25 -05:00
0b78950d14 feat: Adds equipment info database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 15:16:09 -05:00
754019eac4 feat: Adds component loss database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 14:15:50 -05:00
a51e1b34d0 feat: Adds project database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-30 14:02:58 -05:00
c32ffcff8c feat: Begins live database client tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m35s
2026-01-30 12:02:11 -05:00
4f3cc2c7ea WIP: Cleans up ManualDClient and adds some more document strings.
Some checks failed
CI / Linux Tests (push) Failing after 7m3s
2026-01-29 22:56:39 -05:00
9b618d55fa WIP: Adds more tagged types for rectangular size calculations. 2026-01-29 20:50:33 -05:00
9379774fae feat: Begin using Tagged types
All checks were successful
CI / Linux Tests (push) Successful in 5m23s
2026-01-29 17:10:35 -05:00
18a5ef06d3 feat: Rename items in database client for consistency.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-29 15:47:24 -05:00
6723f7a410 feat: Adds some documentation strings in ManualDCore module. 2026-01-29 15:16:26 -05:00
5440024038 feat: Renames EffectiveLength to EquivalentLength
All checks were successful
CI / Linux Tests (push) Successful in 9m34s
2026-01-29 11:25:20 -05:00
f005b43936 feat: Try to build image in ci instead of relying on just.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-01-29 10:52:07 -05:00
f44b35ab3d feat: Try extractions/setup-just
Some checks failed
CI / Linux Tests (push) Failing after 7s
2026-01-29 10:46:00 -05:00
bbf9a8b390 feat: Setup just directly in ci workflow
Some checks failed
CI / Linux Tests (push) Failing after 10s
2026-01-29 10:43:49 -05:00
c52cee212f feat: Adds ci workflow.
Some checks failed
CI / Linux Tests (push) Failing after 5s
2026-01-29 10:36:29 -05:00
105 changed files with 6085 additions and 2520 deletions

View File

@@ -0,0 +1,40 @@
{
"name": "swift-manual-d-dev",
"image": "git.housh.dev/michael/swift-dev-container:latest",
"features": {
"ghcr.io/devcontainers/features/git:1": {
"version": "os-provided",
"ppa": "false"
},
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
"ghcr.io/rocker-org/devcontainer-features/pandoc:1": {},
//"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/wxw-matt/devcontainer-features/apt:latest": {
"packages": "weasyprint gnupg2 tmux"
},
"ghcr.io/swift-server-community/swift-devcontainer-features/jemalloc:1": { },
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
"remoteEnv": {
// Set TERM, prevents problems when "xterm-ghostty" get's passed from host,
// which causes weird typing and display problems.
"TERM": "xterm-256color",
// Less backtrace error warnings from swift.
"SWIFT_BACKTRACE": "enable=no"
},
"remoteUser": "swift",
"forwardPorts": [8080],
"mounts": [
{
"type": "bind",
"source": "${localEnv:HOME}/.local/share/nvim",
"target": "/home/swift/.local/share/nvim"
}
]
}

31
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:
ubuntu:
name: Linux Tests
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.test
push: false
load: true
tags: michael/ductcalc:test
- name: Run Tests
run: |
docker run --rm michael/ductcalc:test swift test

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ tailwindcss
*.pdf
.env
.env*
default.profraw
rooms.csv

View File

@@ -1,5 +1,5 @@
{
"originHash" : "c3efcfd33bc1490f59ae406e4e5292027b2d01cafee9fc625652213505df50fb",
"originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00",
"pins" : [
{
"identity" : "async-http-client",
@@ -73,6 +73,15 @@
"version" : "1.53.0"
}
},
{
"identity" : "fluent-postgres-driver",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
"state" : {
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
"version" : "2.12.0"
}
},
{
"identity" : "fluent-sqlite-driver",
"kind" : "remoteSourceControl",
@@ -100,6 +109,24 @@
"version" : "0.14.0"
}
},
{
"identity" : "postgres-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-kit.git",
"state" : {
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
"version" : "2.15.1"
}
},
{
"identity" : "postgres-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-nio.git",
"state" : {
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
"version" : "1.30.1"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
@@ -397,6 +424,15 @@
"version" : "1.6.3"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "swift-url-routing",
"kind" : "remoteSourceControl",
@@ -406,6 +442,15 @@
"version" : "0.6.2"
}
},
{
"identity" : "swift-validations",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-validations.git",
"state" : {
"revision" : "95ea5d267e37f6cdb9f91c5c8a01e718b9299db6",
"version" : "0.3.4"
}
},
{
"identity" : "vapor",
"kind" : "remoteSourceControl",

View File

@@ -6,10 +6,10 @@ let package = Package(
name: "swift-manual-d",
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "CSVParser", targets: ["CSVParser"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvClient", targets: ["EnvClient"]),
.library(name: "EnvVars", targets: ["EnvVars"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]),
@@ -23,27 +23,30 @@ let package = Package(
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0"),
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.target(name: "ApiController"),
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
@@ -51,16 +54,6 @@ let package = Package(
.product(name: "VaporRouting", package: "vapor-routing"),
]
),
.target(
name: "ApiController",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "AuthClient",
dependencies: [
@@ -70,6 +63,20 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "CSVParser",
dependencies: [
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.testTarget(
name: "CSVParsingTests",
dependencies: [
.target(name: "CSVParser")
]
),
.target(
name: "DatabaseClient",
dependencies: [
@@ -78,11 +85,20 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "Vapor", package: "vapor"),
.product(name: "Validations", package: "swift-validations"),
]
),
.testTarget(
name: "DatabaseClientTests",
dependencies: [
.target(name: "App"),
.target(name: "DatabaseClient"),
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
]
),
.target(
name: "EnvClient",
name: "EnvVars",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
@@ -106,7 +122,7 @@ let package = Package(
.target(
name: "PdfClient",
dependencies: [
.target(name: "EnvClient"),
.target(name: "EnvVars"),
.target(name: "FileClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -120,11 +136,10 @@ let package = Package(
.target(name: "HTMLSnapshotTesting"),
.target(name: "PdfClient"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
],
resources: [
.copy("__Snapshots__")
]
// ,
// resources: [
// .copy("__Snapshots__")
// ]
),
.target(
name: "ProjectClient",
@@ -138,24 +153,19 @@ let package = Package(
.target(
name: "ManualDCore",
dependencies: [
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
]
),
.testTarget(
name: "ApiRouteTests",
dependencies: [
.target(name: "ManualDCore")
]
),
.target(
name: "ManualDClient",
dependencies: [
"ManualDCore",
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Tagged", package: "swift-tagged"),
]
),
.target(
@@ -177,6 +187,7 @@ let package = Package(
name: "ViewController",
dependencies: [
.target(name: "AuthClient"),
.target(name: "CSVParser"),
.target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),

View File

@@ -7,12 +7,7 @@
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-green-400: oklch(79.2% 0.209 151.711);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-slate-300: oklch(86.9% 0.022 252.894);
--color-slate-900: oklch(20.8% 0.042 265.755);
--color-gray-200: oklch(92.8% 0.006 264.531);
--color-gray-400: oklch(70.7% 0.022 261.325);
--color-black: #000;
@@ -30,7 +25,6 @@
--text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-bold: 700;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
@@ -6218,6 +6212,9 @@
.hidden {
display: none;
}
.inline {
display: inline;
}
.inline-block {
display: inline-block;
}
@@ -6982,9 +6979,6 @@
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-selector {
border-radius: var(--radius-selector);
}
@@ -7449,15 +7443,9 @@
.bg-error {
background-color: var(--color-error);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-secondary {
background-color: var(--color-secondary);
}
.bg-white {
background-color: var(--color-white);
}
.divider-accent {
@layer daisyui.l1.l2 {
&:before, &:after {
@@ -7866,15 +7854,9 @@
}
}
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
@@ -8530,15 +8512,9 @@
.text-info {
color: var(--color-info);
}
.text-slate-900 {
color: var(--color-slate-900);
}
.text-success {
color: var(--color-success);
}
.text-white {
color: var(--color-white);
}
.lowercase {
text-transform: lowercase;
}
@@ -8607,10 +8583,6 @@
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -8619,13 +8591,6 @@
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.outline-1 {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.-outline-offset-1 {
outline-offset: calc(1px * -1);
}
.btn-ghost {
@layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
@@ -8650,9 +8615,6 @@
}
}
}
.outline-slate-300 {
outline-color: var(--color-slate-300);
}
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
@@ -9406,16 +9368,6 @@
}
}
}
.invalid\:border-red-500 {
&:invalid {
border-color: var(--color-red-500);
}
}
.out-of-range\:border-red-500 {
&:out-of-range {
border-color: var(--color-red-500);
}
}
.hover\:bg-neutral {
&:hover {
@media (hover: hover) {
@@ -9423,13 +9375,6 @@
}
}
}
.hover\:bg-red-600 {
&:hover {
@media (hover: hover) {
background-color: var(--color-red-600);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -9437,22 +9382,6 @@
}
}
}
.focus\:outline {
&:focus {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
.focus\:-outline-offset-2 {
&:focus {
outline-offset: calc(2px * -1);
}
}
.focus\:outline-indigo-600 {
&:focus {
outline-color: var(--color-indigo-600);
}
}
.data-active\:bg-neutral {
&[data-active] {
background-color: var(--color-neutral);

View File

@@ -1,38 +0,0 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
extension DependencyValues {
public var apiController: ApiController {
get { self[ApiController.self] }
set { self[ApiController.self] = newValue }
}
}
@DependencyClient
public struct ApiController: Sendable {
public var json: @Sendable (Request) async throws -> (any Encodable)?
}
extension ApiController {
public struct Request: Sendable {
public let route: SiteRoute.Api
public let logger: Logger
public init(route: SiteRoute.Api, logger: Logger) {
self.route = route
self.logger = logger
}
}
}
extension ApiController: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
json: { request in
try await request.route.respond(logger: request.logger)
}
)
}

View File

@@ -1,129 +0,0 @@
import DatabaseClient
import Dependencies
import Logging
import ManualDCore
extension SiteRoute.Api {
func respond(logger: Logger) async throws -> (any Encodable)? {
switch self {
case .project(let route):
return try await route.respond(logger: logger)
case .room(let route):
return try await route.respond(logger: logger)
case .equipment(let route):
return try await route.respond(logger: logger)
case .componentLoss(let route):
return try await route.respond(logger: logger)
}
}
}
extension SiteRoute.Api.ProjectRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
// return try await database.projects.create(request)
// FIX:
fatalError()
case .delete(let id):
try await database.projects.delete(id)
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()
}
case .get(let id):
guard let project = try await database.projects.get(id) else {
logger.error("Project not found for id: \(id)")
throw ApiError("Project not found.")
}
return project
case .index:
// FIX: Fix to return projects.
return [Project]()
}
}
}
extension SiteRoute.Api.RoomRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.rooms.create(request)
case .delete(let id):
try await database.rooms.delete(id)
return nil
case .get(let id):
guard let room = try await database.rooms.get(id) else {
logger.error("Room not found for id: \(id)")
throw ApiError("Room not found.")
}
return room
}
}
}
extension SiteRoute.Api.EquipmentRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.equipment.create(request)
case .delete(let id):
try await database.equipment.delete(id)
return nil
case .fetch(let projectID):
return try await database.equipment.fetch(projectID)
case .get(let id):
guard let room = try await database.equipment.get(id) else {
logger.error("Equipment not found for id: \(id)")
throw ApiError("Equipment not found.")
}
return room
}
}
}
extension SiteRoute.Api.ComponentLossRoute {
func respond(logger: Logger) async throws -> (any Encodable)? {
@Dependency(\.database) var database
switch self {
case .create(let request):
return try await database.componentLoss.create(request)
case .delete(let id):
try await database.componentLoss.delete(id)
return nil
case .fetch(let projectID):
return try await database.componentLoss.fetch(projectID)
case .get(let id):
guard let room = try await database.componentLoss.get(id) else {
logger.error("Component loss not found for id: \(id)")
throw ApiError("Component loss not found.")
}
return room
}
}
}
public struct ApiError: Error {
let message: String
init(_ message: String) {
self.message = message
}
}

View File

@@ -1,37 +1,37 @@
import ApiController
import ManualDCore
import Vapor
extension ApiController {
func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
-> any AsyncResponseEncodable
{
guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
return HTTPStatus.ok
}
return AnyJSONResponse(value: encodable)
}
}
struct AnyJSONResponse: AsyncResponseEncodable {
public var headers: HTTPHeaders = ["Content-Type": "application/json"]
let value: any Encodable
init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
if additionalHeaders.contains(name: .contentType) {
self.headers = additionalHeaders
} else {
headers.add(contentsOf: additionalHeaders)
}
self.value = value
}
func encodeResponse(for request: Request) async throws -> Response {
try Response(
status: .ok,
headers: headers,
body: .init(data: JSONEncoder().encode(value))
)
}
}
// import ApiController
// import ManualDCore
// import Vapor
//
// extension ApiController {
//
// func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
// -> any AsyncResponseEncodable
// {
// guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
// return HTTPStatus.ok
// }
// return AnyJSONResponse(value: encodable)
// }
// }
//
// struct AnyJSONResponse: AsyncResponseEncodable {
// public var headers: HTTPHeaders = ["Content-Type": "application/json"]
// let value: any Encodable
//
// init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
// if additionalHeaders.contains(name: .contentType) {
// self.headers = additionalHeaders
// } else {
// headers.add(contentsOf: additionalHeaders)
// }
// self.value = value
// }
//
// func encodeResponse(for request: Request) async throws -> Response {
// try Response(
// status: .ok,
// headers: headers,
// body: .init(data: JSONEncoder().encode(value))
// )
// }
// }

View File

@@ -1,7 +1,8 @@
import ApiController
// import ApiController
import AuthClient
import DatabaseClient
import Dependencies
import EnvVars
import ManualDCore
import PdfClient
import Vapor
@@ -12,27 +13,31 @@ import ViewController
struct DependenciesMiddleware: AsyncMiddleware {
private let values: DependencyValues.Continuation
private let apiController: ApiController
// private let apiController: ApiController
private let database: DatabaseClient
private let environment: EnvVars
private let viewController: ViewController
init(
database: DatabaseClient,
apiController: ApiController = .liveValue,
environment: EnvVars,
// apiController: ApiController = .liveValue,
viewController: ViewController = .liveValue
) {
self.values = withEscapedDependencies { $0 }
self.apiController = apiController
// self.apiController = apiController
self.database = database
self.environment = environment
self.viewController = viewController
}
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
try await values.yield {
try await withDependencies {
$0.apiController = apiController
$0.authClient = .live(on: request)
// $0.apiController = apiController
$0.auth = .live(on: request)
$0.database = database
$0.environment = environment
// $0.dateFormatter = .liveValue
$0.viewController = viewController
$0.pdfClient = .liveValue

View File

@@ -1,7 +1,9 @@
import DatabaseClient
import Dependencies
import Elementary
import EnvVars
import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver
import ManualDCore
import NIOSSL
@@ -14,12 +16,15 @@ import ViewController
// configures your application
public func configure(
_ app: Application,
in environment: EnvVars,
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
) async throws {
// Setup the database client.
let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient)
let databaseClient = try await setupDatabase(
on: app, environment: environment, factory: makeDatabaseClient
)
// Add the global middlewares.
addMiddleware(to: app, database: databaseClient)
addMiddleware(to: app, database: databaseClient, environment: environment)
#if DEBUG
// Live reload of the application for development when launched with the `./swift-dev` command
// app.lifecycle.use(BrowserSyncHandler())
@@ -33,7 +38,11 @@ public func configure(
addCommands(to: app)
}
private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) {
private func addMiddleware(
to app: Application,
database databaseClient: DatabaseClient,
environment: EnvVars
) {
// cors middleware should come before default error middleware using `at: .beginning`
let corsConfiguration = CORSMiddleware.Configuration(
allowedOrigin: .all,
@@ -48,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(app.sessions.middleware)
app.middleware.use(DependenciesMiddleware(database: databaseClient))
app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
}
private func setupDatabase(
on app: Application,
environment: EnvVars,
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
) async throws -> DatabaseClient {
switch app.environment {
case .production, .development:
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite"
case .production:
let configuration = try environment.postgresConfiguration()
app.databases.use(.postgres(configuration: configuration), as: .psql)
case .development:
let dbFileName = environment.sqlitePath ?? "db.sqlite"
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
default:
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
@@ -65,9 +78,7 @@ private func setupDatabase(
let databaseClient = makeDatabaseClient(app.db)
if app.environment != .testing {
try await app.migrations.add(databaseClient.migrations())
}
try await app.migrations.add(databaseClient.migrations())
return databaseClient
}
@@ -102,8 +113,6 @@ extension SiteRoute {
fileprivate func middleware() -> [any Middleware]? {
switch self {
case .api:
return nil
case .health:
return nil
case .view(let route):
@@ -119,13 +128,10 @@ private func siteHandler(
request: Request,
route: SiteRoute
) async throws -> any AsyncResponseEncodable {
@Dependency(\.apiController) var apiController
@Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient
switch route {
case .api(let route):
return try await apiController.respond(route, request: request)
case .health:
return HTTPStatus.ok
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
@@ -136,3 +142,30 @@ private func siteHandler(
return try await viewController.respond(route: route, request: request)
}
}
extension EnvVars {
func postgresConfiguration() throws -> SQLPostgresConfiguration {
guard let hostname = postgresHostname,
let username = postgresUsername,
let password = postgresPassword,
let database = postgresDatabase
else {
throw EnvError("Missing environment variables for postgres connection.")
}
return .init(
hostname: hostname,
username: username,
password: password,
database: database,
tls: .disable
)
}
}
struct EnvError: Error {
let reason: String
init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -1,5 +1,6 @@
import DatabaseClient
import Dependencies
import EnvVars
import Logging
import NIOCore
import NIOPosix
@@ -17,11 +18,15 @@ enum Entrypoint {
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
let executorTakeoverSuccess =
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug(
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
)
do {
try await configure(app)
try await configure(app, in: EnvVars.live())
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()

View File

@@ -5,17 +5,23 @@ import ManualDCore
import Vapor
extension DependencyValues {
public var authClient: AuthClient {
/// Authentication dependency, for handling authentication tasks.
public var auth: AuthClient {
get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue }
}
}
/// Represents authentication tasks that are used in the application.
@DependencyClient
public struct AuthClient: Sendable {
/// Create a new user and log them in.
public var createAndLogin: @Sendable (User.Create) async throws -> User
/// Get the current user.
public var currentUser: @Sendable () throws -> User
/// Login a user.
public var login: @Sendable (User.Login) async throws -> User
/// Logout a user.
public var logout: @Sendable () throws -> Void
}

View File

@@ -0,0 +1,43 @@
import Dependencies
import DependenciesMacros
import ManualDCore
import Parsing
extension DependencyValues {
public var csvParser: CSVParser {
get { self[CSVParser.self] }
set { self[CSVParser.self] = newValue }
}
}
@DependencyClient
public struct CSVParser: Sendable {
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.Create]
}
extension CSVParser: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
parseRooms: { csv in
guard let string = String(data: csv.file, encoding: .utf8) else {
throw CSVParsingError("Unreadable file data")
}
let rows = try RoomCSVParser().parse(string[...].utf8)
let rooms = rows.reduce(into: [Room.Create]()) {
if case .room(let room) = $1 {
$0.append(room)
}
}
return rooms
}
)
}
public struct CSVParsingError: Error {
let reason: String
public init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -0,0 +1,51 @@
import ManualDCore
import Parsing
struct RoomCSVParser: Parser {
var body: some Parser<Substring.UTF8View, [RoomRowType]> {
Many {
RoomRowParser()
} separator: {
"\n".utf8
}
}
}
struct RoomRowParser: Parser {
var body: some Parser<Substring.UTF8View, RoomRowType> {
OneOf {
RoomCreateParser().map { RoomRowType.room($0) }
Prefix { $0 != UInt8(ascii: "\n") }
.map(.string)
.map { RoomRowType.header($0) }
}
}
}
enum RoomRowType {
case header(String)
case room(Room.Create)
}
struct RoomCreateParser: ParserPrinter {
var body: some ParserPrinter<Substring.UTF8View, Room.Create> {
ParsePrint {
Prefix { $0 != UInt8(ascii: ",") }.map(.string)
",".utf8
Double.parser()
",".utf8
Optionally {
Double.parser()
}
",".utf8
Optionally {
Double.parser()
}
",".utf8
Int.parser()
}
.map(.memberwise(Room.Create.init))
}
}

View File

@@ -4,23 +4,132 @@ import FluentKit
import ManualDCore
extension DependencyValues {
/// The database dependency.
public var database: DatabaseClient {
get { self[DatabaseClient.self] }
set { self[DatabaseClient.self] = newValue }
}
}
/// Represents the database interactions used by the application.
@DependencyClient
public struct DatabaseClient: Sendable {
/// Database migrations.
public var migrations: Migrations
/// Interactions with the projects table.
public var projects: Projects
/// Interactions with the rooms table.
public var rooms: Rooms
/// Interactions with the equipment table.
public var equipment: Equipment
public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient
/// Interactions with the component losses table.
public var componentLosses: ComponentLosses
/// Interactions with the equivalent lengths table.
public var equivalentLengths: EquivalentLengths
/// Interactions with the users table.
public var users: Users
public var userProfile: UserProfile
/// Interactions with the user profiles table.
public var userProfiles: UserProfiles
/// Interactions with the trunk sizes table.
public var trunkSizes: TrunkSizes
@DependencyClient
public struct ComponentLosses: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
@DependencyClient
public struct EquivalentLengths: Sendable {
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
public var update:
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
}
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
@DependencyClient
public struct Migrations: Sendable {
public var all: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.all()
}
}
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Project.ID, Room.Create) async throws -> Room
public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room]
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
@DependencyClient
public struct UserProfiles: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient: TestDependencyKey {
@@ -29,10 +138,10 @@ extension DatabaseClient: TestDependencyKey {
projects: .testValue,
rooms: .testValue,
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
componentLosses: .testValue,
equivalentLengths: .testValue,
users: .testValue,
userProfile: .testValue,
userProfiles: .testValue,
trunkSizes: .testValue
)
@@ -42,44 +151,11 @@ extension DatabaseClient: TestDependencyKey {
projects: .live(database: database),
rooms: .live(database: database),
equipment: .live(database: database),
componentLoss: .live(database: database),
effectiveLength: .live(database: database),
componentLosses: .live(database: database),
equivalentLengths: .live(database: database),
users: .live(database: database),
userProfile: .live(database: database),
userProfiles: .live(database: database),
trunkSizes: .live(database: database)
)
}
}
extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.run()
}
}
}
extension DatabaseClient.Migrations: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self(
run: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
TrunkSize.Migrate(),
]
}
)
}

View File

@@ -0,0 +1,48 @@
import Validations
extension Validator {
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator())
}
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]?>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator().optional())
}
}
extension Array where Element: Validatable {
static func validator() -> some Validation<Self> {
ArrayValidator<Element>()
}
}
struct ArrayValidator<Element>: Validation where Element: Validatable {
func validate(_ value: [Element]) throws {
for item in value {
try item.validate()
}
}
}
struct ForEachValidator<T, E>: Validation where T: Validation, T.Value == E {
let validator: T
init(@ValidationBuilder<E> builder: () -> T) {
self.validator = builder()
}
func validate(_ value: [E]) throws {
for item in value {
try validator.validate(item)
}
}
}

View File

@@ -3,31 +3,19 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import SQLKit
import Validations
extension DatabaseClient {
@DependencyClient
public struct ComponentLoss: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
}
extension DatabaseClient.ComponentLoss: TestDependencyKey {
extension DatabaseClient.ComponentLosses: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.ComponentLoss {
extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
let model = request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
@@ -48,13 +36,13 @@ extension DatabaseClient.ComponentLoss {
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
try updates.validate()
// try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
@@ -64,40 +52,9 @@ extension DatabaseClient.ComponentLoss {
extension ComponentPressureLoss.Create {
func toModel() throws(ValidationError) -> ComponentLossModel {
try validate()
func toModel() -> ComponentLossModel {
return .init(name: name, value: value, projectID: projectID)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
extension ComponentPressureLoss.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
}
if let value {
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
}
extension ComponentPressureLoss {
@@ -184,3 +141,19 @@ final class ComponentLossModel: Model, @unchecked Sendable {
}
}
}
extension ComponentLossModel: Validatable {
var body: some Validation<ComponentLossModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.value) {
Double.greaterThan(0.0)
Double.lessThanOrEquals(1.0)
}
.errorLabel("Value", inline: true)
}
}
}

View File

@@ -3,29 +3,16 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
}
import Validations
extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Equipment {
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
let model = request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
@@ -51,10 +38,9 @@ extension DatabaseClient.Equipment {
guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
@@ -64,8 +50,7 @@ extension DatabaseClient.Equipment {
extension EquipmentInfo.Create {
func toModel() throws(ValidationError) -> EquipmentModel {
try validate()
func toModel() -> EquipmentModel {
return .init(
staticPressure: staticPressure,
heatingCFM: heatingCFM,
@@ -74,44 +59,6 @@ extension EquipmentInfo.Create {
)
}
func validate() throws(ValidationError) {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
guard staticPressure <= 1.0 else {
throw ValidationError("Equipment info static pressure should be less than 1.0.")
}
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
extension EquipmentInfo.Update {
var hasUpdates: Bool {
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
}
func validate() throws(ValidationError) {
if let staticPressure {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
}
if let heatingCFM {
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
if let coolingCFM {
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
}
extension EquipmentInfo {
@@ -211,3 +158,22 @@ final class EquipmentModel: Model, @unchecked Sendable {
}
}
}
extension EquipmentModel: Validatable {
var body: some Validation<EquipmentModel> {
Validator.accumulating {
Validator.validate(\.staticPressure) {
Double.greaterThan(0.0)
Double.lessThan(1.0)
}
.errorLabel("Static Pressure", inline: true)
Validator.validate(\.heatingCFM, with: .greaterThan(0))
.errorLabel("Heating CFM", inline: true)
Validator.validate(\.coolingCFM, with: .greaterThan(0))
.errorLabel("Cooling CFM", inline: true)
}
}
}

View File

@@ -3,28 +3,16 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct EffectiveLengthClient: Sendable {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
public var update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
}
}
extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
@@ -66,7 +54,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
}
try model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
@@ -74,10 +62,12 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
}
}
extension EffectiveLength.Create {
extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel {
try validate()
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
return try .init(
name: name,
type: type.rawValue,
@@ -86,15 +76,9 @@ extension EffectiveLength.Create {
projectID: projectID
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Effective length name can not be empty.")
}
}
}
extension EffectiveLength {
extension EquivalentLength {
struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength"
@@ -173,20 +157,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
$project.id = projectID
}
func toDTO() throws -> EffectiveLength {
func toDTO() throws -> EquivalentLength {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
type: .init(rawValue: type)!,
straightLengths: straightLengths,
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups),
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EffectiveLength.Update) throws {
func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}
@@ -197,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
self.straightLengths = straightLengths
}
if let groups = updates.groups {
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
self.groups = try JSONEncoder().encode(groups)
}
}
}
extension EffectiveLengthModel: Validatable {
var body: some Validation<EffectiveLengthModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(
\.straightLengths,
with: [Int].empty().or(
ForEachValidator {
Int.greaterThan(0)
})
)
.errorLabel("Straight Lengths", inline: true)
}
}
}
extension EquivalentLength.FittingGroup: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.group) {
Int.greaterThanOrEquals(1)
Int.lessThanOrEquals(12)
}
.errorLabel("Group", inline: true)
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
.errorLabel("Letter", inline: true)
Validator.validate(\.value, with: .greaterThan(0))
.errorLabel("Value", inline: true)
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
.errorLabel("Quantity", inline: true)
}
}
}

View File

@@ -0,0 +1,22 @@
import Dependencies
import ManualDCore
extension DatabaseClient.Migrations: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
all: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EquivalentLength.Migrate(),
TrunkSize.Migrate(),
]
}
)
}

View File

@@ -0,0 +1,10 @@
import Fluent
import Validations
extension Model where Self: Validations.Validatable {
func validateAndSave(on database: any Database) async throws {
try self.validate()
try await self.save(on: database)
}
}

View File

@@ -3,20 +3,7 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
}
import Validations
extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self()
@@ -24,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
public static func live(database: any Database) -> Self {
.init(
create: { userID, request in
let model = try request.toModel(userID: userID)
try await model.save(on: database)
let model = request.toModel(userID: userID)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
@@ -35,28 +22,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
let model = try await ProjectModel.fetchDetail(for: id, on: database)
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
@@ -76,44 +42,25 @@ extension DatabaseClient.Projects: TestDependencyKey {
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
let model = try await ProjectModel.fetchDetail(for: id, on: database)
var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil
if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
model.equivalentLengths.filter({ $0.type == "return" }).first != nil
{
equivalentLengthsCompleted = true
}
let componentLosses = try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equipmentInfo = try await EquipmentModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.first()
return .init(
equipmentInfo: equipmentInfo != nil,
rooms: roomsCount > 0,
equipmentInfo: model.equipment != nil,
rooms: model.rooms.count > 0,
equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0
frictionRate: model.componentLosses.count > 0
)
},
getSensibleHeatRatio: { id in
guard
let shr = try await ProjectModel.query(on: database)
let model = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
@@ -121,7 +68,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
else {
throw NotFoundError()
}
return shr.sensibleHeatRatio
return model.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
@@ -135,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
@@ -148,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
extension Project.Create {
func toModel(userID: User.ID) throws -> ProjectModel {
try validate()
func toModel(userID: User.ID) -> ProjectModel {
return .init(
name: name,
streetAddress: streetAddress,
@@ -160,70 +105,6 @@ extension Project.Create {
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project {
@@ -241,7 +122,7 @@ extension Project {
.field("sensibleHeatRatio", .double)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
.field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
.unique(on: "userID", "name")
.create()
}
@@ -364,4 +245,67 @@ final class ProjectModel: Model, @unchecked Sendable {
self.sensibleHeatRatio = sensibleHeatRatio
}
}
/// Returns a ``ProjectModel`` with all the relations eagerly loaded.
static func fetchDetail(
for projectID: Project.ID,
on database: any Database
) async throws -> ProjectModel {
guard
let model =
try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == projectID)
.first()
else {
throw NotFoundError()
}
return model
}
}
extension ProjectModel: Validatable {
var body: some Validation<ProjectModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
Validator.validate(\.sensibleHeatRatio) {
Validator {
Double.greaterThan(0)
Double.lessThanOrEquals(1.0)
}
.optional()
}
.errorLabel("Sensible Heat Ratio", inline: true)
}
}
}

View File

@@ -3,31 +3,25 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
}
import Validations
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
create: { projectID, request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
createMany: { projectID, rooms in
try await rooms.asyncMap { request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
}
},
delete: { id in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
@@ -41,8 +35,11 @@ extension DatabaseClient.Rooms: TestDependencyKey {
model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID
}
if model.rectangularSizes?.count == 0 {
model.rectangularSizes = nil
}
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
},
@@ -61,11 +58,9 @@ extension DatabaseClient.Rooms: TestDependencyKey {
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
},
@@ -88,68 +83,15 @@ extension DatabaseClient.Rooms: TestDependencyKey {
extension Room.Create {
func toModel() throws(ValidationError) -> RoomModel {
try validate()
func toModel(projectID: Project.ID) throws -> RoomModel {
return .init(
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
coolingLoad: coolingLoad,
registerCount: registerCount,
projectID: projectID
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
extension Room.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
}
if let heatingLoad {
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
}
if let coolingTotal {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
if let registerCount {
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
}
extension Room {
@@ -161,8 +103,7 @@ extension Room {
.id()
.field("name", .string, .required)
.field("heatingLoad", .double, .required)
.field("coolingTotal", .double, .required)
.field("coolingSensible", .double)
.field("coolingLoad", .dictionary, .required)
.field("registerCount", .int8, .required)
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
@@ -180,7 +121,7 @@ extension Room {
}
}
final class RoomModel: Model, @unchecked Sendable {
final class RoomModel: Model, @unchecked Sendable, Validatable {
static let schema = "room"
@@ -193,11 +134,8 @@ final class RoomModel: Model, @unchecked Sendable {
@Field(key: "heatingLoad")
var heatingLoad: Double
@Field(key: "coolingTotal")
var coolingTotal: Double
@Field(key: "coolingSensible")
var coolingSensible: Double?
@Field(key: "coolingLoad")
var coolingLoad: Room.CoolingLoad
@Field(key: "registerCount")
var registerCount: Int
@@ -220,8 +158,7 @@ final class RoomModel: Model, @unchecked Sendable {
id: UUID? = nil,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double? = nil,
coolingLoad: Room.CoolingLoad,
registerCount: Int,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
@@ -231,8 +168,7 @@ final class RoomModel: Model, @unchecked Sendable {
self.id = id
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.coolingLoad = coolingLoad
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
@@ -246,8 +182,7 @@ final class RoomModel: Model, @unchecked Sendable {
projectID: $project.id,
name: name,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
coolingLoad: coolingLoad,
registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
@@ -263,11 +198,8 @@ final class RoomModel: Model, @unchecked Sendable {
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
self.coolingTotal = coolingTotal
}
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
self.coolingSensible = coolingSensible
if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad {
self.coolingLoad = coolingLoad
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
@@ -278,4 +210,54 @@ final class RoomModel: Model, @unchecked Sendable {
}
var body: some Validation<RoomModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
.errorLabel("Heating Load", inline: true)
Validator.validate(\.coolingLoad)
.errorLabel("Cooling Load", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals(1))
.errorLabel("Register Count", inline: true)
Validator.validate(\.rectangularSizes)
}
}
}
extension Room.CoolingLoad: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
// Ensure that at least one of the values is not nil.
Validator.oneOf {
Validator.validate(\.total, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
Validator.validate(\.sensible, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
}
Validator.validate(\.total, with: Double.greaterThan(0).optional())
Validator.validate(\.sensible, with: Double.greaterThan(0).optional())
}
}
}
extension Room.RectangularSize: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.register, with: Int.greaterThanOrEquals(1).optional())
.errorLabel("Register", inline: true)
Validator.validate(\.height, with: Int.greaterThanOrEquals(1))
.errorLabel("Height", inline: true)
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
extension Sequence {
// Taken from: https://forums.swift.org/t/are-there-any-kind-of-asyncmap-method-for-a-normal-sequence/77354/7
func asyncMap<Result: Sendable>(
_ transform: @escaping @Sendable (Element) async throws -> Result
) async rethrows -> [Result] where Element: Sendable {
try await withThrowingTaskGroup(of: (Int, Result).self) { group in
var i = 0
var iterator = self.makeIterator()
var results = [Result?]()
results.reserveCapacity(underestimatedCount)
func submitTask() throws {
try Task.checkCancellation()
if let element = iterator.next() {
results.append(nil)
group.addTask { [i] in try await (i, transform(element)) }
i += 1
}
}
// Add initial tasks
for _ in 0..<ProcessInfo.processInfo.processorCount {
try submitTask()
}
// Submit more tasks as results complete
while let (index, result) = try await group.next() {
results[index] = result
try submitTask()
}
return results.compactMap { $0 }
}
}
}

View File

@@ -3,19 +3,7 @@ import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
}
import Validations
extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self()
@@ -23,12 +11,12 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static func live(database: any Database) -> Self {
.init(
create: { request in
try request.validate()
// try request.validate()
let trunk = request.toModel()
var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database)
try await trunk.validateAndSave(on: database)
for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else {
@@ -40,7 +28,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
registers: registers,
type: request.type
)
try await model.save(on: database)
try await model.validateAndSave(on: database)
roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
@@ -50,7 +38,9 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
id: trunk.requireID(),
projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!,
rooms: roomProxies
rooms: roomProxies,
height: trunk.height,
name: trunk.name
)
},
delete: { id in
@@ -91,7 +81,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
else {
throw NotFoundError()
}
try updates.validate()
// try updates.validate()
try await model.applyUpdates(updates, on: database)
return try model.toDTO()
}
@@ -101,17 +91,6 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
extension TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
func toModel() -> TrunkModel {
.init(
projectID: projectID,
@@ -122,21 +101,6 @@ extension TrunkSize.Create {
}
}
extension TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
}
extension TrunkSize {
struct Migrate: AsyncMigration {
@@ -211,15 +175,22 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
}
func toDTO() throws -> TrunkSize.RoomProxy {
// guard let room = try await RoomModel.find($room.id, on: database) else {
// throw NotFoundError()
// }
return .init(
room: try room.toDTO(),
registers: registers
)
}
}
extension TrunkRoomModel: Validatable {
var body: some Validation<TrunkRoomModel> {
Validator.validate(\.registers) {
[Int].notEmpty()
ForEachValidator {
Int.greaterThanOrEquals(1)
}
}
}
}
final class TrunkModel: Model, @unchecked Sendable {
@@ -261,19 +232,6 @@ final class TrunkModel: Model, @unchecked Sendable {
}
func toDTO() throws -> TrunkSize {
// let rooms = try await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
// for room in self.rooms {
// group.addTask {
// try await room.toDTO(on: database)
// }
// }
//
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
// $0.append($1)
// }
//
// }
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
$0.append(try $1.toDTO())
}
@@ -303,7 +261,7 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name
}
if hasChanges {
try await self.save(on: database)
try await self.validateAndSave(on: database)
}
guard let updateRooms = updates.rooms else {
@@ -324,7 +282,7 @@ final class TrunkModel: Model, @unchecked Sendable {
currRoom.registers = registers
}
if currRoom.hasChanges {
try await currRoom.save(on: database)
try await currRoom.validateAndSave(on: database)
}
} else {
database.logger.debug("CREATING NEW TrunkRoomModel")
@@ -351,19 +309,27 @@ final class TrunkModel: Model, @unchecked Sendable {
}
}
extension TrunkModel: Validatable {
var body: some Validation<TrunkModel> {
Validator.accumulating {
Validator.validate(\.height, with: Int.greaterThan(0).optional())
.errorLabel("Height", inline: true)
Validator.validate(\.name, with: String.notEmpty().optional())
.errorLabel("Name", inline: true)
}
}
}
extension Array where Element == TrunkModel {
func toDTO() throws -> [TrunkSize] {
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
// for model in self {
// group.addTask {
// try await model.toDTO(on: database)
// }
// }
return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO())
}
}
// }
}

View File

@@ -0,0 +1,20 @@
import ManualDCore
import Validations
// Declaring this in seperate file because some Vapor imports
// have same name's and this was easiest solution.
extension User.Create: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.email, with: .email())
.errorLabel("Email", inline: true)
Validator.validate(\.password.count, with: .greaterThanOrEquals(8))
.errorLabel("Password Count", inline: true)
Validator.validate(\.confirmPassword, with: .equals(password))
.mapError(ValidationError("Confirm password does not match."))
.errorLabel("Confirm Password", inline: true)
}
}
}

View File

@@ -1,30 +1,19 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Vapor
import Validations
extension DatabaseClient {
@DependencyClient
public struct UserProfile: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
}
extension DatabaseClient.UserProfile: TestDependencyKey {
extension DatabaseClient.UserProfiles: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
try profile.validate()
let model = profile.toModel()
try await model.save(on: database)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
@@ -48,10 +37,9 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
@@ -61,30 +49,6 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
func toModel() -> UserProfileModel {
.init(
userID: userID,
@@ -100,47 +64,6 @@ extension User.Profile.Create {
}
}
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
if let companyName {
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
@@ -281,3 +204,31 @@ final class UserProfileModel: Model, @unchecked Sendable {
}
}
extension UserProfileModel: Validatable {
var body: some Validation<UserProfileModel> {
Validator.accumulating {
Validator.validate(\.firstName, with: .notEmpty())
.errorLabel("First Name", inline: true)
Validator.validate(\.lastName, with: .notEmpty())
.errorLabel("Last Name", inline: true)
Validator.validate(\.companyName, with: .notEmpty())
.errorLabel("Company", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
}
}
}

View File

@@ -1,28 +1,17 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
try request.validate()
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
@@ -46,6 +35,11 @@ extension DatabaseClient.Users: TestDependencyKey {
throw NotFoundError()
}
// Verify the password matches the user's hashed password.
guard try user.verifyPassword(request.password) else {
throw Abort(.unauthorized)
}
let token: User.Token
// Check if there's a user token
@@ -126,21 +120,8 @@ extension User {
extension User.Create {
func toModel() throws -> UserModel {
try validate()
return try .init(email: email, passwordHash: User.hashPassword(password))
}
func validate() throws {
guard !email.isEmpty else {
throw ValidationError("Email should not be empty")
}
guard password.count > 8 else {
throw ValidationError("Password should be more than 8 characters long.")
}
guard password == confirmPassword else {
throw ValidationError("Passwords do not match.")
}
}
}
final class UserModel: Model, @unchecked Sendable {

View File

@@ -1,175 +0,0 @@
// import Dependencies
// import DependenciesMacros
// import Fluent
// import Foundation
// import ManualDCore
//
// extension DatabaseClient {
// @DependencyClient
// public struct RectangularDuct: Sendable {
// public var create:
// @Sendable (DuctSizing.RectangularDuct.Create) async throws -> DuctSizing.RectangularDuct
// public var delete: @Sendable (DuctSizing.RectangularDuct.ID) async throws -> Void
// public var fetch: @Sendable (Room.ID) async throws -> [DuctSizing.RectangularDuct]
// public var get:
// @Sendable (DuctSizing.RectangularDuct.ID) async throws -> DuctSizing.RectangularDuct?
// public var update:
// @Sendable (DuctSizing.RectangularDuct.ID, DuctSizing.RectangularDuct.Update) async throws ->
// DuctSizing.RectangularDuct
// }
// }
//
// extension DatabaseClient.RectangularDuct: TestDependencyKey {
// public static let testValue = Self()
//
// public static func live(database: any Database) -> Self {
// .init(
// create: { request in
// try request.validate()
// let model = request.toModel()
// try await model.save(on: database)
// return try model.toDTO()
// },
// delete: { id in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try await model.delete(on: database)
// },
// fetch: { roomID in
// try await RectangularDuctModel.query(on: database)
// .with(\.$room)
// .filter(\.$room.$id == roomID)
// .all()
// .map { try $0.toDTO() }
// },
// get: { id in
// try await RectangularDuctModel.find(id, on: database)
// .map { try $0.toDTO() }
// },
// update: { id, updates in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try updates.validate()
// model.applyUpdates(updates)
// if model.hasChanges {
// try await model.save(on: database)
// }
// return try model.toDTO()
// }
// )
// }
// }
//
// extension DuctSizing.RectangularDuct.Create {
//
// func validate() throws(ValidationError) {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
//
// func toModel() -> RectangularDuctModel {
// .init(roomID: roomID, height: height)
// }
// }
//
// extension DuctSizing.RectangularDuct.Update {
//
// func validate() throws(ValidationError) {
// if let height {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
// }
//
// extension DuctSizing.RectangularDuct {
// struct Migrate: AsyncMigration {
// let name = "CreateRectangularDuct"
//
// func prepare(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema)
// .id()
// .field("register", .int8)
// .field("height", .int8, .required)
// .field("roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade))
// .field("createdAt", .datetime)
// .field("updatedAt", .datetime)
// .create()
// }
//
// func revert(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema).delete()
// }
// }
// }
//
// final class RectangularDuctModel: Model, @unchecked Sendable {
//
// static let schema = "rectangularDuct"
//
// @ID(key: .id)
// var id: UUID?
//
// @Parent(key: "roomID")
// var room: RoomModel
//
// @Field(key: "height")
// var height: Int
//
// @Field(key: "register")
// var register: Int?
//
// @Timestamp(key: "createdAt", on: .create, format: .iso8601)
// var createdAt: Date?
//
// @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
// var updatedAt: Date?
//
// init() {}
//
// init(
// id: UUID? = nil,
// roomID: Room.ID,
// register: Int? = nil,
// height: Int
// ) {
// self.id = id
// $room.id = roomID
// self.register = register
// self.height = height
// }
//
// func toDTO() throws -> DuctSizing.RectangularDuct {
// return try .init(
// id: requireID(),
// roomID: $room.id,
// register: register,
// height: height,
// createdAt: createdAt!,
// updatedAt: updatedAt!
// )
// }
//
// func applyUpdates(_ updates: DuctSizing.RectangularDuct.Update) {
// if let height = updates.height, height != self.height {
// self.height = height
// }
// if let register = updates.register, register != self.register {
// self.register = register
// }
// }
// }

View File

@@ -1,74 +0,0 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var env: @Sendable () throws -> EnvVars {
get { self[EnvClient.self].env }
set { self[EnvClient.self].env = newValue }
}
}
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
}
}
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
})
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -0,0 +1,100 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var environment: EnvVars {
get { self[EnvVars.self] }
set { self[EnvVars.self] = newValue }
}
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
/// The postgres hostname, used for production database connection.
public let postgresHostname: String?
/// The postgres username, used for production database connection.
public let postgresUsername: String?
/// The postgres password, used for production database connection.
public let postgresPassword: String?
/// The postgres database, used for production database connection.
public let postgresDatabase: String?
/// The path to the sqlite database, used for development database connection.
public let sqlitePath: String?
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint",
postgresHostname: String? = "localhost",
postgresUsername: String? = "vapor",
postgresPassword: String? = "super-secret",
postgresDatabase: String? = "vapor",
sqlitePath: String? = "db.sqlite"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
self.postgresHostname = postgresHostname
self.postgresUsername = postgresUsername
self.postgresPassword = postgresPassword
self.postgresDatabase = postgresDatabase
self.sqlitePath = sqlitePath
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
case postgresHostname = "POSTGRES_HOSTNAME"
case postgresUsername = "POSTGRES_USERNAME"
case postgresPassword = "POSTGRES_PASSWORD"
case postgresDatabase = "POSTGRES_DATABASE"
case sqlitePath = "SQLITE_PATH"
}
public static func live(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Self {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(env, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
}
}
extension EnvVars: TestDependencyKey {
public static let testValue = Self()
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -4,6 +4,7 @@ import Foundation
import Vapor
extension DependencyValues {
/// Dependency used for file operations.
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
@@ -14,9 +15,27 @@ extension DependencyValues {
public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void
public var writeFile: @Sendable (String, String) async throws -> Void
public var removeFile: @Sendable (String) async throws -> Void
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response
/// Write contents to a file.
///
/// > Warning: This will overwrite a file if it exists.
public var writeFile: @Sendable (_ contents: String, _ path: String) async throws -> Void
/// Remove a file.
public var removeFile: @Sendable (_ path: String) async throws -> Void
/// Stream a file.
public var streamFile:
@Sendable (_ path: String, @escaping OnCompleteHandler) async throws -> Response
/// Stream a file at the given path.
///
/// - Paramters:
/// - path: The path to the file to stream.
/// - onComplete: Completion handler to run when done streaming the file.
public func streamFile(
at path: String,
onComplete: @escaping OnCompleteHandler = {}
) async throws -> Response {
try await streamFile(path, onComplete)
}
}
extension FileClient: TestDependencyKey {

View File

@@ -11,12 +11,12 @@ extension Snapshotting where Value == (any HTML), Format == String {
}
}
extension Snapshotting where Value == String, Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { $0 }
snapshotting.pathExtension = "html"
return snapshotting
}
}
// extension Snapshotting where Value == String, Format == String {
// public static var html: Snapshotting {
// var snapshotting = SimplySnapshotting.lines
// .pullback { $0 }
//
// snapshotting.pathExtension = "html"
// return snapshotting
// }
// }

View File

@@ -3,13 +3,12 @@ import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
public var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
public func coolingSensiblePerRegister(projectSHR: Double) throws -> Double {
let sensible = try coolingLoad.ensured(shr: projectSHR).sensible
return sensible / Double(registerCount)
}
}
@@ -30,8 +29,8 @@ extension TrunkSize.RoomProxy {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
@@ -41,15 +40,11 @@ extension TrunkSize {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength }
@@ -101,16 +96,16 @@ func roundSize(_ size: Double) throws -> Int {
}
}
func velocity(cfm: Int, roundSize: Int) -> Int {
let cfm = Double(cfm)
func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
let cfm = Double(cfm.rawValue)
let roundSize = Double(roundSize)
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
return Int(round(velocity))
}
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int {
let cfm = pow(Double(request.designCFM), 0.4)
let fr = pow(request.frictionRate / 1.76, 0.2)
func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
let cfm = pow(Double(cfm.rawValue), 0.4)
let fr = pow(frictionRate / 1.76, 0.2)
let size = 0.55 * (cfm / fr)
return try roundSize(size)
}

View File

@@ -2,8 +2,10 @@ import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
import Tagged
extension DependencyValues {
/// Dependency that performs manual-d duct sizing calculations.
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
@@ -15,12 +17,61 @@ extension DependencyValues {
///
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
public var rectangularSize:
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
/// Calculates the duct size for the given cfm and friction rate.
public var ductSize: @Sendable (CFM, DesignFrictionRate) async throws -> DuctSize
/// Calculates the design friction rate for the given request.
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
public var rectangularSize: @Sendable (RoundSize, Height) async throws -> RectangularSize
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Int,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: designCFM), .init(rawValue: designFrictionRate))
}
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Double,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: Int(designCFM)), .init(rawValue: designFrictionRate))
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: RoundSize,
height: Height
) async throws -> RectangularSize {
try await rectangularSize(roundSize, height)
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: Int,
height: Int
) async throws -> RectangularSize {
try await rectangularSize(.init(rawValue: roundSize), .init(rawValue: height))
}
}
extension ManualDClient: TestDependencyKey {
@@ -28,21 +79,20 @@ extension ManualDClient: TestDependencyKey {
}
extension ManualDClient {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
public init(
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
/// A name space for tags used by the ManualDClient.
public enum Tag {
public enum CFM {}
public enum DesignFrictionRate {}
public enum Height {}
public enum Round {}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public typealias CFM = Tagged<Tag.CFM, Int>
public typealias DesignFrictionRate = Tagged<Tag.DesignFrictionRate, Double>
public typealias Height = Tagged<Tag.Height, Int>
public typealias RoundSize = Tagged<Tag.Round, Int>
public struct DuctSize: Codable, Equatable, Sendable {
public let calculatedSize: Double
public let finalSize: Int
@@ -66,58 +116,20 @@ extension ManualDClient {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public let totalEquivalentLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
totalEquivalentLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
self.totalEquivalentLength = totalEquivalentLength
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public struct RectangularSize: Codable, Equatable, Sendable {
public let height: Int
public let width: Int

View File

@@ -4,41 +4,47 @@ import ManualDCore
extension ManualDClient: DependencyKey {
public static let liveValue: Self = .init(
ductSize: { request in
guard request.designCFM > 0 else {
ductSize: { cfm, frictionRate in
guard cfm > 0 else {
throw ManualDError(message: "Design CFM should be greater than 0.")
}
let fr = pow(request.frictionRate, 0.5)
let ductulatorSize = pow(Double(request.designCFM) / (3.12 * fr), 0.38)
let fr = pow(frictionRate.rawValue, 0.5)
let ductulatorSize = pow(Double(cfm.rawValue) / (3.12 * fr), 0.38)
let finalSize = try roundSize(ductulatorSize)
let flexSize = try flexSize(request)
let flexSize = try flexSize(cfm, frictionRate.rawValue)
return .init(
calculatedSize: ductulatorSize,
finalSize: finalSize,
flexSize: flexSize,
velocity: velocity(cfm: request.designCFM, roundSize: finalSize)
velocity: velocity(cfm: cfm, roundSize: finalSize)
)
},
frictionRate: { request in
// Ensure the total effective length is greater than 0.
guard request.totalEffectiveLength > 0 else {
guard request.totalEquivalentLength > 0 else {
throw ManualDError(message: "Total Effective Length should be greater than 0.")
}
let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
return .init(availableStaticPressure: availableStaticPressure, value: frictionRate)
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEquivalentLength)
return .init(
availableStaticPressure: availableStaticPressure,
value: frictionRate
)
},
totalEquivalentLength: { request in
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
return trunkLengths + runoutLengths + groupLengths
},
rectangularSize: { request in
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
// totalEquivalentLength: { request in
// let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
// let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
// let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
// return trunkLengths + runoutLengths + groupLengths
// },
rectangularSize: { round, height in
let width = (Double.pi * (pow(Double(round.rawValue) / 2.0, 2.0))) / Double(height.rawValue)
return .init(
height: height.rawValue,
width: Int(width.rounded(.toNearestOrEven))
)
}
)
}

View File

@@ -1,6 +1,10 @@
import Dependencies
import Foundation
/// Represents component pressure losses used in the friction rate worksheet.
///
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
/// need to be overcome by the system fan.
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
@@ -44,7 +48,7 @@ extension ComponentPressureLoss {
self.value = value
}
// Return's commonly used default component pressure losses.
/// Commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] {
[
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
@@ -75,20 +79,7 @@ extension Array where Element == ComponentPressureLoss {
}
}
public typealias ComponentPressureLosses = [String: Double]
#if DEBUG
extension ComponentPressureLosses {
public static var mock: Self {
[
"evaporator-coil": 0.2,
"filter": 0.1,
"supply-outlet": 0.03,
"return-grille": 0.03,
"balancing-damper": 0.03,
]
}
}
extension Array where Element == ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> Self {

View File

@@ -1,13 +1,14 @@
import Foundation
// import Foundation
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double
public let sensible: Double
public var latent: Double { total - sensible }
public var shr: Double { sensible / total }
public init(total: Double, sensible: Double) {
self.total = total
self.sensible = sensible
}
}
//
// public struct CoolingLoad: Codable, Equatable, Sendable {
// public let total: Double
// public let sensible: Double
// public var latent: Double { total - sensible }
// public var shr: Double { sensible / total }
//
// public init(total: Double, sensible: Double) {
// self.total = total
// self.sensible = sensible
// }
// }

View File

@@ -152,11 +152,12 @@ extension DuctSizes {
public static func mock(
equipmentInfo: EquipmentInfo,
rooms: [Room],
trunks: [TrunkSize]
trunks: [TrunkSize],
shr: Double
) -> Self {
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingLoad = rooms.totalCoolingLoad
let totalCoolingLoad = try! rooms.totalCoolingLoad(shr: shr)
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
array += RoomContainer.mock(
@@ -164,7 +165,8 @@ extension DuctSizes {
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
totalCoolingCFM: Double(equipmentInfo.coolingCFM),
shr: shr
)
}
@@ -187,14 +189,15 @@ extension DuctSizes {
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: Double
totalCoolingCFM: Double,
shr: Double
) -> [Self] {
var retval = [DuctSizes.RoomContainer]()
let heatingLoad = room.heatingLoad / Double(room.registerCount)
let heatingFraction = heatingLoad / totalHeatingLoad
let heatingCFM = totalHeatingCFM * heatingFraction
// Not really accurate, but works for mocks.
let coolingLoad = room.coolingTotal / Double(room.registerCount)
let coolingLoad = (try! room.coolingLoad.ensured(shr: shr).total) / Double(room.registerCount)
let coolingFraction = coolingLoad / totalCoolingLoad
let coolingCFM = totalCoolingCFM * coolingFraction

View File

@@ -1,215 +0,0 @@
import Dependencies
import Foundation
// TODO: Not sure how to model effective length groups in the database.
// thinking perhaps just have a 'data' field that encoded / decodes
// to swift types??
public struct EffectiveLength: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EffectiveLength {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public init(
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let type: EffectiveLengthType?
public let straightLengths: [Int]?
public let groups: [Group]?
public init(
name: String? = nil,
type: EffectiveLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EffectiveLength.Group]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
public struct Group: Codable, Equatable, Sendable {
public let group: Int
public let letter: String
public let value: Double
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
public struct MaxContainer: Codable, Equatable, Sendable {
public let supply: EffectiveLength?
public let `return`: EffectiveLength?
public var total: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
self.supply = supply
self.return = `return`
}
}
}
extension EffectiveLength {
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EffectiveLength.Group {
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EffectiveLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
public static let mocks: [Self] = [
.init(
id: UUID(0),
projectID: UUID(0),
name: "Test Supply - 1",
type: .supply,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(1),
projectID: UUID(0),
name: "Test Return - 1",
type: .return,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
]
}
#endif

View File

@@ -1,5 +1,7 @@
import Foundation
// TODO: These are not used, should they be removed??
// TODO: Add other description / label for items that have same group & letter, but
// different effective length.
public struct EffectiveLengthGroup: Codable, Equatable, Sendable {

View File

@@ -1,6 +1,10 @@
import Dependencies
import Foundation
/// Represents the equipment information for a project.
///
/// This is used in the friction rate worksheet and sizing ducts. It holds on to items
/// such as the target static pressure, cooling CFM, and heating CFM for the project.
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID

View File

@@ -0,0 +1,239 @@
import Dependencies
import Foundation
/// Represents the equivalent length of a single duct path.
///
/// These consist of both straight lengths of duct / trunks, as well as the
/// equivalent length of duct fittings. They are used to determine the worst
/// case total equivalent length of duct that the system fan has to move air
/// through.
///
/// There can be many equivalent lengths saved for a project, however the only
/// ones that matter in most calculations are the longest supply path and the
/// the longest return path.
///
/// It is required that project has at least one equivalent length saved for
/// the supply and one saved for return, otherwise duct sizes can not be calculated.
public struct EquivalentLength: Codable, Equatable, Identifiable, Sendable {
/// The id of the equivalent length.
public let id: UUID
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
/// When this equivalent length was created in the database.
public let createdAt: Date
/// When this equivalent length was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EquivalentLength {
/// Represents the data needed to create a new ``EquivalentLength`` in the database.
public struct Create: Codable, Equatable, Sendable {
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
public init(
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the data needed to update an ``EquivalentLength`` in the database.
///
/// Only the supplied fields are updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name / label for this equivalent length.
public let name: String?
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType?
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]?
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]?
public init(
name: String? = nil,
type: EquivalentLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EquivalentLength.FittingGroup]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the type of equivalent length, either supply or return.
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
/// Represents a Manual-D fitting group.
///
/// These are defined by Manual-D and convert different types of fittings into
/// an equivalent length of straight duct.
public struct FittingGroup: Codable, Equatable, Sendable {
/// The fitting group number.
public let group: Int
/// The fitting group letter.
public let letter: String
/// The equivalent length of the fitting.
public let value: Double
/// The quantity of the fittings in the path.
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
// TODO: Should these not be optional and we just throw an error or return nil from
// a database query.
/// Represents the max ``EquivalentLength``'s for a project.
///
/// Calculating the duct sizes for a project requires there to be a max supply
/// and a max return equivalent length, so this container represents those values
/// when they exist in the database.
public struct MaxContainer: Codable, Equatable, Sendable {
/// The longest supply equivalent length.
public let supply: EquivalentLength?
/// The longest return equivalent length.
public let `return`: EquivalentLength?
public init(supply: EquivalentLength? = nil, return: EquivalentLength? = nil) {
self.supply = supply
self.return = `return`
}
public var totalEquivalentLength: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
}
}
extension EquivalentLength {
/// The calculated total equivalent length.
///
/// This is the sum of all the straigth lengths and fitting groups (with quantities).
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EquivalentLength.FittingGroup {
/// The calculated total equivalent length for the fitting groups.
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EquivalentLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -1,4 +1,17 @@
import Foundation
import Tagged
extension Tagged where RawValue == Double {
public func string(digits: Int = 2) -> String {
rawValue.string(digits: digits)
}
}
extension Tagged where RawValue == Int {
public func string() -> String {
rawValue.string()
}
}
extension Double {

View File

@@ -1,8 +1,14 @@
/// Holds onto values returned when calculating the design
/// friction rate for a project.
///
/// **NOTE:** This is not stored in the database, it is calculated on the fly.
public struct FrictionRate: Codable, Equatable, Sendable {
/// The available static pressure is the equipment's design static pressure
/// minus the ``ComponentPressureLoss``es for the project.
public let availableStaticPressure: Double
/// The calculated design friction rate value.
public let value: Double
/// Whether the design friction rate is within a valid range.
public var hasErrors: Bool { error != nil }
public init(
@@ -13,6 +19,7 @@ public struct FrictionRate: Codable, Equatable, Sendable {
self.value = value
}
/// The error if the design friction rate is out of a valid range.
public var error: FrictionRateError? {
if value >= 0.18 {
return .init(
@@ -37,8 +44,13 @@ public struct FrictionRate: Codable, Equatable, Sendable {
}
}
/// Represents an error when the ``FrictionRate`` is out of a valid range.
///
/// This holds onto the reason for the error as well as possible resolutions.
public struct FrictionRateError: Error, Equatable, Sendable {
/// The reason for the error.
public let reason: String
/// The possible resolutions to the error.
public let resolutions: [String]
public init(

View File

@@ -1,16 +1,29 @@
import Dependencies
import Foundation
/// Represents a single duct design project / system.
///
/// Holds items such as project name and address.
public struct Project: Codable, Equatable, Identifiable, Sendable {
/// The unique ID of the project.
public let id: UUID
/// The name of the project.
public let name: String
/// The street address of the project.
public let streetAddress: String
/// The city of the project.
public let city: String
/// The state of the project.
public let state: String
/// The zip code of the project.
public let zipCode: String
/// The global sensible heat ratio for the project.
///
/// **NOTE:** This is used for calculating the sensible cooling load for rooms.
public let sensibleHeatRatio: Double?
/// When the project was created in the database.
public let createdAt: Date
/// When the project was updated in the database.
public let updatedAt: Date
public init(
@@ -37,14 +50,20 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
}
extension Project {
/// Represents the data needed to create a new project.
public struct Create: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String
/// The street address of the project.
public let streetAddress: String
/// The city of the project.
public let city: String
/// The state of the project.
public let state: String
/// The zip code of the project.
public let zipCode: String
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double?
public init(
@@ -64,11 +83,19 @@ extension Project {
}
}
/// Represents steps that are completed in order to calculate the duct sizes
/// for a project.
///
/// This is primarily used on the web pages to display errors or color of the
/// different steps of a project.
public struct CompletedSteps: Codable, Equatable, Sendable {
/// Whether there is ``EquipmentInfo`` for a project.
public let equipmentInfo: Bool
/// Whether there are ``Room``'s for a project.
public let rooms: Bool
/// Whether there are ``EquivalentLength``'s for a project.
public let equivalentLength: Bool
/// Whether there is a ``FrictionRate`` for a project.
public let frictionRate: Bool
public init(
@@ -84,20 +111,30 @@ extension Project {
}
}
/// Represents project details loaded from the database.
///
/// This is generally used to perform duct sizing calculations for the
/// project, once all the steps have been completed.
public struct Detail: Codable, Equatable, Sendable {
/// The project.
public let project: Project
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss]
/// The equipment info for the project.
public let equipmentInfo: EquipmentInfo
public let equivalentLengths: [EffectiveLength]
/// The equivalent lengths for the project.
public let equivalentLengths: [EquivalentLength]
/// The rooms in the project.
public let rooms: [Room]
/// The trunk sizes in the project.
public let trunks: [TrunkSize]
public init(
project: Project,
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: [EffectiveLength],
equivalentLengths: [EquivalentLength],
rooms: [Room],
trunks: [TrunkSize]
) {
@@ -110,13 +147,22 @@ extension Project {
}
}
/// Represents fields that can be updated for a project that has already been created.
///
/// Only fields that are supplied get updated in the database.
public struct Update: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String?
/// The street address of the project.
public let streetAddress: String?
/// The city of the project.
public let city: String?
/// The state of the project.
public let state: String?
/// The zip code of the project.
public let zipCode: String?
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double?
public init(

View File

@@ -1,16 +1,39 @@
import Dependencies
import Foundation
/// Represents a room in a project.
///
/// This contains data such as the heating and cooling load for the
/// room, the number of registers in the room, and any rectangular
/// duct size calculations stored for the room.
public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
public let coolingTotal: Double
public let coolingSensible: Double?
/// The cooling load required for the room (from Manual-J).
public let coolingLoad: CoolingLoad
/// The number of registers for the room.
public let registerCount: Int
/// The rectangular duct size calculations for a room.
///
/// **NOTE:** These are optionally set after the round sizes have been calculate
/// for a room.
public let rectangularSizes: [RectangularSize]?
/// When the room was created in the database.
public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date
public init(
@@ -18,8 +41,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double? = nil,
coolingLoad: CoolingLoad,
registerCount: Int = 1,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
@@ -29,34 +51,73 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.projectID = projectID
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.coolingLoad = coolingLoad
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Represents the cooling load of a room.
///
/// Generally only one of the values is provided by a Manual-J room x room
/// calculation.
///
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double?
public let sensible: Double?
public init(total: Double? = nil, sensible: Double? = nil) {
self.total = total
self.sensible = sensible
}
/// Calculates the cooling load based on the shr.
///
/// Generally Manual-J room x room loads provide either the total load or the
/// sensible load, so this allows us to calculate whichever is not provided.
public func ensured(shr: Double) throws -> (total: Double, sensible: Double) {
switch (total, sensible) {
case (.none, .none):
throw CoolingLoadError("Both the total and sensible loads are nil.")
case (.some(let total), .some(let sensible)):
return (total, sensible)
case (.some(let total), .none):
return (total, total * shr)
case (.none, .some(let sensible)):
return (sensible / shr, sensible)
}
}
}
}
extension Room {
/// Represents the data required to create a new room for a project.
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
public let coolingTotal: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
public var coolingLoad: Room.CoolingLoad {
.init(total: coolingTotal, sensible: coolingSensible)
}
public init(
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int = 1
) {
self.projectID = projectID
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
@@ -65,10 +126,25 @@ extension Room {
}
}
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
public struct CSV: Equatable, Sendable {
public let file: Data
public init(file: Data) {
self.file = file
}
}
/// Represents a rectangular size calculation that is stored in the
/// database for a given room.
///
/// These are done after the round duct sizes have been calculated and
/// can be used to calculate the equivalent rectangular size for a given run.
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the rectangular size.
public let id: UUID
/// The register the rectangular size is associated with.
public let register: Int?
/// The height of the rectangular size, the width gets calculated.
public let height: Int
public init(
@@ -82,14 +158,30 @@ extension Room {
}
}
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Onlly fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double?
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int?
/// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]?
public var coolingLoad: CoolingLoad? {
guard coolingTotal != nil || coolingSensible != nil else {
return nil
}
return .init(total: coolingTotal, sensible: coolingSensible)
}
public init(
name: String? = nil,
heatingLoad: Double? = nil,
@@ -120,57 +212,39 @@ extension Room {
extension Array where Element == Room {
/// The sum of heating loads for an array of rooms.
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
/// The sum of total cooling loads for an array of rooms.
public func totalCoolingLoad(shr: Double) throws -> Double {
try reduce(into: 0) { $0 += try $1.coolingLoad.ensured(shr: shr).total }
}
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) throws -> Double {
try reduce(into: 0) {
// let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += try $1.coolingLoad.ensured(shr: shr).sensible
}
}
}
public struct CoolingLoadError: Error, Equatable, Sendable {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}
#if DEBUG
extension Room {
public static let mocks = [
Room(
id: UUID(0),
projectID: UUID(0),
name: "Kitchen",
heatingLoad: 12345,
coolingTotal: 1234,
registerCount: 2,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(1),
projectID: UUID(1),
name: "Bedroom - 1",
heatingLoad: 12345,
coolingTotal: 1456,
registerCount: 1,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(2),
projectID: UUID(2),
name: "Family Room",
heatingLoad: 12345,
coolingTotal: 1673,
registerCount: 3,
createdAt: Date(),
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@@ -182,8 +256,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
coolingLoad: .init(total: 2472),
// coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
@@ -194,8 +268,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
coolingLoad: .init(total: 2916),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -206,8 +280,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
coolingLoad: .init(total: 7446),
// coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
@@ -218,8 +292,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
coolingLoad: .init(total: 5096),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -230,8 +304,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
coolingLoad: .init(total: 6829),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
@@ -242,8 +316,8 @@ extension Array where Element == Room {
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
coolingLoad: .init(total: 2076),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,

View File

@@ -1,270 +0,0 @@
import CasePathsCore
import Foundation
@preconcurrency import URLRouting
extension SiteRoute {
/// Represents api routes.
///
/// The routes return json as opposed to view routes that return html.
public enum Api: Sendable, Equatable {
case project(Self.ProjectRoute)
case room(Self.RoomRoute)
case equipment(Self.EquipmentRoute)
case componentLoss(Self.ComponentLossRoute)
public static let rootPath = Path {
"api"
"v1"
}
public static let router = OneOf {
Route(.case(Self.project)) {
rootPath
ProjectRoute.router
}
Route(.case(Self.room)) {
rootPath
RoomRoute.router
}
Route(.case(Self.equipment)) {
rootPath
EquipmentRoute.router
}
Route(.case(Self.componentLoss)) {
rootPath
ComponentLossRoute.router
}
}
}
}
extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create)
case delete(id: Project.ID)
case detail(id: Project.ID, route: DetailRoute)
case get(id: Project.ID)
case index
static let rootPath = "projects"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(Project.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.delete
}
Route(.case(Self.get(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.get
}
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.detail(id:route:))) {
Path {
rootPath
Project.ID.parser()
}
DetailRoute.router
}
}
}
}
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath
"completed"
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum RoomRoute: Sendable, Equatable {
case create(Room.Create)
case delete(id: Room.ID)
case get(id: Room.ID)
static let rootPath = "rooms"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(Room.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
Room.ID.parser()
}
Method.delete
}
Route(.case(Self.get(id:))) {
Path {
rootPath
Room.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum EquipmentRoute: Sendable, Equatable {
case create(EquipmentInfo.Create)
case delete(id: EquipmentInfo.ID)
case fetch(projectID: Project.ID)
case get(id: EquipmentInfo.ID)
static let rootPath = "equipment"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(EquipmentInfo.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
EquipmentInfo.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path { rootPath }
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
EquipmentInfo.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum ComponentLossRoute: Sendable, Equatable {
case create(ComponentPressureLoss.Create)
case delete(id: ComponentPressureLoss.ID)
case fetch(projectID: Project.ID)
case get(id: ComponentPressureLoss.ID)
static let rootPath = "componentLoss"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(ComponentPressureLoss.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path { rootPath }
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.get
}
}
}
}
extension SiteRoute.Api {
public enum EffectiveLengthRoute: Equatable, Sendable {
case create(EffectiveLength.Create)
case delete(id: EffectiveLength.ID)
case fetch(projectID: Project.ID)
case get(id: EffectiveLength.ID)
static let rootPath = "effectiveLength"
public static let router = OneOf {
Route(.case(Self.create)) {
Path {
rootPath
"create"
}
Method.post
Body(.json(EffectiveLength.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.delete
}
Route(.case(Self.fetch(projectID:))) {
Path {
rootPath
}
Method.get
Query {
Field("projectID") { Project.ID.parser() }
}
}
Route(.case(Self.get(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.get
}
}
}
}

View File

@@ -5,14 +5,10 @@ import Foundation
public enum SiteRoute: Equatable, Sendable {
case api(Self.Api)
case health
case view(Self.View)
public static let router = OneOf {
Route(.case(Self.api)) {
SiteRoute.Api.router
}
Route(.case(Self.health)) {
Path { "health" }
Method.get

View File

@@ -188,6 +188,7 @@ extension SiteRoute.View.ProjectRoute {
}
public enum RoomRoute: Equatable, Sendable {
case csv(Room.CSV)
case delete(id: Room.ID)
case index
case submit(Room.Create)
@@ -197,6 +198,23 @@ extension SiteRoute.View.ProjectRoute {
static let rootPath = "rooms"
public static let router = OneOf {
Route(.case(Self.csv)) {
Path {
rootPath
"csv"
}
Headers {
Field("Content-Type") { "multipart/form-data" }
}
Method.post
Body().map(.memberwise(Room.CSV.init))
// Body {
// FormData {
//
// }
// .map(.memberwise(Room.CSV.init))
// }
}
Route(.case(Self.delete)) {
Path {
rootPath
@@ -215,12 +233,14 @@ extension SiteRoute.View.ProjectRoute {
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
// Field("projectID") { Project.ID.parser() }
Field("name", .string)
Field("heatingLoad") { Double.parser() }
Field("coolingTotal") { Double.parser() }
Optionally {
Field("coolingSensible", default: nil) { Double.parser() }
Field("coolingTotal") { Double.parser() }
}
Optionally {
Field("coolingSensible") { Double.parser() }
}
Field("registerCount") { Digits() }
}
@@ -394,11 +414,11 @@ extension SiteRoute.View.ProjectRoute {
}
public enum EquivalentLengthRoute: Equatable, Sendable {
case delete(id: EffectiveLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
case delete(id: EquivalentLength.ID)
case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
case index
case submit(FormStep)
case update(EffectiveLength.ID, StepThree)
case update(EquivalentLength.ID, StepThree)
static let rootPath = "effective-lengths"
@@ -406,7 +426,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.delete
}
@@ -424,7 +444,7 @@ extension SiteRoute.View.ProjectRoute {
Field("type") { FieldType.parser() }
Optionally {
Field("style", default: nil) {
EffectiveLength.EffectiveLengthType.parser()
EquivalentLength.EffectiveLengthType.parser()
}
}
}
@@ -437,16 +457,16 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -490,10 +510,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
}
.map(.memberwise(StepOne.init))
}
@@ -505,10 +525,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -525,10 +545,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -567,22 +587,22 @@ extension SiteRoute.View.ProjectRoute {
}
public struct StepOne: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
}
public struct StepTwo: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int]
public init(
id: EffectiveLength.ID? = nil,
id: EquivalentLength.ID? = nil,
name: String,
type: EffectiveLength.EffectiveLengthType,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int]
) {
self.id = id
@@ -593,9 +613,9 @@ extension SiteRoute.View.ProjectRoute {
}
public struct StepThree: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int]
public let groupGroups: [Int]
public let groupLetters: [String]

View File

@@ -1,5 +1,6 @@
import Foundation
/// Represents supported color themes for the website.
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua
case cupcake
@@ -13,6 +14,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case retro
case synthwave
/// Represents dark color themes.
public static let darkThemes = [
Self.aqua,
Self.cyberpunk,
@@ -22,6 +24,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
Self.synthwave,
]
/// Represents light color themes.
public static let lightThemes = [
Self.cupcake,
Self.light,

View File

@@ -1,14 +1,23 @@
import Dependencies
import Foundation
// Represents the database model.
/// Represents trunk calculations for a project.
///
/// These are used to size trunk ducts / runs for multiple rooms or registers.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
/// The unique identifier of the trunk size.
public let id: UUID
/// The project the trunk size is for.
public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [RoomProxy]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
@@ -29,12 +38,19 @@ public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
}
extension TrunkSize {
/// Represents the data needed to create a new ``TrunkSize`` in the database.
public struct Create: Codable, Equatable, Sendable {
/// The project the trunk size is for.
public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
@@ -52,11 +68,19 @@ extension TrunkSize {
}
}
/// Represents the fields that can be updated on a ``TrunkSize`` in the database.
///
/// Only supplied fields are updated.
public struct Update: Codable, Equatable, Sendable {
/// The type of the trunk size (supply or return).
public let type: TrunkType?
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]]?
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
@@ -72,18 +96,29 @@ extension TrunkSize {
}
}
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
/// A container / wrapper around a ``Room`` that is used with a ``TrunkSize``.
///
/// This is needed because a room can have multiple registers and it is possible
/// that a trunk does not serve all registers in that room.
@dynamicMemberLookup
public struct RoomProxy: Codable, Equatable, Sendable {
public var id: Room.ID { room.id }
/// The room associated with the ``TrunkSize``.
public let room: Room
/// The specific room registers associated with the ``TrunkSize``.
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
public subscript<T>(dynamicMember keyPath: KeyPath<Room, T>) -> T {
room[keyPath: keyPath]
}
}
/// Represents the type of a ``TrunkSize``, either supply or return.
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply

View File

@@ -1,12 +1,17 @@
import Dependencies
import Foundation
// FIX: Remove username.
/// Represents a user of the site.
///
public struct User: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the user.
public let id: UUID
/// The user's email address.
public let email: String
/// When the user was created in the database.
public let createdAt: Date
/// When the user was updated in the database.
public let updatedAt: Date
public init(
@@ -23,10 +28,14 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
}
extension User {
/// Represents the data required to create a new user.
public struct Create: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// The password confirmation, must match the password.
public let confirmPassword: String
public init(
@@ -40,9 +49,13 @@ extension User {
}
}
/// Represents data required to login a user.
public struct Login: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// An optional page / route to navigate to after logging in the user.
public let next: String?
public init(email: String, password: String, next: String? = nil) {
@@ -52,10 +65,13 @@ extension User {
}
}
/// Represents a user session token, for a logged in user.
public struct Token: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the token.
public let id: UUID
/// The user id the token is for.
public let userID: User.ID
/// The token value.
public let value: String
public init(id: UUID, userID: User.ID, value: String) {

View File

@@ -2,19 +2,32 @@ import Dependencies
import Foundation
extension User {
/// Represents a user's profile. Which contains extra information about a user of the site.
public struct Profile: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the profile
public let id: UUID
/// The user id the profile is for.
public let userID: User.ID
/// The user's first name.
public let firstName: String
/// The user's last name.
public let lastName: String
/// The user's company name.
public let companyName: String
/// The user's street address.
public let streetAddress: String
/// The user's city.
public let city: String
/// The user's state.
public let state: String
/// The user's zip code.
public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme?
/// When the profile was created in the database.
public let createdAt: Date
/// When the profile was updated in the database.
public let updatedAt: Date
public init(
@@ -49,15 +62,25 @@ extension User {
extension User.Profile {
/// Represents the data required to create a user profile.
public struct Create: Codable, Equatable, Sendable {
/// The user id the profile is for.
public let userID: User.ID
/// The user's first name.
public let firstName: String
/// The user's last name.
public let lastName: String
/// The user's company name.
public let companyName: String
/// The user's street address.
public let streetAddress: String
/// The user's city.
public let city: String
/// The user's state.
public let state: String
/// The user's zip code.
public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme?
public init(
@@ -83,14 +106,25 @@ extension User.Profile {
}
}
/// Represents the fields that can be updated on a user's profile.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// The user's first name.
public let firstName: String?
/// The user's last name.
public let lastName: String?
/// The user's company name.
public let companyName: String?
/// The user's street address.
public let streetAddress: String?
/// The user's city.
public let city: String?
/// The user's state.
public let state: String?
/// The user's zip code.
public let zipCode: String?
/// An optional theme that the user prefers.
public let theme: Theme?
public init(

View File

@@ -1,7 +1,7 @@
import Dependencies
import DependenciesMacros
import Elementary
import EnvClient
import EnvVars
import FileClient
import Foundation
import ManualDCore
@@ -32,8 +32,7 @@ public struct PdfClient: Sendable {
/// - Parameters:
/// - request: The project data used to generate the pdf.
public func generatePdf(request: Request) async throws -> Response {
let html = try await self.html(request)
return try await self.generatePdf(request.project.id, html)
try await self.generatePdf(request.project.id, html(request))
}
}
@@ -47,9 +46,8 @@ extension PdfClient: DependencyKey {
},
generatePdf: { projectID, html in
@Dependency(\.fileClient) var fileClient
@Dependency(\.env) var env
@Dependency(\.environment) var environment
let envVars = try env()
let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
@@ -58,10 +56,10 @@ extension PdfClient: DependencyKey {
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: envVars.pandocPath)
process.executableURL = URL(fileURLWithPath: environment.pandocPath)
process.arguments = [
"\(baseUrl).html",
"--pdf-engine=\(envVars.pdfEngine)",
"--pdf-engine=\(environment.pdfEngine)",
"--from=html",
"--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf",
@@ -76,17 +74,26 @@ extension PdfClient: DependencyKey {
}
extension PdfClient {
/// Container for the data required to generate a pdf for a given project.
/// Represents the data required to generate a pdf for a given project.
public struct Request: Codable, Equatable, Sendable {
/// The project we're generating a pdf for.
public let project: Project
/// The rooms in the project.
public let rooms: [Room]
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss]
/// The calculated duct sizes for the project.
public let ductSizes: DuctSizes
/// The equipment information for the project.
public let equipmentInfo: EquipmentInfo
public let maxSupplyTEL: EffectiveLength
public let maxReturnTEL: EffectiveLength
/// The max supply equivalent length for the project.
public let maxSupplyTEL: EquivalentLength
/// The max return equivalent length for the project.
public let maxReturnTEL: EquivalentLength
/// The calculated design friction rate for the project.
public let frictionRate: FrictionRate
/// The project wide sensible heat ratio.
public let projectSHR: Double
var totalEquivalentLength: Double {
@@ -99,8 +106,8 @@ extension PdfClient {
componentLosses: [ComponentPressureLoss],
ductSizes: DuctSizes,
equipmentInfo: EquipmentInfo,
maxSupplyTEL: EffectiveLength,
maxReturnTEL: EffectiveLength,
maxSupplyTEL: EquivalentLength,
maxReturnTEL: EquivalentLength,
frictionRate: FrictionRate,
projectSHR: Double
) {
@@ -116,9 +123,12 @@ extension PdfClient {
}
}
/// Represents the response after generating a pdf.
public struct Response: Equatable, Sendable {
/// The path to the html file used to generate the pdf from.
public let htmlPath: String
/// The path to the pdf file.
public let pdfPath: String
public init(htmlPath: String, pdfPath: String) {
@@ -134,13 +144,18 @@ extension PdfClient {
let rooms = Room.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
let equipmentInfo = EquipmentInfo.mock(projectID: project.id)
let equivalentLengths = EffectiveLength.mock(projectID: project.id)
let equivalentLengths = EquivalentLength.mock(projectID: project.id)
return .init(
project: project,
rooms: rooms,
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks),
ductSizes: .mock(
equipmentInfo: equipmentInfo,
rooms: rooms,
trunks: trunks,
shr: project.sensibleHeatRatio ?? 0.83
),
equipmentInfo: equipmentInfo,
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,

View File

@@ -2,7 +2,7 @@ import Elementary
import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
let effectiveLengths: [EquivalentLength]
var body: some HTML<HTMLTag.table> {
table {
@@ -41,7 +41,7 @@ struct EffectiveLengthsTable: HTML, Sendable {
}
struct EffectiveLengthGroupTable: HTML, Sendable {
let groups: [EffectiveLength.Group]
let groups: [EquivalentLength.FittingGroup]
var body: some HTML<HTMLTag.table> {
table {

View File

@@ -21,10 +21,9 @@ struct RoomsTable: HTML, Sendable {
tr {
td { room.name }
td { room.heatingLoad.string(digits: 0) }
td { room.coolingTotal.string(digits: 0) }
td { try! room.coolingLoad.ensured(shr: projectSHR).total.string(digits: 0) }
td {
(room.coolingSensible
?? (room.coolingTotal * projectSHR)).string(digits: 0)
try! room.coolingLoad.ensured(shr: projectSHR).sensible.string(digits: 0)
}
td { room.registerCount.string() }
}
@@ -37,10 +36,10 @@ struct RoomsTable: HTML, Sendable {
rooms.totalHeatingLoad.string(digits: 0)
}
td(.class("coolingTotal label")) {
rooms.totalCoolingLoad.string(digits: 0)
try! rooms.totalCoolingLoad(shr: projectSHR).string(digits: 0)
}
td(.class("coolingSensible label")) {
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
try! rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
}
td {}
}

View File

@@ -1,9 +0,0 @@
import Foundation
public struct ProjectClientError: Error {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -15,12 +15,15 @@ extension DependencyValues {
/// Useful helper utilities for project's.
///
/// This is primarily used for implementing logic required to get the needed data
/// for the view controller client to render views.
/// for the view controller to render views.
@DependencyClient
public struct ProjectClient: Sendable {
public var calculateDuctSizes: @Sendable (Project.ID) async throws -> DuctSizes
/// Calculates the room duct sizes for the given project.
public var calculateRoomDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer]
/// Calculates the trunk duct sizes for the given project.
public var calculateTrunkDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer]
@@ -29,6 +32,13 @@ public struct ProjectClient: Sendable {
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
public var generatePdf: @Sendable (Project.ID) async throws -> Response
public func calculateDuctSizes(_ projectID: Project.ID) async throws -> DuctSizes {
.init(
rooms: try await calculateRoomDuctSizes(projectID),
trunks: try await calculateTrunkDuctSizes(projectID)
)
}
}
extension ProjectClient: TestDependencyKey {
@@ -60,12 +70,12 @@ extension ProjectClient {
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let componentLosses: [ComponentPressureLoss]
public let equivalentLengths: EffectiveLength.MaxContainer
public let equivalentLengths: EquivalentLength.MaxContainer
public let frictionRate: FrictionRate?
public init(
componentLosses: [ComponentPressureLoss],
equivalentLengths: EffectiveLength.MaxContainer,
equivalentLengths: EquivalentLength.MaxContainer,
frictionRate: FrictionRate? = nil
) {
self.componentLosses = componentLosses

View File

@@ -1,7 +1,7 @@
import DatabaseClient
import ManualDCore
extension DatabaseClient.ComponentLoss {
extension DatabaseClient.ComponentLosses {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)

View File

@@ -9,32 +9,18 @@ extension DatabaseClient {
details: Project.Detail
) async throws -> (DuctSizes, DuctSizeSharedRequest) {
let (rooms, shared) = try await calculateRoomDuctSizes(details: details)
let (trunks, _) = try await calculateTrunkDuctSizes(details: details)
return (.init(rooms: rooms, trunks: trunks), shared)
}
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
let rooms = try await rooms.fetch(projectID)
return try await (
manualD.calculateDuctSizes(
.init(
rooms: rooms,
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
trunks: calculateTrunkDuctSizes(details: details, shared: shared)
),
shared,
rooms
shared
)
}
func calculateRoomDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
) async throws -> (rooms: [DuctSizes.RoomContainer], shared: DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
@@ -42,54 +28,29 @@ extension DatabaseClient {
return (rooms, shared)
}
func calculateRoomDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateRoomSizes(
rooms: rooms.fetch(projectID),
sharedRequest: shared
),
shared
)
}
func calculateTrunkDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
details: Project.Detail,
shared: DuctSizeSharedRequest? = nil
) async throws -> [DuctSizes.TrunkContainer] {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let trunks = try await manualD.calculateTrunkSizes(
let sharedRequest: DuctSizeSharedRequest
if let shared {
sharedRequest = shared
} else {
sharedRequest = try sharedDuctRequest(details: details)
}
return try await manualD.calculateTrunkSizes(
rooms: details.rooms,
trunks: details.trunks,
sharedRequest: shared
)
return (trunks, shared)
}
func calculateTrunkDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateTrunkSizes(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared
sharedRequest: sharedRequest
)
}
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
let projectSHR = try details.project.ensuredSHR()
guard
let dfrResponse = designFrictionRate(
componentLosses: details.componentLosses,
@@ -100,10 +61,6 @@ extension DatabaseClient {
throw ProjectClientError("Project not complete.")
}
guard let projectSHR = details.project.sensibleHeatRatio else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return .init(
@@ -115,40 +72,14 @@ extension DatabaseClient {
)
}
func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest {
guard let dfrResponse = try await designFrictionRate(projectID: projectID) else {
throw ProjectClientError("Project not complete.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return try await .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: ensuredSHR(projectID)
)
}
// Fetches the project sensible heat ratio or throws an error if it's nil.
func ensuredSHR(_ projectID: Project.ID) async throws -> Double {
guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
return projectSHR
}
// Internal container.
struct DesignFrictionRateResponse: Equatable, Sendable {
typealias EnsuredTEL = (supply: EffectiveLength, return: EffectiveLength)
typealias EnsuredTEL = (supply: EquivalentLength, return: EquivalentLength)
let designFrictionRate: Double
let equipmentInfo: EquipmentInfo
let telMaxContainer: EffectiveLength.MaxContainer
let telMaxContainer: EquivalentLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL {
@@ -167,9 +98,9 @@ extension DatabaseClient {
func designFrictionRate(
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: EffectiveLength.MaxContainer
equivalentLengths: EquivalentLength.MaxContainer
) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.total,
guard let tel = equivalentLengths.totalEquivalentLength,
componentLosses.count > 0
else { return nil }
@@ -183,18 +114,13 @@ extension DatabaseClient {
}
func designFrictionRate(
projectID: Project.ID
) async throws -> DesignFrictionRateResponse? {
}
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
extension Project {
func ensuredSHR() throws -> Double {
guard let shr = sensibleHeatRatio else {
throw ProjectClientError("Sensible heat ratio not set on project id: \(id)")
}
return try await designFrictionRate(
componentLosses: componentLoss.fetch(projectID),
equipmentInfo: equipmentInfo,
equivalentLengths: effectiveLength.fetchMax(projectID)
)
return shr
}
}

View File

@@ -0,0 +1,53 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
import PdfClient
extension DatabaseClient {
/// Generate a pdf request for the given project.
func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError.notFound(.frictionRate(projectID))
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
fileprivate init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -4,8 +4,8 @@ import ManualDCore
struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo
let maxSupplyLength: EffectiveLength
let maxReturnLenght: EffectiveLength
let maxSupplyLength: EquivalentLength
let maxReturnLenght: EquivalentLength
let designFrictionRate: Double
let projectSHR: Double
}
@@ -41,18 +41,19 @@ extension ManualDClient {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let coolingLoad = try room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
)
for n in 1...room.registerCount {
@@ -63,7 +64,8 @@ extension ManualDClient {
if let rectangularSize {
let response = try await self.rectangularSize(
.init(round: sizes.finalSize, height: rectangularSize.height)
round: sizes.finalSize,
height: rectangularSize.height
)
rectangularWidth = response.width
}
@@ -100,23 +102,25 @@ extension ManualDClient {
var retval = [DuctSizes.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let coolingLoad = try trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.rectangularSize(
.init(round: sizes.finalSize, height: height)
round: sizes.finalSize,
height: height
)
width = rectangularSize.width
}
@@ -142,7 +146,7 @@ extension ManualDClient {
extension DuctSizes.SizeContainer {
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
sizes: ManualDClient.DuctSize,
height: Int?,
width: Int?
) {
@@ -160,7 +164,7 @@ extension DuctSizes.SizeContainer {
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
sizes: ManualDClient.DuctSize,
rectangularSize: Room.RectangularSize?,
width: Int?
) {
@@ -177,18 +181,18 @@ extension DuctSizes.SizeContainer {
}
}
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
// extension Room {
//
// var heatingLoadPerRegister: Double {
//
// heatingLoad / Double(registerCount)
// }
//
// func coolingSensiblePerRegister(projectSHR: Double) -> Double {
// let sensible = coolingSensible ?? (coolingTotal * projectSHR)
// return sensible / Double(registerCount)
// }
// }
extension TrunkSize.RoomProxy {
@@ -206,8 +210,8 @@ extension TrunkSize.RoomProxy {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
@@ -217,7 +221,7 @@ extension TrunkSize {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
func totalCoolingSensible(projectSHR: Double) throws -> Double {
try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}

View File

@@ -8,7 +8,7 @@ extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.total else {
guard let totalEquivalentLength = maxContainer.totalEquivalentLength else {
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
}
@@ -19,7 +19,7 @@ extension ManualDClient {
.init(
externalStaticPressure: details.equipmentInfo.staticPressure,
componentPressureLosses: details.componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
totalEquivalentLength: Int(totalEquivalentLength)
)
)
)
@@ -28,15 +28,15 @@ extension ManualDClient {
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
@Dependency(\.database) var database
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.equivalentLengths.fetchMax(projectID)
let equipmentInfo = try await database.equipment.fetch(projectID)
guard let staticPressure = equipmentInfo?.staticPressure else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
guard let totalEquivalentLength = lengths.total else {
guard let totalEquivalentLength = lengths.totalEquivalentLength else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
@@ -46,8 +46,8 @@ extension ManualDClient {
frictionRate: frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: database.componentLoss.fetch(projectID),
totalEffectiveLength: Int(totalEquivalentLength)
componentPressureLosses: componentLosses,
totalEquivalentLength: Int(totalEquivalentLength)
)
)
)

View File

@@ -1,7 +1,7 @@
import ManualDCore
extension Project.Detail {
var maxContainer: EffectiveLength.MaxContainer {
var maxContainer: EquivalentLength.MaxContainer {
.init(
supply: equivalentLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })

View File

@@ -15,18 +15,21 @@ extension ProjectClient: DependencyKey {
@Dependency(\.fileClient) var fileClient
return .init(
calculateDuctSizes: { projectID in
try await database.calculateDuctSizes(projectID: projectID).0
},
calculateRoomDuctSizes: { projectID in
try await database.calculateRoomDuctSizes(projectID: projectID).0
guard let details = try await database.projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
return try await database.calculateRoomDuctSizes(details: details).rooms
},
calculateTrunkDuctSizes: { projectID in
try await database.calculateTrunkDuctSizes(projectID: projectID).0
guard let details = try await database.projects.detail(projectID) else {
throw ProjectClientError.notFound(.project(projectID))
}
return try await database.calculateTrunkDuctSizes(details: details)
},
createProject: { userID, request in
let project = try await database.projects.create(userID, request)
try await database.componentLoss.createDefaults(projectID: project.id)
try await database.componentLosses.createDefaults(projectID: project.id)
return try await .init(
projectID: project.id,
rooms: database.rooms.fetch(project.id),
@@ -42,13 +45,10 @@ extension ProjectClient: DependencyKey {
request: database.makePdfRequest(projectID)
)
let response = try await fileClient.streamFile(
pdfResponse.pdfPath,
{
try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath)
}
)
let response = try await fileClient.streamFile(at: pdfResponse.pdfPath) {
try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath)
}
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
@@ -61,50 +61,3 @@ extension ProjectClient: DependencyKey {
}
}
extension DatabaseClient {
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError("Project not found. id: \(projectID)")
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError("Friction rate not found. id: \(projectID)")
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import ManualDCore
public struct ProjectClientError: Error {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
static func notFound(_ notFound: NotFound) -> Self {
.init(notFound.reason)
}
enum NotFound {
case project(Project.ID)
case frictionRate(Project.ID)
var reason: String {
switch self {
case .project(let id):
return "Project not found. id: \(id)"
case .frictionRate(let id):
return """
Friction unable to be calculated. id: \(id)
This usually means that not all the required steps have been completed.
Calculating the friction rate requires the component pressure losses to be set and
have a max equivalent length for both the supply and return.
"""
}
}
}
}

View File

@@ -1,4 +1,5 @@
import Elementary
import Tagged
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
@@ -25,4 +26,5 @@ extension Badge where Inner == Number {
public init(number: Double, digits: Int = 2) {
self.inner = Number(number, digits: digits)
}
}

View File

@@ -26,32 +26,6 @@ public struct SubmitButton: HTML, Sendable {
}
}
public struct CancelButton: HTML, Sendable {
let title: String
let type: HTMLAttribute<HTMLTag.button>.ButtonType
public init(
title: String = "Cancel",
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
) {
self.title = title
self.type = type
}
public var body: some HTML<HTMLTag.button> {
button(
.class(
"""
text-white font-bold text-xl bg-red-500 hover:bg-red-600 px-4 py-2 rounded-lg shadow-lg
"""
),
.type(type)
) {
title
}
}
}
public struct EditButton: HTML, Sendable {
let title: String?
let type: HTMLAttribute<HTMLTag.button>.ButtonType

View File

@@ -9,13 +9,6 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
}
}
extension HTMLAttribute where Tag == HTMLTag.form {
public static func action(route: SiteRoute.View) -> Self {
action(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ string: String?) -> Self {
@@ -42,9 +35,6 @@ extension HTMLAttribute where Tag == HTMLTag.button {
}
extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline"))
}
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
attributes(.class("hidden"), when: shouldHide)

View File

@@ -1,45 +0,0 @@
import Elementary
// TODO: Remove, using svg's.
public struct Icon: HTML, Sendable {
let icon: String
public init(icon: String) {
self.icon = icon
}
public var body: some HTML {
i(.data("lucide", value: icon)) {}
}
}
extension Icon {
public init(_ icon: Key) {
self.init(icon: icon.icon)
}
public enum Key: String {
case circlePlus
case close
case doorClosed
case mapPin
case rulerDimensionLine
case squareFunction
case wind
var icon: String {
switch self {
case .circlePlus: return "circle-plus"
case .close: return "x"
case .doorClosed: return "door-closed"
case .mapPin: return "map-pin"
case .rulerDimensionLine: return "ruler-dimension-line"
case .squareFunction: return "square-function"
case .wind: return rawValue
}
}
}
}

View File

@@ -21,60 +21,6 @@ public struct LabeledInput: HTML, Sendable {
}
}
public struct Input: HTML, Sendable {
let id: String?
let name: String?
let placeholder: String
private var _name: String {
guard let name else {
return id ?? ""
}
return name
}
init(
id: String? = nil,
name: String? = nil,
placeholder: String
) {
self.id = id
self.name = name
self.placeholder = placeholder
}
public init(
id: String,
name: String? = nil,
placeholder: String
) {
self.id = id
self.name = name
self.placeholder = placeholder
}
public init(
name: String,
placeholder: String
) {
self.init(id: nil, name: name, placeholder: placeholder)
}
public var body: some HTML<HTMLTag.input> {
input(
.id(id ?? ""), .name(_name), .placeholder(placeholder),
.class(
"""
input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
"""
)
)
}
}
extension HTMLAttribute where Tag == HTMLTag.input {
public static func max(_ value: String) -> Self {

View File

@@ -6,15 +6,6 @@ public struct Number: HTML, Sendable {
let fractionDigits: Int
let value: Double
// private var formatter: NumberFormatter {
// let formatter = NumberFormatter()
// formatter.maximumFractionDigits = fractionDigits
// formatter.numberStyle = .decimal
// formatter.groupingSize = 3
// formatter.groupingSeparator = ","
// return formatter
// }
public init(
_ value: Double,
digits fractionDigits: Int = 2

View File

@@ -12,8 +12,8 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
}
}
var groups: [EffectiveLength.Group] {
var groups = [EffectiveLength.Group]()
var groups: [EquivalentLength.FittingGroup] {
var groups = [EquivalentLength.FittingGroup]()
for (n, group) in groupGroups.enumerated() {
groups.append(
.init(
@@ -29,7 +29,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
}
}
extension EffectiveLength.Create {
extension EquivalentLength.Create {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
@@ -45,7 +45,7 @@ extension EffectiveLength.Create {
}
}
extension EffectiveLength.Update {
extension EquivalentLength.Update {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID

View File

@@ -54,14 +54,14 @@ extension ViewController: DependencyKey {
extension ViewController.Request {
func currentUser() throws -> User {
@Dependency(\.authClient.currentUser) var currentUser
@Dependency(\.auth.currentUser) var currentUser
return try currentUser()
}
func authenticate(
_ login: User.Login
) async throws -> User {
@Dependency(\.authClient) var auth
@Dependency(\.auth) var auth
return try await auth.login(login)
}
@@ -69,7 +69,7 @@ extension ViewController.Request {
func createAndAuthenticate(
_ signup: User.Create
) async throws -> User {
@Dependency(\.authClient) var auth
@Dependency(\.auth) var auth
return try await auth.createAndLogin(signup)
}
}

View File

@@ -1,3 +1,4 @@
import CSVParser
import DatabaseClient
import Dependencies
import Elementary
@@ -70,7 +71,7 @@ extension ViewController.Request {
case .submitProfile(let profile):
return await view {
await ResultView {
_ = try await database.userProfile.create(profile)
_ = try await database.userProfiles.create(profile)
let userID = profile.userID
// let user = try currentUser()
return (
@@ -108,7 +109,7 @@ extension ViewController.Request {
get async {
@Dependency(\.database) var database
guard let user = try? currentUser() else { return nil }
return try? await database.userProfile.fetch(user.id)?.theme
return try? await database.userProfiles.fetch(user.id)?.theme
}
}
@@ -284,10 +285,18 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.csvParser) var csvParser
@Dependency(\.database) var database
switch self {
case .csv(let csv):
return await roomsView(on: request, projectID: projectID) {
let rooms = try await csvParser.parseRooms(csv)
_ = try await database.rooms.createMany(projectID, rooms)
}
// return EmptyHTML()
case .delete(let id):
return await ResultView {
try await database.rooms.delete(id)
@@ -298,7 +307,7 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
case .submit(let form):
return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.create(form)
_ = try await database.rooms.create(projectID, form)
}
case .update(let id, let form):
@@ -366,8 +375,8 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
return await request.view {
await ResultView {
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.equivalentLengths.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
@@ -407,16 +416,16 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
return EmptyHTML()
case .delete(let id):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id)
_ = try await database.componentLosses.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form)
_ = try await database.componentLosses.create(form)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form)
_ = try await database.componentLosses.update(id, form)
}
}
}
@@ -464,7 +473,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .delete(let id):
return await ResultView {
try await database.effectiveLength.delete(id)
try await database.equivalentLengths.delete(id)
}
case .index:
@@ -481,16 +490,16 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
_ = try await database.equivalentLengths.update(id, .init(form: form, projectID: projectID))
}
case .submit(let step):
switch step {
case .one(let stepOne):
return await ResultView {
var effectiveLength: EffectiveLength? = nil
var effectiveLength: EquivalentLength? = nil
if let id = stepOne.id {
effectiveLength = try await database.effectiveLength.get(id)
effectiveLength = try await database.equivalentLengths.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
@@ -503,9 +512,9 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .two(let stepTwo):
return await ResultView {
request.logger.debug("ViewController: Got step two...")
var effectiveLength: EffectiveLength? = nil
var effectiveLength: EquivalentLength? = nil
if let id = stepTwo.id {
effectiveLength = try await database.effectiveLength.get(id)
effectiveLength = try await database.equivalentLengths.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
@@ -515,7 +524,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
}
case .three(let stepThree):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create(
_ = try await database.equivalentLengths.create(
.init(form: stepThree, projectID: projectID)
)
}
@@ -536,7 +545,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID)
try await database.equivalentLengths.fetch(projectID)
)
} onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
@@ -652,11 +661,11 @@ extension SiteRoute.View.UserRoute.Profile {
return await view(on: request)
case .submit(let form):
return await view(on: request) {
_ = try await database.userProfile.create(form)
_ = try await database.userProfiles.create(form)
}
case .update(let id, let updates):
return await view(on: request) {
_ = try await database.userProfile.update(id, updates)
_ = try await database.userProfiles.update(id, updates)
}
}
}
@@ -673,7 +682,7 @@ extension SiteRoute.View.UserRoute.Profile {
let user = try request.currentUser()
return (
user,
try await database.userProfile.fetch(user.id)
try await database.userProfiles.fetch(user.id)
)
} onSuccess: { (user, profile) in
UserView(user: user, profile: profile)

View File

@@ -7,7 +7,7 @@ import Styleguide
struct EffectiveLengthForm: HTML, Sendable {
static func id(_ equivalentLength: EffectiveLength?) -> String {
static func id(_ equivalentLength: EquivalentLength?) -> String {
let base = "equivalentLengthForm"
guard let equivalentLength else { return base }
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
@@ -15,15 +15,15 @@ struct EffectiveLengthForm: HTML, Sendable {
let projectID: Project.ID
let dismiss: Bool
let type: EffectiveLength.EffectiveLengthType
let effectiveLength: EffectiveLength?
let type: EquivalentLength.EffectiveLengthType
let effectiveLength: EquivalentLength?
var id: String { Self.id(effectiveLength) }
init(
projectID: Project.ID,
dismiss: Bool,
type: EffectiveLength.EffectiveLengthType = .supply
type: EquivalentLength.EffectiveLengthType = .supply
) {
self.projectID = projectID
self.dismiss = dismiss
@@ -32,7 +32,7 @@ struct EffectiveLengthForm: HTML, Sendable {
}
init(
effectiveLength: EffectiveLength
effectiveLength: EquivalentLength
) {
self.dismiss = true
self.type = effectiveLength.type
@@ -55,7 +55,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepOne: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
@@ -97,7 +97,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepTwo: HTML, Sendable {
let projectID: Project.ID
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
@@ -152,7 +152,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepThree: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
var route: String {
@@ -254,10 +254,10 @@ struct StraightLengthField: HTML, Sendable {
struct GroupField: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType
let group: EffectiveLength.Group?
let style: EquivalentLength.EffectiveLengthType
let group: EquivalentLength.FittingGroup?
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) {
init(style: EquivalentLength.EffectiveLengthType, group: EquivalentLength.FittingGroup? = nil) {
self.style = style
self.group = group
}
@@ -307,7 +307,7 @@ struct GroupField: HTML, Sendable {
struct GroupSelect: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType
let style: EquivalentLength.EffectiveLengthType
var body: some HTML {
label(.class("select")) {
@@ -328,13 +328,13 @@ struct GroupSelect: HTML, Sendable {
struct GroupTypeSelect: HTML, Sendable {
let projectID: Project.ID
let selected: EffectiveLength.EffectiveLengthType
let selected: EquivalentLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases {
for value in EquivalentLength.EffectiveLengthType.allCases {
option(
.value("\(value.rawValue)"),
) { value.title }
@@ -345,7 +345,7 @@ struct GroupTypeSelect: HTML, Sendable {
}
}
extension EffectiveLength.EffectiveLengthType {
extension EquivalentLength.EffectiveLengthType {
var title: String { rawValue.capitalized }

View File

@@ -5,9 +5,9 @@ import Styleguide
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
let effectiveLengths: [EquivalentLength]
private var sortedLengths: [EffectiveLength] {
private var sortedLengths: [EquivalentLength] {
effectiveLengths.sorted {
$0.totalEquivalentLength > $1.totalEquivalentLength
}
@@ -55,7 +55,7 @@ struct EffectiveLengthsTable: HTML, Sendable {
struct EffectiveLenghtRow: HTML, Sendable {
let effectiveLength: EffectiveLength
let effectiveLength: EquivalentLength
private var deleteRoute: SiteRoute.View {
.project(

View File

@@ -7,14 +7,14 @@ struct EffectiveLengthsView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let effectiveLengths: [EffectiveLength]
let effectiveLengths: [EquivalentLength]
var supplies: [EffectiveLength] {
var supplies: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .supply })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}
var returns: [EffectiveLength] {
var returns: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .return })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}

View File

@@ -8,7 +8,7 @@ struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer
let equivalentLengths: EquivalentLength.MaxContainer
let frictionRate: FrictionRate?
private var availableStaticPressure: Double? {

View File

@@ -233,19 +233,19 @@ extension ManualDClient {
func frictionRate(
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
effectiveLength: EquivalentLength.MaxContainer
) async throws -> FrictionRate? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}
guard let totalEquivalentLength = effectiveLength.total else {
guard let totalEquivalentLength = effectiveLength.totalEquivalentLength else {
return nil
}
return try await self.frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
totalEquivalentLength: Int(totalEquivalentLength)
)
)
}

View File

@@ -47,8 +47,6 @@ struct RoomForm: HTML, Sendable {
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
if let id = room?.id {
input(.class("hidden"), .name("id"), .value("\(id)"))
}
@@ -73,14 +71,15 @@ struct RoomForm: HTML, Sendable {
.value(room?.heatingLoad)
)
// TODO: Add description that only one is required (cooling total or sensible)
LabeledInput(
"Cooling Total",
.name("coolingTotal"),
.type(.number),
.placeholder("1234"),
.required,
.placeholder("1234 (Optional)"),
.min("0"),
.value(room?.coolingTotal)
.value(room?.coolingLoad.total)
)
LabeledInput(
@@ -89,7 +88,7 @@ struct RoomForm: HTML, Sendable {
.type(.number),
.placeholder("1234 (Optional)"),
.min("0"),
.value(room?.coolingSensible)
.value(room?.coolingLoad.sensible)
)
LabeledInput(

View File

@@ -11,6 +11,11 @@ struct RoomsView: HTML, Sendable {
let rooms: [Room]
let sensibleHeatRatio: Double?
private var csvRoute: String {
SiteRoute.router.path(for: .view(.project(.detail(projectID, .rooms(.index)))))
.appendingPath("csv")
}
var body: some HTML {
div(.class("flex w-full flex-col")) {
PageTitleRow {
@@ -44,6 +49,18 @@ struct RoomsView: HTML, Sendable {
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
.attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil)
Tooltip("Upload csv file", position: .left) {
form(
.hx.post(csvRoute),
.hx.target("body"),
.hx.swap(.outerHTML),
.custom(name: "enctype", value: "multipart/form-data")
) {
input(.type(.file), .name("file"), .accept(".csv"))
SubmitButton()
}
}
}
div(.class("flex items-end space-x-4 font-bold")) {
@@ -54,13 +71,15 @@ struct RoomsView: HTML, Sendable {
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Total" }
Badge(number: rooms.totalCoolingLoad, digits: 0)
// TODO: ResultView ??
Badge(number: try! rooms.totalCoolingLoad(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Sensible" }
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
// TODO: ResultView ??
Badge(number: try! rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-info"))
}
}
@@ -125,10 +144,11 @@ struct RoomsView: HTML, Sendable {
let shr: Double
var coolingSensible: Double {
guard let value = room.coolingSensible else {
return room.coolingTotal * shr
}
return value
try! room.coolingLoad.ensured(shr: shr).sensible
// guard let value = room.coolingSensible else {
// return room.coolingTotal * shr
// }
// return value
}
init(room: Room, shr: Double?) {
@@ -147,7 +167,7 @@ struct RoomsView: HTML, Sendable {
}
td {
div(.class("flex justify-center")) {
Number(room.coolingTotal, digits: 0)
Number(try! room.coolingLoad.ensured(shr: shr).total, digits: 0)
// .attributes(.class("text-success"))
}
}

17
TODO.md
View File

@@ -1,9 +1,18 @@
# TODO's
- [x] Fix theme not working when selected upon signup.
- [ ] Pdf generation
- [ ] Add postgres / mysql support
- [x] Pdf generation
- [x] Add postgres / mysql support
- [ ] Opensource / license ??
- [ ] Figure out domain to host (currently thinking ductcalc.pro)
- [ ] Logo / navbar name may have to change if it's not duct-calc.
- [ ] MainPage meta items will have to change also
- [ ] Logo / navbar name may have to change if it's not duct-calc.
- [ ] MainPage meta items will have to change also
- [ ] Add ability for either sensible or total load while specifying a room load.
- CoolCalc current version specifies the sensible cooling for a room break down,
and currently we require the total load and calculate sensible based on project
shr.
- [ ] Add ability to associate room load / airflow with another room.
- [ ] Trunk size form, room / register selection is wonky when labels are long.
- They will overlap each other making it difficult to read / decipher which checkbox belongs
to which label.
- [ ] Add select all rooms for trunks, useful for sizing main supply or return trunks.

View File

@@ -1,104 +0,0 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
import URLRouting
@Suite("ProjectRouteTests")
struct ProjectRouteTests {
let router = SiteRoute.Api.router
@Test
func create() throws {
let json = """
{
\"name\": \"Test\",
\"streetAddress\": \"1234 Seasme Street\",
\"city\": \"Nowhere\",
\"state\": \"OH\",
\"zipCode\": \"55555\"
}
"""
var request = URLRequestData(
method: "POST",
path: "/api/v1/projects",
body: .init(json.utf8)
)
let route = try router.parse(&request)
#expect(
route
== .project(
.create(
.init(
name: "Test",
streetAddress: "1234 Seasme Street",
city: "Nowhere",
state: "OH",
zipCode: "55555"
)
)
)
)
}
@Test
func delete() throws {
let id = UUID(0)
var request = URLRequestData(
method: "DELETE",
path: "/api/v1/projects/\(id)"
)
let route = try router.parse(&request)
#expect(route == .project(.delete(id: id)))
}
@Test
func get() throws {
let id = UUID(0)
var request = URLRequestData(
method: "GET",
path: "/api/v1/projects/\(id)"
)
let route = try router.parse(&request)
#expect(route == .project(.get(id: id)))
}
@Test
func index() throws {
var request = URLRequestData(
method: "GET",
path: "/api/v1/projects"
)
let route = try router.parse(&request)
#expect(route == .project(.index))
}
@Test
func formData() throws {
let p = Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
}
.map(.memberwise(SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.init))
}
var request = URLRequestData(
body: .init(
"name=Test&type=supply&straightLengths=20&straightLengths=10"
.utf8
)
)
let value = try p.parse(&request)
print(value)
#expect(value.straightLengths == [20, 10])
}
}

View File

@@ -0,0 +1,22 @@
import CSVParser
import Foundation
import Testing
@Suite
struct CSVParsingTests {
@Test
func roomParsing() async throws {
let parser = CSVParser.liveValue
let input = """
Name,Heating Load,Cooling Total,Cooling Sensible,Register Count
Bed-1,12345,12345,,2
Bed-2,1223,,1123,1
"""
let rooms = try await parser.parseRooms(.init(file: Data(input.utf8)))
#expect(rooms.count == 2)
}
}

View File

@@ -0,0 +1,68 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct ComponentLossTests {
@Test
func happyPaths() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database) var database
// let project = try await database.projects.create(user.id, .mock)
let componentLoss = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
let fetched = try await database.componentLosses.fetch(project.id)
#expect(fetched == [componentLoss])
let got = try await database.componentLosses.get(componentLoss.id)
#expect(got == componentLoss)
let updated = try await database.componentLosses.update(
componentLoss.id, .init(name: "Updated", value: nil)
)
#expect(updated.id == componentLoss.id)
#expect(updated.value == componentLoss.value)
#expect(updated.name == "Updated")
try await database.componentLosses.delete(componentLoss.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.componentLosses) var componentLosses
await #expect(throws: NotFoundError.self) {
try await componentLosses.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await componentLosses.update(UUID(0), .init(name: "Updated"))
}
}
}
@Test(
arguments: [
ComponentLossModel(name: "", value: 0.2, projectID: UUID(0)),
ComponentLossModel(name: "Foo", value: -0.2, projectID: UUID(0)),
ComponentLossModel(name: "Foo", value: 1.2, projectID: UUID(0)),
ComponentLossModel(name: "", value: -0.2, projectID: UUID(0)),
]
)
func validations(model: ComponentLossModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,69 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct EquipmentTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database) var database
let equipment = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
let fetched = try await database.equipment.fetch(project.id)
#expect(fetched == equipment)
let got = try await database.equipment.get(equipment.id)
#expect(got == equipment)
let updated = try await database.equipment.update(
equipment.id, .init(heatingCFM: 900)
)
#expect(updated.heatingCFM == 900)
#expect(updated.id == equipment.id)
try await database.equipment.delete(equipment.id)
}
}
@Test
func notFound() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.equipment) var equipment
let fetched = try await equipment.fetch(project.id)
#expect(fetched == nil)
await #expect(throws: NotFoundError.self) {
try await equipment.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await equipment.update(UUID(0), .init(staticPressure: 0.3))
}
}
}
@Test(
arguments: [
EquipmentModel(staticPressure: -1, heatingCFM: 1000, coolingCFM: 1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 0.5, heatingCFM: -1, coolingCFM: 1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 0.5, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
EquipmentModel(staticPressure: 1.1, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
]
)
func validations(model: EquipmentModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,123 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct EquivalentLengthTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { user, project in
@Dependency(\.database.equivalentLengths) var equivalentLengths
let equivalentLength = try await equivalentLengths.create(
.init(
projectID: project.id,
name: "Test",
type: .supply,
straightLengths: [10],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "a", value: 30, quantity: 2),
]
)
)
let fetched = try await equivalentLengths.fetch(project.id)
#expect(fetched == [equivalentLength])
let got = try await equivalentLengths.get(equivalentLength.id)
#expect(got == equivalentLength)
var max = try await equivalentLengths.fetchMax(project.id)
#expect(max.supply == equivalentLength)
#expect(max.return == nil)
let returnLength = try await equivalentLengths.create(
.init(
projectID: project.id,
name: "Test",
type: .return,
straightLengths: [10],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "a", value: 30, quantity: 2),
]
)
)
max = try await equivalentLengths.fetchMax(project.id)
#expect(max.supply == equivalentLength)
#expect(max.return == returnLength)
let updated = try await equivalentLengths.update(
equivalentLength.id, .init(name: "Supply Test")
)
#expect(updated.name == "Supply Test")
#expect(updated.id == equivalentLength.id)
try await equivalentLengths.delete(equivalentLength.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.equivalentLengths) var equivalentLengths
await #expect(throws: NotFoundError.self) {
try await equivalentLengths.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await equivalentLengths.update(UUID(0), .init())
}
}
}
@Test(
arguments: [
EquivalentLength.Create(
projectID: UUID(0), name: "", type: .return, straightLengths: [], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [-1, 1], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, -1], groups: []
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: -1, letter: "a", value: 1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "1", value: 1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "a", value: -1.0, quantity: 1)
]
),
EquivalentLength.Create(
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
groups: [
.init(group: 1, letter: "a", value: 1.0, quantity: -1)
]
),
]
)
func validations(model: EquivalentLength.Create) {
#expect(throws: (any Error).self) {
try model.toModel().validate()
}
}
}

View File

@@ -0,0 +1,83 @@
import App
import DatabaseClient
import Dependencies
import Fluent
import FluentSQLiteDriver
import Foundation
import ManualDCore
import NIO
import Vapor
// Helper to create an in-memory database used for testing.
func withDatabase(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void
) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app, in: .live())
let database = app.db
try await app.autoMigrate()
try await withDependencies {
$0.uuid = .incrementing
$0.date = .init { Date() }
$0.database = .live(database: database)
setupDependencies(&$0)
} operation: {
try await operation()
}
try await app.autoRevert()
try await app.asyncShutdown()
} catch {
try? await app.autoRevert()
try await app.asyncShutdown()
throw error
}
}
/// Set's up the database and a test user for running tests that require a
/// a user.
func withTestUser(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: (User) async throws -> Void
) async throws {
try await withDatabase(setupDependencies: setupDependencies) {
@Dependency(\.database.users) var users
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
try await operation(user)
}
}
/// Set's up the database and a test user for running tests that require a
/// a user.
func withTestUserAndProject(
setupDependencies: (inout DependencyValues) -> Void = { _ in },
operation: (User, Project) async throws -> Void
) async throws {
try await withDatabase(setupDependencies: setupDependencies) {
@Dependency(\.database) var database
let user = try await database.users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
let project = try await database.projects.create(user.id, .mock)
try await operation(user, project)
}
}
extension Project.Create {
static let mock = Self(
name: "Testy McTestface",
streetAddress: "1234 Sesame St",
city: "Nowhere",
state: "MN",
zipCode: "55555",
sensibleHeatRatio: 0.83
)
}

View File

@@ -0,0 +1,206 @@
import Dependencies
import Fluent
import FluentSQLiteDriver
import ManualDCore
import Testing
import Vapor
@testable import DatabaseClient
@Suite
struct ProjectTests {
@Test
func projectHappyPaths() async throws {
try await withTestUser { user in
@Dependency(\.database.projects) var projects
let project = try await projects.create(user.id, .mock)
let got = try await projects.get(project.id)
#expect(got == project)
let page = try await projects.fetch(user.id, .init(page: 1, per: 25))
#expect(page.items.first! == project)
let updated = try await projects.update(project.id, .init(sensibleHeatRatio: 0.83))
#expect(updated.sensibleHeatRatio == 0.83)
#expect(updated.id == project.id)
let shr = try await projects.getSensibleHeatRatio(project.id)
#expect(shr == 0.83)
try await projects.delete(project.id)
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.projects) var projects
await #expect(throws: NotFoundError.self) {
try await projects.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await projects.update(UUID(0), .init(name: "Foo"))
}
await #expect(throws: NotFoundError.self) {
try await projects.getSensibleHeatRatio(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await projects.getCompletedSteps(UUID(0))
}
}
}
@Test
func completedSteps() async throws {
try await withTestUser { user in
@Dependency(\.database) var database
let project = try await database.projects.create(user.id, .mock)
var completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == false)
#expect(completed.equivalentLength == false)
#expect(completed.frictionRate == false)
#expect(completed.rooms == false)
_ = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == true)
_ = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.frictionRate == true)
_ = try await database.rooms.create(
project.id,
.init(name: "Test", heatingLoad: 12345, coolingTotal: 12345)
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.rooms == true)
_ = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: [])
)
completed = try await database.projects.getCompletedSteps(project.id)
// Should not be complete until we have both return and supply for a project.
#expect(completed.equivalentLength == false)
_ = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: [])
)
completed = try await database.projects.getCompletedSteps(project.id)
#expect(completed.equipmentInfo == true)
#expect(completed.equivalentLength == true)
#expect(completed.frictionRate == true)
#expect(completed.rooms == true)
}
}
@Test
func detail() async throws {
try await withTestUser { user in
@Dependency(\.database) var database
let project = try await database.projects.create(user.id, .mock)
var detail = try await database.projects.detail(project.id)
#expect(detail == nil)
let equipment = try await database.equipment.create(
.init(projectID: project.id, heatingCFM: 1000, coolingCFM: 1000)
)
detail = try await database.projects.detail(project.id)
#expect(detail != nil)
let componentLoss = try await database.componentLosses.create(
.init(projectID: project.id, name: "Test", value: 0.2)
)
let room = try await database.rooms.create(
project.id,
.init(name: "Test", heatingLoad: 12345, coolingTotal: 12345)
)
let supplyLength = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Supply", type: .supply, straightLengths: [1], groups: [])
)
let returnLength = try await database.equivalentLengths.create(
.init(
projectID: project.id, name: "Return", type: .return, straightLengths: [1], groups: [])
)
detail = try await database.projects.detail(project.id)
#expect(detail?.componentLosses == [componentLoss])
#expect(detail?.equipmentInfo == equipment)
#expect(detail?.rooms == [room])
#expect(detail?.equivalentLengths.contains(supplyLength) == true)
#expect(detail?.equivalentLengths.contains(returnLength) == true)
}
}
@Test(
arguments: [
ProjectModel(
name: "", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "", city: "Nowhere", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "", state: "OH", zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "",
zipCode: "55555",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "",
sensibleHeatRatio: nil, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "55555",
sensibleHeatRatio: -1, userID: UUID(0)
),
ProjectModel(
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
zipCode: "55555",
sensibleHeatRatio: 1.1, userID: UUID(0)
),
]
)
func validations(model: ProjectModel) {
var errors = [String]()
#expect(throws: (any Error).self) {
do {
try model.validate()
} catch {
// Just checking to make sure I'm not testing the same error over and over /
// making sure I've reset to good values / only testing one property at a time.
#expect(!errors.contains("\(error)"))
errors.append("\(error)")
throw error
}
}
}
}

View File

@@ -0,0 +1,160 @@
import Dependencies
import Foundation
import ManualDCore
import Parsing
import Testing
@testable import DatabaseClient
@Suite
struct RoomTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.rooms) var rooms
let room = try await rooms.create(
project.id,
.init(name: "Test", heatingLoad: 1234, coolingTotal: 1234)
)
let fetched = try await rooms.fetch(project.id)
#expect(fetched == [room])
let got = try await rooms.get(room.id)
#expect(got == room)
let updated = try await rooms.update(
room.id,
.init(rectangularSizes: [.init(id: UUID(0), register: 1, height: 8)])
)
#expect(updated.id == room.id)
let updatedSize = try await rooms.updateRectangularSize(
room.id, .init(id: UUID(0), register: 1, height: 10)
)
#expect(updatedSize.id == room.id)
let deletedSize = try await rooms.deleteRectangularSize(room.id, UUID(0))
#expect(deletedSize.rectangularSizes == nil)
try await rooms.delete(room.id)
}
}
@Test
func createMany() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.rooms) var rooms
let created = try await rooms.createMany(
project.id,
[
.init(name: "Test 1", heatingLoad: 1234, coolingTotal: 1234),
.init(name: "Test 2", heatingLoad: 1234, coolingTotal: 1234),
]
)
#expect(created.count == 2)
#expect(created[0].name == "Test 1")
#expect(created[1].name == "Test 2")
}
}
@Test
func notFound() async throws {
try await withDatabase {
@Dependency(\.database.rooms) var rooms
await #expect(throws: NotFoundError.self) {
try await rooms.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await rooms.deleteRectangularSize(UUID(0), UUID(1))
}
await #expect(throws: NotFoundError.self) {
try await rooms.update(UUID(0), .init())
}
await #expect(throws: NotFoundError.self) {
try await rooms.updateRectangularSize(UUID(0), .init(height: 8))
}
}
}
@Test(
arguments: [
Room.Create(
// projectID: UUID(0),
name: "",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
// projectID: UUID(0),
name: "Test",
heatingLoad: -12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
// projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: -12344,
coolingSensible: nil,
registerCount: 1
),
Room.Create(
// projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: -123,
registerCount: 1
),
Room.Create(
// projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: 12344,
coolingSensible: nil,
registerCount: -1
),
Room.Create(
// projectID: UUID(0),
name: "",
heatingLoad: -12345,
coolingTotal: -12344,
coolingSensible: -1,
registerCount: -1
),
Room.Create(
// projectID: UUID(0),
name: "Test",
heatingLoad: 12345,
coolingTotal: nil,
coolingSensible: nil,
registerCount: 1
),
]
)
func validations(room: Room.Create) throws {
#expect(throws: (any Error).self) {
// do {
try room.toModel(projectID: UUID(0)).validate()
// } catch {
// print("\(error)")
// throw error
// }
}
}
}

View File

@@ -0,0 +1,95 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
@testable import DatabaseClient
@Suite
struct TrunkSizeTests {
@Test
func happyPath() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database) var database
let room = try await database.rooms.create(
project.id,
.init(
name: "Test", heatingLoad: 12345, coolingTotal: 12345,
coolingSensible: nil, registerCount: 5
)
)
let trunk = try await database.trunkSizes.create(
.init(
projectID: project.id,
type: .supply,
rooms: [room.id: [1, 2, 3]],
height: 8,
name: "Test Trunk"
)
)
let fetched = try await database.trunkSizes.fetch(project.id)
#expect(fetched == [trunk])
let got = try await database.trunkSizes.get(trunk.id)
#expect(got == trunk)
let updated = try await database.trunkSizes.update(
trunk.id, .init(type: .return)
)
#expect(updated.type == .return)
#expect(updated.id == trunk.id)
try await database.trunkSizes.delete(trunk.id)
}
}
@Test
func notFound() async throws {
try await withTestUserAndProject { _, project in
@Dependency(\.database.trunkSizes) var trunks
await #expect(throws: NotFoundError.self) {
try await trunks.create(
.init(projectID: project.id, type: .supply, rooms: [UUID(0): [1]])
)
}
await #expect(throws: NotFoundError.self) {
try await trunks.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await trunks.update(UUID(0), .init(type: .return))
}
}
}
@Test(
arguments: [
TrunkModel(projectID: UUID(0), type: .return, height: 8, name: ""),
TrunkModel(projectID: UUID(0), type: .return, height: -8, name: "Test"),
]
)
func validations(model: TrunkModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
@Test(
arguments: [
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [-1, 1], type: .return),
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [1, -1], type: .return),
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [], type: .return),
]
)
func trunkRoomModelValidations(model: TrunkRoomModel) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -0,0 +1,190 @@
import Dependencies
import Foundation
import ManualDCore
import Testing
import Vapor
@testable import DatabaseClient
@Suite
struct UserDatabaseTests {
@Test
func happyPaths() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
#expect(user.email == "testy@example.com")
// Test login the user in
let token = try await users.login(
.init(email: user.email, password: "super-secret")
)
#expect(token.userID == user.id)
// Test the same token is returned.
let token2 = try await users.login(
.init(email: user.email, password: "super-secret")
)
#expect(token.id == token2.id)
// Test logging out
try await users.logout(token.id)
try await users.delete(user.id)
let shouldBeNilUser = try await users.get(user.id)
#expect(shouldBeNilUser == nil)
}
}
@Test
func deleteFailsWithInvalidUserID() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
await #expect(throws: NotFoundError.self) {
try await users.delete(UUID(0))
}
}
}
@Test
func logoutIgnoresUnfoundTokenID() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
try await users.logout(UUID(0))
}
}
@Test
func loginFails() async throws {
try await withDatabase {
@Dependency(\.database.users) var users
await #expect(throws: NotFoundError.self) {
try await users.login(
.init(email: "foo@example.com", password: "super-secret")
)
}
let user = try await users.create(
.init(email: "testy@example.com", password: "super-secret", confirmPassword: "super-secret")
)
// Ensure can not login with invalid password
await #expect(throws: Abort.self) {
try await users.login(
.init(email: user.email, password: "wrong-password")
)
}
}
}
@Test
func userProfileHappyPath() async throws {
try await withTestUser { user in
@Dependency(\.database.userProfiles) var profiles
let profile = try await profiles.create(
.init(
userID: user.id,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "12345 Sesame St",
city: "Nowhere",
state: "FL",
zipCode: "55555"
)
)
let fetched = try await profiles.fetch(user.id)
#expect(fetched == profile)
let got = try await profiles.get(profile.id)
#expect(got == profile)
let updated = try await profiles.update(profile.id, .init(firstName: "Updated"))
#expect(updated.firstName == "Updated")
#expect(updated.id == profile.id)
try await profiles.delete(profile.id)
}
}
@Test
func testUserProfileFails() async throws {
try await withDatabase {
@Dependency(\.database.userProfiles) var profiles
await #expect(throws: NotFoundError.self) {
try await profiles.delete(UUID(0))
}
await #expect(throws: NotFoundError.self) {
try await profiles.update(UUID(0), .init(firstName: "Foo"))
}
}
}
@Test(
arguments: [
UserProfileModel(
userID: UUID(0), firstName: "", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "", city: "Nowhere", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "", state: "CA", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "", zipCode: "55555"
),
UserProfileModel(
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: ""
),
]
)
func profileValidations(model: UserProfileModel) {
var errors = [String]()
#expect(throws: (any Error).self) {
do {
try model.validate()
} catch {
// Just checking to make sure I'm not testing the same error over and over /
// making sure I've reset to good values / only testing one property at a time.
#expect(!errors.contains("\(error)"))
errors.append("\(error)")
throw error
}
}
}
@Test(
arguments: [
User.Create(email: "", password: "super-secret", confirmPassword: "super-secret"),
User.Create(email: "testy@example.com", password: "", confirmPassword: "super-secret"),
User.Create(email: "testy@example.com", password: "super-secret", confirmPassword: ""),
User.Create(email: "testy@example.com", password: "super", confirmPassword: "super"),
]
)
func userValidations(model: User.Create) {
#expect(throws: (any Error).self) {
try model.validate()
}
}
}

View File

@@ -24,62 +24,16 @@ struct ManualDClientTests {
@Test
func ductSize() async throws {
let response = try await manualD.ductSize(
.init(designCFM: 88, frictionRate: 0.06)
)
let response = try await manualD.ductSize(88, 0.06)
#expect(numberFormatter.string(for: response.calculatedSize) == "6.07")
#expect(response.finalSize == 7)
#expect(response.flexSize == 7)
#expect(response.velocity == 329)
}
// @Test
// func frictionRate() async throws {
// let response = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 185
// )
// )
// #expect(numberFormatter.string(for: response.availableStaticPressure) == "0.11")
// #expect(numberFormatter.string(for: response.frictionRate) == "0.06")
// }
// @Test
// func frictionRateFails() async throws {
// await #expect(throws: ManualDError.self) {
// _ = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 0
// )
// )
// }
// }
@Test
func totalEffectiveLength() async throws {
let response = try await manualD.totalEquivalentLength(
.init(
trunkLengths: [25],
runoutLengths: [10],
effectiveLengthGroups: [
// NOTE: These are made up and may not correspond to actual manual-d group tel's.
EffectiveLengthGroup(group: 1, letter: "a", effectiveLength: 20, category: .supply),
EffectiveLengthGroup(group: 2, letter: "a", effectiveLength: 30, category: .supply),
EffectiveLengthGroup(group: 3, letter: "a", effectiveLength: 10, category: .supply),
EffectiveLengthGroup(group: 12, letter: "a", effectiveLength: 10, category: .supply),
]
)
)
#expect(response == 105)
}
@Test
func equivalentRectangularDuct() async throws {
let response = try await manualD.rectangularSize(.init(round: 7, height: 8))
let response = try await manualD.rectangularSize(round: 7, height: 8)
#expect(response.height == 8)
#expect(response.width == 5)
}

View File

@@ -18,7 +18,7 @@ struct ViewControllerTests {
func login() async throws {
try await withDependencies {
$0.viewController = .liveValue
$0.authClient = .failing
$0.auth = .failing
} operation: {
@Dependency(\.viewController) var viewController
@@ -31,7 +31,7 @@ struct ViewControllerTests {
func signup() async throws {
try await withDependencies {
$0.viewController = .liveValue
$0.authClient = .failing
$0.auth = .failing
} operation: {
@Dependency(\.viewController) var viewController
@@ -86,7 +86,7 @@ struct ViewControllerTests {
let project = Project.mock
let rooms = Room.mock(projectID: project.id)
let equipment = EquipmentInfo.mock(projectID: project.id)
let tels = EffectiveLength.mock(projectID: project.id)
let tels = EquivalentLength.mock(projectID: project.id)
let componentLosses = ComponentPressureLoss.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
@@ -100,6 +100,13 @@ struct ViewControllerTests {
)
}
let mockDuctSizes = DuctSizes.mock(
equipmentInfo: equipment,
rooms: rooms,
trunks: trunks,
shr: project.sensibleHeatRatio ?? 0.83
)
try await withDefaultDependencies {
$0.database.projects.get = { _ in project }
$0.database.projects.getCompletedSteps = { _ in
@@ -108,13 +115,16 @@ struct ViewControllerTests {
$0.database.projects.getSensibleHeatRatio = { _ in 0.83 }
$0.database.rooms.fetch = { _ in rooms }
$0.database.equipment.fetch = { _ in equipment }
$0.database.effectiveLength.fetch = { _ in tels }
$0.database.effectiveLength.fetchMax = { _ in
$0.database.equivalentLengths.fetch = { _ in tels }
$0.database.equivalentLengths.fetchMax = { _ in
.init(supply: tels.first, return: tels.last)
}
$0.database.componentLoss.fetch = { _ in componentLosses }
$0.projectClient.calculateDuctSizes = { _ in
.mock(equipmentInfo: equipment, rooms: rooms, trunks: trunks)
$0.database.componentLosses.fetch = { _ in componentLosses }
$0.projectClient.calculateRoomDuctSizes = { _ in
mockDuctSizes.rooms
}
$0.projectClient.calculateTrunkDuctSizes = { _ in
mockDuctSizes.trunks
}
} operation: {
@Dependency(\.viewController) var viewController
@@ -163,8 +173,8 @@ struct ViewControllerTests {
return try await withDependencies {
$0.viewController = .liveValue
$0.authClient.currentUser = { user }
$0.database.userProfile.fetch = { _ in profile }
$0.auth.currentUser = { user }
$0.database.userProfiles.fetch = { _ in profile }
$0.manualD = .liveValue
try await updateDependencies(&$0)
} operation: {

View File

@@ -63,6 +63,12 @@ p-6 w-full">
</div>
</button>
</div>
<div class="tooltip tooltip-left" data-tip="Upload csv file">
<form hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms/csv" hx-target="body" hx-swap="outerHTML" enctype="multipart/form-data">
<input type="file" name="file" accept=".csv">
<button class="btn btn-secondary" type="submit">Submit</button>
</form>
</div>
</div>
<div class="flex items-end space-x-4 font-bold">
<span class="text-lg">Heating Total</span>
@@ -151,14 +157,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000001.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000001" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000001">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Bed-1">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="3913.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2472.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2472.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -204,14 +209,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000002.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000002" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000002">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Entry">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8284.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2916.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2916.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -257,14 +261,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000003.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000003" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000003">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Family Room">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="9785.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="7446.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="7446.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -310,14 +313,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000004.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000004" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000004">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Kitchen">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="4518.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="5096.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="5096.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -363,14 +365,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000005.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000005" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000005">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Living Room">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="7553.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="6829.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="6829.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -416,14 +417,13 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000006.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000006" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000006">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Master">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8202.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2076.0">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2076.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
@@ -441,13 +441,12 @@ p-6 w-full">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
Name<label class="input w-full"><span class="label"></span>
<label class="input w-full"><span class="label">Name</span>
<input name="name" type="text" placeholder="Name" required autofocus value="">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="">
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More