50 Commits

Author SHA1 Message Date
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
2e2c424850 Merge branch 'feat-pdf' 2026-01-29 09:34:41 -05:00
93894e4c25 feat: Adds pdf client tests, updates view controller snapshots that have changed. 2026-01-29 09:33:43 -05:00
bab031f241 feat: Adds pdf support to docker images. 2026-01-28 16:41:12 -05:00
c82f20bb60 WIP: Mostly done with pdf client, need to add tests. 2026-01-28 15:47:56 -05:00
458b3bd644 feat: Adds EnvClient 2026-01-28 11:34:52 -05:00
58023c4dbc feat: Adds view snapshot tests 2026-01-28 10:26:59 -05:00
30241fec60 feat: Updates pdf css to a smaller font size. 2026-01-27 14:16:25 -05:00
273da46db2 feat: Adds file client. 2026-01-27 13:50:42 -05:00
6064b5267a WIP: Initial pdf generation and download, needs improvement and put somewhere different. 2026-01-27 12:53:55 -05:00
69e8acc5d8 WIP: Cleans up ResultView, fixes creating a new trunk size after changes to the database client, pdf button shows html now. 2026-01-27 10:12:51 -05:00
066b3003d0 WIP: Using project detail for duct size calculations, fix trunk sizes querying database for room data, it's now eagerly loaded. 2026-01-27 08:59:36 -05:00
1663c0a514 WIP: Working on a project detail database request to minimize database calls. 2026-01-26 16:43:16 -05:00
e08d896758 feat: Adds better mock support for models to aid in testing / viewing a mock project for the pdf client. 2026-01-26 13:39:27 -05:00
b3c6c27a96 feat: Updates database client migrations to be called as a function. 2026-01-26 11:13:29 -05:00
5fa11ae584 WIP: Style updates for pdf view. 2026-01-18 18:49:20 -05:00
04a7405ca4 WIP: Html view that prints to pdf ok. 2026-01-17 20:40:49 -05:00
0fe80d05c6 WIP: Begins work on pdf client. 2026-01-17 12:59:46 -05:00
3ec1ee2814 feat: Begins creating an auth client and integrates into view controller routes. 2026-01-16 17:04:05 -05:00
761ba29c1e feat: Cleans up / renames some manual-d client routes. 2026-01-16 12:10:33 -05:00
13c4bb33b5 feat: Reorganizes / creates duct sizes container, uses it in views and projectClient. 2026-01-16 11:40:21 -05:00
146baa7815 feat: Moves TrunkSize to be it's own namespace rather than being under DuctSizing, as it's got it's own database model, etc. 2026-01-16 10:48:07 -05:00
b5436c2073 feat: Moves rectangular size to room namespace instead of under duct sizing, since it's stored on the room database model. 2026-01-16 10:26:11 -05:00
59c1c9ec4a feat: Uses shared duct size container for both room and trunk duct sizing containers. 2026-01-16 10:16:31 -05:00
65fc8565b6 feat: More cleanup / renaming for project client. 2026-01-16 09:40:15 -05:00
d14477e97a feat: Cleans up / moves helpers for view controller to project client. 2026-01-16 09:31:35 -05:00
dbec7fb920 WIP: Attempt at breaking out some logic / middleware between database and view layer, to remove some code from the view controller. Not complete, maybe revert. 2026-01-15 23:02:36 -05:00
6b8cb73434 feat: Adds TODO's. 2026-01-15 19:51:26 -05:00
140 changed files with 13346 additions and 2853 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

5
.gitignore vendored
View File

@@ -9,3 +9,8 @@ DerivedData/
.swift-version .swift-version
node_modules/ node_modules/
tailwindcss tailwindcss
.envrc
*.pdf
.env
.env*
default.profraw

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2", "originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@@ -73,6 +73,15 @@
"version" : "1.53.0" "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", "identity" : "fluent-sqlite-driver",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -100,6 +109,24 @@
"version" : "0.14.0" "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", "identity" : "routing-kit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -226,6 +253,15 @@
"version" : "4.2.0" "version" : "4.2.0"
} }
}, },
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "93a8aa4937030b606de42f44b17870249f49af0b",
"version" : "1.3.4"
}
},
{ {
"identity" : "swift-dependencies", "identity" : "swift-dependencies",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -361,6 +397,15 @@
"version" : "2.9.1" "version" : "2.9.1"
} }
}, },
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{ {
"identity" : "swift-syntax", "identity" : "swift-syntax",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -379,6 +424,15 @@
"version" : "1.6.3" "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", "identity" : "swift-url-routing",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -388,6 +442,15 @@
"version" : "0.6.2" "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", "identity" : "vapor",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -6,8 +6,13 @@ let package = Package(
name: "swift-manual-d", name: "swift-manual-d",
products: [ products: [
.executable(name: "App", targets: ["App"]), .executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]), .library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvVars", targets: ["EnvVars"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]),
.library(name: "ProjectClient", targets: ["ProjectClient"]),
.library(name: "ManualDCore", targets: ["ManualDCore"]), .library(name: "ManualDCore", targets: ["ManualDCore"]),
.library(name: "ManualDClient", targets: ["ManualDClient"]), .library(name: "ManualDClient", targets: ["ManualDClient"]),
.library(name: "Styleguide", targets: ["Styleguide"]), .library(name: "Styleguide", targets: ["Styleguide"]),
@@ -17,25 +22,30 @@ let package = Package(
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"), .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.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-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/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-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/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/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/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.git", from: "0.6.0"),
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.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/vapor-community/vapor-elementary.git", from: "0.1.0"),
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.1.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
.target(name: "ApiController"), .target(name: "AuthClient"),
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "ViewController"), .target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"),
@@ -44,13 +54,12 @@ let package = Package(
] ]
), ),
.target( .target(
name: "ApiController", name: "AuthClient",
dependencies: [ dependencies: [
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "ManualDCore"), .target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
] ]
), ),
.target( .target(
@@ -61,29 +70,87 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "Vapor", package: "vapor"), .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: "EnvVars",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "FileClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "HTMLSnapshotTesting",
dependencies: [
.product(name: "Elementary", package: "elementary"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
),
.target(
name: "PdfClient",
dependencies: [
.target(name: "EnvVars"),
.target(name: "FileClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
]
),
.testTarget(
name: "PdfClientTests",
dependencies: [
.target(name: "HTMLSnapshotTesting"),
.target(name: "PdfClient"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
],
resources: [
.copy("__Snapshots__")
]
),
.target(
name: "ProjectClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "PdfClient"),
.product(name: "Vapor", package: "vapor"),
] ]
), ),
.target( .target(
name: "ManualDCore", name: "ManualDCore",
dependencies: [ dependencies: [
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "URLRouting", package: "swift-url-routing"), .product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
]
),
.testTarget(
name: "ApiRouteTests",
dependencies: [
.target(name: "ManualDCore")
] ]
), ),
.target( .target(
name: "ManualDClient", name: "ManualDClient",
dependencies: [ dependencies: [
"ManualDCore", .target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Tagged", package: "swift-tagged"),
] ]
), ),
.target( .target(
@@ -104,7 +171,10 @@ let package = Package(
.target( .target(
name: "ViewController", name: "ViewController",
dependencies: [ dependencies: [
.target(name: "AuthClient"),
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),
.target(name: "ManualDClient"), .target(name: "ManualDClient"),
.target(name: "ManualDCore"), .target(name: "ManualDCore"),
.target(name: "Styleguide"), .target(name: "Styleguide"),
@@ -115,5 +185,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"), .product(name: "Vapor", package: "vapor"),
] ]
), ),
.testTarget(
name: "ViewControllerTests",
dependencies: [
.target(name: "ViewController"),
.target(name: "HTMLSnapshotTesting"),
],
resources: [
.copy("__Snapshots__")
]
),
] ]
) )

12
Public/css/htmx.css Normal file
View File

@@ -0,0 +1,12 @@
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}

View File

@@ -2,4 +2,3 @@
@plugin "daisyui" { @plugin "daisyui" {
themes: all; themes: all;
} }

View File

