Compare commits
50 Commits
0.1.1
...
5f03056534
| Author | SHA1 | Date | |
|---|---|---|---|
|
5f03056534
|
|||
|
10dd0dac82
|
|||
|
3cc7fe9926
|
|||
|
bad4a49f41
|
|||
|
9276f88426
|
|||
|
a3fb87f86e
|
|||
|
b359a3317f
|
|||
|
e0ec15b91e
|
|||
|
44a0964181
|
|||
|
0b78950d14
|
|||
|
754019eac4
|
|||
|
a51e1b34d0
|
|||
|
c32ffcff8c
|
|||
|
4f3cc2c7ea
|
|||
|
9b618d55fa
|
|||
|
9379774fae
|
|||
|
18a5ef06d3
|
|||
|
6723f7a410
|
|||
|
5440024038
|
|||
|
f005b43936
|
|||
|
f44b35ab3d
|
|||
|
bbf9a8b390
|
|||
|
c52cee212f
|
|||
|
2e2c424850
|
|||
|
93894e4c25
|
|||
|
bab031f241
|
|||
|
c82f20bb60
|
|||
|
458b3bd644
|
|||
|
58023c4dbc
|
|||
|
30241fec60
|
|||
|
273da46db2
|
|||
|
6064b5267a
|
|||
|
69e8acc5d8
|
|||
|
066b3003d0
|
|||
|
1663c0a514
|
|||
|
e08d896758
|
|||
|
b3c6c27a96
|
|||
|
5fa11ae584
|
|||
|
04a7405ca4
|
|||
|
0fe80d05c6
|
|||
|
3ec1ee2814
|
|||
|
761ba29c1e
|
|||
|
13c4bb33b5
|
|||
|
146baa7815
|
|||
|
b5436c2073
|
|||
|
59c1c9ec4a
|
|||
|
65fc8565b6
|
|||
|
d14477e97a
|
|||
|
dbec7fb920
|
|||
|
6b8cb73434
|
40
.devcontainer/devcontainer.json
Normal file
40
.devcontainer/devcontainer.json
Normal 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
31
.gitea/workflows/ci.yaml
Normal 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
5
.gitignore
vendored
@@ -9,3 +9,8 @@ DerivedData/
|
||||
.swift-version
|
||||
node_modules/
|
||||
tailwindcss
|
||||
.envrc
|
||||
*.pdf
|
||||
.env
|
||||
.env*
|
||||
default.profraw
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2",
|
||||
"originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -73,6 +73,15 @@
|
||||
"version" : "1.53.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "fluent-postgres-driver",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
|
||||
"state" : {
|
||||
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
|
||||
"version" : "2.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "fluent-sqlite-driver",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -100,6 +109,24 @@
|
||||
"version" : "0.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "postgres-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/postgres-kit.git",
|
||||
"state" : {
|
||||
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
|
||||
"version" : "2.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "postgres-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/postgres-nio.git",
|
||||
"state" : {
|
||||
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
|
||||
"version" : "1.30.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "routing-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -226,6 +253,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -361,6 +397,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -379,6 +424,15 @@
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-tagged",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||
"state" : {
|
||||
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||
"version" : "0.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-url-routing",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -388,6 +442,15 @@
|
||||
"version" : "0.6.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-validations",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/m-housh/swift-validations.git",
|
||||
"state" : {
|
||||
"revision" : "95ea5d267e37f6cdb9f91c5c8a01e718b9299db6",
|
||||
"version" : "0.3.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vapor",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
104
Package.swift
104
Package.swift
@@ -6,8 +6,13 @@ let package = Package(
|
||||
name: "swift-manual-d",
|
||||
products: [
|
||||
.executable(name: "App", targets: ["App"]),
|
||||
.library(name: "ApiController", targets: ["ApiController"]),
|
||||
.library(name: "AuthClient", targets: ["AuthClient"]),
|
||||
.library(name: "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: "ManualDClient", targets: ["ManualDClient"]),
|
||||
.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/fluent.git", from: "4.9.0"),
|
||||
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
|
||||
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
|
||||
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
|
||||
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
|
||||
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
|
||||
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.1.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "App",
|
||||
dependencies: [
|
||||
.target(name: "ApiController"),
|
||||
.target(name: "AuthClient"),
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "ViewController"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
.product(name: "NIOCore", package: "swift-nio"),
|
||||
.product(name: "NIOPosix", package: "swift-nio"),
|
||||
@@ -44,13 +54,12 @@ let package = Package(
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "ApiController",
|
||||
name: "AuthClient",
|
||||
dependencies: [
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "ManualDCore"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
@@ -61,29 +70,87 @@ let package = Package(
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
.product(name: "Validations", package: "swift-validations"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "DatabaseClientTests",
|
||||
dependencies: [
|
||||
.target(name: "App"),
|
||||
.target(name: "DatabaseClient"),
|
||||
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
|
||||
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "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(
|
||||
name: "ManualDCore",
|
||||
dependencies: [
|
||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "URLRouting", package: "swift-url-routing"),
|
||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ApiRouteTests",
|
||||
dependencies: [
|
||||
.target(name: "ManualDCore")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "ManualDClient",
|
||||
dependencies: [
|
||||
"ManualDCore",
|
||||
.target(name: "ManualDCore"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Tagged", package: "swift-tagged"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
@@ -104,7 +171,10 @@ let package = Package(
|
||||
.target(
|
||||
name: "ViewController",
|
||||
dependencies: [
|
||||
.target(name: "AuthClient"),
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "PdfClient"),
|
||||
.target(name: "ProjectClient"),
|
||||
.target(name: "ManualDClient"),
|
||||
.target(name: "ManualDCore"),
|
||||
.target(name: "Styleguide"),
|
||||
@@ -115,5 +185,15 @@ let package = Package(
|
||||
.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
12
Public/css/htmx.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||
--color-red-600: oklch(57.7% 0.245 27.325);
|
||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||
--color-slate-300: oklch(86.9% 0.022 252.894);
|
||||
--color-slate-900: oklch(20.8% 0.042 265.755);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-gray-400: oklch(70.7% 0.022 261.325);
|
||||
--color-black: #000;
|
||||
@@ -30,7 +25,6 @@
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--font-weight-bold: 700;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -6218,6 +6212,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -6982,9 +6979,6 @@
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.rounded-selector {
|
||||
border-radius: var(--radius-selector);
|
||||
}
|
||||
@@ -7449,15 +7443,9 @@
|
||||
.bg-error {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
.bg-red-500 {
|
||||
background-color: var(--color-red-500);
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.divider-accent {
|
||||
@layer daisyui.l1.l2 {
|
||||
&:before, &:after {
|
||||
@@ -7866,15 +7854,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.py-1\.5 {
|
||||
padding-block: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -8530,15 +8512,9 @@
|
||||
.text-info {
|
||||
color: var(--color-info);
|
||||
}
|
||||
.text-slate-900 {
|
||||
color: var(--color-slate-900);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
@@ -8607,10 +8583,6 @@
|
||||
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-lg {
|
||||
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-sm {
|
||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
@@ -8619,13 +8591,6 @@
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.outline-1 {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.-outline-offset-1 {
|
||||
outline-offset: calc(1px * -1);
|
||||
}
|
||||
.btn-ghost {
|
||||
@layer daisyui.l1 {
|
||||
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
|
||||
@@ -8650,9 +8615,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.outline-slate-300 {
|
||||
outline-color: var(--color-slate-300);
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
@@ -9406,16 +9368,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.invalid\:border-red-500 {
|
||||
&:invalid {
|
||||
border-color: var(--color-red-500);
|
||||
}
|
||||
}
|
||||
.out-of-range\:border-red-500 {
|
||||
&:out-of-range {
|
||||
border-color: var(--color-red-500);
|
||||
}
|
||||
}
|
||||
.hover\:bg-neutral {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -9423,13 +9375,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-red-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-red-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-white {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -9437,22 +9382,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:outline {
|
||||
&:focus {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
}
|
||||
.focus\:-outline-offset-2 {
|
||||
&:focus {
|
||||
outline-offset: calc(2px * -1);
|
||||
}
|
||||
}
|
||||
.focus\:outline-indigo-600 {
|
||||
&:focus {
|
||||
outline-color: var(--color-indigo-600);
|
||||
}
|
||||
}
|
||||
.data-active\:bg-neutral {
|
||||
&[data-active] {
|
||||
background-color: var(--color-neutral);
|
||||
|
||||
109
Public/css/pdf.css
Normal file
109
Public/css/pdf.css
Normal 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;
|
||||
}
|
||||
63
Public/js/htmx-download.js
Normal file
63
Public/js/htmx-download.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,37 @@
|
||||
import ApiController
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
|
||||
extension ApiController {
|
||||
|
||||
func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
||||
-> any AsyncResponseEncodable
|
||||
{
|
||||
guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
||||
return HTTPStatus.ok
|
||||
}
|
||||
return AnyJSONResponse(value: encodable)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnyJSONResponse: AsyncResponseEncodable {
|
||||
public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
||||
let value: any Encodable
|
||||
|
||||
init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
||||
if additionalHeaders.contains(name: .contentType) {
|
||||
self.headers = additionalHeaders
|
||||
} else {
|
||||
headers.add(contentsOf: additionalHeaders)
|
||||
}
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func encodeResponse(for request: Request) async throws -> Response {
|
||||
try Response(
|
||||
status: .ok,
|
||||
headers: headers,
|
||||
body: .init(data: JSONEncoder().encode(value))
|
||||
)
|
||||
}
|
||||
}
|
||||
// import ApiController
|
||||
// import ManualDCore
|
||||
// import Vapor
|
||||
//
|
||||
// extension ApiController {
|
||||
//
|
||||
// func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
||||
// -> any AsyncResponseEncodable
|
||||
// {
|
||||
// guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
||||
// return HTTPStatus.ok
|
||||
// }
|
||||
// return AnyJSONResponse(value: encodable)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// struct AnyJSONResponse: AsyncResponseEncodable {
|
||||
// public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
||||
// let value: any Encodable
|
||||
//
|
||||
// init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
||||
// if additionalHeaders.contains(name: .contentType) {
|
||||
// self.headers = additionalHeaders
|
||||
// } else {
|
||||
// headers.add(contentsOf: additionalHeaders)
|
||||
// }
|
||||
// self.value = value
|
||||
// }
|
||||
//
|
||||
// func encodeResponse(for request: Request) async throws -> Response {
|
||||
// try Response(
|
||||
// status: .ok,
|
||||
// headers: headers,
|
||||
// body: .init(data: JSONEncoder().encode(value))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -12,11 +12,7 @@ extension ViewController {
|
||||
.init(
|
||||
route: route,
|
||||
isHtmxRequest: request.isHtmxRequest,
|
||||
logger: request.logger,
|
||||
authenticateUser: { request.session.authenticate($0) },
|
||||
currentUser: {
|
||||
try request.auth.require(User.self)
|
||||
}
|
||||
logger: request.logger
|
||||
)
|
||||
)
|
||||
return AnyHTMLResponse(value: html)
|
||||
|
||||
@@ -1,37 +1,47 @@
|
||||
import ApiController
|
||||
// import ApiController
|
||||
import AuthClient
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import EnvVars
|
||||
import ManualDCore
|
||||
import PdfClient
|
||||
import Vapor
|
||||
import ViewController
|
||||
|
||||
// Taken from discussions page on `swift-dependencies`.
|
||||
|
||||
// FIX: Use live view controller.
|
||||
struct DependenciesMiddleware: AsyncMiddleware {
|
||||
|
||||
private let values: DependencyValues.Continuation
|
||||
private let apiController: ApiController
|
||||
// private let apiController: ApiController
|
||||
private let database: DatabaseClient
|
||||
private let environment: EnvVars
|
||||
private let viewController: ViewController
|
||||
|
||||
init(
|
||||
database: DatabaseClient,
|
||||
apiController: ApiController = .liveValue,
|
||||
environment: EnvVars,
|
||||
// apiController: ApiController = .liveValue,
|
||||
viewController: ViewController = .liveValue
|
||||
) {
|
||||
self.values = withEscapedDependencies { $0 }
|
||||
self.apiController = apiController
|
||||
// self.apiController = apiController
|
||||
self.database = database
|
||||
self.environment = environment
|
||||
self.viewController = viewController
|
||||
}
|
||||
|
||||
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
||||
try await values.yield {
|
||||
try await withDependencies {
|
||||
$0.apiController = apiController
|
||||
// $0.apiController = apiController
|
||||
$0.auth = .live(on: request)
|
||||
$0.database = database
|
||||
$0.environment = environment
|
||||
// $0.dateFormatter = .liveValue
|
||||
$0.viewController = viewController
|
||||
$0.pdfClient = .liveValue
|
||||
$0.fileClient = .live(fileIO: request.fileio)
|
||||
} operation: {
|
||||
try await next.respond(to: request)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ private let viewRouteMiddleware: [any Middleware] = [
|
||||
extension SiteRoute.View {
|
||||
var middleware: [any Middleware]? {
|
||||
switch self {
|
||||
case .project, .user:
|
||||
return viewRouteMiddleware
|
||||
case .login, .signup, .test:
|
||||
return nil
|
||||
case .project, .user:
|
||||
return viewRouteMiddleware
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import EnvVars
|
||||
import Fluent
|
||||
import FluentPostgresDriver
|
||||
import FluentSQLiteDriver
|
||||
import ManualDCore
|
||||
import NIOSSL
|
||||
import ProjectClient
|
||||
import Vapor
|
||||
import VaporElementary
|
||||
@preconcurrency import VaporRouting
|
||||
@@ -13,12 +16,15 @@ import ViewController
|
||||
// configures your application
|
||||
public func configure(
|
||||
_ app: Application,
|
||||
in environment: EnvVars,
|
||||
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
|
||||
) async throws {
|
||||
// Setup the database client.
|
||||
let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient)
|
||||
let databaseClient = try await setupDatabase(
|
||||
on: app, environment: environment, factory: makeDatabaseClient
|
||||
)
|
||||
// Add the global middlewares.
|
||||
addMiddleware(to: app, database: databaseClient)
|
||||
addMiddleware(to: app, database: databaseClient, environment: environment)
|
||||
#if DEBUG
|
||||
// Live reload of the application for development when launched with the `./swift-dev` command
|
||||
// app.lifecycle.use(BrowserSyncHandler())
|
||||
@@ -32,7 +38,11 @@ public func configure(
|
||||
addCommands(to: app)
|
||||
}
|
||||
|
||||
private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) {
|
||||
private func addMiddleware(
|
||||
to app: Application,
|
||||
database databaseClient: DatabaseClient,
|
||||
environment: EnvVars
|
||||
) {
|
||||
// cors middleware should come before default error middleware using `at: .beginning`
|
||||
let corsConfiguration = CORSMiddleware.Configuration(
|
||||
allowedOrigin: .all,
|
||||
@@ -47,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
|
||||
|
||||
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||
app.middleware.use(app.sessions.middleware)
|
||||
app.middleware.use(DependenciesMiddleware(database: databaseClient))
|
||||
app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
|
||||
}
|
||||
|
||||
private func setupDatabase(
|
||||
on app: Application,
|
||||
environment: EnvVars,
|
||||
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
|
||||
) async throws -> DatabaseClient {
|
||||
switch app.environment {
|
||||
case .production, .development:
|
||||
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite"
|
||||
case .production:
|
||||
let configuration = try environment.postgresConfiguration()
|
||||
app.databases.use(.postgres(configuration: configuration), as: .psql)
|
||||
case .development:
|
||||
let dbFileName = environment.sqlitePath ?? "db.sqlite"
|
||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
|
||||
default:
|
||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
|
||||
@@ -64,9 +78,7 @@ private func setupDatabase(
|
||||
|
||||
let databaseClient = makeDatabaseClient(app.db)
|
||||
|
||||
if app.environment != .testing {
|
||||
try await app.migrations.add(databaseClient.migrations.run())
|
||||
}
|
||||
try await app.migrations.add(databaseClient.migrations())
|
||||
|
||||
return databaseClient
|
||||
}
|
||||
@@ -101,8 +113,6 @@ extension SiteRoute {
|
||||
|
||||
fileprivate func middleware() -> [any Middleware]? {
|
||||
switch self {
|
||||
case .api:
|
||||
return nil
|
||||
case .health:
|
||||
return nil
|
||||
case .view(let route):
|
||||
@@ -111,20 +121,51 @@ extension SiteRoute {
|
||||
}
|
||||
}
|
||||
|
||||
extension DuctSizes: Content {}
|
||||
|
||||
@Sendable
|
||||
private func siteHandler(
|
||||
request: Request,
|
||||
route: SiteRoute
|
||||
) async throws -> any AsyncResponseEncodable {
|
||||
@Dependency(\.apiController) var apiController
|
||||
@Dependency(\.viewController) var viewController
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
|
||||
switch route {
|
||||
case .api(let route):
|
||||
return try await apiController.respond(route, request: request)
|
||||
case .health:
|
||||
return HTTPStatus.ok
|
||||
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
|
||||
// need to handle it seperately.
|
||||
case .view(.project(.detail(let projectID, .pdf))):
|
||||
return try await projectClient.generatePdf(projectID)
|
||||
case .view(let route):
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import EnvVars
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
@@ -17,11 +18,15 @@ enum Entrypoint {
|
||||
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
|
||||
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
|
||||
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
|
||||
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
|
||||
let executorTakeoverSuccess =
|
||||
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||
app.logger.debug(
|
||||
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
|
||||
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
|
||||
)
|
||||
|
||||
do {
|
||||
try await configure(app)
|
||||
try await configure(app, in: EnvVars.live())
|
||||
} catch {
|
||||
app.logger.report(error: error)
|
||||
try? await app.asyncShutdown()
|
||||
|
||||
57
Sources/AuthClient/Interface.swift
Normal file
57
Sources/AuthClient/Interface.swift
Normal 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,132 @@ import FluentKit
|
||||
import ManualDCore
|
||||
|
||||
extension DependencyValues {
|
||||
/// The database dependency.
|
||||
public var database: DatabaseClient {
|
||||
get { self[DatabaseClient.self] }
|
||||
set { self[DatabaseClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the database interactions used by the application.
|
||||
@DependencyClient
|
||||
public struct DatabaseClient: Sendable {
|
||||
/// Database migrations.
|
||||
public var migrations: Migrations
|
||||
/// Interactions with the projects table.
|
||||
public var projects: Projects
|
||||
/// Interactions with the rooms table.
|
||||
public var rooms: Rooms
|
||||
/// Interactions with the equipment table.
|
||||
public var equipment: Equipment
|
||||
public var componentLoss: ComponentLoss
|
||||
public var effectiveLength: EffectiveLengthClient
|
||||
/// Interactions with the component losses table.
|
||||
public var componentLosses: ComponentLosses
|
||||
/// Interactions with the equivalent lengths table.
|
||||
public var equivalentLengths: EquivalentLengths
|
||||
/// Interactions with the users table.
|
||||
public var users: Users
|
||||
public var userProfile: UserProfile
|
||||
/// Interactions with the user profiles table.
|
||||
public var userProfiles: UserProfiles
|
||||
/// Interactions with the trunk sizes table.
|
||||
public var trunkSizes: TrunkSizes
|
||||
|
||||
@DependencyClient
|
||||
public struct ComponentLosses: Sendable {
|
||||
public var create:
|
||||
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
||||
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
||||
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
||||
public var update:
|
||||
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
||||
ComponentPressureLoss
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct EquivalentLengths: Sendable {
|
||||
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
|
||||
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
|
||||
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
|
||||
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
|
||||
public var update:
|
||||
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct Equipment: Sendable {
|
||||
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
|
||||
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
||||
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
||||
public var update:
|
||||
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct Migrations: Sendable {
|
||||
public var all: @Sendable () async throws -> [any AsyncMigration]
|
||||
|
||||
public func callAsFunction() async throws -> [any AsyncMigration] {
|
||||
try await self.all()
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct Projects: Sendable {
|
||||
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
||||
public var delete: @Sendable (Project.ID) async throws -> Void
|
||||
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
|
||||
public var get: @Sendable (Project.ID) async throws -> Project?
|
||||
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
|
||||
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
||||
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
||||
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct Rooms: Sendable {
|
||||
public var create: @Sendable (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 {
|
||||
@@ -29,10 +138,10 @@ extension DatabaseClient: TestDependencyKey {
|
||||
projects: .testValue,
|
||||
rooms: .testValue,
|
||||
equipment: .testValue,
|
||||
componentLoss: .testValue,
|
||||
effectiveLength: .testValue,
|
||||
componentLosses: .testValue,
|
||||
equivalentLengths: .testValue,
|
||||
users: .testValue,
|
||||
userProfile: .testValue,
|
||||
userProfiles: .testValue,
|
||||
trunkSizes: .testValue
|
||||
)
|
||||
|
||||
@@ -42,40 +151,11 @@ extension DatabaseClient: TestDependencyKey {
|
||||
projects: .live(database: database),
|
||||
rooms: .live(database: database),
|
||||
equipment: .live(database: database),
|
||||
componentLoss: .live(database: database),
|
||||
effectiveLength: .live(database: database),
|
||||
componentLosses: .live(database: database),
|
||||
equivalentLengths: .live(database: database),
|
||||
users: .live(database: database),
|
||||
userProfile: .live(database: database),
|
||||
userProfiles: .live(database: database),
|
||||
trunkSizes: .live(database: database)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Migrations: Sendable {
|
||||
public var run: @Sendable () async throws -> [any AsyncMigration]
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
48
Sources/DatabaseClient/Internal/Array+validator.swift
Normal file
48
Sources/DatabaseClient/Internal/Array+validator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,19 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import SQLKit
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct ComponentLoss: Sendable {
|
||||
public var create:
|
||||
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
||||
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
||||
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
||||
public var update:
|
||||
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
||||
ComponentPressureLoss
|
||||
}
|
||||
}
|
||||
|
||||
extension DatabaseClient.ComponentLoss: TestDependencyKey {
|
||||
extension DatabaseClient.ComponentLosses: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
}
|
||||
|
||||
extension DatabaseClient.ComponentLoss {
|
||||
extension DatabaseClient.ComponentLosses {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel()
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -48,13 +36,13 @@ extension DatabaseClient.ComponentLoss {
|
||||
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
||||
},
|
||||
update: { id, updates in
|
||||
try updates.validate()
|
||||
// try updates.validate()
|
||||
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -64,40 +52,9 @@ extension DatabaseClient.ComponentLoss {
|
||||
|
||||
extension ComponentPressureLoss.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> ComponentLossModel {
|
||||
try validate()
|
||||
func toModel() -> ComponentLossModel {
|
||||
return .init(name: name, value: value, projectID: projectID)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Component loss name should not be empty.")
|
||||
}
|
||||
guard value > 0 else {
|
||||
throw ValidationError("Component loss value should be greater than 0.")
|
||||
}
|
||||
guard value < 1.0 else {
|
||||
throw ValidationError("Component loss value should be less than 1.0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentPressureLoss.Update {
|
||||
func validate() throws(ValidationError) {
|
||||
if let name {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Component loss name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let value {
|
||||
guard value > 0 else {
|
||||
throw ValidationError("Component loss value should be greater than 0.")
|
||||
}
|
||||
guard value < 1.0 else {
|
||||
throw ValidationError("Component loss value should be less than 1.0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentPressureLoss {
|
||||
@@ -184,3 +141,19 @@ final class ComponentLossModel: Model, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentLossModel: Validatable {
|
||||
|
||||
var body: some Validation<ComponentLossModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(\.value) {
|
||||
Double.greaterThan(0.0)
|
||||
Double.lessThanOrEquals(1.0)
|
||||
}
|
||||
.errorLabel("Value", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,16 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Equipment: Sendable {
|
||||
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
|
||||
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
||||
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
||||
public var update:
|
||||
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
||||
}
|
||||
}
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Equipment: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
}
|
||||
|
||||
extension DatabaseClient.Equipment {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel()
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -51,10 +38,9 @@ extension DatabaseClient.Equipment {
|
||||
guard let model = try await EquipmentModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -64,8 +50,7 @@ extension DatabaseClient.Equipment {
|
||||
|
||||
extension EquipmentInfo.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> EquipmentModel {
|
||||
try validate()
|
||||
func toModel() -> EquipmentModel {
|
||||
return .init(
|
||||
staticPressure: staticPressure,
|
||||
heatingCFM: heatingCFM,
|
||||
@@ -74,44 +59,6 @@ extension EquipmentInfo.Create {
|
||||
)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard staticPressure >= 0 else {
|
||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
||||
}
|
||||
guard staticPressure <= 1.0 else {
|
||||
throw ValidationError("Equipment info static pressure should be less than 1.0.")
|
||||
}
|
||||
guard heatingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
guard coolingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentInfo.Update {
|
||||
var hasUpdates: Bool {
|
||||
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let staticPressure {
|
||||
guard staticPressure >= 0 else {
|
||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
||||
}
|
||||
}
|
||||
if let heatingCFM {
|
||||
guard heatingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
if let coolingCFM {
|
||||
guard coolingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentInfo {
|
||||
@@ -211,3 +158,22 @@ final class EquipmentModel: Model, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentModel: Validatable {
|
||||
|
||||
var body: some Validation<EquipmentModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.staticPressure) {
|
||||
Double.greaterThan(0.0)
|
||||
Double.lessThan(1.0)
|
||||
}
|
||||
.errorLabel("Static Pressure", inline: true)
|
||||
|
||||
Validator.validate(\.heatingCFM, with: .greaterThan(0))
|
||||
.errorLabel("Heating CFM", inline: true)
|
||||
|
||||
Validator.validate(\.coolingCFM, with: .greaterThan(0))
|
||||
.errorLabel("Cooling CFM", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,16 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct EffectiveLengthClient: Sendable {
|
||||
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
||||
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
||||
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
|
||||
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
|
||||
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
|
||||
public var update:
|
||||
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
|
||||
}
|
||||
}
|
||||
|
||||
extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
||||
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -66,7 +54,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
||||
}
|
||||
try model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -74,10 +62,12 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLength.Create {
|
||||
extension EquivalentLength.Create {
|
||||
|
||||
func toModel() throws -> EffectiveLengthModel {
|
||||
try validate()
|
||||
if groups.count > 0 {
|
||||
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||
}
|
||||
return try .init(
|
||||
name: name,
|
||||
type: type.rawValue,
|
||||
@@ -86,15 +76,9 @@ extension EffectiveLength.Create {
|
||||
projectID: projectID
|
||||
)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Effective length name can not be empty.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLength {
|
||||
extension EquivalentLength {
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
let name = "CreateEffectiveLength"
|
||||
@@ -173,20 +157,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
||||
$project.id = projectID
|
||||
}
|
||||
|
||||
func toDTO() throws -> EffectiveLength {
|
||||
func toDTO() throws -> EquivalentLength {
|
||||
try .init(
|
||||
id: requireID(),
|
||||
projectID: $project.id,
|
||||
name: name,
|
||||
type: .init(rawValue: type)!,
|
||||
straightLengths: straightLengths,
|
||||
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups),
|
||||
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
|
||||
createdAt: createdAt!,
|
||||
updatedAt: updatedAt!
|
||||
)
|
||||
}
|
||||
|
||||
func applyUpdates(_ updates: EffectiveLength.Update) throws {
|
||||
func applyUpdates(_ updates: EquivalentLength.Update) throws {
|
||||
if let name = updates.name, name != self.name {
|
||||
self.name = name
|
||||
}
|
||||
@@ -197,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
||||
self.straightLengths = straightLengths
|
||||
}
|
||||
if let groups = updates.groups {
|
||||
if groups.count > 0 {
|
||||
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||
}
|
||||
self.groups = try JSONEncoder().encode(groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLengthModel: Validatable {
|
||||
|
||||
var body: some Validation<EffectiveLengthModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(
|
||||
\.straightLengths,
|
||||
with: [Int].empty().or(
|
||||
ForEachValidator {
|
||||
Int.greaterThan(0)
|
||||
})
|
||||
)
|
||||
.errorLabel("Straight Lengths", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquivalentLength.FittingGroup: Validatable {
|
||||
|
||||
public var body: some Validation<Self> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.group) {
|
||||
Int.greaterThanOrEquals(1)
|
||||
Int.lessThanOrEquals(12)
|
||||
}
|
||||
.errorLabel("Group", inline: true)
|
||||
|
||||
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
|
||||
.errorLabel("Letter", inline: true)
|
||||
|
||||
Validator.validate(\.value, with: .greaterThan(0))
|
||||
.errorLabel("Value", inline: true)
|
||||
|
||||
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
|
||||
.errorLabel("Quantity", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/DatabaseClient/Internal/Migrations.swift
Normal file
22
Sources/DatabaseClient/Internal/Migrations.swift
Normal 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(),
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
10
Sources/DatabaseClient/Internal/Model+validateAndSave.swift
Normal file
10
Sources/DatabaseClient/Internal/Model+validateAndSave.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Projects: Sendable {
|
||||
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
||||
public var delete: @Sendable (Project.ID) async throws -> Void
|
||||
public var get: @Sendable (Project.ID) async throws -> Project?
|
||||
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
|
||||
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
||||
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
||||
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
||||
}
|
||||
}
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Projects: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -23,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { userID, request in
|
||||
let model = try request.toModel(userID: userID)
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel(userID: userID)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -33,47 +21,51 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
}
|
||||
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
|
||||
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
|
||||
},
|
||||
getCompletedSteps: { id in
|
||||
let roomsCount = try await RoomModel.query(on: database)
|
||||
.with(\.$project)
|
||||
.filter(\.$project.$id == id)
|
||||
.count()
|
||||
|
||||
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
|
||||
.with(\.$project)
|
||||
.filter(\.$project.$id == id)
|
||||
.all()
|
||||
|
||||
let model = try await ProjectModel.fetchDetail(for: id, on: database)
|
||||
var equivalentLengthsCompleted = false
|
||||
|
||||
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
||||
equivalentLengths.filter({ $0.type == "return" }).first != nil
|
||||
if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
||||
model.equivalentLengths.filter({ $0.type == "return" }).first != nil
|
||||
{
|
||||
equivalentLengthsCompleted = true
|
||||
}
|
||||
|
||||
let componentLosses = try await ComponentLossModel.query(on: database)
|
||||
.with(\.$project)
|
||||
.filter(\.$project.$id == id)
|
||||
.count()
|
||||
|
||||
let equipmentInfo = try await EquipmentModel.query(on: database)
|
||||
.with(\.$project)
|
||||
.filter(\.$project.$id == id)
|
||||
.first()
|
||||
|
||||
return .init(
|
||||
equipmentInfo: equipmentInfo != nil,
|
||||
rooms: roomsCount > 0,
|
||||
equipmentInfo: model.equipment != nil,
|
||||
rooms: model.rooms.count > 0,
|
||||
equivalentLength: equivalentLengthsCompleted,
|
||||
frictionRate: componentLosses > 0
|
||||
frictionRate: model.componentLosses.count > 0
|
||||
)
|
||||
},
|
||||
getSensibleHeatRatio: { id in
|
||||
guard let 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()
|
||||
}
|
||||
return model.sensibleHeatRatio
|
||||
@@ -90,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -103,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
|
||||
extension Project.Create {
|
||||
|
||||
func toModel(userID: User.ID) throws -> ProjectModel {
|
||||
try validate()
|
||||
func toModel(userID: User.ID) -> ProjectModel {
|
||||
return .init(
|
||||
name: name,
|
||||
streetAddress: streetAddress,
|
||||
@@ -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 {
|
||||
@@ -196,7 +122,7 @@ extension Project {
|
||||
.field("sensibleHeatRatio", .double)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
|
||||
.field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||
.unique(on: "userID", "name")
|
||||
.create()
|
||||
}
|
||||
@@ -242,6 +168,18 @@ final class ProjectModel: Model, @unchecked Sendable {
|
||||
@Children(for: \.$project)
|
||||
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")
|
||||
var user: UserModel
|
||||
|
||||
@@ -307,4 +245,67 @@ final class ProjectModel: Model, @unchecked Sendable {
|
||||
self.sensibleHeatRatio = sensibleHeatRatio
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a ``ProjectModel`` with all the relations eagerly loaded.
|
||||
static func fetchDetail(
|
||||
for projectID: Project.ID,
|
||||
on database: any Database
|
||||
) async throws -> ProjectModel {
|
||||
guard
|
||||
let model =
|
||||
try await ProjectModel.query(on: database)
|
||||
.with(\.$componentLosses)
|
||||
.with(\.$equipment)
|
||||
.with(\.$equivalentLengths)
|
||||
.with(\.$rooms)
|
||||
.with(
|
||||
\.$trunks,
|
||||
{ trunk in
|
||||
trunk.with(
|
||||
\.$rooms,
|
||||
{
|
||||
$0.with(\.$room)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.filter(\.$id == projectID)
|
||||
.first()
|
||||
else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectModel: Validatable {
|
||||
|
||||
var body: some Validation<ProjectModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(\.streetAddress, with: .notEmpty())
|
||||
.errorLabel("Address", inline: true)
|
||||
|
||||
Validator.validate(\.city, with: .notEmpty())
|
||||
.errorLabel("City", inline: true)
|
||||
|
||||
Validator.validate(\.state, with: .notEmpty())
|
||||
.errorLabel("State", inline: true)
|
||||
|
||||
Validator.validate(\.zipCode, with: .notEmpty())
|
||||
.errorLabel("Zip", inline: true)
|
||||
|
||||
Validator.validate(\.sensibleHeatRatio) {
|
||||
Validator {
|
||||
Double.greaterThan(0)
|
||||
Double.lessThanOrEquals(1.0)
|
||||
}
|
||||
.optional()
|
||||
}
|
||||
.errorLabel("Sensible Heat Ratio", inline: true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Rooms: Sendable {
|
||||
public var create: @Sendable (Room.Create) async throws -> Room
|
||||
public var delete: @Sendable (Room.ID) async throws -> Void
|
||||
public var deleteRectangularSize:
|
||||
@Sendable (Room.ID, 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
|
||||
}
|
||||
}
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -26,9 +12,16 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
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
|
||||
guard let model = try await RoomModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
@@ -42,8 +35,11 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
model.rectangularSizes?.removeAll {
|
||||
$0.id == rectangularDuctID
|
||||
}
|
||||
if model.rectangularSizes?.count == 0 {
|
||||
model.rectangularSizes = nil
|
||||
}
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
},
|
||||
@@ -62,11 +58,9 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
guard let model = try await RoomModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
},
|
||||
@@ -89,8 +83,7 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
|
||||
extension Room.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> RoomModel {
|
||||
try validate()
|
||||
func toModel() throws -> RoomModel {
|
||||
return .init(
|
||||
name: name,
|
||||
heatingLoad: heatingLoad,
|
||||
@@ -100,57 +93,6 @@ extension Room.Create {
|
||||
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 {
|
||||
@@ -181,7 +123,7 @@ extension Room {
|
||||
}
|
||||
}
|
||||
|
||||
final class RoomModel: Model, @unchecked Sendable {
|
||||
final class RoomModel: Model, @unchecked Sendable, Validatable {
|
||||
|
||||
static let schema = "room"
|
||||
|
||||
@@ -204,7 +146,7 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
var registerCount: Int
|
||||
|
||||
@Field(key: "rectangularSizes")
|
||||
var rectangularSizes: [DuctSizing.RectangularDuct]?
|
||||
var rectangularSizes: [Room.RectangularSize]?
|
||||
|
||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||
var createdAt: Date?
|
||||
@@ -224,7 +166,7 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
coolingTotal: Double,
|
||||
coolingSensible: Double? = nil,
|
||||
registerCount: Int,
|
||||
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
||||
rectangularSizes: [Room.RectangularSize]? = nil,
|
||||
createdAt: Date? = nil,
|
||||
updatedAt: Date? = nil,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sources/DatabaseClient/Internal/Sequence+asyncMap.swift
Normal file
41
Sources/DatabaseClient/Internal/Sequence+asyncMap.swift
Normal 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 }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -23,12 +11,12 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
try request.validate()
|
||||
// try request.validate()
|
||||
|
||||
let trunk = request.toModel()
|
||||
var roomProxies = [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 {
|
||||
guard let room = try await RoomModel.find(roomID, on: database) else {
|
||||
@@ -40,15 +28,19 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
registers: registers,
|
||||
type: request.type
|
||||
)
|
||||
try await model.save(on: database)
|
||||
try await roomProxies.append(model.toDTO(on: database))
|
||||
try await model.validateAndSave(on: database)
|
||||
roomProxies.append(
|
||||
.init(room: try room.toDTO(), registers: registers)
|
||||
)
|
||||
}
|
||||
|
||||
return try .init(
|
||||
id: trunk.requireID(),
|
||||
projectID: trunk.$project.id,
|
||||
type: .init(rawValue: trunk.type)!,
|
||||
rooms: roomProxies
|
||||
rooms: roomProxies,
|
||||
height: trunk.height,
|
||||
name: trunk.name
|
||||
)
|
||||
},
|
||||
delete: { id in
|
||||
@@ -60,48 +52,44 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
fetch: { projectID in
|
||||
try await TrunkModel.query(on: database)
|
||||
.with(\.$project)
|
||||
.with(\.$rooms)
|
||||
.with(\.$rooms, { $0.with(\.$room) })
|
||||
.filter(\.$project.$id == projectID)
|
||||
.all()
|
||||
.toDTO(on: database)
|
||||
.toDTO()
|
||||
},
|
||||
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 try await model.toDTO(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
update: { id, updates in
|
||||
guard
|
||||
let model =
|
||||
try await TrunkModel
|
||||
.query(on: database)
|
||||
.with(\.$rooms)
|
||||
.with(\.$rooms, { $0.with(\.$room) })
|
||||
.filter(\.$id == id)
|
||||
.first()
|
||||
else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
// try updates.validate()
|
||||
try await model.applyUpdates(updates, on: database)
|
||||
return try await model.toDTO(on: database)
|
||||
return try model.toDTO()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DuctSizing.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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
extension TrunkSize.Create {
|
||||
|
||||
func toModel() -> TrunkModel {
|
||||
.init(
|
||||
@@ -113,22 +101,7 @@ extension DuctSizing.TrunkSize.Create {
|
||||
}
|
||||
}
|
||||
|
||||
extension DuctSizing.TrunkSize.Update {
|
||||
func validate() throws(ValidationError) {
|
||||
if let rooms {
|
||||
guard rooms.count > 0 else {
|
||||
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||
}
|
||||
}
|
||||
if let height {
|
||||
guard height > 0 else {
|
||||
throw ValidationError("Trunk size height should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DuctSizing.TrunkSize {
|
||||
extension TrunkSize {
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
let name = "CreateTrunkSize"
|
||||
@@ -192,7 +165,7 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
|
||||
trunkID: TrunkModel.IDValue,
|
||||
roomID: RoomModel.IDValue,
|
||||
registers: [Int],
|
||||
type: DuctSizing.TrunkSize.TrunkType
|
||||
type: TrunkSize.TrunkType
|
||||
) {
|
||||
self.id = id
|
||||
$trunk.id = trunkID
|
||||
@@ -201,16 +174,23 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
|
||||
self.type = type.rawValue
|
||||
}
|
||||
|
||||
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy {
|
||||
guard let room = try await RoomModel.find($room.id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
func toDTO() throws -> TrunkSize.RoomProxy {
|
||||
return .init(
|
||||
room: try room.toDTO(),
|
||||
registers: registers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkRoomModel: Validatable {
|
||||
var body: some Validation<TrunkRoomModel> {
|
||||
Validator.validate(\.registers) {
|
||||
[Int].notEmpty()
|
||||
ForEachValidator {
|
||||
Int.greaterThanOrEquals(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TrunkModel: Model, @unchecked Sendable {
|
||||
@@ -240,7 +220,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
projectID: Project.ID,
|
||||
type: DuctSizing.TrunkSize.TrunkType,
|
||||
type: TrunkSize.TrunkType,
|
||||
height: Int? = nil,
|
||||
name: String? = nil
|
||||
) {
|
||||
@@ -251,18 +231,9 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize {
|
||||
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in
|
||||
for room in self.rooms {
|
||||
group.addTask {
|
||||
try await room.toDTO(on: database)
|
||||
}
|
||||
}
|
||||
|
||||
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
|
||||
$0.append($1)
|
||||
}
|
||||
|
||||
func toDTO() throws -> TrunkSize {
|
||||
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||
$0.append(try $1.toDTO())
|
||||
}
|
||||
|
||||
return try .init(
|
||||
@@ -277,7 +248,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func applyUpdates(
|
||||
_ updates: DuctSizing.TrunkSize.Update,
|
||||
_ updates: TrunkSize.Update,
|
||||
on database: any Database
|
||||
) async throws {
|
||||
if let type = updates.type, type.rawValue != self.type {
|
||||
@@ -290,7 +261,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
self.name = name
|
||||
}
|
||||
if hasChanges {
|
||||
try await self.save(on: database)
|
||||
try await self.validateAndSave(on: database)
|
||||
}
|
||||
|
||||
guard let updateRooms = updates.rooms else {
|
||||
@@ -311,7 +282,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
currRoom.registers = registers
|
||||
}
|
||||
if currRoom.hasChanges {
|
||||
try await currRoom.save(on: database)
|
||||
try await currRoom.validateAndSave(on: database)
|
||||
}
|
||||
} else {
|
||||
database.logger.debug("CREATING NEW TrunkRoomModel")
|
||||
@@ -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] {
|
||||
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
|
||||
for model in self {
|
||||
group.addTask {
|
||||
try await model.toDTO(on: database)
|
||||
}
|
||||
}
|
||||
var body: some Validation<TrunkModel> {
|
||||
Validator.accumulating {
|
||||
|
||||
Validator.validate(\.height, with: Int.greaterThan(0).optional())
|
||||
.errorLabel("Height", inline: true)
|
||||
|
||||
Validator.validate(\.name, with: String.notEmpty().optional())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Sources/DatabaseClient/Internal/User+validation.swift
Normal file
20
Sources/DatabaseClient/Internal/User+validation.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct UserProfile: Sendable {
|
||||
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
|
||||
public var delete: @Sendable (User.Profile.ID) async throws -> Void
|
||||
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
|
||||
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
|
||||
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
|
||||
}
|
||||
}
|
||||
|
||||
extension DatabaseClient.UserProfile: TestDependencyKey {
|
||||
extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { profile in
|
||||
try profile.validate()
|
||||
let model = profile.toModel()
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -48,10 +37,9 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
|
||||
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -61,30 +49,6 @@ extension DatabaseClient.UserProfile: TestDependencyKey {
|
||||
|
||||
extension User.Profile.Create {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !firstName.isEmpty else {
|
||||
throw ValidationError("User first name should not be empty.")
|
||||
}
|
||||
guard !lastName.isEmpty else {
|
||||
throw ValidationError("User last name should not be empty.")
|
||||
}
|
||||
guard !companyName.isEmpty else {
|
||||
throw ValidationError("User company name should not be empty.")
|
||||
}
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("User street address should not be empty.")
|
||||
}
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("User city should not be empty.")
|
||||
}
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("User state should not be empty.")
|
||||
}
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("User zip code should not be empty.")
|
||||
}
|
||||
}
|
||||
|
||||
func toModel() -> UserProfileModel {
|
||||
.init(
|
||||
userID: userID,
|
||||
@@ -100,47 +64,6 @@ extension User.Profile.Create {
|
||||
}
|
||||
}
|
||||
|
||||
extension User.Profile.Update {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let firstName {
|
||||
guard !firstName.isEmpty else {
|
||||
throw ValidationError("User first name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let lastName {
|
||||
guard !lastName.isEmpty else {
|
||||
throw ValidationError("User last name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let companyName {
|
||||
guard !companyName.isEmpty else {
|
||||
throw ValidationError("User company name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let streetAddress {
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("User street address should not be empty.")
|
||||
}
|
||||
}
|
||||
if let city {
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("User city should not be empty.")
|
||||
}
|
||||
}
|
||||
if let state {
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("User state should not be empty.")
|
||||
}
|
||||
}
|
||||
if let zipCode {
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("User zip code should not be empty.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension User.Profile {
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
@@ -281,3 +204,31 @@ final class UserProfileModel: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UserProfileModel: Validatable {
|
||||
|
||||
var body: some Validation<UserProfileModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.firstName, with: .notEmpty())
|
||||
.errorLabel("First Name", inline: true)
|
||||
|
||||
Validator.validate(\.lastName, with: .notEmpty())
|
||||
.errorLabel("Last Name", inline: true)
|
||||
|
||||
Validator.validate(\.companyName, with: .notEmpty())
|
||||
.errorLabel("Company", inline: true)
|
||||
|
||||
Validator.validate(\.streetAddress, with: .notEmpty())
|
||||
.errorLabel("Address", inline: true)
|
||||
|
||||
Validator.validate(\.city, with: .notEmpty())
|
||||
.errorLabel("City", inline: true)
|
||||
|
||||
Validator.validate(\.state, with: .notEmpty())
|
||||
.errorLabel("State", inline: true)
|
||||
|
||||
Validator.validate(\.zipCode, with: .notEmpty())
|
||||
.errorLabel("Zip", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
|
||||
extension DatabaseClient {
|
||||
@DependencyClient
|
||||
public struct Users: Sendable {
|
||||
public var create: @Sendable (User.Create) async throws -> User
|
||||
public var delete: @Sendable (User.ID) async throws -> Void
|
||||
public var get: @Sendable (User.ID) async throws -> User?
|
||||
public var login: @Sendable (User.Login) async throws -> User.Token
|
||||
public var logout: @Sendable (User.Token.ID) async throws -> Void
|
||||
// public var token: @Sendable (User.ID) async throws -> User.Token
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DatabaseClient.Users: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
try request.validate()
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
return try model.toDTO()
|
||||
@@ -46,6 +35,11 @@ extension DatabaseClient.Users: TestDependencyKey {
|
||||
throw NotFoundError()
|
||||
}
|
||||
|
||||
// Verify the password matches the user's hashed password.
|
||||
guard try user.verifyPassword(request.password) else {
|
||||
throw Abort(.unauthorized)
|
||||
}
|
||||
|
||||
let token: User.Token
|
||||
|
||||
// Check if there's a user token
|
||||
@@ -126,21 +120,8 @@ extension User {
|
||||
extension User.Create {
|
||||
|
||||
func toModel() throws -> UserModel {
|
||||
try validate()
|
||||
return try .init(email: email, passwordHash: User.hashPassword(password))
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
guard !email.isEmpty else {
|
||||
throw ValidationError("Email should not be empty")
|
||||
}
|
||||
guard password.count > 8 else {
|
||||
throw ValidationError("Password should be more than 8 characters long.")
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
throw ValidationError("Passwords do not match.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class UserModel: Model, @unchecked Sendable {
|
||||
@@ -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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
100
Sources/EnvVars/Interface.swift
Normal file
100
Sources/EnvVars/Interface.swift
Normal 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()
|
||||
}()
|
||||
59
Sources/FileClient/Interface.swift
Normal file
59
Sources/FileClient/Interface.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
22
Sources/HTMLSnapshotTesting/Snapshotting.swift
Normal file
22
Sources/HTMLSnapshotTesting/Snapshotting.swift
Normal 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
|
||||
// }
|
||||
// }
|
||||
@@ -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
|
||||
// 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 {
|
||||
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 {
|
||||
var totalEffectiveLength: Int {
|
||||
reduce(0) { $0 + $1.effectiveLength }
|
||||
@@ -101,16 +97,16 @@ func roundSize(_ size: Double) throws -> Int {
|
||||
}
|
||||
}
|
||||
|
||||
func velocity(cfm: Int, roundSize: Int) -> Int {
|
||||
let cfm = Double(cfm)
|
||||
func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
|
||||
let cfm = Double(cfm.rawValue)
|
||||
let roundSize = Double(roundSize)
|
||||
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
|
||||
return Int(round(velocity))
|
||||
}
|
||||
|
||||
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int {
|
||||
let cfm = pow(Double(request.designCFM), 0.4)
|
||||
let fr = pow(request.frictionRate / 1.76, 0.2)
|
||||
func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
|
||||
let cfm = pow(Double(cfm.rawValue), 0.4)
|
||||
let fr = pow(frictionRate / 1.76, 0.2)
|
||||
let size = 0.55 * (cfm / fr)
|
||||
return try roundSize(size)
|
||||
}
|
||||
|
||||
141
Sources/ManualDClient/Interface.swift
Normal file
141
Sources/ManualDClient/Interface.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,47 @@ import ManualDCore
|
||||
|
||||
extension ManualDClient: DependencyKey {
|
||||
public static let liveValue: Self = .init(
|
||||
ductSize: { request in
|
||||
guard request.designCFM > 0 else {
|
||||
ductSize: { cfm, frictionRate in
|
||||
guard cfm > 0 else {
|
||||
throw ManualDError(message: "Design CFM should be greater than 0.")
|
||||
}
|
||||
let fr = pow(request.frictionRate, 0.5)
|
||||
let ductulatorSize = pow(Double(request.designCFM) / (3.12 * fr), 0.38)
|
||||
let fr = pow(frictionRate.rawValue, 0.5)
|
||||
let ductulatorSize = pow(Double(cfm.rawValue) / (3.12 * fr), 0.38)
|
||||
let finalSize = try roundSize(ductulatorSize)
|
||||
let flexSize = try flexSize(request)
|
||||
let flexSize = try flexSize(cfm, frictionRate.rawValue)
|
||||
return .init(
|
||||
ductulatorSize: ductulatorSize,
|
||||
calculatedSize: ductulatorSize,
|
||||
finalSize: finalSize,
|
||||
flexSize: flexSize,
|
||||
velocity: velocity(cfm: request.designCFM, roundSize: finalSize)
|
||||
velocity: velocity(cfm: cfm, roundSize: finalSize)
|
||||
)
|
||||
},
|
||||
frictionRate: { request in
|
||||
// Ensure the total effective length is greater than 0.
|
||||
guard request.totalEffectiveLength > 0 else {
|
||||
guard request.totalEquivalentLength > 0 else {
|
||||
throw ManualDError(message: "Total Effective Length should be greater than 0.")
|
||||
}
|
||||
|
||||
let totalComponentLosses = request.componentPressureLosses.total
|
||||
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
|
||||
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
|
||||
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
|
||||
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEquivalentLength)
|
||||
return .init(
|
||||
availableStaticPressure: availableStaticPressure,
|
||||
value: frictionRate
|
||||
)
|
||||
},
|
||||
totalEffectiveLength: { request in
|
||||
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
||||
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
||||
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
||||
return trunkLengths + runoutLengths + groupLengths
|
||||
},
|
||||
equivalentRectangularDuct: { request in
|
||||
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
||||
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
|
||||
// totalEquivalentLength: { request in
|
||||
// let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
||||
// let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
||||
// let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
||||
// return trunkLengths + runoutLengths + groupLengths
|
||||
// },
|
||||
rectangularSize: { round, height in
|
||||
let width = (Double.pi * (pow(Double(round.rawValue) / 2.0, 2.0))) / Double(height.rawValue)
|
||||
return .init(
|
||||
height: height.rawValue,
|
||||
width: Int(width.rounded(.toNearestOrEven))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
/// Represents component pressure losses used in the friction rate worksheet.
|
||||
///
|
||||
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
|
||||
/// need to be overcome by the system fan.
|
||||
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
public let id: UUID
|
||||
@@ -43,7 +48,7 @@ extension ComponentPressureLoss {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
// Return's commonly used default component pressure losses.
|
||||
/// Commonly used default component pressure losses.
|
||||
public static func `default`(projectID: Project.ID) -> [Self] {
|
||||
[
|
||||
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
|
||||
@@ -74,22 +79,63 @@ extension Array where Element == ComponentPressureLoss {
|
||||
}
|
||||
}
|
||||
|
||||
public typealias ComponentPressureLosses = [String: Double]
|
||||
|
||||
#if DEBUG
|
||||
extension ComponentPressureLosses {
|
||||
public static var mock: Self {
|
||||
[
|
||||
"evaporator-coil": 0.2,
|
||||
"filter": 0.1,
|
||||
"supply-outlet": 0.03,
|
||||
"return-grille": 0.03,
|
||||
"balancing-damper": 0.03,
|
||||
]
|
||||
|
||||
extension Array where Element == ComponentPressureLoss {
|
||||
public static func mock(projectID: Project.ID) -> Self {
|
||||
ComponentPressureLoss.mock(projectID: projectID)
|
||||
}
|
||||
}
|
||||
|
||||
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] {
|
||||
[
|
||||
.init(
|
||||
256
Sources/ManualDCore/DuctSizes.swift
Normal file
256
Sources/ManualDCore/DuctSizes.swift
Normal 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
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
// TODO: These are not used, should they be removed??
|
||||
|
||||
// TODO: Add other description / label for items that have same group & letter, but
|
||||
// different effective length.
|
||||
public struct EffectiveLengthGroup: Codable, Equatable, Sendable {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
/// Represents the equipment information for a project.
|
||||
///
|
||||
/// This is used in the friction rate worksheet and sizing ducts. It holds on to items
|
||||
/// such as the target static pressure, cooling CFM, and heating CFM for the project.
|
||||
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public let projectID: Project.ID
|
||||
@@ -70,6 +74,21 @@ extension EquipmentInfo {
|
||||
|
||||
#if DEBUG
|
||||
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(
|
||||
id: UUID(0),
|
||||
projectID: UUID(0),
|
||||
|
||||
239
Sources/ManualDCore/EquivalentLength.swift
Normal file
239
Sources/ManualDCore/EquivalentLength.swift
Normal 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
|
||||
37
Sources/ManualDCore/Extensions/Numbers+string.swift
Normal file
37
Sources/ManualDCore/Extensions/Numbers+string.swift
Normal 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)!
|
||||
}
|
||||
12
Sources/ManualDCore/Extensions/PageRequest+extensions.swift
Normal file
12
Sources/ManualDCore/Extensions/PageRequest+extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
69
Sources/ManualDCore/FrictionRate.swift
Normal file
69
Sources/ManualDCore/FrictionRate.swift
Normal 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
|
||||
@@ -1,16 +1,29 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
/// Represents a single duct design project / system.
|
||||
///
|
||||
/// Holds items such as project name and address.
|
||||
public struct Project: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
/// The unique ID of the project.
|
||||
public let id: UUID
|
||||
/// The name of the project.
|
||||
public let name: String
|
||||
/// The street address of the project.
|
||||
public let streetAddress: String
|
||||
/// The city of the project.
|
||||
public let city: String
|
||||
/// The state of the project.
|
||||
public let state: String
|
||||
/// The zip code of the project.
|
||||
public let zipCode: String
|
||||
/// The global sensible heat ratio for the project.
|
||||
///
|
||||
/// **NOTE:** This is used for calculating the sensible cooling load for rooms.
|
||||
public let sensibleHeatRatio: Double?
|
||||
/// When the project was created in the database.
|
||||
public let createdAt: Date
|
||||
/// When the project was updated in the database.
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
@@ -37,14 +50,20 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
|
||||
extension Project {
|
||||
|
||||
/// Represents the data needed to create a new project.
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
|
||||
/// The name of the project.
|
||||
public let name: String
|
||||
/// The street address of the project.
|
||||
public let streetAddress: String
|
||||
/// The city of the project.
|
||||
public let city: String
|
||||
/// The state of the project.
|
||||
public let state: String
|
||||
/// The zip code of the project.
|
||||
public let zipCode: String
|
||||
/// The global sensible heat ratio for the project.
|
||||
public let sensibleHeatRatio: Double?
|
||||
|
||||
public init(
|
||||
@@ -64,11 +83,19 @@ extension Project {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents steps that are completed in order to calculate the duct sizes
|
||||
/// for a project.
|
||||
///
|
||||
/// This is primarily used on the web pages to display errors or color of the
|
||||
/// different steps of a project.
|
||||
public struct CompletedSteps: Codable, Equatable, Sendable {
|
||||
|
||||
/// Whether there is ``EquipmentInfo`` for a project.
|
||||
public let equipmentInfo: Bool
|
||||
/// Whether there are ``Room``'s for a project.
|
||||
public let rooms: Bool
|
||||
/// Whether there are ``EquivalentLength``'s for a project.
|
||||
public let equivalentLength: Bool
|
||||
/// Whether there is a ``FrictionRate`` for a project.
|
||||
public let frictionRate: Bool
|
||||
|
||||
public init(
|
||||
@@ -84,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 {
|
||||
|
||||
/// The name of the project.
|
||||
public let name: String?
|
||||
/// The street address of the project.
|
||||
public let streetAddress: String?
|
||||
/// The city of the project.
|
||||
public let city: String?
|
||||
/// The state of the project.
|
||||
public let state: String?
|
||||
/// The zip code of the project.
|
||||
public let zipCode: String?
|
||||
/// The global sensible heat ratio for the project.
|
||||
public let sensibleHeatRatio: Double?
|
||||
|
||||
public init(
|
||||
@@ -114,16 +186,22 @@ extension Project {
|
||||
#if DEBUG
|
||||
|
||||
extension Project {
|
||||
public static let mock = Self(
|
||||
id: UUID(0),
|
||||
name: "Testy McTestface",
|
||||
streetAddress: "1234 Sesame Street",
|
||||
city: "Monroe",
|
||||
state: "OH",
|
||||
zipCode: "55555",
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
|
||||
public static var mock: Self {
|
||||
@Dependency(\.uuid) var uuid
|
||||
@Dependency(\.date.now) var now
|
||||
|
||||
return .init(
|
||||
id: uuid(),
|
||||
name: "Testy McTestface",
|
||||
streetAddress: "1234 Sesame Street",
|
||||
city: "Monroe",
|
||||
state: "OH",
|
||||
zipCode: "55555",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
/// Represents a room in a project.
|
||||
///
|
||||
/// This contains data such as the heating and cooling load for the
|
||||
/// room, the number of registers in the room, and any rectangular
|
||||
/// duct size calculations stored for the room.
|
||||
public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
/// The unique id of the room.
|
||||
public let id: UUID
|
||||
/// The project this room is associated with.
|
||||
public let projectID: Project.ID
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
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?
|
||||
/// The number of registers for the room.
|
||||
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
|
||||
/// When the room was updated in the database.
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
@@ -21,7 +42,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
coolingTotal: Double,
|
||||
coolingSensible: Double? = nil,
|
||||
registerCount: Int = 1,
|
||||
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
||||
rectangularSizes: [RectangularSize]? = nil,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
@@ -39,13 +60,19 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
|
||||
extension Room {
|
||||
|
||||
/// Represents the data required to create a new room for a project.
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
/// The project this room is associated with.
|
||||
public let projectID: Project.ID
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
public let coolingTotal: Double
|
||||
/// An optional sensible cooling load for the room.
|
||||
public let coolingSensible: Double?
|
||||
/// The number of registers for the room.
|
||||
public let registerCount: Int
|
||||
|
||||
public 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 {
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String?
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double?
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
public let coolingTotal: Double?
|
||||
/// An optional sensible cooling load for the room.
|
||||
public let coolingSensible: Double?
|
||||
/// The number of registers for the room.
|
||||
public let registerCount: Int?
|
||||
public let rectangularSizes: [DuctSizing.RectangularDuct]?
|
||||
/// The rectangular duct size calculations for a room.
|
||||
public let rectangularSizes: [RectangularSize]?
|
||||
|
||||
public init(
|
||||
name: String? = nil,
|
||||
@@ -89,7 +149,7 @@ extension Room {
|
||||
}
|
||||
|
||||
public init(
|
||||
rectangularSizes: [DuctSizing.RectangularDuct]
|
||||
rectangularSizes: [RectangularSize]
|
||||
) {
|
||||
self.name = nil
|
||||
self.heatingLoad = nil
|
||||
@@ -103,14 +163,20 @@ extension Room {
|
||||
|
||||
extension Array where Element == Room {
|
||||
|
||||
/// The sum of heating loads for an array of rooms.
|
||||
public var totalHeatingLoad: Double {
|
||||
reduce(into: 0) { $0 += $1.heatingLoad }
|
||||
}
|
||||
|
||||
/// The sum of total cooling loads for an array of rooms.
|
||||
public var totalCoolingLoad: Double {
|
||||
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 {
|
||||
reduce(into: 0) {
|
||||
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||
@@ -122,38 +188,86 @@ extension Array where Element == Room {
|
||||
#if DEBUG
|
||||
|
||||
extension Room {
|
||||
public static let mocks = [
|
||||
Room(
|
||||
id: UUID(0),
|
||||
projectID: UUID(0),
|
||||
name: "Kitchen",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 1234,
|
||||
registerCount: 2,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
),
|
||||
Room(
|
||||
id: UUID(1),
|
||||
projectID: UUID(1),
|
||||
name: "Bedroom - 1",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 1456,
|
||||
registerCount: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
),
|
||||
Room(
|
||||
id: UUID(2),
|
||||
projectID: UUID(2),
|
||||
name: "Family Room",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 1673,
|
||||
registerCount: 3,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
),
|
||||
]
|
||||
|
||||
public static func mock(projectID: Project.ID) -> [Self] {
|
||||
@Dependency(\.uuid) var uuid
|
||||
@Dependency(\.date.now) var now
|
||||
|
||||
return [
|
||||
.init(
|
||||
id: uuid(),
|
||||
projectID: projectID,
|
||||
name: "Bed-1",
|
||||
heatingLoad: 3913,
|
||||
coolingTotal: 2472,
|
||||
coolingSensible: nil,
|
||||
registerCount: 1,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
),
|
||||
.init(
|
||||
id: uuid(),
|
||||
projectID: projectID,
|
||||
name: "Entry",
|
||||
heatingLoad: 8284,
|
||||
coolingTotal: 2916,
|
||||
coolingSensible: nil,
|
||||
registerCount: 2,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
),
|
||||
.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@ import Foundation
|
||||
|
||||
public enum SiteRoute: Equatable, Sendable {
|
||||
|
||||
case api(Self.Api)
|
||||
case health
|
||||
case view(Self.View)
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.api)) {
|
||||
SiteRoute.Api.router
|
||||
}
|
||||
Route(.case(Self.health)) {
|
||||
Path { "health" }
|
||||
Method.get
|
||||
|
||||
@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
case equipment(EquipmentInfoRoute)
|
||||
case equivalentLength(EquivalentLengthRoute)
|
||||
case frictionRate(FrictionRateRoute)
|
||||
case pdf
|
||||
case rooms(RoomRoute)
|
||||
|
||||
static let router = OneOf {
|
||||
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Route(.case(Self.frictionRate)) {
|
||||
FrictionRateRoute.router
|
||||
}
|
||||
Route(.case(Self.pdf)) {
|
||||
Path { "pdf" }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.rooms)) {
|
||||
RoomRoute.router
|
||||
}
|
||||
@@ -389,11 +394,11 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
|
||||
public enum EquivalentLengthRoute: Equatable, Sendable {
|
||||
case delete(id: EffectiveLength.ID)
|
||||
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
|
||||
case delete(id: EquivalentLength.ID)
|
||||
case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
|
||||
case index
|
||||
case submit(FormStep)
|
||||
case update(EffectiveLength.ID, StepThree)
|
||||
case update(EquivalentLength.ID, StepThree)
|
||||
|
||||
static let rootPath = "effective-lengths"
|
||||
|
||||
@@ -401,7 +406,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
EffectiveLength.ID.parser()
|
||||
EquivalentLength.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
@@ -419,7 +424,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Field("type") { FieldType.parser() }
|
||||
Optionally {
|
||||
Field("style", default: nil) {
|
||||
EffectiveLength.EffectiveLengthType.parser()
|
||||
EquivalentLength.EffectiveLengthType.parser()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,16 +437,16 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Route(.case(Self.update)) {
|
||||
Path {
|
||||
rootPath
|
||||
EffectiveLength.ID.parser()
|
||||
EquivalentLength.ID.parser()
|
||||
}
|
||||
Method.patch
|
||||
Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||
}
|
||||
Field("name", .string)
|
||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||
Many {
|
||||
Field("straightLengths") {
|
||||
Int.parser()
|
||||
@@ -485,10 +490,10 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||
}
|
||||
Field("name", .string)
|
||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||
}
|
||||
.map(.memberwise(StepOne.init))
|
||||
}
|
||||
@@ -500,10 +505,10 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||
}
|
||||
Field("name", .string)
|
||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||
Many {
|
||||
Field("straightLengths") {
|
||||
Int.parser()
|
||||
@@ -520,10 +525,10 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||
}
|
||||
Field("name", .string)
|
||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||
Many {
|
||||
Field("straightLengths") {
|
||||
Int.parser()
|
||||
@@ -562,22 +567,22 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
|
||||
public struct StepOne: Codable, Equatable, Sendable {
|
||||
public let id: EffectiveLength.ID?
|
||||
public let id: EquivalentLength.ID?
|
||||
public let name: String
|
||||
public let type: EffectiveLength.EffectiveLengthType
|
||||
public let type: EquivalentLength.EffectiveLengthType
|
||||
}
|
||||
|
||||
public struct StepTwo: Codable, Equatable, Sendable {
|
||||
|
||||
public let id: EffectiveLength.ID?
|
||||
public let id: EquivalentLength.ID?
|
||||
public let name: String
|
||||
public let type: EffectiveLength.EffectiveLengthType
|
||||
public let type: EquivalentLength.EffectiveLengthType
|
||||
public let straightLengths: [Int]
|
||||
|
||||
public init(
|
||||
id: EffectiveLength.ID? = nil,
|
||||
id: EquivalentLength.ID? = nil,
|
||||
name: String,
|
||||
type: EffectiveLength.EffectiveLengthType,
|
||||
type: EquivalentLength.EffectiveLengthType,
|
||||
straightLengths: [Int]
|
||||
) {
|
||||
self.id = id
|
||||
@@ -588,9 +593,9 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
|
||||
public struct StepThree: Codable, Equatable, Sendable {
|
||||
public let id: EffectiveLength.ID?
|
||||
public let id: EquivalentLength.ID?
|
||||
public let name: String
|
||||
public let type: EffectiveLength.EffectiveLengthType
|
||||
public let type: EquivalentLength.EffectiveLengthType
|
||||
public let straightLengths: [Int]
|
||||
public let groupGroups: [Int]
|
||||
public let groupLetters: [String]
|
||||
@@ -627,7 +632,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
Method.delete
|
||||
Query {
|
||||
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
|
||||
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
|
||||
Field("register") { Int.parser() }
|
||||
}
|
||||
.map(.memberwise(DeleteRectangularDuct.init))
|
||||
@@ -642,7 +647,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
|
||||
Field("id") { Room.RectangularSize.ID.parser() }
|
||||
}
|
||||
Field("register") { Int.parser() }
|
||||
Field("height") { Int.parser() }
|
||||
@@ -658,19 +663,19 @@ extension SiteRoute.View.ProjectRoute {
|
||||
|
||||
public struct DeleteRectangularDuct: Equatable, Sendable {
|
||||
|
||||
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
|
||||
public let rectangularSizeID: Room.RectangularSize.ID
|
||||
public let register: Int
|
||||
|
||||
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
|
||||
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
|
||||
self.rectangularSizeID = rectangularSizeID
|
||||
self.register = register
|
||||
}
|
||||
}
|
||||
|
||||
public enum TrunkRoute: Equatable, Sendable {
|
||||
case delete(DuctSizing.TrunkSize.ID)
|
||||
case delete(TrunkSize.ID)
|
||||
case submit(TrunkSizeForm)
|
||||
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
|
||||
case update(TrunkSize.ID, TrunkSizeForm)
|
||||
|
||||
public static let rootPath = "trunk"
|
||||
|
||||
@@ -678,7 +683,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Route(.case(Self.delete)) {
|
||||
Path {
|
||||
rootPath
|
||||
DuctSizing.TrunkSize.ID.parser()
|
||||
TrunkSize.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
@@ -690,7 +695,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Body {
|
||||
FormData {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
|
||||
Field("type") { TrunkSize.TrunkType.parser() }
|
||||
Optionally {
|
||||
Field("height") { Int.parser() }
|
||||
|
||||
@@ -708,13 +713,13 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Route(.case(Self.update)) {
|
||||
Path {
|
||||
rootPath
|
||||
DuctSizing.TrunkSize.ID.parser()
|
||||
TrunkSize.ID.parser()
|
||||
}
|
||||
Method.patch
|
||||
Body {
|
||||
FormData {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
|
||||
Field("type") { TrunkSize.TrunkType.parser() }
|
||||
Optionally {
|
||||
Field("height") { Int.parser() }
|
||||
}
|
||||
@@ -732,17 +737,43 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
|
||||
public struct RoomRectangularForm: Equatable, Sendable {
|
||||
public let id: DuctSizing.RectangularDuct.ID?
|
||||
|
||||
public let id: Room.RectangularSize.ID?
|
||||
public let register: 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 let projectID: Project.ID
|
||||
public let type: DuctSizing.TrunkSize.TrunkType
|
||||
public let type: TrunkSize.TrunkType
|
||||
public let height: Int?
|
||||
public let name: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents supported color themes for the website.
|
||||
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
|
||||
case aqua
|
||||
case cupcake
|
||||
@@ -13,6 +14,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
|
||||
case retro
|
||||
case synthwave
|
||||
|
||||
/// Represents dark color themes.
|
||||
public static let darkThemes = [
|
||||
Self.aqua,
|
||||
Self.cyberpunk,
|
||||
@@ -22,6 +24,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
|
||||
Self.synthwave,
|
||||
]
|
||||
|
||||
/// Represents light color themes.
|
||||
public static let lightThemes = [
|
||||
Self.cupcake,
|
||||
Self.light,
|
||||
|
||||
149
Sources/ManualDCore/TrunkSize.swift
Normal file
149
Sources/ManualDCore/TrunkSize.swift
Normal 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
|
||||
@@ -1,12 +1,17 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
// FIX: Remove username.
|
||||
/// Represents a user of the site.
|
||||
///
|
||||
public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
/// The unique id of the user.
|
||||
public let id: UUID
|
||||
/// The user's email address.
|
||||
public let email: String
|
||||
/// When the user was created in the database.
|
||||
public let createdAt: Date
|
||||
/// When the user was updated in the database.
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
@@ -23,10 +28,14 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
|
||||
extension User {
|
||||
/// Represents the data required to create a new user.
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
|
||||
/// The user's email address.
|
||||
public let email: String
|
||||
/// The password for the user.
|
||||
public let password: String
|
||||
/// The password confirmation, must match the password.
|
||||
public let confirmPassword: String
|
||||
|
||||
public init(
|
||||
@@ -40,9 +49,13 @@ extension User {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents data required to login a user.
|
||||
public struct Login: Codable, Equatable, Sendable {
|
||||
/// The user's email address.
|
||||
public let email: String
|
||||
/// The password for the user.
|
||||
public let password: String
|
||||
/// An optional page / route to navigate to after logging in the user.
|
||||
public let next: String?
|
||||
|
||||
public init(email: String, password: String, next: String? = nil) {
|
||||
@@ -52,10 +65,13 @@ extension User {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a user session token, for a logged in user.
|
||||
public struct Token: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
/// The unique id of the token.
|
||||
public let id: UUID
|
||||
/// The user id the token is for.
|
||||
public let userID: User.ID
|
||||
/// The token value.
|
||||
public let value: String
|
||||
|
||||
public init(id: UUID, userID: User.ID, value: String) {
|
||||
@@ -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
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
|
||||
extension User {
|
||||
/// Represents a user's profile. Which contains extra information about a user of the site.
|
||||
public struct Profile: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
/// The unique id of the profile
|
||||
public let id: UUID
|
||||
/// The user id the profile is for.
|
||||
public let userID: User.ID
|
||||
/// The user's first name.
|
||||
public let firstName: String
|
||||
/// The user's last name.
|
||||
public let lastName: String
|
||||
/// The user's company name.
|
||||
public let companyName: String
|
||||
/// The user's street address.
|
||||
public let streetAddress: String
|
||||
/// The user's city.
|
||||
public let city: String
|
||||
/// The user's state.
|
||||
public let state: String
|
||||
/// The user's zip code.
|
||||
public let zipCode: String
|
||||
/// An optional theme that the user prefers.
|
||||
public let theme: Theme?
|
||||
/// When the profile was created in the database.
|
||||
public let createdAt: Date
|
||||
/// When the profile was updated in the database.
|
||||
public let updatedAt: Date
|
||||
|
||||
public init(
|
||||
@@ -48,15 +62,25 @@ extension User {
|
||||
|
||||
extension User.Profile {
|
||||
|
||||
/// Represents the data required to create a user profile.
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
/// The user id the profile is for.
|
||||
public let userID: User.ID
|
||||
/// The user's first name.
|
||||
public let firstName: String
|
||||
/// The user's last name.
|
||||
public let lastName: String
|
||||
/// The user's company name.
|
||||
public let companyName: String
|
||||
/// The user's street address.
|
||||
public let streetAddress: String
|
||||
/// The user's city.
|
||||
public let city: String
|
||||
/// The user's state.
|
||||
public let state: String
|
||||
/// The user's zip code.
|
||||
public let zipCode: String
|
||||
/// An optional theme that the user prefers.
|
||||
public let theme: Theme?
|
||||
|
||||
public init(
|
||||
@@ -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 {
|
||||
/// The user's first name.
|
||||
public let firstName: String?
|
||||
/// The user's last name.
|
||||
public let lastName: String?
|
||||
/// The user's company name.
|
||||
public let companyName: String?
|
||||
/// The user's street address.
|
||||
public let streetAddress: String?
|
||||
/// The user's city.
|
||||
public let city: String?
|
||||
/// The user's state.
|
||||
public let state: String?
|
||||
/// The user's zip code.
|
||||
public let zipCode: String?
|
||||
/// An optional theme that the user prefers.
|
||||
public let theme: Theme?
|
||||
|
||||
public init(
|
||||
@@ -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
|
||||
|
||||
163
Sources/PdfClient/Interface.swift
Normal file
163
Sources/PdfClient/Interface.swift
Normal 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
|
||||
107
Sources/PdfClient/Request+html.swift
Normal file
107
Sources/PdfClient/Request+html.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
37
Sources/PdfClient/Views/DuctSizeTable.swift
Normal file
37
Sources/PdfClient/Views/DuctSizeTable.swift
Normal 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() ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Sources/PdfClient/Views/EquipmentTable.swift
Normal file
38
Sources/PdfClient/Views/EquipmentTable.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Sources/PdfClient/Views/EquivalentLengthTable.swift
Normal file
68
Sources/PdfClient/Views/EquivalentLengthTable.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Sources/PdfClient/Views/FrictionRateTable.swift
Normal file
47
Sources/PdfClient/Views/FrictionRateTable.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Sources/PdfClient/Views/ProjectTable.swift
Normal file
33
Sources/PdfClient/Views/ProjectTable.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
33
Sources/PdfClient/Views/RegisterTable.swift
Normal file
33
Sources/PdfClient/Views/RegisterTable.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Sources/PdfClient/Views/RoomTable.swift
Normal file
50
Sources/PdfClient/Views/RoomTable.swift
Normal 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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Sources/PdfClient/Views/TrunkTable.swift
Normal file
42
Sources/PdfClient/Views/TrunkTable.swift
Normal 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() ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Sources/ProjectClient/Interface.swift
Normal file
86
Sources/ProjectClient/Interface.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
63
Sources/ProjectClient/Live.swift
Normal file
63
Sources/ProjectClient/Live.swift
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
35
Sources/ProjectClient/ProjectClientError.swift
Normal file
35
Sources/ProjectClient/ProjectClientError.swift
Normal 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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Elementary
|
||||
import Tagged
|
||||
|
||||
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
|
||||
|
||||
@@ -25,4 +26,5 @@ extension Badge where Inner == Number {
|
||||
public init(number: Double, digits: Int = 2) {
|
||||
self.inner = Number(number, digits: digits)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,32 +26,6 @@ public struct SubmitButton: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CancelButton: HTML, Sendable {
|
||||
let title: String
|
||||
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
||||
|
||||
public init(
|
||||
title: String = "Cancel",
|
||||
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
|
||||
) {
|
||||
self.title = title
|
||||
self.type = type
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.button> {
|
||||
button(
|
||||
.class(
|
||||
"""
|
||||
text-white font-bold text-xl bg-red-500 hover:bg-red-600 px-4 py-2 rounded-lg shadow-lg
|
||||
"""
|
||||
),
|
||||
.type(type)
|
||||
) {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditButton: HTML, Sendable {
|
||||
let title: String?
|
||||
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
||||
|
||||
@@ -6,7 +6,7 @@ public struct DateView: HTML, Sendable {
|
||||
|
||||
var formatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.dateFormat = "MM/dd/yyyy"
|
||||
return formatter
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,6 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.form {
|
||||
|
||||
public static func action(route: SiteRoute.View) -> Self {
|
||||
action(SiteRoute.View.router.path(for: route))
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.input {
|
||||
|
||||
public static func value(_ string: String?) -> Self {
|
||||
@@ -42,9 +35,6 @@ extension HTMLAttribute where Tag == HTMLTag.button {
|
||||
}
|
||||
|
||||
extension HTML where Tag: HTMLTrait.Attributes.Global {
|
||||
public func badge() -> _AttributedElement<Self> {
|
||||
attributes(.class("badge badge-lg badge-outline"))
|
||||
}
|
||||
|
||||
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
|
||||
attributes(.class("hidden"), when: shouldHide)
|
||||
|
||||
@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
|
||||
extension HTMLAttribute.hx {
|
||||
@Sendable
|
||||
public static func indicator() -> HTMLAttribute {
|
||||
indicator(".hx-indicator")
|
||||
indicator(".htmx-indicator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,60 +21,6 @@ public struct LabeledInput: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Input: HTML, Sendable {
|
||||
|
||||
let id: String?
|
||||
let name: String?
|
||||
let placeholder: String
|
||||
|
||||
private var _name: String {
|
||||
guard let name else {
|
||||
return id ?? ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
name: String? = nil,
|
||||
placeholder: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String? = nil,
|
||||
placeholder: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
placeholder: String
|
||||
) {
|
||||
self.init(id: nil, name: name, placeholder: placeholder)
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.input> {
|
||||
input(
|
||||
.id(id ?? ""), .name(_name), .placeholder(placeholder),
|
||||
.class(
|
||||
"""
|
||||
input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
|
||||
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
|
||||
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
|
||||
"""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.input {
|
||||
|
||||
public static func max(_ value: String) -> Self {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
public struct Number: HTML, Sendable {
|
||||
let fractionDigits: Int
|
||||
let value: Double
|
||||
|
||||
private var formatter: NumberFormatter {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter
|
||||
}
|
||||
|
||||
public init(
|
||||
_ value: Double,
|
||||
digits fractionDigits: Int = 2
|
||||
@@ -27,6 +21,6 @@ public struct Number: HTML, Sendable {
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.span> {
|
||||
span { formatter.string(for: value) ?? "N/A" }
|
||||
span { value.string(digits: fractionDigits) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,76 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
|
||||
public struct ResultView<
|
||||
V: Sendable,
|
||||
E: Error,
|
||||
ValueView: HTML,
|
||||
ErrorView: HTML
|
||||
>: HTML {
|
||||
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
|
||||
|
||||
let onSuccess: @Sendable (V) -> ValueView
|
||||
let onError: @Sendable (E) -> ErrorView
|
||||
let result: Result<V, E>
|
||||
let result: Result<ValueView, any Error>
|
||||
let errorView: @Sendable (any Error) -> ErrorView
|
||||
|
||||
public init(
|
||||
result: Result<V, E>,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView,
|
||||
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView
|
||||
) {
|
||||
self.result = result
|
||||
self.onError = onError
|
||||
self.onSuccess = onSuccess
|
||||
_ content: @escaping @Sendable () async throws -> ValueView,
|
||||
onError: @escaping @Sendable (any Error) -> ErrorView
|
||||
) async {
|
||||
self.result = await Result(catching: content)
|
||||
self.errorView = onError
|
||||
}
|
||||
|
||||
public var body: some HTML {
|
||||
switch result {
|
||||
case .success(let value):
|
||||
onSuccess(value)
|
||||
case .success(let view):
|
||||
view
|
||||
case .failure(let error):
|
||||
onError(error)
|
||||
errorView(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ResultView {
|
||||
extension ResultView where ErrorView == Styleguide.ErrorView {
|
||||
|
||||
public init(
|
||||
result: Result<V, E>,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||
) where ErrorView == Styleguide.ErrorView<E> {
|
||||
self.init(result: result, onSuccess: onSuccess) { error in
|
||||
Styleguide.ErrorView(error: error)
|
||||
}
|
||||
_ content: @escaping @Sendable () async throws -> ValueView
|
||||
) async {
|
||||
await self.init(
|
||||
content,
|
||||
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(
|
||||
catching: @escaping @Sendable () async throws(E) -> V,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||
) async where ErrorView == Styleguide.ErrorView<E> {
|
||||
catching: @escaping @Sendable () async throws -> Void
|
||||
) async where ValueView == EmptyHTML {
|
||||
await self.init(
|
||||
result: .init(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),
|
||||
catching: catching,
|
||||
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: E) {
|
||||
public init(error: any Error) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.div> {
|
||||
div {
|
||||
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
|
||||
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
|
||||
p {
|
||||
"\(error)"
|
||||
"\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
|
||||
}
|
||||
}
|
||||
|
||||
var groups: [EffectiveLength.Group] {
|
||||
var groups = [EffectiveLength.Group]()
|
||||
var groups: [EquivalentLength.FittingGroup] {
|
||||
var groups = [EquivalentLength.FittingGroup]()
|
||||
for (n, group) in groupGroups.enumerated() {
|
||||
groups.append(
|
||||
.init(
|
||||
@@ -29,7 +29,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLength.Create {
|
||||
extension EquivalentLength.Create {
|
||||
|
||||
init(
|
||||
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||
@@ -45,7 +45,7 @@ extension EffectiveLength.Create {
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLength.Update {
|
||||
extension EquivalentLength.Update {
|
||||
init(
|
||||
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||
projectID: Project.ID
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import ManualDCore
|
||||
|
||||
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(
|
||||
projectID: projectID,
|
||||
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(
|
||||
type: type,
|
||||
rooms: makeRooms(logger: logger),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AuthClient
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Elementary
|
||||
@@ -15,10 +16,6 @@ public typealias AnySendableHTML = (any HTML & Sendable)
|
||||
|
||||
@DependencyClient
|
||||
public struct ViewController: Sendable {
|
||||
|
||||
public typealias AuthenticateHandler = @Sendable (User) -> Void
|
||||
public typealias CurrentUserHandler = @Sendable () throws -> User
|
||||
|
||||
public var view: @Sendable (Request) async throws -> AnySendableHTML
|
||||
}
|
||||
|
||||
@@ -29,21 +26,15 @@ extension ViewController {
|
||||
public let route: SiteRoute.View
|
||||
public let isHtmxRequest: Bool
|
||||
public let logger: Logger
|
||||
public let authenticateUser: AuthenticateHandler
|
||||
public let currentUser: CurrentUserHandler
|
||||
|
||||
public init(
|
||||
route: SiteRoute.View,
|
||||
isHtmxRequest: Bool,
|
||||
logger: Logger,
|
||||
authenticateUser: @escaping AuthenticateHandler,
|
||||
currentUser: @escaping CurrentUserHandler
|
||||
logger: Logger
|
||||
) {
|
||||
self.route = route
|
||||
self.isHtmxRequest = isHtmxRequest
|
||||
self.logger = logger
|
||||
self.authenticateUser = authenticateUser
|
||||
self.currentUser = currentUser
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,28 +53,23 @@ extension ViewController: DependencyKey {
|
||||
|
||||
extension ViewController.Request {
|
||||
|
||||
func currentUser() throws -> User {
|
||||
@Dependency(\.auth.currentUser) var currentUser
|
||||
return try currentUser()
|
||||
}
|
||||
|
||||
func authenticate(
|
||||
_ login: User.Login
|
||||
) async throws -> User {
|
||||
@Dependency(\.database.users) var users
|
||||
let token = try await users.login(login)
|
||||
let user = try await users.get(token.userID)!
|
||||
authenticateUser(user)
|
||||
logger.debug("Logged in user: \(user.id)")
|
||||
return user
|
||||
@Dependency(\.auth) var auth
|
||||
return try await auth.login(login)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createAndAuthenticate(
|
||||
_ signup: User.Create
|
||||
) async throws -> User {
|
||||
@Dependency(\.database.users) var users
|
||||
let user = try await users.create(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
|
||||
@Dependency(\.auth) var auth
|
||||
return try await auth.createAndLogin(signup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import Dependencies
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import PdfClient
|
||||
import ProjectClient
|
||||
import Styleguide
|
||||
|
||||
extension ViewController.Request {
|
||||
@@ -10,20 +12,28 @@ extension ViewController.Request {
|
||||
func render() async -> AnySendableHTML {
|
||||
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
@Dependency(\.pdfClient) var pdfClient
|
||||
|
||||
switch route {
|
||||
case .test:
|
||||
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
|
||||
return await view {
|
||||
await ResultView {
|
||||
return (
|
||||
try await database.projects.getCompletedSteps(projectID),
|
||||
try await database.calculateDuctSizes(projectID: projectID)
|
||||
)
|
||||
} onSuccess: { (_, result) in
|
||||
TestPage(trunks: result.trunks, rooms: result.rooms)
|
||||
}
|
||||
}
|
||||
// let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
|
||||
// return await view {
|
||||
// await ResultView {
|
||||
//
|
||||
// // return (
|
||||
// // try await database.projects.getCompletedSteps(projectID),
|
||||
// // try await projectClient.calculateDuctSizes(projectID)
|
||||
// // )
|
||||
// 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):
|
||||
switch route {
|
||||
case .index(let next):
|
||||
@@ -60,7 +70,7 @@ extension ViewController.Request {
|
||||
case .submitProfile(let profile):
|
||||
return await view {
|
||||
await ResultView {
|
||||
_ = try await database.userProfile.create(profile)
|
||||
_ = try await database.userProfiles.create(profile)
|
||||
let userID = profile.userID
|
||||
// let user = try currentUser()
|
||||
return (
|
||||
@@ -98,7 +108,7 @@ extension ViewController.Request {
|
||||
get async {
|
||||
@Dependency(\.database) var database
|
||||
guard let user = try? currentUser() else { return nil }
|
||||
return try? await database.userProfile.fetch(user.id)?.theme
|
||||
return try? await database.userProfiles.fetch(user.id)?.theme
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +126,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
|
||||
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
|
||||
switch self {
|
||||
case .index:
|
||||
@@ -124,7 +135,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
let user = try request.currentUser()
|
||||
return try await (
|
||||
user.id,
|
||||
database.projects.fetchPage(userID: user.id)
|
||||
database.projects.fetch(user.id, .first)
|
||||
)
|
||||
|
||||
} onSuccess: { (userID, projects) in
|
||||
@@ -146,19 +157,14 @@ extension SiteRoute.View.ProjectRoute {
|
||||
return await request.view {
|
||||
await ResultView {
|
||||
let user = try request.currentUser()
|
||||
let project = try await database.projects.create(user.id, form)
|
||||
try await database.componentLoss.createDefaults(projectID: project.id)
|
||||
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
|
||||
return try await projectClient.createProject(user.id, form)
|
||||
} onSuccess: { response in
|
||||
ProjectView(
|
||||
projectID: projectID,
|
||||
projectID: response.projectID,
|
||||
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)
|
||||
case .frictionRate(let route):
|
||||
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):
|
||||
return await route.renderView(on: request, projectID: projectID)
|
||||
}
|
||||
@@ -354,8 +366,8 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
|
||||
return await request.view {
|
||||
await ResultView {
|
||||
let equipment = try await database.equipment.fetch(projectID)
|
||||
let componentLosses = try await database.componentLoss.fetch(projectID)
|
||||
let lengths = try await database.effectiveLength.fetchMax(projectID)
|
||||
let componentLosses = try await database.componentLosses.fetch(projectID)
|
||||
let lengths = try await database.equivalentLengths.fetchMax(projectID)
|
||||
|
||||
return (
|
||||
try await database.projects.getCompletedSteps(projectID),
|
||||
@@ -372,7 +384,7 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
|
||||
FrictionRateView(
|
||||
componentLosses: losses,
|
||||
equivalentLengths: lengths,
|
||||
frictionRateResponse: frictionRate
|
||||
frictionRate: frictionRate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -395,16 +407,16 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
|
||||
return EmptyHTML()
|
||||
case .delete(let id):
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.componentLoss.delete(id)
|
||||
_ = try await database.componentLosses.delete(id)
|
||||
}
|
||||
case .submit(let form):
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.componentLoss.create(form)
|
||||
_ = try await database.componentLosses.create(form)
|
||||
}
|
||||
|
||||
case .update(let id, let form):
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.componentLoss.update(id, form)
|
||||
_ = try await database.componentLosses.update(id, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,32 +428,21 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
|
||||
) async -> AnySendableHTML {
|
||||
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.manualD) var manualD
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
|
||||
return await request.view {
|
||||
await ResultView {
|
||||
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 (
|
||||
try await database.projects.getCompletedSteps(projectID),
|
||||
componentLosses,
|
||||
lengths,
|
||||
try await manualD.frictionRate(
|
||||
equipmentInfo: equipment,
|
||||
componentLosses: componentLosses,
|
||||
effectiveLength: lengths
|
||||
)
|
||||
try await projectClient.frictionRate(projectID)
|
||||
)
|
||||
} onSuccess: { (steps, losses, lengths, frictionRate) in
|
||||
} onSuccess: { (steps, response) in
|
||||
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
|
||||
FrictionRateView(
|
||||
componentLosses: losses,
|
||||
equivalentLengths: lengths,
|
||||
frictionRateResponse: frictionRate
|
||||
componentLosses: response.componentLosses,
|
||||
equivalentLengths: response.equivalentLengths,
|
||||
frictionRate: response.frictionRate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -463,7 +464,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||
|
||||
case .delete(let id):
|
||||
return await ResultView {
|
||||
try await database.effectiveLength.delete(id)
|
||||
try await database.equivalentLengths.delete(id)
|
||||
}
|
||||
|
||||
case .index:
|
||||
@@ -480,16 +481,16 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||
|
||||
case .update(let id, let form):
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
|
||||
_ = try await database.equivalentLengths.update(id, .init(form: form, projectID: projectID))
|
||||
}
|
||||
|
||||
case .submit(let step):
|
||||
switch step {
|
||||
case .one(let stepOne):
|
||||
return await ResultView {
|
||||
var effectiveLength: EffectiveLength? = nil
|
||||
var effectiveLength: EquivalentLength? = nil
|
||||
if let id = stepOne.id {
|
||||
effectiveLength = try await database.effectiveLength.get(id)
|
||||
effectiveLength = try await database.equivalentLengths.get(id)
|
||||
}
|
||||
return effectiveLength
|
||||
} onSuccess: { effectiveLength in
|
||||
@@ -502,9 +503,9 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||
case .two(let stepTwo):
|
||||
return await ResultView {
|
||||
request.logger.debug("ViewController: Got step two...")
|
||||
var effectiveLength: EffectiveLength? = nil
|
||||
var effectiveLength: EquivalentLength? = nil
|
||||
if let id = stepTwo.id {
|
||||
effectiveLength = try await database.effectiveLength.get(id)
|
||||
effectiveLength = try await database.equivalentLengths.get(id)
|
||||
}
|
||||
return effectiveLength
|
||||
} onSuccess: { effectiveLength in
|
||||
@@ -514,7 +515,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||
}
|
||||
case .three(let stepThree):
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.effectiveLength.create(
|
||||
_ = try await database.equivalentLengths.create(
|
||||
.init(form: stepThree, projectID: projectID)
|
||||
)
|
||||
}
|
||||
@@ -535,7 +536,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||
try await catching()
|
||||
return (
|
||||
try await database.projects.getCompletedSteps(projectID),
|
||||
try await database.effectiveLength.fetch(projectID)
|
||||
try await database.equivalentLengths.fetch(projectID)
|
||||
)
|
||||
} onSuccess: { (steps, equivalentLengths) in
|
||||
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
|
||||
@@ -555,6 +556,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
) async -> AnySendableHTML {
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.manualD) var manualD
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
|
||||
switch self {
|
||||
case .index:
|
||||
@@ -563,8 +565,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
case .deleteRectangularSize(let roomID, let request):
|
||||
return await ResultView {
|
||||
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
|
||||
return try await database.calculateDuctSizes(projectID: projectID)
|
||||
.rooms
|
||||
return try await projectClient.calculateRoomDuctSizes(projectID)
|
||||
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
|
||||
.first!
|
||||
} onSuccess: { room in
|
||||
@@ -577,8 +578,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
roomID,
|
||||
.init(id: form.id ?? .init(), register: form.register, height: form.height)
|
||||
)
|
||||
return try await database.calculateDuctSizes(projectID: projectID)
|
||||
.rooms
|
||||
return try await projectClient.calculateRoomDuctSizes(projectID)
|
||||
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
|
||||
.first!
|
||||
} onSuccess: { room in
|
||||
@@ -612,17 +612,18 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
catching: @escaping @Sendable () async throws -> Void = {}
|
||||
) async -> AnySendableHTML {
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.projectClient) var project
|
||||
|
||||
return await request.view {
|
||||
await ResultView {
|
||||
try await catching()
|
||||
return (
|
||||
try await database.projects.getCompletedSteps(projectID),
|
||||
try await database.calculateDuctSizes(projectID: projectID)
|
||||
try await project.calculateDuctSizes(projectID)
|
||||
)
|
||||
} onSuccess: { (steps, ducts) in
|
||||
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)
|
||||
case .submit(let form):
|
||||
return await view(on: request) {
|
||||
_ = try await database.userProfile.create(form)
|
||||
_ = try await database.userProfiles.create(form)
|
||||
}
|
||||
case .update(let id, let updates):
|
||||
return await view(on: request) {
|
||||
_ = try await database.userProfile.update(id, updates)
|
||||
_ = try await database.userProfiles.update(id, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,7 +673,7 @@ extension SiteRoute.View.UserRoute.Profile {
|
||||
let user = try request.currentUser()
|
||||
return (
|
||||
user,
|
||||
try await database.userProfile.fetch(user.id)
|
||||
try await database.userProfiles.fetch(user.id)
|
||||
)
|
||||
} onSuccess: { (user, profile) in
|
||||
UserView(user: user, profile: profile)
|
||||
|
||||
@@ -7,8 +7,7 @@ struct DuctSizingView: HTML, Sendable {
|
||||
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
|
||||
let rooms: [DuctSizing.RoomContainer]
|
||||
let trunks: [DuctSizing.TrunkContainer]
|
||||
let ductSizes: DuctSizes
|
||||
|
||||
var body: some HTML {
|
||||
div(.class("space-y-4")) {
|
||||
@@ -21,13 +20,30 @@ struct DuctSizingView: HTML, Sendable {
|
||||
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"))
|
||||
}
|
||||
|
||||
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 {
|
||||
RoomsTable(rooms: rooms)
|
||||
if ductSizes.rooms.count != 0 {
|
||||
RoomsTable(rooms: ductSizes.rooms)
|
||||
|
||||
PageTitleRow {
|
||||
PageTitle {
|
||||
@@ -42,13 +58,13 @@ struct DuctSizingView: HTML, Sendable {
|
||||
.tooltip("Add trunk / runout")
|
||||
}
|
||||
|
||||
if trunks.count > 0 {
|
||||
TrunkTable(trunks: trunks, rooms: rooms)
|
||||
if ductSizes.trunks.count > 0 {
|
||||
TrunkTable(ductSizes: ductSizes)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TrunkSizeForm(rooms: rooms, dismiss: true)
|
||||
TrunkSizeForm(rooms: ductSizes.rooms, dismiss: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import Styleguide
|
||||
|
||||
struct RectangularSizeForm: HTML, Sendable {
|
||||
|
||||
static func id(_ room: DuctSizing.RoomContainer) -> String {
|
||||
static func id(_ room: DuctSizes.RoomContainer) -> String {
|
||||
let base = "rectangularSize"
|
||||
return "\(base)_\(room.roomName.idString)"
|
||||
}
|
||||
@@ -13,12 +13,12 @@ struct RectangularSizeForm: HTML, Sendable {
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
|
||||
let id: String
|
||||
let room: DuctSizing.RoomContainer
|
||||
let room: DuctSizes.RoomContainer
|
||||
let dismiss: Bool
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
room: DuctSizing.RoomContainer,
|
||||
room: DuctSizes.RoomContainer,
|
||||
dismiss: Bool = true
|
||||
) {
|
||||
self.id = Self.id(room)
|
||||
@@ -40,7 +40,7 @@ struct RectangularSizeForm: HTML, Sendable {
|
||||
}
|
||||
|
||||
var height: Int? {
|
||||
room.rectangularSize?.height
|
||||
room.ductSize.height
|
||||
}
|
||||
|
||||
var body: some HTML<HTMLTag.dialog> {
|
||||
@@ -54,7 +54,7 @@ struct RectangularSizeForm: HTML, Sendable {
|
||||
.hx.swap(.outerHTML)
|
||||
) {
|
||||
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(
|
||||
"Height",
|
||||
|
||||
@@ -9,7 +9,7 @@ extension DuctSizingView {
|
||||
struct RoomsTable: HTML, Sendable {
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
|
||||
let rooms: [DuctSizing.RoomContainer]
|
||||
let rooms: [DuctSizes.RoomContainer]
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
|
||||
@@ -34,17 +34,17 @@ extension DuctSizingView {
|
||||
|
||||
struct RoomRow: HTML, Sendable {
|
||||
|
||||
static func id(_ room: DuctSizing.RoomContainer) -> String {
|
||||
static func id(_ room: DuctSizes.RoomContainer) -> String {
|
||||
"roomRow_\(room.roomName.idString)"
|
||||
}
|
||||
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
|
||||
let room: DuctSizing.RoomContainer
|
||||
let room: DuctSizes.RoomContainer
|
||||
let formID = UUID().idString
|
||||
|
||||
var deleteRoute: String {
|
||||
guard let id = room.rectangularSize?.id else { return "" }
|
||||
guard let id = room.rectangularID else { return "" }
|
||||
|
||||
return SiteRoute.View.router.path(
|
||||
for: .project(
|
||||
@@ -80,7 +80,7 @@ extension DuctSizingView {
|
||||
|
||||
span(.class("label")) { "Design" }
|
||||
div(.class("flex justify-center")) {
|
||||
Badge(number: room.designCFM.value, digits: 0)
|
||||
Badge(number: room.ductSize.designCFM.value, digits: 0)
|
||||
}
|
||||
|
||||
span(.class("label")) { "Heating" }
|
||||
@@ -103,28 +103,28 @@ extension DuctSizingView {
|
||||
|
||||
div(.class("label")) { "Calculated" }
|
||||
div(.class("flex justify-center")) {
|
||||
Badge(number: room.roundSize, digits: 2)
|
||||
Badge(number: room.ductSize.roundSize, digits: 2)
|
||||
}
|
||||
div {}
|
||||
|
||||
div(.class("label")) { "Final" }
|
||||
div(.class("flex justify-center")) {
|
||||
Badge(number: room.finalSize)
|
||||
Badge(number: room.ductSize.finalSize)
|
||||
.attributes(.class("badge-secondary"))
|
||||
}
|
||||
div {}
|
||||
|
||||
div(.class("label")) { "Flex" }
|
||||
div(.class("flex justify-center")) {
|
||||
Badge(number: room.flexSize)
|
||||
Badge(number: room.ductSize.flexSize)
|
||||
.attributes(.class("badge-primary"))
|
||||
}
|
||||
div {}
|
||||
|
||||
div(.class("label")) { "Rectangular" }
|
||||
div(.class("flex justify-center")) {
|
||||
if let width = room.rectangularWidth,
|
||||
let height = room.rectangularSize?.height
|
||||
if let width = room.ductSize.width,
|
||||
let height = room.ductSize.height
|
||||
{
|
||||
Badge {
|
||||
span { "\(width) x \(height)" }
|
||||
@@ -134,7 +134,7 @@ extension DuctSizingView {
|
||||
}
|
||||
div(.class("flex justify-end")) {
|
||||
div(.class("join")) {
|
||||
if room.rectangularSize != nil {
|
||||
if room.ductSize.width != nil {
|
||||
Tooltip("Delete Size", position: .bottom) {
|
||||
TrashButton()
|
||||
.attributes(.class("join-item btn-ghost"))
|
||||
@@ -142,7 +142,7 @@ extension DuctSizingView {
|
||||
.hx.delete(deleteRoute),
|
||||
.hx.target("#\(rowID)"),
|
||||
.hx.swap(.outerHTML),
|
||||
when: room.rectangularSize != nil
|
||||
when: room.ductSize.width != nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Styleguide
|
||||
|
||||
struct TrunkSizeForm: HTML, Sendable {
|
||||
|
||||
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String {
|
||||
static func id(_ trunk: DuctSizes.TrunkContainer? = nil) -> String {
|
||||
let base = "trunkSizeForm"
|
||||
guard let trunk else { return base }
|
||||
return "\(base)_\(trunk.id.idString)"
|
||||
@@ -13,17 +13,17 @@ struct TrunkSizeForm: HTML, Sendable {
|
||||
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
|
||||
let container: DuctSizing.TrunkContainer?
|
||||
let rooms: [DuctSizing.RoomContainer]
|
||||
let container: DuctSizes.TrunkContainer?
|
||||
let rooms: [DuctSizes.RoomContainer]
|
||||
let dismiss: Bool
|
||||
|
||||
var trunk: DuctSizing.TrunkSize? {
|
||||
var trunk: TrunkSize? {
|
||||
container?.trunk
|
||||
}
|
||||
|
||||
init(
|
||||
trunk: DuctSizing.TrunkContainer? = nil,
|
||||
rooms: [DuctSizing.RoomContainer],
|
||||
trunk: DuctSizes.TrunkContainer? = nil,
|
||||
rooms: [DuctSizes.RoomContainer],
|
||||
dismiss: Bool = true
|
||||
) {
|
||||
self.container = trunk
|
||||
@@ -56,7 +56,7 @@ struct TrunkSizeForm: HTML, Sendable {
|
||||
label(.class("select w-full")) {
|
||||
span(.class("label")) { "Type" }
|
||||
select(.name("type")) {
|
||||
for type in DuctSizing.TrunkSize.TrunkType.allCases {
|
||||
for type in TrunkSize.TrunkType.allCases {
|
||||
option(.value(type.rawValue)) { type.rawValue.capitalized }
|
||||
.attributes(.selected, when: trunk?.type == type)
|
||||
}
|
||||
@@ -121,8 +121,8 @@ struct TrunkSizeForm: HTML, Sendable {
|
||||
|
||||
}
|
||||
|
||||
extension Array where Element == DuctSizing.TrunkSize.RoomProxy {
|
||||
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool {
|
||||
extension Array where Element == TrunkSize.RoomProxy {
|
||||
func hasRoom(_ room: DuctSizes.RoomContainer) -> Bool {
|
||||
first {
|
||||
$0.id == room.roomID
|
||||
&& $0.registers.contains(room.roomRegister)
|
||||
|
||||
@@ -7,11 +7,10 @@ extension DuctSizingView {
|
||||
|
||||
struct TrunkTable: HTML, Sendable {
|
||||
|
||||
let trunks: [DuctSizing.TrunkContainer]
|
||||
let rooms: [DuctSizing.RoomContainer]
|
||||
let ductSizes: DuctSizes
|
||||
|
||||
private var sortedTrunks: [DuctSizing.TrunkContainer] {
|
||||
trunks
|
||||
private var sortedTrunks: [DuctSizes.TrunkContainer] {
|
||||
ductSizes.trunks
|
||||
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
|
||||
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
|
||||
}
|
||||
@@ -29,7 +28,7 @@ extension DuctSizingView {
|
||||
}
|
||||
tbody {
|
||||
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
|
||||
|
||||
let trunk: DuctSizing.TrunkContainer
|
||||
let rooms: [DuctSizing.RoomContainer]
|
||||
let trunk: DuctSizes.TrunkContainer
|
||||
let rooms: [DuctSizes.RoomContainer]
|
||||
|
||||
var body: some HTML<HTMLTag.tr> {
|
||||
tr {
|
||||
@@ -135,17 +134,18 @@ extension DuctSizingView {
|
||||
}
|
||||
|
||||
private var registerIDS: [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()
|
||||
trunk.registerIDS(rooms: rooms)
|
||||
// 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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Styleguide
|
||||
|
||||
struct EffectiveLengthForm: HTML, Sendable {
|
||||
|
||||
static func id(_ equivalentLength: EffectiveLength?) -> String {
|
||||
static func id(_ equivalentLength: EquivalentLength?) -> String {
|
||||
let base = "equivalentLengthForm"
|
||||
guard let equivalentLength else { return base }
|
||||
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
|
||||
@@ -15,15 +15,15 @@ struct EffectiveLengthForm: HTML, Sendable {
|
||||
|
||||
let projectID: Project.ID
|
||||
let dismiss: Bool
|
||||
let type: EffectiveLength.EffectiveLengthType
|
||||
let effectiveLength: EffectiveLength?
|
||||
let type: EquivalentLength.EffectiveLengthType
|
||||
let effectiveLength: EquivalentLength?
|
||||
|
||||
var id: String { Self.id(effectiveLength) }
|
||||
|
||||
init(
|
||||
projectID: Project.ID,
|
||||
dismiss: Bool,
|
||||
type: EffectiveLength.EffectiveLengthType = .supply
|
||||
type: EquivalentLength.EffectiveLengthType = .supply
|
||||
) {
|
||||
self.projectID = projectID
|
||||
self.dismiss = dismiss
|
||||
@@ -32,7 +32,7 @@ struct EffectiveLengthForm: HTML, Sendable {
|
||||
}
|
||||
|
||||
init(
|
||||
effectiveLength: EffectiveLength
|
||||
effectiveLength: EquivalentLength
|
||||
) {
|
||||
self.dismiss = true
|
||||
self.type = effectiveLength.type
|
||||
@@ -55,7 +55,7 @@ struct EffectiveLengthForm: HTML, Sendable {
|
||||
|
||||
struct StepOne: HTML, Sendable {
|
||||
let projectID: Project.ID
|
||||
let effectiveLength: EffectiveLength?
|
||||
let effectiveLength: EquivalentLength?
|
||||
|
||||
var route: String {
|
||||
let baseRoute = SiteRoute.View.router.path(
|
||||
@@ -97,7 +97,7 @@ struct EffectiveLengthForm: HTML, Sendable {
|
||||
struct StepTwo: HTML, Sendable {
|
||||
let projectID: Project.ID
|
||||
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
|
||||
let effectiveLength: EffectiveLength?
|
||||
let effectiveLength: EquivalentLength?
|
||||
|
||||
var route: String {
|
||||
let baseRoute = SiteRoute.View.router.path(
|
||||
@@ -152,7 +152,7 @@ struct EffectiveLengthForm: HTML, Sendable {
|
||||
|
||||
struct StepThree: HTML, Sendable {
|
||||
let projectID: Project.ID
|
||||
let effectiveLength: EffectiveLength?
|
||||
let effectiveLength: EquivalentLength?
|
||||
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
|
||||
|
||||
var route: String {
|
||||
@@ -254,10 +254,10 @@ struct StraightLengthField: HTML, Sendable {
|
||||
|
||||
struct GroupField: HTML, Sendable {
|
||||
|
||||
let style: EffectiveLength.EffectiveLengthType
|
||||
let group: EffectiveLength.Group?
|
||||
let style: EquivalentLength.EffectiveLengthType
|
||||
let group: EquivalentLength.FittingGroup?
|
||||
|
||||
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) {
|
||||
init(style: EquivalentLength.EffectiveLengthType, group: EquivalentLength.FittingGroup? = nil) {
|
||||
self.style = style
|
||||
self.group = group
|
||||
}
|
||||
@@ -307,7 +307,7 @@ struct GroupField: HTML, Sendable {
|
||||
|
||||
struct GroupSelect: HTML, Sendable {
|
||||
|
||||
let style: EffectiveLength.EffectiveLengthType
|
||||
let style: EquivalentLength.EffectiveLengthType
|
||||
|
||||
var body: some HTML {
|
||||
label(.class("select")) {
|
||||
@@ -328,13 +328,13 @@ struct GroupSelect: HTML, Sendable {
|
||||
struct GroupTypeSelect: HTML, Sendable {
|
||||
|
||||
let projectID: Project.ID
|
||||
let selected: EffectiveLength.EffectiveLengthType
|
||||
let selected: EquivalentLength.EffectiveLengthType
|
||||
|
||||
var body: some HTML<HTMLTag.label> {
|
||||
label(.class("select w-full")) {
|
||||
span(.class("label")) { "Type" }
|
||||
select(.name("type"), .id("type")) {
|
||||
for value in EffectiveLength.EffectiveLengthType.allCases {
|
||||
for value in EquivalentLength.EffectiveLengthType.allCases {
|
||||
option(
|
||||
.value("\(value.rawValue)"),
|
||||
) { value.title }
|
||||
@@ -345,7 +345,7 @@ struct GroupTypeSelect: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLength.EffectiveLengthType {
|
||||
extension EquivalentLength.EffectiveLengthType {
|
||||
|
||||
var title: String { rawValue.capitalized }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user