@@ -7,12 +7,7 @@
'Noto Color Emoji'; 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; 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-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-200: oklch(92.8% 0.006 264.531);
--color-gray-400: oklch(70.7% 0.022 261.325); --color-gray-400: oklch(70.7% 0.022 261.325);
--color-black: #000; --color-black: #000;
@@ -30,7 +25,6 @@
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-bold: 700; --font-weight-bold: 700;
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem; --radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
@@ -6218,6 +6212,9 @@
.hidden { .hidden {
display: none; display: none;
} }
.inline {
display: inline;
}
.inline-block { .inline-block {
display: inline-block; display: inline-block;
} }
@@ -6982,9 +6979,6 @@
.rounded-lg { .rounded-lg {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
} }
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-selector { .rounded-selector {
border-radius: var(--radius-selector); border-radius: var(--radius-selector);
} }
@@ -7449,15 +7443,9 @@
.bg-error { .bg-error {
background-color: var(--color-error); background-color: var(--color-error);
} }
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-secondary { .bg-secondary {
background-color: var(--color-secondary); background-color: var(--color-secondary);
} }
.bg-white {
background-color: var(--color-white);
}
.divider-accent { .divider-accent {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
&:before, &:after { &:before, &:after {
@@ -7866,15 +7854,9 @@
} }
} }
} }
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 { .px-4 {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 { .py-2 {
padding-block: calc(var(--spacing) * 2); padding-block: calc(var(--spacing) * 2);
} }
@@ -8530,15 +8512,9 @@
.text-info { .text-info {
color: var(--color-info); color: var(--color-info);
} }
.text-slate-900 {
color: var(--color-slate-900);
}
.text-success { .text-success {
color: var(--color-success); color: var(--color-success);
} }
.text-white {
color: var(--color-white);
}
.lowercase { .lowercase {
text-transform: lowercase; text-transform: lowercase;
} }
@@ -8607,10 +8583,6 @@
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); --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); 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 { .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)); --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); 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-style: var(--tw-outline-style);
outline-width: 1px; outline-width: 1px;
} }
.outline-1 {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.-outline-offset-1 {
outline-offset: calc(1px * -1);
}
.btn-ghost { .btn-ghost {
@layer daisyui.l1 { @layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) { &: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 {
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,); 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\:bg-neutral {
&:hover { &:hover {
@media (hover: 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\:text-white {
&:hover { &:hover {
@media (hover: 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\:bg-neutral {
&[data-active] { &[data-active] {
background-color: var(--color-neutral); background-color: var(--color-neutral);

109
Public/css/pdf.css Normal file
View File

@@ -0,0 +1,109 @@
@media print {
body {
-webkit-print-color-adjust: exact;
color-adjust: exact;
print-color-adjust: exact;
}
table td, table th {
-webkit-print-color-adjust: exact;
}
}
* {
font-size: 12px;
}
h1 { font-size: 24px; }
h2 { font-size: 16px; }
table {
border-collapse: collapse;
max-width: 100%;
margin: 10px auto;
border: none !important;
border-style: none;
}
th, td {
padding: 10px;
border: none;
border-style: none;
}
.table-bordered {
border: 1px solid #ccc;
}
.table-bordered th, td {
border: 1px solid #ccc;
}
.table-bordered tr:nth-child(even) {
background-color: #f2f2f2;
}
.w-full {
width: 100%;
}
.w-half {
width: 50%;
}
.table-footer {
background-color: #75af4c;
color: white;
font-weight: bold;
}
.bg-green {
background-color: #4CAF50;
color: white;
}
.heating {
color: red;
}
.coolingTotal {
color: blue;
}
.coolingSensible {
color: cyan;
}
.justify-end {
text-align: end;
}
.flex {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
}
.flex table {
width: 50%;
margin: 0;
flex: 1 1 calc(50% - 10px);
}
.container {
display: flex;
width: 100%;
gap: 10px;
}
.table-container {
flex: 1;
min-width: 0;
}
.table-container table {
width: 100%;
border-collapse: collapse;
}
.customerTable {
width: 50%;
}
.section {
padding: 10px;
}
.label {
font-weight: bold;
}
.error {
color: red;
font-weight: bold;
}
.effectiveLengthGroupTable, .effectiveLengthGroupHeader {
background-color: white;
color: black;
font-weight: bold;
}
.headline {
padding: 10px 0;
}

View File

@@ -0,0 +1,63 @@
// Copied from: https://github.com/dakixr/htmx-download/blob/main/htmx-download.js
htmx.defineExtension('htmx-download', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
// Set the responseType to 'arraybuffer' to handle binary data
evt.detail.xhr.responseType = 'arraybuffer';
}
if (name === 'htmx:beforeSwap') {
const xhr = evt.detail.xhr;
if (xhr.status === 200) {
// Parse headers
const headers = {};
const headerStr = xhr.getAllResponseHeaders();
const headerArr = headerStr.trim().split(/[\r\n]+/);
headerArr.forEach((line) => {
const parts = line.split(": ");
const header = parts.shift().toLowerCase();
const value = parts.join(": ");
headers[header] = value;
});
// Extract filename
let filename = 'downloaded_file.xlsx';
if (headers['content-disposition']) {
const filenameMatch = headers['content-disposition'].match(/filename\*?=(?:UTF-8'')?"?([^;\n"]+)/i);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
}
}
// Determine MIME type
const mimetype = headers['content-type'] || 'application/octet-stream';
// Create Blob
const blob = new Blob([xhr.response], { type: mimetype });
const url = URL.createObjectURL(blob);
// Trigger download
const link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
setTimeout(() => {
URL.revokeObjectURL(url);
link.remove();
}, 100);
} else {
console.warn(`[htmx-download] Unexpected response status: ${xhr.status}`);
}
// Prevent htmx from swapping content
evt.detail.shouldSwap = false;
}
},
});

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

View File

@@ -12,11 +12,7 @@ extension ViewController {
.init( .init(
route: route, route: route,
isHtmxRequest: request.isHtmxRequest, isHtmxRequest: request.isHtmxRequest,
logger: request.logger, logger: request.logger
authenticateUser: { request.session.authenticate($0) },
currentUser: {
try request.auth.require(User.self)
}
) )
) )
return AnyHTMLResponse(value: html) return AnyHTMLResponse(value: html)

View File

@@ -1,37 +1,47 @@
import ApiController // import ApiController
import AuthClient
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import EnvVars
import ManualDCore
import PdfClient
import Vapor import Vapor
import ViewController import ViewController
// Taken from discussions page on `swift-dependencies`. // Taken from discussions page on `swift-dependencies`.
// FIX: Use live view controller.
struct DependenciesMiddleware: AsyncMiddleware { struct DependenciesMiddleware: AsyncMiddleware {
private let values: DependencyValues.Continuation private let values: DependencyValues.Continuation
private let apiController: ApiController // private let apiController: ApiController
private let database: DatabaseClient private let database: DatabaseClient
private let environment: EnvVars
private let viewController: ViewController private let viewController: ViewController
init( init(
database: DatabaseClient, database: DatabaseClient,
apiController: ApiController = .liveValue, environment: EnvVars,
// apiController: ApiController = .liveValue,
viewController: ViewController = .liveValue viewController: ViewController = .liveValue
) { ) {
self.values = withEscapedDependencies { $0 } self.values = withEscapedDependencies { $0 }
self.apiController = apiController // self.apiController = apiController
self.database = database self.database = database
self.environment = environment
self.viewController = viewController self.viewController = viewController
} }
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
try await values.yield { try await values.yield {
try await withDependencies { try await withDependencies {
$0.apiController = apiController // $0.apiController = apiController
$0.auth = .live(on: request)
$0.database = database $0.database = database
$0.environment = environment
// $0.dateFormatter = .liveValue // $0.dateFormatter = .liveValue
$0.viewController = viewController $0.viewController = viewController
$0.pdfClient = .liveValue
$0.fileClient = .live(fileIO: request.fileio)
} operation: { } operation: {
try await next.respond(to: request) try await next.respond(to: request)
} }

View File

@@ -14,10 +14,10 @@ private let viewRouteMiddleware: [any Middleware] = [
extension SiteRoute.View { extension SiteRoute.View {
var middleware: [any Middleware]? { var middleware: [any Middleware]? {
switch self { switch self {
case .project, .user:
return viewRouteMiddleware
case .login, .signup, .test: case .login, .signup, .test:
return nil return nil
case .project, .user:
return viewRouteMiddleware
} }
} }
} }

View File

@@ -1,10 +1,13 @@
import DatabaseClient import DatabaseClient
import Dependencies import Dependencies
import Elementary import Elementary
import EnvVars
import Fluent import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver import FluentSQLiteDriver
import ManualDCore import ManualDCore
import NIOSSL import NIOSSL
import ProjectClient
import Vapor import Vapor
import VaporElementary import VaporElementary
@preconcurrency import VaporRouting @preconcurrency import VaporRouting
@@ -13,12 +16,15 @@ import ViewController
// configures your application // configures your application
public func configure( public func configure(
_ app: Application, _ app: Application,
in environment: EnvVars,
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) } makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
) async throws { ) async throws {
// Setup the database client. // 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. // Add the global middlewares.
addMiddleware(to: app, database: databaseClient) addMiddleware(to: app, database: databaseClient, environment: environment)
#if DEBUG #if DEBUG
// Live reload of the application for development when launched with the `./swift-dev` command // Live reload of the application for development when launched with the `./swift-dev` command
// app.lifecycle.use(BrowserSyncHandler()) // app.lifecycle.use(BrowserSyncHandler())
@@ -32,7 +38,11 @@ public func configure(
addCommands(to: app) 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` // cors middleware should come before default error middleware using `at: .beginning`
let corsConfiguration = CORSMiddleware.Configuration( let corsConfiguration = CORSMiddleware.Configuration(
allowedOrigin: .all, allowedOrigin: .all,
@@ -47,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(app.sessions.middleware) app.middleware.use(app.sessions.middleware)
app.middleware.use(DependenciesMiddleware(database: databaseClient)) app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
} }
private func setupDatabase( private func setupDatabase(
on app: Application, on app: Application,
environment: EnvVars,
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
) async throws -> DatabaseClient { ) async throws -> DatabaseClient {
switch app.environment { switch app.environment {
case .production, .development: case .production:
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite" 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) app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
default: default:
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite) app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
@@ -64,9 +78,7 @@ private func setupDatabase(
let databaseClient = makeDatabaseClient(app.db) let databaseClient = makeDatabaseClient(app.db)
if app.environment != .testing { try await app.migrations.add(databaseClient.migrations())
try await app.migrations.add(databaseClient.migrations.run())
}
return databaseClient return databaseClient
} }
@@ -101,8 +113,6 @@ extension SiteRoute {
fileprivate func middleware() -> [any Middleware]? { fileprivate func middleware() -> [any Middleware]? {
switch self { switch self {
case .api:
return nil
case .health: case .health:
return nil return nil
case .view(let route): case .view(let route):
@@ -111,20 +121,51 @@ extension SiteRoute {
} }
} }
extension DuctSizes: Content {}
@Sendable @Sendable
private func siteHandler( private func siteHandler(
request: Request, request: Request,
route: SiteRoute route: SiteRoute
) async throws -> any AsyncResponseEncodable { ) async throws -> any AsyncResponseEncodable {
@Dependency(\.apiController) var apiController
@Dependency(\.viewController) var viewController @Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient
switch route { switch route {
case .api(let route):
return try await apiController.respond(route, request: request)
case .health: case .health:
return HTTPStatus.ok return HTTPStatus.ok
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
// need to handle it seperately.
case .view(.project(.detail(let projectID, .pdf))):
return try await projectClient.generatePdf(projectID)
case .view(let route): case .view(let route):
return try await viewController.respond(route: route, request: request) 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 DatabaseClient
import Dependencies import Dependencies
import EnvVars
import Logging import Logging
import NIOCore import NIOCore
import NIOPosix 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. // 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. // 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. // If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() let executorTakeoverSuccess =
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)]) NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug(
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
)
do { do {
try await configure(app) try await configure(app, in: EnvVars.live())
} catch { } catch {
app.logger.report(error: error) app.logger.report(error: error)
try? await app.asyncShutdown() try? await app.asyncShutdown()

View File

@@ -0,0 +1,57 @@
import DatabaseClient
import Dependencies
import DependenciesMacros
import ManualDCore
import Vapor
extension DependencyValues {
/// 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
}
extension AuthClient: TestDependencyKey {
public static let testValue = Self()
public static func live(on request: Request) -> Self {
@Dependency(\.database) var database
return .init(
createAndLogin: { createForm in
let user = try await database.users.create(createForm)
_ = try await database.users.login(
.init(email: createForm.email, password: createForm.password)
)
request.auth.login(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
currentUser: {
try request.auth.require(User.self)
},
login: { loginForm in
let token = try await database.users.login(loginForm)
let user = try await database.users.get(token.userID)!
request.session.authenticate(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
logout: { request.auth.logout(User.self) }
)
}
}

View File

@@ -4,23 +4,132 @@ import FluentKit
import ManualDCore import ManualDCore
extension DependencyValues { extension DependencyValues {
/// The database dependency.
public var database: DatabaseClient { public var database: DatabaseClient {
get { self[DatabaseClient.self] } get { self[DatabaseClient.self] }
set { self[DatabaseClient.self] = newValue } set { self[DatabaseClient.self] = newValue }
} }
} }
/// Represents the database interactions used by the application.
@DependencyClient @DependencyClient
public struct DatabaseClient: Sendable { public struct DatabaseClient: Sendable {
/// Database migrations.
public var migrations: Migrations public var migrations: Migrations
/// Interactions with the projects table.
public var projects: Projects public var projects: Projects
/// Interactions with the rooms table.
public var rooms: Rooms public var rooms: Rooms
/// Interactions with the equipment table.
public var equipment: Equipment public var equipment: Equipment
public var componentLoss: ComponentLoss /// Interactions with the component losses table.
public var effectiveLength: EffectiveLengthClient 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 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 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 (Room.Create) async throws -> Room
public var createMany: @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
}
@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 { extension DatabaseClient: TestDependencyKey {
@@ -29,10 +138,10 @@ extension DatabaseClient: TestDependencyKey {
projects: .testValue, projects: .testValue,
rooms: .testValue, rooms: .testValue,
equipment: .testValue, equipment: .testValue,
componentLoss: .testValue, componentLosses: .testValue,
effectiveLength: .testValue, equivalentLengths: .testValue,
users: .testValue, users: .testValue,
userProfile: .testValue, userProfiles: .testValue,
trunkSizes: .testValue trunkSizes: .testValue
) )
@@ -42,40 +151,11 @@ extension DatabaseClient: TestDependencyKey {
projects: .live(database: database), projects: .live(database: database),
rooms: .live(database: database), rooms: .live(database: database),
equipment: .live(database: database), equipment: .live(database: database),
componentLoss: .live(database: database), componentLosses: .live(database: database),
effectiveLength: .live(database: database), equivalentLengths: .live(database: database),
users: .live(database: database), users: .live(database: database),
userProfile: .live(database: database), userProfiles: .live(database: database),
trunkSizes: .live(database: database) trunkSizes: .live(database: database)
) )
} }
} }
extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
}
}
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(),
DuctSizing.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 Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import SQLKit
import Validations
extension DatabaseClient { extension DatabaseClient.ComponentLosses: TestDependencyKey {
@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 {
public static let testValue = Self() public static let testValue = Self()
} }
extension DatabaseClient.ComponentLoss { extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -48,13 +36,13 @@ extension DatabaseClient.ComponentLoss {
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() } try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
}, },
update: { id, updates in update: { id, updates in
try updates.validate() // try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else { guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -64,40 +52,9 @@ extension DatabaseClient.ComponentLoss {
extension ComponentPressureLoss.Create { extension ComponentPressureLoss.Create {
func toModel() throws(ValidationError) -> ComponentLossModel { func toModel() -> ComponentLossModel {
try validate()
return .init(name: name, value: value, projectID: projectID) 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 { 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 Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
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
}
}
extension DatabaseClient.Equipment: TestDependencyKey { extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
}
extension DatabaseClient.Equipment {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -51,10 +38,9 @@ extension DatabaseClient.Equipment {
guard let model = try await EquipmentModel.find(id, on: database) else { guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -64,8 +50,7 @@ extension DatabaseClient.Equipment {
extension EquipmentInfo.Create { extension EquipmentInfo.Create {
func toModel() throws(ValidationError) -> EquipmentModel { func toModel() -> EquipmentModel {
try validate()
return .init( return .init(
staticPressure: staticPressure, staticPressure: staticPressure,
heatingCFM: heatingCFM, 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 { 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 Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient { extension DatabaseClient.EquivalentLengths: TestDependencyKey {
@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 {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -66,7 +54,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
} }
try model.applyUpdates(updates) try model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -74,10 +62,12 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
} }
} }
extension EffectiveLength.Create { extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel { func toModel() throws -> EffectiveLengthModel {
try validate() if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
return try .init( return try .init(
name: name, name: name,
type: type.rawValue, type: type.rawValue,
@@ -86,15 +76,9 @@ extension EffectiveLength.Create {
projectID: projectID 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 { struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength" let name = "CreateEffectiveLength"
@@ -173,20 +157,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
$project.id = projectID $project.id = projectID
} }
func toDTO() throws -> EffectiveLength { func toDTO() throws -> EquivalentLength {
try .init( try .init(
id: requireID(), id: requireID(),
projectID: $project.id, projectID: $project.id,
name: name, name: name,
type: .init(rawValue: type)!, type: .init(rawValue: type)!,
straightLengths: straightLengths, straightLengths: straightLengths,
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups), groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!, createdAt: createdAt!,
updatedAt: updatedAt! updatedAt: updatedAt!
) )
} }
func applyUpdates(_ updates: EffectiveLength.Update) throws { func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name { if let name = updates.name, name != self.name {
self.name = name self.name = name
} }
@@ -197,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
self.straightLengths = straightLengths self.straightLengths = straightLengths
} }
if let groups = updates.groups { if let groups = updates.groups {
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
self.groups = try JSONEncoder().encode(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,19 +3,7 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
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 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
}
}
extension DatabaseClient.Projects: TestDependencyKey { extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -23,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { userID, request in create: { userID, request in
let model = try request.toModel(userID: userID) let model = request.toModel(userID: userID)
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -33,47 +21,51 @@ extension DatabaseClient.Projects: TestDependencyKey {
} }
try await model.delete(on: database) try await model.delete(on: database)
}, },
detail: { id in
let model = try await ProjectModel.fetchDetail(for: id, on: database)
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() } try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
}, },
getCompletedSteps: { id in getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database) let model = try await ProjectModel.fetchDetail(for: id, on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
var equivalentLengthsCompleted = false var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil, if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil model.equivalentLengths.filter({ $0.type == "return" }).first != nil
{ {
equivalentLengthsCompleted = true 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( return .init(
equipmentInfo: equipmentInfo != nil, equipmentInfo: model.equipment != nil,
rooms: roomsCount > 0, rooms: model.rooms.count > 0,
equivalentLength: equivalentLengthsCompleted, equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0 frictionRate: model.componentLosses.count > 0
) )
}, },
getSensibleHeatRatio: { id in getSensibleHeatRatio: { id in
guard let model = try await ProjectModel.find(id, on: database) else { guard
let model = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError() throw NotFoundError()
} }
return model.sensibleHeatRatio return model.sensibleHeatRatio
@@ -90,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
guard let model = try await ProjectModel.find(id, on: database) else { guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -103,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
extension Project.Create { extension Project.Create {
func toModel(userID: User.ID) throws -> ProjectModel { func toModel(userID: User.ID) -> ProjectModel {
try validate()
return .init( return .init(
name: name, name: name,
streetAddress: streetAddress, streetAddress: streetAddress,
@@ -115,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 { extension Project {
@@ -196,7 +122,7 @@ extension Project {
.field("sensibleHeatRatio", .double) .field("sensibleHeatRatio", .double)
.field("createdAt", .datetime) .field("createdAt", .datetime)
.field("updatedAt", .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") .unique(on: "userID", "name")
.create() .create()
} }
@@ -242,6 +168,18 @@ final class ProjectModel: Model, @unchecked Sendable {
@Children(for: \.$project) @Children(for: \.$project)
var componentLosses: [ComponentLossModel] var componentLosses: [ComponentLossModel]
@OptionalChild(for: \.$project)
var equipment: EquipmentModel?
@Children(for: \.$project)
var equivalentLengths: [EffectiveLengthModel]
@Children(for: \.$project)
var rooms: [RoomModel]
@Children(for: \.$project)
var trunks: [TrunkModel]
@Parent(key: "userID") @Parent(key: "userID")
var user: UserModel var user: UserModel
@@ -307,4 +245,67 @@ final class ProjectModel: Model, @unchecked Sendable {
self.sensibleHeatRatio = sensibleHeatRatio 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,21 +3,7 @@ import DependenciesMacros
import Fluent import Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
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, DuctSizing.RectangularDuct.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, DuctSizing.RectangularDuct) async throws -> Room
}
}
extension DatabaseClient.Rooms: TestDependencyKey { extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -26,9 +12,16 @@ extension DatabaseClient.Rooms: TestDependencyKey {
.init( .init(
create: { request in create: { request in
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
createMany: { rooms in
try await rooms.asyncMap { request in
let model = try request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
}
},
delete: { id in delete: { id in
guard let model = try await RoomModel.find(id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
@@ -42,8 +35,11 @@ extension DatabaseClient.Rooms: TestDependencyKey {
model.rectangularSizes?.removeAll { model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID $0.id == rectangularDuctID
} }
if model.rectangularSizes?.count == 0 {
model.rectangularSizes = nil
}
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
}, },
@@ -62,11 +58,9 @@ extension DatabaseClient.Rooms: TestDependencyKey {
guard let model = try await RoomModel.find(id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
}, },
@@ -89,8 +83,7 @@ extension DatabaseClient.Rooms: TestDependencyKey {
extension Room.Create { extension Room.Create {
func toModel() throws(ValidationError) -> RoomModel { func toModel() throws -> RoomModel {
try validate()
return .init( return .init(
name: name, name: name,
heatingLoad: heatingLoad, heatingLoad: heatingLoad,
@@ -100,57 +93,6 @@ extension Room.Create {
projectID: projectID 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 { extension Room {
@@ -181,7 +123,7 @@ extension Room {
} }
} }
final class RoomModel: Model, @unchecked Sendable { final class RoomModel: Model, @unchecked Sendable, Validatable {
static let schema = "room" static let schema = "room"
@@ -204,7 +146,7 @@ final class RoomModel: Model, @unchecked Sendable {
var registerCount: Int var registerCount: Int
@Field(key: "rectangularSizes") @Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]? var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601) @Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date? var createdAt: Date?
@@ -224,7 +166,7 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: Double, coolingTotal: Double,
coolingSensible: Double? = nil, coolingSensible: Double? = nil,
registerCount: Int, registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil, rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil, createdAt: Date? = nil,
updatedAt: Date? = nil, updatedAt: Date? = nil,
projectID: Project.ID projectID: Project.ID
@@ -279,4 +221,38 @@ 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(\.coolingTotal, with: .greaterThanOrEquals(0))
.errorLabel("Cooling Total", inline: true)
Validator.validate(\.coolingSensible, with: Double.greaterThanOrEquals(0).optional())
.errorLabel("Cooling Sensible", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals(1))
.errorLabel("Register Count", inline: true)
Validator.validate(\.rectangularSizes)
}
}
}
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 Fluent
import Foundation import Foundation
import ManualDCore import ManualDCore
import Validations
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
public var update:
@Sendable (DuctSizing.TrunkSize.ID, DuctSizing.TrunkSize.Update) async throws ->
DuctSizing.TrunkSize
}
}
extension DatabaseClient.TrunkSizes: TestDependencyKey { extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
@@ -23,12 +11,12 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
try request.validate() // try request.validate()
let trunk = request.toModel() let trunk = request.toModel()
var roomProxies = [DuctSizing.TrunkSize.RoomProxy]() var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database) try await trunk.validateAndSave(on: database)
for (roomID, registers) in request.rooms { for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else { guard let room = try await RoomModel.find(roomID, on: database) else {
@@ -40,15 +28,19 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
registers: registers, registers: registers,
type: request.type type: request.type
) )
try await model.save(on: database) try await model.validateAndSave(on: database)
try await roomProxies.append(model.toDTO(on: database)) roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
} }
return try .init( return try .init(
id: trunk.requireID(), id: trunk.requireID(),
projectID: trunk.$project.id, projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!, type: .init(rawValue: trunk.type)!,
rooms: roomProxies rooms: roomProxies,
height: trunk.height,
name: trunk.name
) )
}, },
delete: { id in delete: { id in
@@ -60,48 +52,44 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
fetch: { projectID in fetch: { projectID in
try await TrunkModel.query(on: database) try await TrunkModel.query(on: database)
.with(\.$project) .with(\.$project)
.with(\.$rooms) .with(\.$rooms, { $0.with(\.$room) })
.filter(\.$project.$id == projectID) .filter(\.$project.$id == projectID)
.all() .all()
.toDTO(on: database) .toDTO()
}, },
get: { id in get: { id in
guard let model = try await TrunkModel.find(id, on: database) else { guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
return nil return nil
} }
return try await model.toDTO(on: database) return try model.toDTO()
}, },
update: { id, updates in update: { id, updates in
guard guard
let model = let model =
try await TrunkModel try await TrunkModel
.query(on: database) .query(on: database)
.with(\.$rooms) .with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id) .filter(\.$id == id)
.first() .first()
else { else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate() // try updates.validate()
try await model.applyUpdates(updates, on: database) try await model.applyUpdates(updates, on: database)
return try await model.toDTO(on: database) return try model.toDTO()
} }
) )
} }
} }
extension DuctSizing.TrunkSize.Create { 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 { func toModel() -> TrunkModel {
.init( .init(
@@ -113,22 +101,7 @@ extension DuctSizing.TrunkSize.Create {
} }
} }
extension DuctSizing.TrunkSize.Update { extension TrunkSize {
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 DuctSizing.TrunkSize {
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
let name = "CreateTrunkSize" let name = "CreateTrunkSize"
@@ -192,7 +165,7 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
trunkID: TrunkModel.IDValue, trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue, roomID: RoomModel.IDValue,
registers: [Int], registers: [Int],
type: DuctSizing.TrunkSize.TrunkType type: TrunkSize.TrunkType
) { ) {
self.id = id self.id = id
$trunk.id = trunkID $trunk.id = trunkID
@@ -201,16 +174,23 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
self.type = type.rawValue self.type = type.rawValue
} }
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy { func toDTO() throws -> TrunkSize.RoomProxy {
guard let room = try await RoomModel.find($room.id, on: database) else {
throw NotFoundError()
}
return .init( return .init(
room: try room.toDTO(), room: try room.toDTO(),
registers: registers 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 { final class TrunkModel: Model, @unchecked Sendable {
@@ -240,7 +220,7 @@ final class TrunkModel: Model, @unchecked Sendable {
init( init(
id: UUID? = nil, id: UUID? = nil,
projectID: Project.ID, projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType, type: TrunkSize.TrunkType,
height: Int? = nil, height: Int? = nil,
name: String? = nil name: String? = nil
) { ) {
@@ -251,18 +231,9 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name self.name = name
} }
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize { func toDTO() throws -> TrunkSize {
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
for room in self.rooms { $0.append(try $1.toDTO())
group.addTask {
try await room.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
$0.append($1)
}
} }
return try .init( return try .init(
@@ -277,7 +248,7 @@ final class TrunkModel: Model, @unchecked Sendable {
} }
func applyUpdates( func applyUpdates(
_ updates: DuctSizing.TrunkSize.Update, _ updates: TrunkSize.Update,
on database: any Database on database: any Database
) async throws { ) async throws {
if let type = updates.type, type.rawValue != self.type { if let type = updates.type, type.rawValue != self.type {
@@ -290,7 +261,7 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name self.name = name
} }
if hasChanges { if hasChanges {
try await self.save(on: database) try await self.validateAndSave(on: database)
} }
guard let updateRooms = updates.rooms else { guard let updateRooms = updates.rooms else {
@@ -311,7 +282,7 @@ final class TrunkModel: Model, @unchecked Sendable {
currRoom.registers = registers currRoom.registers = registers
} }
if currRoom.hasChanges { if currRoom.hasChanges {
try await currRoom.save(on: database) try await currRoom.validateAndSave(on: database)
} }
} else { } else {
database.logger.debug("CREATING NEW TrunkRoomModel") database.logger.debug("CREATING NEW TrunkRoomModel")
@@ -338,19 +309,27 @@ final class TrunkModel: Model, @unchecked Sendable {
} }
} }
extension Array where Element == TrunkModel { extension TrunkModel: Validatable {
func toDTO(on database: any Database) async throws -> [DuctSizing.TrunkSize] { var body: some Validation<TrunkModel> {
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in Validator.accumulating {
for model in self {
group.addTask { Validator.validate(\.height, with: Int.greaterThan(0).optional())
try await model.toDTO(on: database) .errorLabel("Height", inline: true)
}
} Validator.validate(\.name, with: String.notEmpty().optional())
.errorLabel("Name", inline: true)
return try await group.reduce(into: [DuctSizing.TrunkSize]()) { }
$0.append($1) }
} }
extension Array where Element == TrunkModel {
func toDTO() throws -> [TrunkSize] {
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 Dependencies
import DependenciesMacros import DependenciesMacros
import Fluent import Fluent
import Foundation
import ManualDCore import ManualDCore
import Vapor import Validations
extension DatabaseClient { extension DatabaseClient.UserProfiles: TestDependencyKey {
@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 {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { profile in create: { profile in
try profile.validate()
let model = profile.toModel() let model = profile.toModel()
try await model.save(on: database) try await model.validateAndSave(on: database)
return try model.toDTO() return try model.toDTO()
}, },
delete: { id in delete: { id in
@@ -48,10 +37,9 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
guard let model = try await UserProfileModel.find(id, on: database) else { guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate()
model.applyUpdates(updates) model.applyUpdates(updates)
if model.hasChanges { if model.hasChanges {
try await model.save(on: database) try await model.validateAndSave(on: database)
} }
return try model.toDTO() return try model.toDTO()
} }
@@ -61,30 +49,6 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
extension User.Profile.Create { 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 { func toModel() -> UserProfileModel {
.init( .init(
userID: userID, 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 { extension User.Profile {
struct Migrate: AsyncMigration { 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 Dependencies
import DependenciesMacros import DependenciesMacros
import Fluent import Fluent
import Foundation
import ManualDCore import ManualDCore
import Vapor 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 { extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { request in
try request.validate()
let model = try request.toModel() let model = try request.toModel()
try await model.save(on: database) try await model.save(on: database)
return try model.toDTO() return try model.toDTO()
@@ -46,6 +35,11 @@ extension DatabaseClient.Users: TestDependencyKey {
throw NotFoundError() 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 let token: User.Token
// Check if there's a user token // Check if there's a user token
@@ -126,21 +120,8 @@ extension User {
extension User.Create { extension User.Create {
func toModel() throws -> UserModel { func toModel() throws -> UserModel {
try validate()
return try .init(email: email, passwordHash: User.hashPassword(password)) 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 { 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

@@ -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

@@ -0,0 +1,59 @@
import Dependencies
import DependenciesMacros
import Foundation
import Vapor
extension DependencyValues {
/// Dependency used for file operations.
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
@DependencyClient
public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void
/// 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 {
public static let testValue = Self()
public static func live(fileIO: FileIO) -> Self {
.init(
writeFile: { contents, path in
try await fileIO.writeFile(ByteBuffer(string: contents), at: path)
},
removeFile: { path in
try FileManager.default.removeItem(atPath: path)
},
streamFile: { path, onComplete in
try await fileIO.asyncStreamFile(at: path) { _ in
try await onComplete()
}
}
)
}
}

View File

@@ -0,0 +1,22 @@
import Elementary
import SnapshotTesting
extension Snapshotting where Value == (any HTML), Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { (html: any HTML) in html.renderFormatted() }
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

@@ -14,7 +14,7 @@ extension Room {
} }
} }
extension DuctSizing.TrunkSize.RoomProxy { extension TrunkSize.RoomProxy {
// We need to make sure if registers got removed after a trunk // We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that // was already made / saved that we do not include registers that
@@ -35,7 +35,7 @@ extension DuctSizing.TrunkSize.RoomProxy {
} }
} }
extension DuctSizing.TrunkSize { extension TrunkSize {
var totalHeatingLoad: Double { var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad } rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
@@ -46,10 +46,6 @@ extension DuctSizing.TrunkSize {
} }
} }
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
extension Array where Element == EffectiveLengthGroup { extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int { var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength } reduce(0) { $0 + $1.effectiveLength }
@@ -101,16 +97,16 @@ func roundSize(_ size: Double) throws -> Int {
} }
} }
func velocity(cfm: Int, roundSize: Int) -> Int { func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
let cfm = Double(cfm) let cfm = Double(cfm.rawValue)
let roundSize = Double(roundSize) let roundSize = Double(roundSize)
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14) let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
return Int(round(velocity)) return Int(round(velocity))
} }
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int { func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
let cfm = pow(Double(request.designCFM), 0.4) let cfm = pow(Double(cfm.rawValue), 0.4)
let fr = pow(request.frictionRate / 1.76, 0.2) let fr = pow(frictionRate / 1.76, 0.2)
let size = 0.55 * (cfm / fr) let size = 0.55 * (cfm / fr)
return try roundSize(size) return try roundSize(size)
} }

View File

@@ -0,0 +1,141 @@
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 }
}
}
/// Performs manual-d duct sizing calculations.
///
///
@DependencyClient
public struct ManualDClient: Sendable {
/// 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 {
public static let testValue = Self()
}
extension ManualDClient {
/// 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 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
public let flexSize: Int
public let velocity: Int
public init(
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.calculatedSize = calculatedSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEquivalentLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEquivalentLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEquivalentLength = totalEquivalentLength
}
}
public struct RectangularSize: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

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

View File

@@ -1,274 +0,0 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRateResponse
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
public func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
try await (
calculateSizes(
rooms: rooms, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR
),
calculateSizes(
rooms: rooms, trunks: trunks, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR)
)
}
func calculateSizes(
rooms: [Room],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
var retval: [DuctSizing.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
)
for n in 1...room.registerCount {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: rectangularSize.height)
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
rectangularSize: rectangularSize,
rectangularWidth: rectangularWidth
)
)
}
}
return retval
}
func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.TrunkContainer] {
var retval = [DuctSizing.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: trunk.height,
width: width
)
)
)
}
return retval
}
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
// MARK: Duct Size
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
}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let ductulatorSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
ductulatorSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.ductulatorSize = ductulatorSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
}
// MARK: - Friction Rate
extension ManualDClient {
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
}
}
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
}
}
}
// MARK: Total Effective Length
extension ManualDClient {
public struct TotalEffectiveLengthRequest: 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
}
}
}
// MARK: Equivalent Rectangular Duct
extension ManualDClient {
public struct EquivalentRectangularDuctRequest: 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 EquivalentRectangularDuctResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

@@ -1,5 +1,10 @@
import Dependencies
import Foundation 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 struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
@@ -43,7 +48,7 @@ extension ComponentPressureLoss {
self.value = value 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] { public static func `default`(projectID: Project.ID) -> [Self] {
[ [
.init(projectID: projectID, name: "supply-outlet", value: 0.03), .init(projectID: projectID, name: "supply-outlet", value: 0.03),
@@ -74,22 +79,63 @@ extension Array where Element == ComponentPressureLoss {
} }
} }
public typealias ComponentPressureLosses = [String: Double]
#if DEBUG #if DEBUG
extension ComponentPressureLosses {
public static var mock: Self { extension Array where Element == ComponentPressureLoss {
[ public static func mock(projectID: Project.ID) -> Self {
"evaporator-coil": 0.2, ComponentPressureLoss.mock(projectID: projectID)
"filter": 0.1,
"supply-outlet": 0.03,
"return-grille": 0.03,
"balancing-damper": 0.03,
]
} }
} }
extension ComponentPressureLoss { extension ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "evaporator-coil",
value: 0.2,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "filter",
value: 0.1,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "supply-outlet",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "return-grille",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "balancing-damper",
value: 0.03,
createdAt: now,
updatedAt: now
),
]
}
public static var mock: [Self] { public static var mock: [Self] {
[ [
.init( .init(

View File

@@ -0,0 +1,256 @@
import Dependencies
import Foundation
public struct DuctSizes: Codable, Equatable, Sendable {
public let rooms: [RoomContainer]
public let trunks: [TrunkContainer]
public init(
rooms: [DuctSizes.RoomContainer],
trunks: [DuctSizes.TrunkContainer]
) {
self.rooms = rooms
self.trunks = trunks
}
}
extension DuctSizes {
public struct SizeContainer: Codable, Equatable, Sendable {
public let rectangularID: Room.RectangularSize.ID?
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let height: Int?
public let width: Int?
public init(
rectangularID: Room.RectangularSize.ID? = nil,
designCFM: DuctSizes.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.rectangularID = rectangularID
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
@dynamicMemberLookup
public struct RoomContainer: Codable, Equatable, Sendable {
public let roomID: Room.ID
public let roomName: String
public let roomRegister: Int
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let ductSize: SizeContainer
public init(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
ductSize: SizeContainer
) {
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.ductSize = ductSize
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
// Represents the database model that the duct sizes have been calculated
// for.
@dynamicMemberLookup
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
public var id: TrunkSize.ID { trunk.id }
public let trunk: TrunkSize
public let ductSize: SizeContainer
public init(
trunk: TrunkSize,
ductSize: SizeContainer
) {
self.trunk = trunk
self.ductSize = ductSize
}
public subscript<T>(dynamicMember keyPath: KeyPath<TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
public func registerIDS(rooms: [RoomContainer]) -> [String] {
trunk.rooms.reduce(into: []) { array, room in
array = room.registers.reduce(into: array) { array, register in
if let room =
rooms
.first(where: { $0.roomID == room.id && $0.roomRegister == register })
{
array.append(room.roomName)
}
}
}
.sorted()
}
}
}
#if DEBUG
extension DuctSizes {
public static func mock(
equipmentInfo: EquipmentInfo,
rooms: [Room],
trunks: [TrunkSize]
) -> Self {
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingLoad = rooms.totalCoolingLoad
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
array += RoomContainer.mock(
room: room,
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
)
}
return .init(
rooms: roomContainers,
trunks: TrunkContainer.mock(
trunks: trunks,
totalHeatingLoad: totalHeatingLoad,
totalCoolingLoad: totalCoolingLoad,
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
)
)
}
}
extension DuctSizes.RoomContainer {
public static func mock(
room: Room,
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: 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 coolingFraction = coolingLoad / totalCoolingLoad
let coolingCFM = totalCoolingCFM * coolingFraction
for n in 1...room.registerCount {
retval.append(
.init(
roomID: room.id,
roomName: room.name,
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
ductSize: .init(
rectangularID: nil,
designCFM: .init(heating: heatingCFM, cooling: coolingCFM),
roundSize: 7,
finalSize: 8,
velocity: 489,
flexSize: 8,
height: nil,
width: nil
)
)
)
}
return retval
}
}
extension DuctSizes.TrunkContainer {
public static func mock(
trunks: [TrunkSize],
totalHeatingLoad: Double,
totalCoolingLoad: Double,
totalHeatingCFM: Double,
totalCoolingCFM: Double
) -> [Self] {
trunks.reduce(into: []) { array, trunk in
array.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: .init(heating: totalHeatingCFM, cooling: totalCoolingCFM),
roundSize: 18,
finalSize: 20,
velocity: 987,
flexSize: 20
)
)
)
}
}
}
#endif

View File

@@ -1,248 +0,0 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct SizeContainer: Codable, Equatable, Sendable {
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let height: Int?
public let width: Int?
public init(
designCFM: DuctSizing.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
// TODO: Uses SizeContainer
public struct RoomContainer: Codable, Equatable, Sendable {
public let roomID: Room.ID
public let roomName: String
public let roomRegister: Int
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let rectangularSize: RectangularDuct?
public let rectangularWidth: Int?
public init(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}
extension DuctSizing {
// Represents the database model that the duct sizes have been calculated
// for.
@dynamicMemberLookup
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
public var id: TrunkSize.ID { trunk.id }
public let trunk: TrunkSize
public let ductSize: SizeContainer
public init(
trunk: TrunkSize,
ductSize: SizeContainer
) {
self.trunk = trunk
self.ductSize = ductSize
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
// TODO: Add an optional label that the user can set.
// Represents the database model.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [RoomProxy]
public let height: Int?
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [DuctSizing.TrunkSize.RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
}
extension DuctSizing.TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct Update: Codable, Equatable, Sendable {
public let type: TrunkType?
public let rooms: [Room.ID: [Int]]?
public let height: Int?
public let name: String?
public init(
type: DuctSizing.TrunkSize.TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
// TODO: Make registers non-optional
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}

View File

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

View File

@@ -1,6 +1,10 @@
import Dependencies import Dependencies
import Foundation 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 struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
public let projectID: Project.ID public let projectID: Project.ID
@@ -70,6 +74,21 @@ extension EquipmentInfo {
#if DEBUG #if DEBUG
extension EquipmentInfo { extension EquipmentInfo {
public static func mock(projectID: Project.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
projectID: projectID,
heatingCFM: 900,
coolingCFM: 1000,
createdAt: now,
updatedAt: now
)
}
public static let mock = Self( public static let mock = Self(
id: UUID(0), id: UUID(0),
projectID: UUID(0), projectID: UUID(0),

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

@@ -0,0 +1,37 @@
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 {
public func string(digits: Int = 2) -> String {
numberString(self, digits: digits)
}
}
extension Int {
public func string() -> String {
numberString(Double(self), digits: 0)
}
}
private func numberString(_ value: Double, digits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = digits
formatter.groupingSize = 3
formatter.groupingSeparator = ","
formatter.numberStyle = .decimal
return formatter.string(for: value)!
}

View File

@@ -0,0 +1,12 @@
import Fluent
extension PageRequest {
public static var first: Self {
.init(page: 1, per: 25)
}
public static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

View File

@@ -0,0 +1,69 @@
/// 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(
availableStaticPressure: Double,
value: Double
) {
self.availableStaticPressure = availableStaticPressure
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(
"Friction rate should be lower than 0.18",
resolutions: [
"Decrease the blower speed",
"Decrease the blower size",
"Increase the Total Equivalent Length",
]
)
} else if value <= 0.02 {
return .init(
"Friction rate should be higher than 0.02",
resolutions: [
"Increase the blower speed",
"Increase the blower size",
"Decrease the Total Equivalent Length",
]
)
}
return nil
}
}
/// 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(
_ reason: String,
resolutions: [String]
) {
self.reason = reason
self.resolutions = resolutions
}
}
#if DEBUG
extension FrictionRate {
public static let mock = Self(availableStaticPressure: 0.21, value: 0.11)
}
#endif

View File

@@ -1,16 +1,29 @@
import Dependencies import Dependencies
import Foundation import Foundation
/// Represents a single duct design project / system.
///
/// Holds items such as project name and address.
public struct Project: Codable, Equatable, Identifiable, Sendable { public struct Project: Codable, Equatable, Identifiable, Sendable {
/// The unique ID of the project.
public let id: UUID public let id: UUID
/// The name of the project.
public let name: String public let name: String
/// The street address of the project.
public let streetAddress: String public let streetAddress: String
/// The city of the project.
public let city: String public let city: String
/// The state of the project.
public let state: String public let state: String
/// The zip code of the project.
public let zipCode: String 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? public let sensibleHeatRatio: Double?
/// When the project was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the project was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -37,14 +50,20 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
} }
extension Project { extension Project {
/// Represents the data needed to create a new project.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String public let name: String
/// The street address of the project.
public let streetAddress: String public let streetAddress: String
/// The city of the project.
public let city: String public let city: String
/// The state of the project.
public let state: String public let state: String
/// The zip code of the project.
public let zipCode: String public let zipCode: String
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
public init( 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 { public struct CompletedSteps: Codable, Equatable, Sendable {
/// Whether there is ``EquipmentInfo`` for a project.
public let equipmentInfo: Bool public let equipmentInfo: Bool
/// Whether there are ``Room``'s for a project.
public let rooms: Bool public let rooms: Bool
/// Whether there are ``EquivalentLength``'s for a project.
public let equivalentLength: Bool public let equivalentLength: Bool
/// Whether there is a ``FrictionRate`` for a project.
public let frictionRate: Bool public let frictionRate: Bool
public init( public init(
@@ -84,13 +111,58 @@ 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
/// 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: [EquivalentLength],
rooms: [Room],
trunks: [TrunkSize]
) {
self.project = project
self.componentLosses = componentLosses
self.equipmentInfo = equipmentInfo
self.equivalentLengths = equivalentLengths
self.rooms = rooms
self.trunks = trunks
}
}
/// 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 { public struct Update: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String? public let name: String?
/// The street address of the project.
public let streetAddress: String? public let streetAddress: String?
/// The city of the project.
public let city: String? public let city: String?
/// The state of the project.
public let state: String? public let state: String?
/// The zip code of the project.
public let zipCode: String? public let zipCode: String?
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
public init( public init(
@@ -114,16 +186,22 @@ extension Project {
#if DEBUG #if DEBUG
extension Project { extension Project {
public static let mock = Self(
id: UUID(0), public static var mock: Self {
name: "Testy McTestface", @Dependency(\.uuid) var uuid
streetAddress: "1234 Sesame Street", @Dependency(\.date.now) var now
city: "Monroe",
state: "OH", return .init(
zipCode: "55555", id: uuid(),
createdAt: Date(), name: "Testy McTestface",
updatedAt: Date() streetAddress: "1234 Sesame Street",
) city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
} }
#endif #endif

View File

@@ -1,16 +1,37 @@
import Dependencies import Dependencies
import Foundation 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 { public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double public let coolingTotal: Double
/// An optional sensible cooling load for the room.
///
/// **NOTE:** This is generally not set, but calculated from the project wide
/// sensible heat ratio.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]? /// 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 public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -21,7 +42,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
coolingTotal: Double, coolingTotal: Double,
coolingSensible: Double? = nil, coolingSensible: Double? = nil,
registerCount: Int = 1, registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil, rectangularSizes: [RectangularSize]? = nil,
createdAt: Date, createdAt: Date,
updatedAt: Date updatedAt: Date
) { ) {
@@ -39,13 +60,19 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
} }
extension Room { extension Room {
/// Represents the data required to create a new room for a project.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The project this room is associated with.
public let projectID: Project.ID public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double public let coolingTotal: Double
/// An optional sensible cooling load for the room.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int public let registerCount: Int
public init( public init(
@@ -65,13 +92,46 @@ extension Room {
} }
} }
/// 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(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String? public let name: String?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double? public let heatingLoad: Double?
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double? public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double? public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int? public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]? /// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]?
public init( public init(
name: String? = nil, name: String? = nil,
@@ -89,7 +149,7 @@ extension Room {
} }
public init( public init(
rectangularSizes: [DuctSizing.RectangularDuct] rectangularSizes: [RectangularSize]
) { ) {
self.name = nil self.name = nil
self.heatingLoad = nil self.heatingLoad = nil
@@ -103,14 +163,20 @@ extension Room {
extension Array where Element == Room { extension Array where Element == Room {
/// The sum of heating loads for an array of rooms.
public var totalHeatingLoad: Double { public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad } reduce(into: 0) { $0 += $1.heatingLoad }
} }
/// The sum of total cooling loads for an array of rooms.
public var totalCoolingLoad: Double { public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal } reduce(into: 0) { $0 += $1.coolingTotal }
} }
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) -> Double { public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) { reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr) let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
@@ -122,38 +188,86 @@ extension Array where Element == Room {
#if DEBUG #if DEBUG
extension Room { extension Room {
public static let mocks = [
Room( public static func mock(projectID: Project.ID) -> [Self] {
id: UUID(0), @Dependency(\.uuid) var uuid
projectID: UUID(0), @Dependency(\.date.now) var now
name: "Kitchen",
heatingLoad: 12345, return [
coolingTotal: 1234, .init(
registerCount: 2, id: uuid(),
createdAt: Date(), projectID: projectID,
updatedAt: Date() name: "Bed-1",
), heatingLoad: 3913,
Room( coolingTotal: 2472,
id: UUID(1), coolingSensible: nil,
projectID: UUID(1), registerCount: 1,
name: "Bedroom - 1", rectangularSizes: nil,
heatingLoad: 12345, createdAt: now,
coolingTotal: 1456, updatedAt: now
registerCount: 1, ),
createdAt: Date(), .init(
updatedAt: Date() id: uuid(),
), projectID: projectID,
Room( name: "Entry",
id: UUID(2), heatingLoad: 8284,
projectID: UUID(2), coolingTotal: 2916,
name: "Family Room", coolingSensible: nil,
heatingLoad: 12345, registerCount: 2,
coolingTotal: 1673, rectangularSizes: nil,
registerCount: 3, createdAt: now,
createdAt: Date(), updatedAt: now
updatedAt: Date() ),
), .init(
] id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
} }
#endif #endif

View File

@@ -1,265 +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 completedSteps
static let rootPath = "details"
static let router = OneOf {
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 { public enum SiteRoute: Equatable, Sendable {
case api(Self.Api)
case health case health
case view(Self.View) case view(Self.View)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.api)) {
SiteRoute.Api.router
}
Route(.case(Self.health)) { Route(.case(Self.health)) {
Path { "health" } Path { "health" }
Method.get Method.get

View File

@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
case equipment(EquipmentInfoRoute) case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute) case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute) case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute) case rooms(RoomRoute)
static let router = OneOf { static let router = OneOf {
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) { Route(.case(Self.frictionRate)) {
FrictionRateRoute.router FrictionRateRoute.router
} }
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) { Route(.case(Self.rooms)) {
RoomRoute.router RoomRoute.router
} }
@@ -389,11 +394,11 @@ extension SiteRoute.View.ProjectRoute {
} }
public enum EquivalentLengthRoute: Equatable, Sendable { public enum EquivalentLengthRoute: Equatable, Sendable {
case delete(id: EffectiveLength.ID) case delete(id: EquivalentLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil) case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
case index case index
case submit(FormStep) case submit(FormStep)
case update(EffectiveLength.ID, StepThree) case update(EquivalentLength.ID, StepThree)
static let rootPath = "effective-lengths" static let rootPath = "effective-lengths"
@@ -401,7 +406,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete(id:))) { Route(.case(Self.delete(id:))) {
Path { Path {
rootPath rootPath
EffectiveLength.ID.parser() EquivalentLength.ID.parser()
} }
Method.delete Method.delete
} }
@@ -419,7 +424,7 @@ extension SiteRoute.View.ProjectRoute {
Field("type") { FieldType.parser() } Field("type") { FieldType.parser() }
Optionally { Optionally {
Field("style", default: nil) { Field("style", default: nil) {
EffectiveLength.EffectiveLengthType.parser() EquivalentLength.EffectiveLengthType.parser()
} }
} }
} }
@@ -432,16 +437,16 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { Path {
rootPath rootPath
EffectiveLength.ID.parser() EquivalentLength.ID.parser()
} }
Method.patch Method.patch
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -485,10 +490,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
} }
.map(.memberwise(StepOne.init)) .map(.memberwise(StepOne.init))
} }
@@ -500,10 +505,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -520,10 +525,10 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() } Field("id", default: nil) { EquivalentLength.ID.parser() }
} }
Field("name", .string) Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() } Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many { Many {
Field("straightLengths") { Field("straightLengths") {
Int.parser() Int.parser()
@@ -562,22 +567,22 @@ extension SiteRoute.View.ProjectRoute {
} }
public struct StepOne: Codable, Equatable, Sendable { public struct StepOne: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
} }
public struct StepTwo: Codable, Equatable, Sendable { public struct StepTwo: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int] public let straightLengths: [Int]
public init( public init(
id: EffectiveLength.ID? = nil, id: EquivalentLength.ID? = nil,
name: String, name: String,
type: EffectiveLength.EffectiveLengthType, type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int] straightLengths: [Int]
) { ) {
self.id = id self.id = id
@@ -588,9 +593,9 @@ extension SiteRoute.View.ProjectRoute {
} }
public struct StepThree: Codable, Equatable, Sendable { public struct StepThree: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID? public let id: EquivalentLength.ID?
public let name: String public let name: String
public let type: EffectiveLength.EffectiveLengthType public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int] public let straightLengths: [Int]
public let groupGroups: [Int] public let groupGroups: [Int]
public let groupLetters: [String] public let groupLetters: [String]
@@ -627,7 +632,7 @@ extension SiteRoute.View.ProjectRoute {
} }
Method.delete Method.delete
Query { Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() } Field("rectangularSize") { Room.RectangularSize.ID.parser() }
Field("register") { Int.parser() } Field("register") { Int.parser() }
} }
.map(.memberwise(DeleteRectangularDuct.init)) .map(.memberwise(DeleteRectangularDuct.init))
@@ -642,7 +647,7 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Optionally { Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() } Field("id") { Room.RectangularSize.ID.parser() }
} }
Field("register") { Int.parser() } Field("register") { Int.parser() }
Field("height") { Int.parser() } Field("height") { Int.parser() }
@@ -658,19 +663,19 @@ extension SiteRoute.View.ProjectRoute {
public struct DeleteRectangularDuct: Equatable, Sendable { public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: DuctSizing.RectangularDuct.ID public let rectangularSizeID: Room.RectangularSize.ID
public let register: Int public let register: Int
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) { public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID self.rectangularSizeID = rectangularSizeID
self.register = register self.register = register
} }
} }
public enum TrunkRoute: Equatable, Sendable { public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID) case delete(TrunkSize.ID)
case submit(TrunkSizeForm) case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm) case update(TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk" public static let rootPath = "trunk"
@@ -678,7 +683,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete)) { Route(.case(Self.delete)) {
Path { Path {
rootPath rootPath
DuctSizing.TrunkSize.ID.parser() TrunkSize.ID.parser()
} }
Method.delete Method.delete
} }
@@ -690,7 +695,7 @@ extension SiteRoute.View.ProjectRoute {
Body { Body {
FormData { FormData {
Field("projectID") { Project.ID.parser() } Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() } Field("type") { TrunkSize.TrunkType.parser() }
Optionally { Optionally {
Field("height") { Int.parser() } Field("height") { Int.parser() }
@@ -708,13 +713,13 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { Path {
rootPath rootPath
DuctSizing.TrunkSize.ID.parser() TrunkSize.ID.parser()
} }
Method.patch Method.patch
Body { Body {
FormData { FormData {
Field("projectID") { Project.ID.parser() } Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() } Field("type") { TrunkSize.TrunkType.parser() }
Optionally { Optionally {
Field("height") { Int.parser() } Field("height") { Int.parser() }
} }
@@ -732,17 +737,43 @@ extension SiteRoute.View.ProjectRoute {
} }
public struct RoomRectangularForm: Equatable, Sendable { public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let id: Room.RectangularSize.ID?
public let register: Int public let register: Int
public let height: Int public let height: Int
public init(
id: Room.RectangularSize.ID? = nil,
register: Int,
height: Int
) {
self.id = id
self.register = register
self.height = height
}
} }
public struct TrunkSizeForm: Equatable, Sendable { public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID public let projectID: Project.ID
public let type: DuctSizing.TrunkSize.TrunkType public let type: TrunkSize.TrunkType
public let height: Int? public let height: Int?
public let name: String? public let name: String?
public let rooms: [String] public let rooms: [String]
public init(
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil,
rooms: [String]
) {
self.projectID = projectID
self.type = type
self.height = height
self.name = name
self.rooms = rooms
}
} }
} }
} }

View File

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

View File

@@ -0,0 +1,149 @@
import Dependencies
import Foundation
/// 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(
id: UUID,
projectID: Project.ID,
type: TrunkType,
rooms: [RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
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(
projectID: Project.ID,
type: TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
/// 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(
type: TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
/// 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 {
/// 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
public static let allCases = [Self.supply, .return]
}
}
#if DEBUG
extension TrunkSize {
public static func mock(projectID: Project.ID, rooms: [Room]) -> [Self] {
@Dependency(\.uuid) var uuid
let allRooms = rooms.reduce(into: [TrunkSize.RoomProxy]()) { array, room in
var registers = [Int]()
for n in 1...room.registerCount {
registers.append(n)
}
array.append(.init(room: room, registers: registers))
}
return [
.init(id: uuid(), projectID: projectID, type: .supply, rooms: allRooms),
.init(id: uuid(), projectID: projectID, type: .return, rooms: allRooms),
]
}
}
#endif

View File

@@ -1,12 +1,17 @@
import Dependencies import Dependencies
import Foundation import Foundation
// FIX: Remove username. /// Represents a user of the site.
///
public struct User: Codable, Equatable, Identifiable, Sendable { public struct User: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the user.
public let id: UUID public let id: UUID
/// The user's email address.
public let email: String public let email: String
/// When the user was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the user was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -23,10 +28,14 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
} }
extension User { extension User {
/// Represents the data required to create a new user.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String public let email: String
/// The password for the user.
public let password: String public let password: String
/// The password confirmation, must match the password.
public let confirmPassword: String public let confirmPassword: String
public init( public init(
@@ -40,9 +49,13 @@ extension User {
} }
} }
/// Represents data required to login a user.
public struct Login: Codable, Equatable, Sendable { public struct Login: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String public let email: String
/// The password for the user.
public let password: String public let password: String
/// An optional page / route to navigate to after logging in the user.
public let next: String? public let next: String?
public init(email: String, password: String, next: String? = nil) { 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 { public struct Token: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the token.
public let id: UUID public let id: UUID
/// The user id the token is for.
public let userID: User.ID public let userID: User.ID
/// The token value.
public let value: String public let value: String
public init(id: UUID, userID: User.ID, value: String) { public init(id: UUID, userID: User.ID, value: String) {
@@ -65,3 +81,15 @@ extension User {
} }
} }
} }
#if DEBUG
extension User {
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(id: uuid(), email: "testy@example.com", createdAt: now, updatedAt: now)
}
}
#endif

View File

@@ -1,19 +1,33 @@
import Dependencies
import Foundation import Foundation
extension User { extension User {
/// Represents a user's profile. Which contains extra information about a user of the site.
public struct Profile: Codable, Equatable, Identifiable, Sendable { public struct Profile: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the profile
public let id: UUID public let id: UUID
/// The user id the profile is for.
public let userID: User.ID public let userID: User.ID
/// The user's first name.
public let firstName: String public let firstName: String
/// The user's last name.
public let lastName: String public let lastName: String
/// The user's company name.
public let companyName: String public let companyName: String
/// The user's street address.
public let streetAddress: String public let streetAddress: String
/// The user's city.
public let city: String public let city: String
/// The user's state.
public let state: String public let state: String
/// The user's zip code.
public let zipCode: String public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
/// When the profile was created in the database.
public let createdAt: Date public let createdAt: Date
/// When the profile was updated in the database.
public let updatedAt: Date public let updatedAt: Date
public init( public init(
@@ -48,15 +62,25 @@ extension User {
extension User.Profile { extension User.Profile {
/// Represents the data required to create a user profile.
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
/// The user id the profile is for.
public let userID: User.ID public let userID: User.ID
/// The user's first name.
public let firstName: String public let firstName: String
/// The user's last name.
public let lastName: String public let lastName: String
/// The user's company name.
public let companyName: String public let companyName: String
/// The user's street address.
public let streetAddress: String public let streetAddress: String
/// The user's city.
public let city: String public let city: String
/// The user's state.
public let state: String public let state: String
/// The user's zip code.
public let zipCode: String public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
public init( public init(
@@ -82,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 { public struct Update: Codable, Equatable, Sendable {
/// The user's first name.
public let firstName: String? public let firstName: String?
/// The user's last name.
public let lastName: String? public let lastName: String?
/// The user's company name.
public let companyName: String? public let companyName: String?
/// The user's street address.
public let streetAddress: String? public let streetAddress: String?
/// The user's city.
public let city: String? public let city: String?
/// The user's state.
public let state: String? public let state: String?
/// The user's zip code.
public let zipCode: String? public let zipCode: String?
/// An optional theme that the user prefers.
public let theme: Theme? public let theme: Theme?
public init( public init(
@@ -113,3 +148,27 @@ extension User.Profile {
} }
} }
} }
#if DEBUG
extension User.Profile {
public static func mock(userID: User.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
userID: userID,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "1234 Sesame St",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -0,0 +1,163 @@
import Dependencies
import DependenciesMacros
import Elementary
import EnvVars
import FileClient
import Foundation
import ManualDCore
extension DependencyValues {
/// Access the pdf client dependency that can be used to generate pdf's for
/// a project.
public var pdfClient: PdfClient {
get { self[PdfClient.self] }
set { self[PdfClient.self] = newValue }
}
}
@DependencyClient
public struct PdfClient: Sendable {
/// Generate the html used to convert to pdf for a project.
public var html: @Sendable (Request) async throws -> (any HTML & Sendable)
/// Converts the generated html to a pdf.
///
/// **NOTE:** This is generally not used directly, instead use the overload that accepts a request,
/// which generates the html and does the conversion all in one step.
public var generatePdf: @Sendable (Project.ID, any HTML & Sendable) async throws -> Response
/// Generate a pdf for the given project request.
///
/// - Parameters:
/// - request: The project data used to generate the pdf.
public func generatePdf(request: Request) async throws -> Response {
try await self.generatePdf(request.project.id, html(request))
}
}
extension PdfClient: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
html: { request in
request.toHTML()
},
generatePdf: { projectID, html in
@Dependency(\.fileClient) var fileClient
@Dependency(\.environment) var environment
let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: environment.pandocPath)
process.arguments = [
"\(baseUrl).html",
"--pdf-engine=\(environment.pdfEngine)",
"--from=html",
"--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf",
]
try process.run()
process.waitUntilExit()
return .init(htmlPath: "\(baseUrl).html", pdfPath: "\(baseUrl).pdf")
}
)
}
extension PdfClient {
/// 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
/// 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 {
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
}
public init(
project: Project,
rooms: [Room],
componentLosses: [ComponentPressureLoss],
ductSizes: DuctSizes,
equipmentInfo: EquipmentInfo,
maxSupplyTEL: EquivalentLength,
maxReturnTEL: EquivalentLength,
frictionRate: FrictionRate,
projectSHR: Double
) {
self.project = project
self.rooms = rooms
self.componentLosses = componentLosses
self.ductSizes = ductSizes
self.equipmentInfo = equipmentInfo
self.maxSupplyTEL = maxSupplyTEL
self.maxReturnTEL = maxReturnTEL
self.frictionRate = frictionRate
self.projectSHR = projectSHR
}
}
/// 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) {
self.htmlPath = htmlPath
self.pdfPath = pdfPath
}
}
}
#if DEBUG
extension PdfClient.Request {
public static func mock(project: Project = .mock) -> Self {
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 = 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),
equipmentInfo: equipmentInfo,
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,
frictionRate: .mock,
projectSHR: 0.83
)
}
}
#endif

View File

@@ -0,0 +1,107 @@
import Elementary
import ManualDCore
extension PdfClient.Request {
func toHTML() -> (some HTML & Sendable) {
PdfDocument(request: self)
}
}
struct PdfDocument: HTMLDocument {
let title = "Duct Calc"
let lang = "en"
let request: PdfClient.Request
var head: some HTML {
link(.rel(.stylesheet), .href("/css/pdf.css"))
}
var body: some HTML {
div {
// h1(.class("headline")) { "Duct Calc" }
h2 { "Project" }
div(.class("flex")) {
ProjectTable(project: request.project)
// HACK:
table {}
}
div(.class("section")) {
div(.class("flex")) {
h2 { "Equipment" }
h2 { "Friction Rate" }
}
div(.class("flex")) {
div(.class("container")) {
div(.class("table-container")) {
EquipmentTable(title: "Equipment", equipmentInfo: request.equipmentInfo)
}
div(.class("table-container")) {
FrictionRateTable(
title: "Friction Rate",
componentLosses: request.componentLosses,
frictionRate: request.frictionRate,
totalEquivalentLength: request.totalEquivalentLength,
displayTotals: false
)
}
}
}
if let error = request.frictionRate.error {
div(.class("section")) {
p(.class("error")) {
error.reason
for resolution in error.resolutions {
br()
" * \(resolution)"
}
}
}
}
}
div(.class("section")) {
h2 { "Duct Sizes" }
DuctSizesTable(rooms: request.ductSizes.rooms)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Supply Trunk / Run Outs" }
TrunkTable(sizes: request.ductSizes, type: .supply)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Return Trunk / Run Outs" }
TrunkTable(sizes: request.ductSizes, type: .return)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Total Equivalent Lengths" }
EffectiveLengthsTable(effectiveLengths: [
request.maxSupplyTEL, request.maxReturnTEL,
])
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Register Detail" }
RegisterDetailTable(rooms: request.ductSizes.rooms)
.attributes(.class("w-full"))
}
div(.class("section")) {
h2 { "Room Detail" }
RoomsTable(rooms: request.rooms, projectSHR: request.projectSHR)
.attributes(.class("w-full"))
}
}
}
}

View File

@@ -0,0 +1,37 @@
import Elementary
import ManualDCore
struct DuctSizesTable: HTML, Sendable {
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Height" }
th { "Width" }
}
}
tbody {
for row in rooms {
tr {
td { row.roomName }
td { row.designCFM.value.string(digits: 0) }
td { row.roundSize.string() }
td { row.velocity.string() }
td { row.flexSize.string() }
td { row.finalSize.string() }
td { row.ductSize.height?.string() ?? "" }
td { row.width?.string() ?? "" }
}
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
import Elementary
import ManualDCore
struct EquipmentTable: HTML, Sendable {
let title: String?
let equipmentInfo: EquipmentInfo
init(title: String? = nil, equipmentInfo: EquipmentInfo) {
self.title = title
self.equipmentInfo = equipmentInfo
}
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { title ?? "" }
th(.class("justify-end")) { "Value" }
}
}
tbody {
tr {
td { "Static Pressure" }
td(.class("justify-end")) { equipmentInfo.staticPressure.string() }
}
tr {
td { "Heating CFM" }
td(.class("justify-end")) { equipmentInfo.heatingCFM.string() }
}
tr {
td { "Cooling CFM" }
td(.class("justify-end")) { equipmentInfo.coolingCFM.string() }
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
import Elementary
import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EquivalentLength]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Type" }
th { "Straight Lengths" }
th { "Groups" }
th { "Total" }
}
}
tbody {
for row in effectiveLengths {
tr {
td { row.name }
td { row.type.rawValue }
td {
ul {
for length in row.straightLengths {
li { length.string() }
}
}
}
td {
EffectiveLengthGroupTable(groups: row.groups)
.attributes(.class("w-full"))
}
td { row.totalEquivalentLength.string(digits: 0) }
}
}
}
}
}
}
struct EffectiveLengthGroupTable: HTML, Sendable {
let groups: [EquivalentLength.FittingGroup]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("effectiveLengthGroupHeader")) {
th { "Name" }
th { "Length" }
th { "Quantity" }
th { "Total" }
}
}
tbody {
for row in groups {
tr {
td { "\(row.group)-\(row.letter)" }
td { row.value.string(digits: 0) }
td { row.quantity.string() }
td { (row.value * Double(row.quantity)).string(digits: 0) }
}
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
import Elementary
import ManualDCore
struct FrictionRateTable: HTML, Sendable {
let title: String?
let componentLosses: [ComponentPressureLoss]
let frictionRate: FrictionRate
let totalEquivalentLength: Double
let displayTotals: Bool
var sortedLosses: [ComponentPressureLoss] {
componentLosses.sorted { $0.value > $1.value }
}
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { title ?? "" }
th(.class("justify-end")) { "Value" }
}
}
tbody {
for row in sortedLosses {
tr {
td { row.name }
td(.class("justify-end")) { row.value.string() }
}
}
if displayTotals {
tr {
td(.class("label justify-end")) { "Available Static Pressure" }
td(.class("justify-end")) { frictionRate.availableStaticPressure.string() }
}
tr {
td(.class("label justify-end")) { "Total Equivalent Length" }
td(.class("justify-end")) { totalEquivalentLength.string() }
}
tr {
td(.class("label justify-end")) { "Friction Rate Design Value" }
td(.class("justify-end")) { frictionRate.value.string() }
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
import Elementary
import ManualDCore
struct ProjectTable: HTML, Sendable {
let project: Project
var body: some HTML<HTMLTag.table> {
table {
tbody {
tr {
td(.class("label")) { "Name" }
td { project.name }
}
tr {
td(.class("label")) { "Address" }
td {
p {
project.streetAddress
br()
project.cityStateZipString
}
}
}
}
}
}
}
extension Project {
var cityStateZipString: String {
return "\(city), \(state) \(zipCode)"
}
}

View File

@@ -0,0 +1,33 @@
import Elementary
import ManualDCore
struct RegisterDetailTable: HTML, Sendable {
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Heating BTU" }
th { "Cooling BTU" }
th { "Heating CFM" }
th { "Cooling CFM" }
th { "Design CFM" }
}
}
tbody {
for row in rooms {
tr {
td { row.roomName }
td { row.heatingLoad.string(digits: 0) }
td { row.coolingLoad.string(digits: 0) }
td { row.heatingCFM.string(digits: 0) }
td { row.coolingCFM.string(digits: 0) }
td { row.designCFM.value.string(digits: 0) }
}
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
import Elementary
import ManualDCore
struct RoomsTable: HTML, Sendable {
let rooms: [Room]
let projectSHR: Double
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Heating BTU" }
th { "Cooling Total BTU" }
th { "Cooling Sensible BTU" }
th { "Register Count" }
}
}
tbody {
for room in rooms {
tr {
td { room.name }
td { room.heatingLoad.string(digits: 0) }
td { room.coolingTotal.string(digits: 0) }
td {
(room.coolingSensible
?? (room.coolingTotal * projectSHR)).string(digits: 0)
}
td { room.registerCount.string() }
}
}
// Totals
// tr(.class("table-footer")) {
tr {
td(.class("label")) { "Totals" }
td(.class("heating label")) {
rooms.totalHeatingLoad.string(digits: 0)
}
td(.class("coolingTotal label")) {
rooms.totalCoolingLoad.string(digits: 0)
}
td(.class("coolingSensible label")) {
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
}
td {}
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
import Elementary
import ManualDCore
struct TrunkTable: HTML, Sendable {
public let sizes: DuctSizes
public let type: TrunkSize.TrunkType
var trunks: [DuctSizes.TrunkContainer] {
sizes.trunks.filter { $0.type == type }
}
var body: some HTML<HTMLTag.table> {
table {
thead(.class("bg-green")) {
tr {
th { "Name" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Height" }
th { "Width" }
}
}
tbody {
for row in trunks {
tr {
td { row.name ?? "" }
td { row.designCFM.value.string(digits: 0) }
td { row.ductSize.roundSize.string() }
td { row.velocity.string() }
td { row.finalSize.string() }
td { row.flexSize.string() }
td { row.ductSize.height?.string() ?? "" }
td { row.width?.string() ?? "" }
}
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
import Dependencies
import DependenciesMacros
import Elementary
import ManualDClient
import ManualDCore
import Vapor
extension DependencyValues {
public var projectClient: ProjectClient {
get { self[ProjectClient.self] }
set { self[ProjectClient.self] = newValue }
}
}
/// Useful helper utilities for project's.
///
/// This is primarily used for implementing logic required to get the needed data
/// for the view controller to render views.
@DependencyClient
public struct ProjectClient: Sendable {
/// 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]
public var createProject:
@Sendable (User.ID, Project.Create) async throws -> CreateProjectResponse
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 {
public static let testValue = Self()
}
extension ProjectClient {
public struct CreateProjectResponse: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let rooms: [Room]
public let sensibleHeatRatio: Double?
public let completedSteps: Project.CompletedSteps
public init(
projectID: Project.ID,
rooms: [Room],
sensibleHeatRatio: Double? = nil,
completedSteps: Project.CompletedSteps
) {
self.projectID = projectID
self.rooms = rooms
self.sensibleHeatRatio = sensibleHeatRatio
self.completedSteps = completedSteps
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let componentLosses: [ComponentPressureLoss]
public let equivalentLengths: EquivalentLength.MaxContainer
public let frictionRate: FrictionRate?
public init(
componentLosses: [ComponentPressureLoss],
equivalentLengths: EquivalentLength.MaxContainer,
frictionRate: FrictionRate? = nil
) {
self.componentLosses = componentLosses
self.equivalentLengths = equivalentLengths
self.frictionRate = frictionRate
}
}
}

View File

@@ -0,0 +1,12 @@
import DatabaseClient
import ManualDCore
extension DatabaseClient.ComponentLosses {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
for loss in defaults {
_ = try await create(loss)
}
}
}

View File

@@ -0,0 +1,126 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension DatabaseClient {
func calculateDuctSizes(
details: Project.Detail
) async throws -> (DuctSizes, DuctSizeSharedRequest) {
let (rooms, shared) = try await calculateRoomDuctSizes(details: details)
return try await (
.init(
rooms: rooms,
trunks: calculateTrunkDuctSizes(details: details, shared: shared)
),
shared
)
}
func calculateRoomDuctSizes(
details: Project.Detail
) async throws -> (rooms: [DuctSizes.RoomContainer], shared: DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let rooms = try await manualD.calculateRoomSizes(rooms: details.rooms, sharedRequest: shared)
return (rooms, shared)
}
func calculateTrunkDuctSizes(
details: Project.Detail,
shared: DuctSizeSharedRequest? = nil
) async throws -> [DuctSizes.TrunkContainer] {
@Dependency(\.manualD) var manualD
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: sharedRequest
)
}
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
let projectSHR = try details.project.ensuredSHR()
guard
let dfrResponse = designFrictionRate(
componentLosses: details.componentLosses,
equipmentInfo: details.equipmentInfo,
equivalentLengths: details.maxContainer
)
else {
throw ProjectClientError("Project not complete.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: projectSHR
)
}
// Internal container.
struct DesignFrictionRateResponse: Equatable, Sendable {
typealias EnsuredTEL = (supply: EquivalentLength, return: EquivalentLength)
let designFrictionRate: Double
let equipmentInfo: EquipmentInfo
let telMaxContainer: EquivalentLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL {
guard let maxSupplyLength = telMaxContainer.supply else {
throw ProjectClientError("Max supply TEL not found")
}
guard let maxReturnLength = telMaxContainer.return else {
throw ProjectClientError("Max supply TEL not found")
}
return (maxSupplyLength, maxReturnLength)
}
}
func designFrictionRate(
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: EquivalentLength.MaxContainer
) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.totalEquivalentLength,
componentLosses.count > 0
else { return nil }
let availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total
return .init(
designFrictionRate: (availableStaticPressure * 100) / tel,
equipmentInfo: equipmentInfo,
telMaxContainer: equivalentLengths
)
}
}
extension Project {
func ensuredSHR() throws -> Double {
guard let shr = sensibleHeatRatio else {
throw ProjectClientError("Sensible heat ratio not set on project id: \(id)")
}
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

@@ -0,0 +1,227 @@
import Logging
import ManualDClient
import ManualDCore
struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo
let maxSupplyLength: EquivalentLength
let maxReturnLenght: EquivalentLength
let designFrictionRate: Double
let projectSHR: Double
}
// TODO: Remove Logger and use depedency logger.
extension ManualDClient {
func calculateDuctSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> DuctSizes {
try await .init(
rooms: calculateRoomSizes(
rooms: rooms,
sharedRequest: sharedRequest
),
trunks: calculateTrunkSizes(
rooms: rooms,
trunks: trunks,
sharedRequest: sharedRequest
)
)
}
func calculateRoomSizes(
rooms: [Room],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.RoomContainer] {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = 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(
cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
)
for n in 1...room.registerCount {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.rectangularSize(
round: sizes.finalSize,
height: rectangularSize.height
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
rectangularSize: rectangularSize,
width: rectangularWidth
)
)
)
}
}
return retval
}
func calculateTrunkSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.TrunkContainer] {
var retval = [DuctSizes.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = 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(
cfm: designCFM.value,
frictionRate: sharedRequest.designFrictionRate
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.rectangularSize(
round: sizes.finalSize,
height: height
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
height: trunk.height,
width: width
)
)
)
}
return retval
}
}
extension DuctSizes.SizeContainer {
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSize,
height: Int?,
width: Int?
) {
self.init(
rectangularID: nil,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: height,
width: width
)
}
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSize,
rectangularSize: Room.RectangularSize?,
width: Int?
) {
self.init(
rectangularID: rectangularSize?.id,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: rectangularSize?.height,
width: width
)
}
}
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 {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
// no longer exist.
private var actualRegisterCount: Int {
guard registers.count <= room.registerCount else {
return room.registerCount
}
return registers.count
}
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
extension TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}

View File

@@ -0,0 +1,55 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.totalEquivalentLength else {
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
}
return try await .init(
componentLosses: details.componentLosses,
equivalentLengths: maxContainer,
frictionRate: frictionRate(
.init(
externalStaticPressure: details.equipmentInfo.staticPressure,
componentPressureLosses: details.componentLosses,
totalEquivalentLength: Int(totalEquivalentLength)
)
)
)
}
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
@Dependency(\.database) var database
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.totalEquivalentLength else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
return try await .init(
componentLosses: componentLosses,
equivalentLengths: lengths,
frictionRate: frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses,
totalEquivalentLength: Int(totalEquivalentLength)
)
)
)
}
}

View File

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

View File

@@ -0,0 +1,63 @@
import DatabaseClient
import Dependencies
import FileClient
import Logging
import ManualDClient
import ManualDCore
import PdfClient
extension ProjectClient: DependencyKey {
public static var liveValue: Self {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient
@Dependency(\.fileClient) var fileClient
return .init(
calculateRoomDuctSizes: { projectID in
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
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.componentLosses.createDefaults(projectID: project.id)
return try await .init(
projectID: project.id,
rooms: database.rooms.fetch(project.id),
sensibleHeatRatio: database.projects.getSensibleHeatRatio(project.id),
completedSteps: database.projects.getCompletedSteps(project.id)
)
},
frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID)
},
generatePdf: { projectID in
let pdfResponse = try await pdfClient.generatePdf(
request: database.makePdfRequest(projectID)
)
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(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
}
)
}
}

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 Elementary
import Tagged
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable { 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) { public init(number: Double, digits: Int = 2) {
self.inner = Number(number, digits: digits) 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 { public struct EditButton: HTML, Sendable {
let title: String? let title: String?
let type: HTMLAttribute<HTMLTag.button>.ButtonType let type: HTMLAttribute<HTMLTag.button>.ButtonType

View File

@@ -6,7 +6,7 @@ public struct DateView: HTML, Sendable {
var formatter: DateFormatter { var formatter: DateFormatter {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateStyle = .short formatter.dateFormat = "MM/dd/yyyy"
return formatter return formatter
} }

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 { extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ string: String?) -> Self { public static func value(_ string: String?) -> Self {
@@ -42,9 +35,6 @@ extension HTMLAttribute where Tag == HTMLTag.button {
} }
extension HTML where Tag: HTMLTrait.Attributes.Global { 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> { public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
attributes(.class("hidden"), when: shouldHide) attributes(.class("hidden"), when: shouldHide)

View File

@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
extension HTMLAttribute.hx { extension HTMLAttribute.hx {
@Sendable @Sendable
public static func indicator() -> HTMLAttribute { public static func indicator() -> HTMLAttribute {
indicator(".hx-indicator") indicator(".htmx-indicator")
} }
} }

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 { extension HTMLAttribute where Tag == HTMLTag.input {
public static func max(_ value: String) -> Self { public static func max(_ value: String) -> Self {

View File

@@ -1,17 +1,11 @@
import Elementary import Elementary
import Foundation import Foundation
import ManualDCore
public struct Number: HTML, Sendable { public struct Number: HTML, Sendable {
let fractionDigits: Int let fractionDigits: Int
let value: Double let value: Double
private var formatter: NumberFormatter {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = fractionDigits
formatter.numberStyle = .decimal
return formatter
}
public init( public init(
_ value: Double, _ value: Double,
digits fractionDigits: Int = 2 digits fractionDigits: Int = 2
@@ -27,6 +21,6 @@ public struct Number: HTML, Sendable {
} }
public var body: some HTML<HTMLTag.span> { public var body: some HTML<HTMLTag.span> {
span { formatter.string(for: value) ?? "N/A" } span { value.string(digits: fractionDigits) }
} }
} }

View File

@@ -1,89 +1,76 @@
import Elementary import Elementary
import Foundation import Foundation
public struct ResultView< public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
V: Sendable,
E: Error,
ValueView: HTML,
ErrorView: HTML
>: HTML {
let onSuccess: @Sendable (V) -> ValueView let result: Result<ValueView, any Error>
let onError: @Sendable (E) -> ErrorView let errorView: @Sendable (any Error) -> ErrorView
let result: Result<V, E>
public init( public init(
result: Result<V, E>, _ content: @escaping @Sendable () async throws -> ValueView,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView, onError: @escaping @Sendable (any Error) -> ErrorView
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView ) async {
) { self.result = await Result(catching: content)
self.result = result self.errorView = onError
self.onError = onError
self.onSuccess = onSuccess
} }
public var body: some HTML { public var body: some HTML {
switch result { switch result {
case .success(let value): case .success(let view):
onSuccess(value) view
case .failure(let error): case .failure(let error):
onError(error) errorView(error)
} }
} }
} }
extension ResultView { extension ResultView where ErrorView == Styleguide.ErrorView {
public init( public init(
result: Result<V, E>, _ content: @escaping @Sendable () async throws -> ValueView
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView ) async {
) where ErrorView == Styleguide.ErrorView<E> { await self.init(
self.init(result: result, onSuccess: onSuccess) { error in content,
Styleguide.ErrorView(error: error) onError: { Styleguide.ErrorView(error: $0) }
} )
}
public init<V: Sendable>(
catching: @escaping @Sendable () async throws -> V,
onSuccess content: @escaping @Sendable (V) -> ValueView
) async where ValueView: Sendable {
await self.init(
{
try await content(catching())
}
)
} }
public init( public init(
catching: @escaping @Sendable () async throws(E) -> V, catching: @escaping @Sendable () async throws -> Void
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView ) async where ValueView == EmptyHTML {
) async where ErrorView == Styleguide.ErrorView<E> {
await self.init( await self.init(
result: .init(catching: catching), catching: catching,
onSuccess: onSuccess
) { error in
Styleguide.ErrorView(error: error)
}
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
) async where ErrorView == Styleguide.ErrorView<E>, V == Void, ValueView == EmptyHTML {
await self.init(
result: .init(catching: catching),
onSuccess: { EmptyHTML() } onSuccess: { EmptyHTML() }
) { error in )
Styleguide.ErrorView(error: error)
}
} }
} }
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {} extension ResultView: Sendable where ValueView: Sendable, ErrorView: Sendable {}
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable { public struct ErrorView: HTML, Sendable {
let error: any Error
let error: E public init(error: any Error) {
public init(error: E) {
self.error = error self.error = error
} }
public var body: some HTML<HTMLTag.div> { public var body: some HTML<HTMLTag.div> {
div { div {
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" } h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
p { p {
"\(error)" "\(error.localizedDescription)"
} }
} }
} }
} }

View File

@@ -1,78 +0,0 @@
import DatabaseClient
import Dependencies
import Fluent
import ManualDClient
import ManualDCore
import Vapor
// FIX: Remove these, not used currently.
extension DatabaseClient.Projects {
func fetchPage(
userID: User.ID,
page: Int = 1,
limit: Int = 25
) async throws -> Page<Project> {
try await fetch(userID, .init(page: page, per: limit))
}
func fetchPage(
userID: User.ID,
page: PageRequest
) async throws -> Page<Project> {
try await fetch(userID, page)
}
}
extension DatabaseClient {
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
@Dependency(\.manualD) var manualD
return try await manualD.calculate(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
designFrictionRateResult: designFrictionRate(projectID: projectID),
projectSHR: projects.getSensibleHeatRatio(projectID)
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> (EquipmentInfo, EffectiveLength.MaxContainer, Double)? {
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
}
let equivalentLengths = try await effectiveLength.fetchMax(projectID)
guard let tel = equivalentLengths.total else { return nil }
let componentLosses = try await componentLoss.fetch(projectID)
guard componentLosses.count > 0 else { return nil }
let availableStaticPressure =
equipmentInfo.staticPressure - componentLosses.total
let designFrictionRate = (availableStaticPressure * 100) / tel
return (equipmentInfo, equivalentLengths, designFrictionRate)
}
}
extension DatabaseClient.ComponentLoss {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
for loss in defaults {
_ = try await create(loss)
}
}
}
extension PageRequest {
static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

View File

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

View File

@@ -1,38 +0,0 @@
import Logging
import ManualDClient
import ManualDCore
extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
guard let designFrictionRateResult else { return ([], []) }
let equipmentInfo = designFrictionRateResult.0
let effectiveLengths = designFrictionRateResult.1
let designFrictionRate = designFrictionRateResult.2
guard let maxSupply = effectiveLengths.supply else { return ([], []) }
guard let maxReturn = effectiveLengths.return else { return ([], []) }
let ductRooms = try await self.calculateSizes(
rooms: rooms,
trunks: trunks,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,
designFrictionRate: designFrictionRate,
projectSHR: projectSHR ?? 1.0,
logger: logger
)
// logger?.debug("Rooms: \(ductRooms)")
return ductRooms
}
}

View File

@@ -4,7 +4,7 @@ import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm { extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create { func toCreate(logger: Logger? = nil) throws -> TrunkSize.Create {
try .init( try .init(
projectID: projectID, projectID: projectID,
type: type, type: type,
@@ -14,7 +14,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
) )
} }
func toUpdate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Update { func toUpdate(logger: Logger? = nil) throws -> TrunkSize.Update {
try .init( try .init(
type: type, type: type,
rooms: makeRooms(logger: logger), rooms: makeRooms(logger: logger),

View File

@@ -1,3 +1,4 @@
import AuthClient
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Elementary import Elementary
@@ -15,10 +16,6 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient @DependencyClient
public struct ViewController: Sendable { public struct ViewController: Sendable {
public typealias AuthenticateHandler = @Sendable (User) -> Void
public typealias CurrentUserHandler = @Sendable () throws -> User
public var view: @Sendable (Request) async throws -> AnySendableHTML public var view: @Sendable (Request) async throws -> AnySendableHTML
} }
@@ -29,21 +26,15 @@ extension ViewController {
public let route: SiteRoute.View public let route: SiteRoute.View
public let isHtmxRequest: Bool public let isHtmxRequest: Bool
public let logger: Logger public let logger: Logger
public let authenticateUser: AuthenticateHandler
public let currentUser: CurrentUserHandler
public init( public init(
route: SiteRoute.View, route: SiteRoute.View,
isHtmxRequest: Bool, isHtmxRequest: Bool,
logger: Logger, logger: Logger
authenticateUser: @escaping AuthenticateHandler,
currentUser: @escaping CurrentUserHandler
) { ) {
self.route = route self.route = route
self.isHtmxRequest = isHtmxRequest self.isHtmxRequest = isHtmxRequest
self.logger = logger self.logger = logger
self.authenticateUser = authenticateUser
self.currentUser = currentUser
} }
} }
@@ -62,28 +53,23 @@ extension ViewController: DependencyKey {
extension ViewController.Request { extension ViewController.Request {
func currentUser() throws -> User {
@Dependency(\.auth.currentUser) var currentUser
return try currentUser()
}
func authenticate( func authenticate(
_ login: User.Login _ login: User.Login
) async throws -> User { ) async throws -> User {
@Dependency(\.database.users) var users @Dependency(\.auth) var auth
let token = try await users.login(login) return try await auth.login(login)
let user = try await users.get(token.userID)!
authenticateUser(user)
logger.debug("Logged in user: \(user.id)")
return user
} }
@discardableResult @discardableResult
func createAndAuthenticate( func createAndAuthenticate(
_ signup: User.Create _ signup: User.Create
) async throws -> User { ) async throws -> User {
@Dependency(\.database.users) var users @Dependency(\.auth) var auth
let user = try await users.create(signup) return try await auth.createAndLogin(signup)
let _ = try await users.login(
.init(email: signup.email, password: signup.password)
)
authenticateUser(user)
logger.debug("Created and logged in user: \(user.id)")
return user
} }
} }

View File

@@ -3,6 +3,8 @@ import Dependencies
import Elementary import Elementary
import Foundation import Foundation
import ManualDCore import ManualDCore
import PdfClient
import ProjectClient
import Styleguide import Styleguide
extension ViewController.Request { extension ViewController.Request {
@@ -10,20 +12,28 @@ extension ViewController.Request {
func render() async -> AnySendableHTML { func render() async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
@Dependency(\.pdfClient) var pdfClient
switch route { switch route {
case .test: case .test:
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")! // let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
return await view { // return await view {
await ResultView { // await ResultView {
return ( //
try await database.projects.getCompletedSteps(projectID), // // return (
try await database.calculateDuctSizes(projectID: projectID) // // try await database.projects.getCompletedSteps(projectID),
) // // try await projectClient.calculateDuctSizes(projectID)
} onSuccess: { (_, result) in // // )
TestPage(trunks: result.trunks, rooms: result.rooms) // return try await pdfClient.html(.mock())
} // } onSuccess: {
} // $0
// // TestPage()
// // TestPage(trunks: result.trunks, rooms: result.rooms)
// }
// }
// return try! await pdfClient.html(.mock())
return EmptyHTML()
case .login(let route): case .login(let route):
switch route { switch route {
case .index(let next): case .index(let next):
@@ -60,7 +70,7 @@ extension ViewController.Request {
case .submitProfile(let profile): case .submitProfile(let profile):
return await view { return await view {
await ResultView { await ResultView {
_ = try await database.userProfile.create(profile) _ = try await database.userProfiles.create(profile)
let userID = profile.userID let userID = profile.userID
// let user = try currentUser() // let user = try currentUser()
return ( return (
@@ -98,7 +108,7 @@ extension ViewController.Request {
get async { get async {
@Dependency(\.database) var database @Dependency(\.database) var database
guard let user = try? currentUser() else { return nil } 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
} }
} }
@@ -116,6 +126,7 @@ extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML { func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
switch self { switch self {
case .index: case .index:
@@ -124,7 +135,7 @@ extension SiteRoute.View.ProjectRoute {
let user = try request.currentUser() let user = try request.currentUser()
return try await ( return try await (
user.id, user.id,
database.projects.fetchPage(userID: user.id) database.projects.fetch(user.id, .first)
) )
} onSuccess: { (userID, projects) in } onSuccess: { (userID, projects) in
@@ -146,19 +157,14 @@ extension SiteRoute.View.ProjectRoute {
return await request.view { return await request.view {
await ResultView { await ResultView {
let user = try request.currentUser() let user = try request.currentUser()
let project = try await database.projects.create(user.id, form) return try await projectClient.createProject(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id) } onSuccess: { response in
let rooms = try await database.rooms.fetch(project.id)
let shr = try await database.projects.getSensibleHeatRatio(project.id)
let completedSteps = try await database.projects.getCompletedSteps(project.id)
return (project.id, rooms, shr, completedSteps)
} onSuccess: { (projectID, rooms, shr, completedSteps) in
ProjectView( ProjectView(
projectID: projectID, projectID: response.projectID,
activeTab: .rooms, activeTab: .rooms,
completedSteps: completedSteps completedSteps: response.completedSteps
) { ) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr) RoomsView(rooms: response.rooms, sensibleHeatRatio: response.sensibleHeatRatio)
} }
} }
} }
@@ -187,6 +193,12 @@ extension SiteRoute.View.ProjectRoute {
return await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route): case .frictionRate(let route):
return await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
// return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route): case .rooms(let route):
return await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
} }
@@ -354,8 +366,8 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
return await request.view { return await request.view {
await ResultView { await ResultView {
let equipment = try await database.equipment.fetch(projectID) let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID) let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID) let lengths = try await database.equivalentLengths.fetchMax(projectID)
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
@@ -372,7 +384,7 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
FrictionRateView( FrictionRateView(
componentLosses: losses, componentLosses: losses,
equivalentLengths: lengths, equivalentLengths: lengths,
frictionRateResponse: frictionRate frictionRate: frictionRate
) )
} }
@@ -395,16 +407,16 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
return EmptyHTML() return EmptyHTML()
case .delete(let id): case .delete(let id):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id) _ = try await database.componentLosses.delete(id)
} }
case .submit(let form): case .submit(let form):
return await view(on: request, projectID: projectID) { 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): case .update(let id, let form):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form) _ = try await database.componentLosses.update(id, form)
} }
} }
} }
@@ -416,32 +428,21 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
) async -> AnySendableHTML { ) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.manualD) var manualD @Dependency(\.projectClient) var projectClient
return await request.view { return await request.view {
await ResultView { await ResultView {
try await catching() try await catching()
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
componentLosses, try await projectClient.frictionRate(projectID)
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
) )
} onSuccess: { (steps, losses, lengths, frictionRate) in } onSuccess: { (steps, response) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) { ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView( FrictionRateView(
componentLosses: losses, componentLosses: response.componentLosses,
equivalentLengths: lengths, equivalentLengths: response.equivalentLengths,
frictionRateResponse: frictionRate frictionRate: response.frictionRate
) )
} }
@@ -463,7 +464,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .delete(let id): case .delete(let id):
return await ResultView { return await ResultView {
try await database.effectiveLength.delete(id) try await database.equivalentLengths.delete(id)
} }
case .index: case .index:
@@ -480,16 +481,16 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .update(let id, let form): case .update(let id, let form):
return await view(on: request, projectID: projectID) { 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): case .submit(let step):
switch step { switch step {
case .one(let stepOne): case .one(let stepOne):
return await ResultView { return await ResultView {
var effectiveLength: EffectiveLength? = nil var effectiveLength: EquivalentLength? = nil
if let id = stepOne.id { if let id = stepOne.id {
effectiveLength = try await database.effectiveLength.get(id) effectiveLength = try await database.equivalentLengths.get(id)
} }
return effectiveLength return effectiveLength
} onSuccess: { effectiveLength in } onSuccess: { effectiveLength in
@@ -502,9 +503,9 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .two(let stepTwo): case .two(let stepTwo):
return await ResultView { return await ResultView {
request.logger.debug("ViewController: Got step two...") request.logger.debug("ViewController: Got step two...")
var effectiveLength: EffectiveLength? = nil var effectiveLength: EquivalentLength? = nil
if let id = stepTwo.id { if let id = stepTwo.id {
effectiveLength = try await database.effectiveLength.get(id) effectiveLength = try await database.equivalentLengths.get(id)
} }
return effectiveLength return effectiveLength
} onSuccess: { effectiveLength in } onSuccess: { effectiveLength in
@@ -514,7 +515,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
} }
case .three(let stepThree): case .three(let stepThree):
return await view(on: request, projectID: projectID) { return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create( _ = try await database.equivalentLengths.create(
.init(form: stepThree, projectID: projectID) .init(form: stepThree, projectID: projectID)
) )
} }
@@ -535,7 +536,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
try await catching() try await catching()
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID) try await database.equivalentLengths.fetch(projectID)
) )
} onSuccess: { (steps, equivalentLengths) in } onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) { ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
@@ -555,6 +556,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
) async -> AnySendableHTML { ) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.manualD) var manualD @Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
switch self { switch self {
case .index: case .index:
@@ -563,8 +565,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
case .deleteRectangularSize(let roomID, let request): case .deleteRectangularSize(let roomID, let request):
return await ResultView { return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID) let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID) return try await projectClient.calculateRoomDuctSizes(projectID)
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == request.register }) .filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first! .first!
} onSuccess: { room in } onSuccess: { room in
@@ -577,8 +578,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
roomID, roomID,
.init(id: form.id ?? .init(), register: form.register, height: form.height) .init(id: form.id ?? .init(), register: form.register, height: form.height)
) )
return try await database.calculateDuctSizes(projectID: projectID) return try await projectClient.calculateRoomDuctSizes(projectID)
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == form.register }) .filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first! .first!
} onSuccess: { room in } onSuccess: { room in
@@ -612,17 +612,18 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
catching: @escaping @Sendable () async throws -> Void = {} catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML { ) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
@Dependency(\.projectClient) var project
return await request.view { return await request.view {
await ResultView { await ResultView {
try await catching() try await catching()
return ( return (
try await database.projects.getCompletedSteps(projectID), try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID) try await project.calculateDuctSizes(projectID)
) )
} onSuccess: { (steps, ducts) in } onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) { ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks) DuctSizingView(ductSizes: ducts)
} }
} }
} }
@@ -651,11 +652,11 @@ extension SiteRoute.View.UserRoute.Profile {
return await view(on: request) return await view(on: request)
case .submit(let form): case .submit(let form):
return await view(on: request) { return await view(on: request) {
_ = try await database.userProfile.create(form) _ = try await database.userProfiles.create(form)
} }
case .update(let id, let updates): case .update(let id, let updates):
return await view(on: request) { return await view(on: request) {
_ = try await database.userProfile.update(id, updates) _ = try await database.userProfiles.update(id, updates)
} }
} }
} }
@@ -672,7 +673,7 @@ extension SiteRoute.View.UserRoute.Profile {
let user = try request.currentUser() let user = try request.currentUser()
return ( return (
user, user,
try await database.userProfile.fetch(user.id) try await database.userProfiles.fetch(user.id)
) )
} onSuccess: { (user, profile) in } onSuccess: { (user, profile) in
UserView(user: user, profile: profile) UserView(user: user, profile: profile)

View File

@@ -7,8 +7,7 @@ struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer] let ductSizes: DuctSizes
let trunks: [DuctSizing.TrunkContainer]
var body: some HTML { var body: some HTML {
div(.class("space-y-4")) { div(.class("space-y-4")) {
@@ -21,13 +20,30 @@ struct DuctSizingView: HTML, Sendable {
Must complete all the previous sections to display duct sizing calculations. Must complete all the previous sections to display duct sizing calculations.
""" """
) )
.hidden(when: rooms.count > 0) .hidden(when: ductSizes.rooms.count > 0)
.attributes(.class("text-error font-bold italic mt-4")) .attributes(.class("text-error font-bold italic mt-4"))
} }
div {
button(
.class("btn btn-primary"),
.hx.get(route: .project(.detail(projectID, .pdf))),
.hx.ext("htmx-download"),
.hx.swap(.none),
.hx.indicator()
) {
span { "PDF" }
Indicator()
}
// div {
// Indicator()
// }
}
} }
if rooms.count != 0 { if ductSizes.rooms.count != 0 {
RoomsTable(rooms: rooms) RoomsTable(rooms: ductSizes.rooms)
PageTitleRow { PageTitleRow {
PageTitle { PageTitle {
@@ -42,13 +58,13 @@ struct DuctSizingView: HTML, Sendable {
.tooltip("Add trunk / runout") .tooltip("Add trunk / runout")
} }
if trunks.count > 0 { if ductSizes.trunks.count > 0 {
TrunkTable(trunks: trunks, rooms: rooms) TrunkTable(ductSizes: ductSizes)
} }
} }
TrunkSizeForm(rooms: rooms, dismiss: true) TrunkSizeForm(rooms: ductSizes.rooms, dismiss: true)
} }
} }

View File

@@ -5,7 +5,7 @@ import Styleguide
struct RectangularSizeForm: HTML, Sendable { struct RectangularSizeForm: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String { static func id(_ room: DuctSizes.RoomContainer) -> String {
let base = "rectangularSize" let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)" return "\(base)_\(room.roomName.idString)"
} }
@@ -13,12 +13,12 @@ struct RectangularSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let id: String let id: String
let room: DuctSizing.RoomContainer let room: DuctSizes.RoomContainer
let dismiss: Bool let dismiss: Bool
init( init(
id: String? = nil, id: String? = nil,
room: DuctSizing.RoomContainer, room: DuctSizes.RoomContainer,
dismiss: Bool = true dismiss: Bool = true
) { ) {
self.id = Self.id(room) self.id = Self.id(room)
@@ -40,7 +40,7 @@ struct RectangularSizeForm: HTML, Sendable {
} }
var height: Int? { var height: Int? {
room.rectangularSize?.height room.ductSize.height
} }
var body: some HTML<HTMLTag.dialog> { var body: some HTML<HTMLTag.dialog> {
@@ -54,7 +54,7 @@ struct RectangularSizeForm: HTML, Sendable {
.hx.swap(.outerHTML) .hx.swap(.outerHTML)
) { ) {
input(.class("hidden"), .name("register"), .value(room.roomRegister)) input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id)) input(.class("hidden"), .name("id"), .value(room.ductSize.rectangularID))
LabeledInput( LabeledInput(
"Height", "Height",

View File

@@ -9,7 +9,7 @@ extension DuctSizingView {
struct RoomsTable: HTML, Sendable { struct RoomsTable: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer] let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> { var body: some HTML<HTMLTag.table> {
@@ -34,17 +34,17 @@ extension DuctSizingView {
struct RoomRow: HTML, Sendable { struct RoomRow: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String { static func id(_ room: DuctSizes.RoomContainer) -> String {
"roomRow_\(room.roomName.idString)" "roomRow_\(room.roomName.idString)"
} }
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let room: DuctSizing.RoomContainer let room: DuctSizes.RoomContainer
let formID = UUID().idString let formID = UUID().idString
var deleteRoute: String { var deleteRoute: String {
guard let id = room.rectangularSize?.id else { return "" } guard let id = room.rectangularID else { return "" }
return SiteRoute.View.router.path( return SiteRoute.View.router.path(
for: .project( for: .project(
@@ -80,7 +80,7 @@ extension DuctSizingView {
span(.class("label")) { "Design" } span(.class("label")) { "Design" }
div(.class("flex justify-center")) { div(.class("flex justify-center")) {
Badge(number: room.designCFM.value, digits: 0) Badge(number: room.ductSize.designCFM.value, digits: 0)
} }
span(.class("label")) { "Heating" } span(.class("label")) { "Heating" }
@@ -103,28 +103,28 @@ extension DuctSizingView {
div(.class("label")) { "Calculated" } div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) { div(.class("flex justify-center")) {
Badge(number: room.roundSize, digits: 2) Badge(number: room.ductSize.roundSize, digits: 2)
} }
div {} div {}
div(.class("label")) { "Final" } div(.class("label")) { "Final" }
div(.class("flex justify-center")) { div(.class("flex justify-center")) {
Badge(number: room.finalSize) Badge(number: room.ductSize.finalSize)
.attributes(.class("badge-secondary")) .attributes(.class("badge-secondary"))
} }
div {} div {}
div(.class("label")) { "Flex" } div(.class("label")) { "Flex" }
div(.class("flex justify-center")) { div(.class("flex justify-center")) {
Badge(number: room.flexSize) Badge(number: room.ductSize.flexSize)
.attributes(.class("badge-primary")) .attributes(.class("badge-primary"))
} }
div {} div {}
div(.class("label")) { "Rectangular" } div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) { div(.class("flex justify-center")) {
if let width = room.rectangularWidth, if let width = room.ductSize.width,
let height = room.rectangularSize?.height let height = room.ductSize.height
{ {
Badge { Badge {
span { "\(width) x \(height)" } span { "\(width) x \(height)" }
@@ -134,7 +134,7 @@ extension DuctSizingView {
} }
div(.class("flex justify-end")) { div(.class("flex justify-end")) {
div(.class("join")) { div(.class("join")) {
if room.rectangularSize != nil { if room.ductSize.width != nil {
Tooltip("Delete Size", position: .bottom) { Tooltip("Delete Size", position: .bottom) {
TrashButton() TrashButton()
.attributes(.class("join-item btn-ghost")) .attributes(.class("join-item btn-ghost"))
@@ -142,7 +142,7 @@ extension DuctSizingView {
.hx.delete(deleteRoute), .hx.delete(deleteRoute),
.hx.target("#\(rowID)"), .hx.target("#\(rowID)"),
.hx.swap(.outerHTML), .hx.swap(.outerHTML),
when: room.rectangularSize != nil when: room.ductSize.width != nil
) )
} }
} }

View File

@@ -5,7 +5,7 @@ import Styleguide
struct TrunkSizeForm: HTML, Sendable { struct TrunkSizeForm: HTML, Sendable {
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String { static func id(_ trunk: DuctSizes.TrunkContainer? = nil) -> String {
let base = "trunkSizeForm" let base = "trunkSizeForm"
guard let trunk else { return base } guard let trunk else { return base }
return "\(base)_\(trunk.id.idString)" return "\(base)_\(trunk.id.idString)"
@@ -13,17 +13,17 @@ struct TrunkSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let container: DuctSizing.TrunkContainer? let container: DuctSizes.TrunkContainer?
let rooms: [DuctSizing.RoomContainer] let rooms: [DuctSizes.RoomContainer]
let dismiss: Bool let dismiss: Bool
var trunk: DuctSizing.TrunkSize? { var trunk: TrunkSize? {
container?.trunk container?.trunk
} }
init( init(
trunk: DuctSizing.TrunkContainer? = nil, trunk: DuctSizes.TrunkContainer? = nil,
rooms: [DuctSizing.RoomContainer], rooms: [DuctSizes.RoomContainer],
dismiss: Bool = true dismiss: Bool = true
) { ) {
self.container = trunk self.container = trunk
@@ -56,7 +56,7 @@ struct TrunkSizeForm: HTML, Sendable {
label(.class("select w-full")) { label(.class("select w-full")) {
span(.class("label")) { "Type" } span(.class("label")) { "Type" }
select(.name("type")) { select(.name("type")) {
for type in DuctSizing.TrunkSize.TrunkType.allCases { for type in TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized } option(.value(type.rawValue)) { type.rawValue.capitalized }
.attributes(.selected, when: trunk?.type == type) .attributes(.selected, when: trunk?.type == type)
} }
@@ -121,8 +121,8 @@ struct TrunkSizeForm: HTML, Sendable {
} }
extension Array where Element == DuctSizing.TrunkSize.RoomProxy { extension Array where Element == TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool { func hasRoom(_ room: DuctSizes.RoomContainer) -> Bool {
first { first {
$0.id == room.roomID $0.id == room.roomID
&& $0.registers.contains(room.roomRegister) && $0.registers.contains(room.roomRegister)

View File

@@ -7,11 +7,10 @@ extension DuctSizingView {
struct TrunkTable: HTML, Sendable { struct TrunkTable: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer] let ductSizes: DuctSizes
let rooms: [DuctSizing.RoomContainer]
private var sortedTrunks: [DuctSizing.TrunkContainer] { private var sortedTrunks: [DuctSizes.TrunkContainer] {
trunks ductSizes.trunks
.sorted(by: { $0.designCFM.value > $1.designCFM.value }) .sorted(by: { $0.designCFM.value > $1.designCFM.value })
.sorted(by: { $0.type.rawValue > $1.type.rawValue }) .sorted(by: { $0.type.rawValue > $1.type.rawValue })
} }
@@ -29,7 +28,7 @@ extension DuctSizingView {
} }
tbody { tbody {
for trunk in sortedTrunks { for trunk in sortedTrunks {
TrunkRow(trunk: trunk, rooms: rooms) TrunkRow(trunk: trunk, rooms: ductSizes.rooms)
} }
} }
} }
@@ -41,8 +40,8 @@ extension DuctSizingView {
@Environment(ProjectViewValue.$projectID) var projectID @Environment(ProjectViewValue.$projectID) var projectID
let trunk: DuctSizing.TrunkContainer let trunk: DuctSizes.TrunkContainer
let rooms: [DuctSizing.RoomContainer] let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.tr> { var body: some HTML<HTMLTag.tr> {
tr { tr {
@@ -135,17 +134,18 @@ extension DuctSizingView {
} }
private var registerIDS: [String] { private var registerIDS: [String] {
trunk.rooms.reduce(into: []) { array, room in trunk.registerIDS(rooms: rooms)
array = room.registers.reduce(into: array) { array, register in // trunk.rooms.reduce(into: []) { array, room in
if let room = // array = room.registers.reduce(into: array) { array, register in
rooms // if let room =
.first(where: { $0.roomID == room.id && $0.roomRegister == register }) // rooms
{ // .first(where: { $0.roomID == room.id && $0.roomRegister == register })
array.append(room.roomName) // {
} // array.append(room.roomName)
} // }
} // }
.sorted() // }
// .sorted()
} }
} }

View File

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

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