Compare commits
114 Commits
1446540109
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
a10f3ef0f5
|
|||
|
e3a731e3fa
|
|||
|
8e66d57994
|
|||
|
87c651eed9
|
|||
|
e8ab85a189
|
|||
|
1fcf331729
|
|||
|
3f0a669b2b
|
|||
|
07fff2cebd
|
|||
|
4b81d3bd1e
|
|||
|
338ccc64df
|
|||
|
729dc0ac55
|
|||
|
86f08a4bb2
|
|||
|
08e67ce308
|
|||
|
dc9f51c04f
|
|||
|
980d99e40b
|
|||
|
06b663052e
|
|||
|
007d13be2f
|
|||
|
88af6f722e
|
|||
|
5a7cf4714b
|
|||
|
e4ddec0d53
|
|||
|
bb88d48eb3
|
|||
|
d957cc1c19
|
|||
|
2aaa408712
|
|||
|
291bed28d5
|
|||
|
1a38922ac0
|
|||
|
76bd788769
|
|||
|
0134c9bfc2
|
|||
|
0775474f57
|
|||
|
f2c79ad56f
|
|||
|
728a6c3000
|
|||
|
57766c990e
|
|||
|
b2b5e32535
|
|||
|
881737978d
|
|||
|
6a764ade2b
|
|||
|
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
|
|||
|
c6a29313aa
|
|||
|
f5afc6e32b
|
|||
|
9709eaaf8e
|
|||
|
4ecd4dba7b
|
|||
|
7471e11bd2
|
|||
|
1b88f81b5f
|
|||
|
86307dfa05
|
|||
|
356e020e3b
|
|||
|
9b5b891744
|
|||
|
658ea9f12e
|
|||
|
7f734e912b
|
|||
|
b5d1f87380
|
|||
|
450791b37e
|
|||
|
71848c607a
|
|||
|
62a82ed674
|
|||
|
dfee50de8e
|
|||
|
f990c4b6db
|
|||
|
930db145a8
|
|||
|
df600a5471
|
|||
|
432533c940
|
|||
|
fa9e8cffb0
|
|||
|
c2aedfac1a
|
|||
|
894bd561ff
|
|||
|
6416b29627
|
|||
|
6aaf39f63c
|
|||
|
0a68177aa8
|
|||
|
f7c6373255
|
|||
|
f835fc7c51
|
|||
|
51edff5a8a
|
|||
|
a7f40efba9
|
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
@@ -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
|
||||||
61
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# branches: ['main']
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.housh.dev
|
||||||
|
USERNAME: michael
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major.minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:build
|
||||||
|
cache-to: mode=min,image-manifest=true,oci-mediatypes=true,type=inline,ref=${{ env.IMAGE_NAME }}:build
|
||||||
|
|
||||||
31
.github/workflows/ci.yaml
vendored
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-latest
|
||||||
|
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: ${{ github.repository_owner }}/ductcalc:test
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
docker run --rm ${{ github.repository_owner }}/ductcalc:test swift test
|
||||||
73
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# branches: ['main']
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
USERNAME: m-housh
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.USERNAME }}
|
||||||
|
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker
|
||||||
|
uses: docker/setup-docker-action@v4
|
||||||
|
with:
|
||||||
|
daemon-config: |
|
||||||
|
{
|
||||||
|
"debug": true,
|
||||||
|
"features": {
|
||||||
|
"containerd-snapshotter": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.USERNAME }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major.minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
|
cache-to: mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:build
|
||||||
|
|
||||||
6
.gitignore
vendored
@@ -9,3 +9,9 @@ DerivedData/
|
|||||||
.swift-version
|
.swift-version
|
||||||
node_modules/
|
node_modules/
|
||||||
tailwindcss
|
tailwindcss
|
||||||
|
.envrc
|
||||||
|
*.pdf
|
||||||
|
.env
|
||||||
|
.env*
|
||||||
|
default.profraw
|
||||||
|
/rooms.csv
|
||||||
|
|||||||
394
LICENSE
@@ -1,18 +1,384 @@
|
|||||||
MIT License
|
Copyright (c) 2026 Michael Housh II
|
||||||
|
|
||||||
Copyright (c) 2025 michael
|
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||||
|
Public License
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
By exercising the Licensed Rights (defined below), You accept and agree
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
to be bound by the terms and conditions of this Creative Commons
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
("Public License"). To the extent this Public License may be
|
||||||
following conditions:
|
interpreted as a contract, You are granted the Licensed Rights in
|
||||||
|
consideration of Your acceptance of these terms and conditions, and the
|
||||||
|
Licensor grants You such rights in consideration of benefits the
|
||||||
|
Licensor receives from making the Licensed Material available under
|
||||||
|
these terms and conditions.
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
||||||
portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
Section 1 -- Definitions.
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
a. Adapted Material means material subject to Copyright and Similar
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
Rights that is derived from or based upon the Licensed Material
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
and in which the Licensed Material is translated, altered,
|
||||||
|
arranged, transformed, or otherwise modified in a manner requiring
|
||||||
|
permission under the Copyright and Similar Rights held by the
|
||||||
|
Licensor. For purposes of this Public License, where the Licensed
|
||||||
|
Material is a musical work, performance, or sound recording,
|
||||||
|
Adapted Material is always produced where the Licensed Material is
|
||||||
|
synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright
|
||||||
|
and Similar Rights in Your contributions to Adapted Material in
|
||||||
|
accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-NC-SA Compatible License means a license listed at
|
||||||
|
creativecommons.org/compatiblelicenses, approved by Creative
|
||||||
|
Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||||
|
closely related to copyright including, without limitation,
|
||||||
|
performance, broadcast, sound recording, and Sui Generis Database
|
||||||
|
Rights, without regard to how the rights are labeled or
|
||||||
|
categorized. For purposes of this Public License, the rights
|
||||||
|
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||||
|
Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the
|
||||||
|
absence of proper authority, may not be circumvented under laws
|
||||||
|
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||||
|
Treaty adopted on December 20, 1996, and/or similar international
|
||||||
|
agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||||
|
any other exception or limitation to Copyright and Similar Rights
|
||||||
|
that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name
|
||||||
|
of a Creative Commons Public License. The License Elements of this
|
||||||
|
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database,
|
||||||
|
or other material to which the Licensor applied this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the
|
||||||
|
terms and conditions of this Public License, which are limited to
|
||||||
|
all Copyright and Similar Rights that apply to Your use of the
|
||||||
|
Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||||
|
under this Public License.
|
||||||
|
|
||||||
|
k. NonCommercial means not primarily intended for or directed towards
|
||||||
|
commercial advantage or monetary compensation. For purposes of
|
||||||
|
this Public License, the exchange of the Licensed Material for
|
||||||
|
other material subject to Copyright and Similar Rights by digital
|
||||||
|
file-sharing or similar means is NonCommercial provided there is
|
||||||
|
no payment of monetary compensation in connection with the
|
||||||
|
exchange.
|
||||||
|
|
||||||
|
l. Share means to provide material to the public by any means or
|
||||||
|
process that requires permission under the Licensed Rights, such
|
||||||
|
as reproduction, public display, public performance, distribution,
|
||||||
|
dissemination, communication, or importation, and to make material
|
||||||
|
available to the public including in ways that members of the
|
||||||
|
public may access the material from a place and at a time
|
||||||
|
individually chosen by them.
|
||||||
|
|
||||||
|
m. Sui Generis Database Rights means rights other than copyright
|
||||||
|
resulting from Directive 96/9/EC of the European Parliament and of
|
||||||
|
the Council of 11 March 1996 on the legal protection of databases,
|
||||||
|
as amended and/or succeeded, as well as other essentially
|
||||||
|
equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
n. You means the individual or entity exercising the Licensed Rights
|
||||||
|
under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
|
||||||
|
Section 2 -- Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License,
|
||||||
|
the Licensor hereby grants You a worldwide, royalty-free,
|
||||||
|
non-sublicensable, non-exclusive, irrevocable license to
|
||||||
|
exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
a. reproduce and Share the Licensed Material, in whole or
|
||||||
|
in part, for NonCommercial purposes only; and
|
||||||
|
|
||||||
|
b. produce, reproduce, and Share Adapted Material for
|
||||||
|
NonCommercial purposes only.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||||
|
Exceptions and Limitations apply to Your use, this Public
|
||||||
|
License does not apply, and You do not need to comply with
|
||||||
|
its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section
|
||||||
|
6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The
|
||||||
|
Licensor authorizes You to exercise the Licensed Rights in
|
||||||
|
all media and formats whether now known or hereafter created,
|
||||||
|
and to make technical modifications necessary to do so. The
|
||||||
|
Licensor waives and/or agrees not to assert any right or
|
||||||
|
authority to forbid You from making technical modifications
|
||||||
|
necessary to exercise the Licensed Rights, including
|
||||||
|
technical modifications necessary to circumvent Effective
|
||||||
|
Technological Measures. For purposes of this Public License,
|
||||||
|
simply making modifications authorized by this Section 2(a)
|
||||||
|
(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
a. Offer from the Licensor -- Licensed Material. Every
|
||||||
|
recipient of the Licensed Material automatically
|
||||||
|
receives an offer from the Licensor to exercise the
|
||||||
|
Licensed Rights under the terms and conditions of this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
b. Additional offer from the Licensor -- Adapted Material.
|
||||||
|
Every recipient of Adapted Material from You
|
||||||
|
automatically receives an offer from the Licensor to
|
||||||
|
exercise the Licensed Rights in the Adapted Material
|
||||||
|
under the conditions of the Adapter's License You apply.
|
||||||
|
|
||||||
|
c. No downstream restrictions. You may not offer or impose
|
||||||
|
any additional or different terms or conditions on, or
|
||||||
|
apply any Effective Technological Measures to, the
|
||||||
|
Licensed Material if doing so restricts exercise of the
|
||||||
|
Licensed Rights by any recipient of the Licensed
|
||||||
|
Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or
|
||||||
|
may be construed as permission to assert or imply that You
|
||||||
|
are, or that Your use of the Licensed Material is, connected
|
||||||
|
with, or sponsored, endorsed, or granted official status by,
|
||||||
|
the Licensor or others designated to receive attribution as
|
||||||
|
provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not
|
||||||
|
licensed under this Public License, nor are publicity,
|
||||||
|
privacy, and/or other similar personality rights; however, to
|
||||||
|
the extent possible, the Licensor waives and/or agrees not to
|
||||||
|
assert any such rights held by the Licensor to the limited
|
||||||
|
extent necessary to allow You to exercise the Licensed
|
||||||
|
Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this
|
||||||
|
Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to
|
||||||
|
collect royalties from You for the exercise of the Licensed
|
||||||
|
Rights, whether directly or through a collecting society
|
||||||
|
under any voluntary or waivable statutory or compulsory
|
||||||
|
licensing scheme. In all other cases the Licensor expressly
|
||||||
|
reserves any right to collect such royalties, including when
|
||||||
|
the Licensed Material is used other than for NonCommercial
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
|
||||||
|
Section 3 -- License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the
|
||||||
|
following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified
|
||||||
|
form), You must:
|
||||||
|
|
||||||
|
a. retain the following if it is supplied by the Licensor
|
||||||
|
with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed
|
||||||
|
Material and any others designated to receive
|
||||||
|
attribution, in any reasonable manner requested by
|
||||||
|
the Licensor (including by pseudonym if
|
||||||
|
designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of
|
||||||
|
warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the
|
||||||
|
extent reasonably practicable;
|
||||||
|
|
||||||
|
b. indicate if You modified the Licensed Material and
|
||||||
|
retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
c. indicate the Licensed Material is licensed under this
|
||||||
|
Public License, and include the text of, or the URI or
|
||||||
|
hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||||
|
reasonable manner based on the medium, means, and context in
|
||||||
|
which You Share the Licensed Material. For example, it may be
|
||||||
|
reasonable to satisfy the conditions by providing a URI or
|
||||||
|
hyperlink to a resource that includes the required
|
||||||
|
information.
|
||||||
|
3. If requested by the Licensor, You must remove any of the
|
||||||
|
information required by Section 3(a)(1)(A) to the extent
|
||||||
|
reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.
|
||||||
|
|
||||||
|
In addition to the conditions in Section 3(a), if You Share
|
||||||
|
Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter's License You apply must be a Creative Commons
|
||||||
|
license with the same License Elements, this version or
|
||||||
|
later, or a BY-NC-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the
|
||||||
|
Adapter's License You apply. You may satisfy this condition
|
||||||
|
in any reasonable manner based on the medium, means, and
|
||||||
|
context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms
|
||||||
|
or conditions on, or apply any Effective Technological
|
||||||
|
Measures to, Adapted Material that restrict exercise of the
|
||||||
|
rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
|
||||||
|
Section 4 -- Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that
|
||||||
|
apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||||
|
to extract, reuse, reproduce, and Share all or a substantial
|
||||||
|
portion of the contents of the database for NonCommercial purposes
|
||||||
|
only;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database
|
||||||
|
contents in a database in which You have Sui Generis Database
|
||||||
|
Rights, then the database in which You have Sui Generis Database
|
||||||
|
Rights (but not its individual contents) is Adapted Material,
|
||||||
|
including for purposes of Section 3(b); and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share
|
||||||
|
all or a substantial portion of the contents of the database.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not
|
||||||
|
replace Your obligations under this Public License where the Licensed
|
||||||
|
Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
|
||||||
|
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||||
|
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||||
|
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||||
|
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||||
|
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||||
|
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||||
|
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||||
|
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||||
|
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||||
|
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||||
|
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||||
|
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||||
|
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||||
|
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||||
|
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||||
|
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided
|
||||||
|
above shall be interpreted in a manner that, to the extent
|
||||||
|
possible, most closely approximates an absolute disclaimer and
|
||||||
|
waiver of all liability.
|
||||||
|
|
||||||
|
|
||||||
|
Section 6 -- Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and
|
||||||
|
Similar Rights licensed here. However, if You fail to comply with
|
||||||
|
this Public License, then Your rights under this Public License
|
||||||
|
terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under
|
||||||
|
Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided
|
||||||
|
it is cured within 30 days of Your discovery of the
|
||||||
|
violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||||
|
right the Licensor may have to seek remedies for Your violations
|
||||||
|
of this Public License.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, the Licensor may also offer the
|
||||||
|
Licensed Material under separate terms or conditions or stop
|
||||||
|
distributing the Licensed Material at any time; however, doing so
|
||||||
|
will not terminate this Public License.
|
||||||
|
|
||||||
|
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||||
|
License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 7 -- Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different
|
||||||
|
terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the
|
||||||
|
Licensed Material not stated herein are separate from and
|
||||||
|
independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
|
||||||
|
Section 8 -- Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and
|
||||||
|
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||||
|
conditions on any use of the Licensed Material that could lawfully
|
||||||
|
be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is
|
||||||
|
deemed unenforceable, it shall be automatically reformed to the
|
||||||
|
minimum extent necessary to make it enforceable. If the provision
|
||||||
|
cannot be reformed, it shall be severed from this Public License
|
||||||
|
without affecting the enforceability of the remaining terms and
|
||||||
|
conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no
|
||||||
|
failure to comply consented to unless expressly agreed to by the
|
||||||
|
Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted
|
||||||
|
as a limitation upon, or waiver of, any privileges and immunities
|
||||||
|
that apply to the Licensor or You, including from the legal
|
||||||
|
processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
=======================================================================
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public
|
||||||
|
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||||
|
its public licenses to material it publishes and in those instances
|
||||||
|
will be considered the “Licensor.” The text of the Creative Commons
|
||||||
|
public licenses is dedicated to the public domain under the CC0 Public
|
||||||
|
Domain Dedication. Except for the limited purpose of indicating that
|
||||||
|
material is shared under a Creative Commons public license or as
|
||||||
|
otherwise permitted by the Creative Commons policies published at
|
||||||
|
creativecommons.org/policies, Creative Commons does not authorize the
|
||||||
|
use of the trademark "Creative Commons" or any other trademark or logo
|
||||||
|
of Creative Commons without its prior written consent including,
|
||||||
|
without limitation, in connection with any unauthorized modifications
|
||||||
|
to any of its public licenses or any other arrangements,
|
||||||
|
understandings, or agreements concerning use of licensed material. For
|
||||||
|
the avoidance of doubt, this paragraph does not form part of the
|
||||||
|
public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2",
|
"originHash" : "5bbd172c602e6484b32782f8cb68faca2d5120adc511acdff74e62fec2178c15",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "async-http-client",
|
"identity" : "async-http-client",
|
||||||
@@ -73,6 +73,15 @@
|
|||||||
"version" : "1.53.0"
|
"version" : "1.53.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "fluent-postgres-driver",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
|
||||||
|
"version" : "2.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "fluent-sqlite-driver",
|
"identity" : "fluent-sqlite-driver",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -100,6 +109,24 @@
|
|||||||
"version" : "0.14.0"
|
"version" : "0.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "postgres-kit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/postgres-kit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
|
||||||
|
"version" : "2.15.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "postgres-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/vapor/postgres-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
|
||||||
|
"version" : "1.30.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "routing-kit",
|
"identity" : "routing-kit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -145,6 +172,15 @@
|
|||||||
"version" : "1.2.1"
|
"version" : "1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-asn1",
|
"identity" : "swift-asn1",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -226,6 +262,15 @@
|
|||||||
"version" : "4.2.0"
|
"version" : "4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-custom-dump",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-custom-dump",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "93a8aa4937030b606de42f44b17870249f49af0b",
|
||||||
|
"version" : "1.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-dependencies",
|
"identity" : "swift-dependencies",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -361,6 +406,15 @@
|
|||||||
"version" : "2.9.1"
|
"version" : "2.9.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-snapshot-testing",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
|
||||||
|
"version" : "1.18.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-syntax",
|
"identity" : "swift-syntax",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -379,6 +433,15 @@
|
|||||||
"version" : "1.6.3"
|
"version" : "1.6.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-tagged",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||||
|
"version" : "0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-url-routing",
|
"identity" : "swift-url-routing",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -388,6 +451,15 @@
|
|||||||
"version" : "0.6.2"
|
"version" : "0.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-validations",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/m-housh/swift-validations.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ae939c146f380ca12d0a04ca1f6b0c4c270fdd5a",
|
||||||
|
"version" : "0.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "vapor",
|
"identity" : "vapor",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
133
Package.swift
@@ -6,36 +6,49 @@ let package = Package(
|
|||||||
name: "swift-manual-d",
|
name: "swift-manual-d",
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "App", targets: ["App"]),
|
.executable(name: "App", targets: ["App"]),
|
||||||
.library(name: "ApiController", targets: ["ApiController"]),
|
.executable(name: "ductcalc", targets: ["CLI"]),
|
||||||
|
.library(name: "AuthClient", targets: ["AuthClient"]),
|
||||||
|
.library(name: "CSVParser", targets: ["CSVParser"]),
|
||||||
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
|
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
|
||||||
|
.library(name: "EnvVars", targets: ["EnvVars"]),
|
||||||
|
.library(name: "FileClient", targets: ["FileClient"]),
|
||||||
|
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
|
||||||
|
.library(name: "PdfClient", targets: ["PdfClient"]),
|
||||||
|
.library(name: "ProjectClient", targets: ["ProjectClient"]),
|
||||||
.library(name: "ManualDCore", targets: ["ManualDCore"]),
|
.library(name: "ManualDCore", targets: ["ManualDCore"]),
|
||||||
.library(name: "ManualDClient", targets: ["ManualDClient"]),
|
.library(name: "ManualDClient", targets: ["ManualDClient"]),
|
||||||
.library(name: "Styleguide", targets: ["Styleguide"]),
|
.library(name: "Styleguide", targets: ["Styleguide"]),
|
||||||
.library(name: "ViewController", targets: ["ViewController"]),
|
.library(name: "ViewController", targets: ["ViewController"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
|
||||||
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
|
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
|
||||||
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
|
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
|
||||||
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
|
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
|
||||||
|
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
|
||||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
|
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
|
||||||
|
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0"),
|
||||||
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
|
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
|
||||||
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
|
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
|
||||||
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
|
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
|
||||||
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
|
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
|
||||||
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
|
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
|
||||||
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
|
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
|
||||||
|
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.3.5"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "App",
|
name: "App",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.target(name: "ApiController"),
|
.target(name: "AuthClient"),
|
||||||
.target(name: "DatabaseClient"),
|
.target(name: "DatabaseClient"),
|
||||||
.target(name: "ViewController"),
|
.target(name: "ViewController"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "Fluent", package: "fluent"),
|
.product(name: "Fluent", package: "fluent"),
|
||||||
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||||
|
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
|
||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
.product(name: "NIOCore", package: "swift-nio"),
|
.product(name: "NIOCore", package: "swift-nio"),
|
||||||
.product(name: "NIOPosix", package: "swift-nio"),
|
.product(name: "NIOPosix", package: "swift-nio"),
|
||||||
@@ -43,14 +56,34 @@ let package = Package(
|
|||||||
.product(name: "VaporRouting", package: "vapor-routing"),
|
.product(name: "VaporRouting", package: "vapor-routing"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "CLI",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "ManualDClient"),
|
||||||
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "ApiController",
|
name: "AuthClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.target(name: "DatabaseClient"),
|
.target(name: "DatabaseClient"),
|
||||||
.target(name: "ManualDCore"),
|
.target(name: "ManualDCore"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "Vapor", package: "vapor"),
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "CSVParser",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "ManualDCore"),
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "CSVParsingTests",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "CSVParser")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
@@ -61,29 +94,91 @@ let package = Package(
|
|||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "Fluent", package: "fluent"),
|
.product(name: "Fluent", package: "fluent"),
|
||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
|
.product(name: "Validations", package: "swift-validations"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "DatabaseClientTests",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "App"),
|
||||||
|
.target(name: "DatabaseClient"),
|
||||||
|
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
|
||||||
|
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.copy("Resources")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "EnvVars",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "FileClient",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "Vapor", package: "vapor"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "HTMLSnapshotTesting",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Elementary", package: "elementary"),
|
||||||
|
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "PdfClient",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "EnvVars"),
|
||||||
|
.target(name: "FileClient"),
|
||||||
|
.target(name: "ManualDCore"),
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "Elementary", package: "elementary"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "PdfClientTests",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "HTMLSnapshotTesting"),
|
||||||
|
.target(name: "PdfClient"),
|
||||||
|
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.copy("__Snapshots__")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "ProjectClient",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "DatabaseClient"),
|
||||||
|
.target(name: "ManualDClient"),
|
||||||
|
.target(name: "PdfClient"),
|
||||||
|
.product(name: "Vapor", package: "vapor"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "ManualDCore",
|
name: "ManualDCore",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "Fluent", package: "fluent"),
|
.product(name: "Fluent", package: "fluent"),
|
||||||
|
.product(name: "Tagged", package: "swift-tagged"),
|
||||||
.product(name: "URLRouting", package: "swift-url-routing"),
|
.product(name: "URLRouting", package: "swift-url-routing"),
|
||||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "ApiRouteTests",
|
|
||||||
dependencies: [
|
|
||||||
.target(name: "ManualDCore")
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "ManualDClient",
|
name: "ManualDClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"ManualDCore",
|
.target(name: "ManualDCore"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "Tagged", package: "swift-tagged"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
@@ -104,7 +199,11 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "ViewController",
|
name: "ViewController",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.target(name: "AuthClient"),
|
||||||
|
.target(name: "CSVParser"),
|
||||||
.target(name: "DatabaseClient"),
|
.target(name: "DatabaseClient"),
|
||||||
|
.target(name: "PdfClient"),
|
||||||
|
.target(name: "ProjectClient"),
|
||||||
.target(name: "ManualDClient"),
|
.target(name: "ManualDClient"),
|
||||||
.target(name: "ManualDCore"),
|
.target(name: "ManualDCore"),
|
||||||
.target(name: "Styleguide"),
|
.target(name: "Styleguide"),
|
||||||
@@ -115,5 +214,15 @@ let package = Package(
|
|||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "ViewControllerTests",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "ViewController"),
|
||||||
|
.target(name: "HTMLSnapshotTesting"),
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.copy("__Snapshots__")
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
12
Public/css/htmx.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.htmx-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: light --default, dark --prefersdark, dracula;
|
themes: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
BIN
Public/files/ManD.Groups.pdf
Executable file
BIN
Public/images/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
Public/images/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
Public/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
Public/images/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1013 B |
BIN
Public/images/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
Public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Public/images/mand_logo.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
Public/images/mand_logo_md.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
Public/images/mand_logo_sm.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Public/images/mand_logo_trimmed.png
Normal file
|
After Width: | Height: | Size: 592 KiB |
3090
Public/js/daisy-multiselect.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
Public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -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 ApiController
|
||||||
import ManualDCore
|
// import ManualDCore
|
||||||
import Vapor
|
// import Vapor
|
||||||
|
//
|
||||||
extension ApiController {
|
// extension ApiController {
|
||||||
|
//
|
||||||
func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
// func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
||||||
-> any AsyncResponseEncodable
|
// -> any AsyncResponseEncodable
|
||||||
{
|
// {
|
||||||
guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
// guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
||||||
return HTTPStatus.ok
|
// return HTTPStatus.ok
|
||||||
}
|
// }
|
||||||
return AnyJSONResponse(value: encodable)
|
// return AnyJSONResponse(value: encodable)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
struct AnyJSONResponse: AsyncResponseEncodable {
|
// struct AnyJSONResponse: AsyncResponseEncodable {
|
||||||
public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
// public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
||||||
let value: any Encodable
|
// let value: any Encodable
|
||||||
|
//
|
||||||
init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
// init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
||||||
if additionalHeaders.contains(name: .contentType) {
|
// if additionalHeaders.contains(name: .contentType) {
|
||||||
self.headers = additionalHeaders
|
// self.headers = additionalHeaders
|
||||||
} else {
|
// } else {
|
||||||
headers.add(contentsOf: additionalHeaders)
|
// headers.add(contentsOf: additionalHeaders)
|
||||||
}
|
// }
|
||||||
self.value = value
|
// self.value = value
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
func encodeResponse(for request: Request) async throws -> Response {
|
// func encodeResponse(for request: Request) async throws -> Response {
|
||||||
try Response(
|
// try Response(
|
||||||
status: .ok,
|
// status: .ok,
|
||||||
headers: headers,
|
// headers: headers,
|
||||||
body: .init(data: JSONEncoder().encode(value))
|
// body: .init(data: JSONEncoder().encode(value))
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ extension ViewController {
|
|||||||
.init(
|
.init(
|
||||||
route: route,
|
route: route,
|
||||||
isHtmxRequest: request.isHtmxRequest,
|
isHtmxRequest: request.isHtmxRequest,
|
||||||
logger: request.logger,
|
logger: request.logger
|
||||||
authenticateUser: { request.session.authenticate($0) },
|
|
||||||
currentUser: {
|
|
||||||
try request.auth.require(User.self)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return AnyHTMLResponse(value: html)
|
return AnyHTMLResponse(value: html)
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
import ApiController
|
// import ApiController
|
||||||
|
import AuthClient
|
||||||
import DatabaseClient
|
import DatabaseClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import EnvVars
|
||||||
|
import ManualDCore
|
||||||
|
import PdfClient
|
||||||
import Vapor
|
import Vapor
|
||||||
import ViewController
|
import ViewController
|
||||||
|
|
||||||
// Taken from discussions page on `swift-dependencies`.
|
// Taken from discussions page on `swift-dependencies`.
|
||||||
|
|
||||||
// FIX: Use live view controller.
|
|
||||||
struct DependenciesMiddleware: AsyncMiddleware {
|
struct DependenciesMiddleware: AsyncMiddleware {
|
||||||
|
|
||||||
private let values: DependencyValues.Continuation
|
private let values: DependencyValues.Continuation
|
||||||
private let apiController: ApiController
|
// private let apiController: ApiController
|
||||||
private let database: DatabaseClient
|
private let database: DatabaseClient
|
||||||
|
private let environment: EnvVars
|
||||||
private let viewController: ViewController
|
private let viewController: ViewController
|
||||||
|
|
||||||
init(
|
init(
|
||||||
database: DatabaseClient,
|
database: DatabaseClient,
|
||||||
apiController: ApiController = .liveValue,
|
environment: EnvVars,
|
||||||
|
// apiController: ApiController = .liveValue,
|
||||||
viewController: ViewController = .liveValue
|
viewController: ViewController = .liveValue
|
||||||
) {
|
) {
|
||||||
self.values = withEscapedDependencies { $0 }
|
self.values = withEscapedDependencies { $0 }
|
||||||
self.apiController = apiController
|
// self.apiController = apiController
|
||||||
self.database = database
|
self.database = database
|
||||||
|
self.environment = environment
|
||||||
self.viewController = viewController
|
self.viewController = viewController
|
||||||
}
|
}
|
||||||
|
|
||||||
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
||||||
try await values.yield {
|
try await values.yield {
|
||||||
try await withDependencies {
|
try await withDependencies {
|
||||||
$0.apiController = apiController
|
// $0.apiController = apiController
|
||||||
|
$0.auth = .live(on: request)
|
||||||
$0.database = database
|
$0.database = database
|
||||||
|
$0.environment = environment
|
||||||
// $0.dateFormatter = .liveValue
|
// $0.dateFormatter = .liveValue
|
||||||
$0.viewController = viewController
|
$0.viewController = viewController
|
||||||
|
$0.pdfClient = .liveValue
|
||||||
|
$0.fileClient = .live(fileIO: request.fileio)
|
||||||
} operation: {
|
} operation: {
|
||||||
try await next.respond(to: request)
|
try await next.respond(to: request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ private let viewRouteMiddleware: [any Middleware] = [
|
|||||||
extension SiteRoute.View {
|
extension SiteRoute.View {
|
||||||
var middleware: [any Middleware]? {
|
var middleware: [any Middleware]? {
|
||||||
switch self {
|
switch self {
|
||||||
case .project:
|
case .home, .login, .signup, .test, .ductulator, .privacyPolicy:
|
||||||
return viewRouteMiddleware
|
|
||||||
case .login, .signup, .test:
|
|
||||||
return nil
|
return nil
|
||||||
|
case .project, .user:
|
||||||
|
return viewRouteMiddleware
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import DatabaseClient
|
import DatabaseClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
import Elementary
|
import Elementary
|
||||||
|
import EnvVars
|
||||||
import Fluent
|
import Fluent
|
||||||
|
import FluentPostgresDriver
|
||||||
import FluentSQLiteDriver
|
import FluentSQLiteDriver
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import NIOSSL
|
import NIOSSL
|
||||||
|
import ProjectClient
|
||||||
import Vapor
|
import Vapor
|
||||||
import VaporElementary
|
import VaporElementary
|
||||||
@preconcurrency import VaporRouting
|
@preconcurrency import VaporRouting
|
||||||
@@ -13,12 +16,15 @@ import ViewController
|
|||||||
// configures your application
|
// configures your application
|
||||||
public func configure(
|
public func configure(
|
||||||
_ app: Application,
|
_ app: Application,
|
||||||
|
in environment: EnvVars,
|
||||||
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
|
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
|
||||||
) async throws {
|
) async throws {
|
||||||
// Setup the database client.
|
// Setup the database client.
|
||||||
let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient)
|
let databaseClient = try await setupDatabase(
|
||||||
|
on: app, environment: environment, factory: makeDatabaseClient
|
||||||
|
)
|
||||||
// Add the global middlewares.
|
// Add the global middlewares.
|
||||||
addMiddleware(to: app, database: databaseClient)
|
addMiddleware(to: app, database: databaseClient, environment: environment)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Live reload of the application for development when launched with the `./swift-dev` command
|
// Live reload of the application for development when launched with the `./swift-dev` command
|
||||||
// app.lifecycle.use(BrowserSyncHandler())
|
// app.lifecycle.use(BrowserSyncHandler())
|
||||||
@@ -32,7 +38,11 @@ public func configure(
|
|||||||
addCommands(to: app)
|
addCommands(to: app)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) {
|
private func addMiddleware(
|
||||||
|
to app: Application,
|
||||||
|
database databaseClient: DatabaseClient,
|
||||||
|
environment: EnvVars
|
||||||
|
) {
|
||||||
// cors middleware should come before default error middleware using `at: .beginning`
|
// cors middleware should come before default error middleware using `at: .beginning`
|
||||||
let corsConfiguration = CORSMiddleware.Configuration(
|
let corsConfiguration = CORSMiddleware.Configuration(
|
||||||
allowedOrigin: .all,
|
allowedOrigin: .all,
|
||||||
@@ -47,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
|
|||||||
|
|
||||||
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||||
app.middleware.use(app.sessions.middleware)
|
app.middleware.use(app.sessions.middleware)
|
||||||
app.middleware.use(DependenciesMiddleware(database: databaseClient))
|
app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupDatabase(
|
private func setupDatabase(
|
||||||
on app: Application,
|
on app: Application,
|
||||||
|
environment: EnvVars,
|
||||||
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
|
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
|
||||||
) async throws -> DatabaseClient {
|
) async throws -> DatabaseClient {
|
||||||
switch app.environment {
|
switch app.environment {
|
||||||
case .production, .development:
|
case .production:
|
||||||
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite"
|
let configuration = try environment.postgresConfiguration()
|
||||||
|
app.databases.use(.postgres(configuration: configuration), as: .psql)
|
||||||
|
case .development:
|
||||||
|
let dbFileName = environment.sqlitePath ?? "db.sqlite"
|
||||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
|
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
|
||||||
default:
|
default:
|
||||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
|
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
|
||||||
@@ -64,9 +78,7 @@ private func setupDatabase(
|
|||||||
|
|
||||||
let databaseClient = makeDatabaseClient(app.db)
|
let databaseClient = makeDatabaseClient(app.db)
|
||||||
|
|
||||||
if app.environment != .testing {
|
try await app.migrations.add(databaseClient.migrations())
|
||||||
try await app.migrations.add(databaseClient.migrations.run())
|
|
||||||
}
|
|
||||||
|
|
||||||
return databaseClient
|
return databaseClient
|
||||||
}
|
}
|
||||||
@@ -101,8 +113,6 @@ extension SiteRoute {
|
|||||||
|
|
||||||
fileprivate func middleware() -> [any Middleware]? {
|
fileprivate func middleware() -> [any Middleware]? {
|
||||||
switch self {
|
switch self {
|
||||||
case .api:
|
|
||||||
return nil
|
|
||||||
case .health:
|
case .health:
|
||||||
return nil
|
return nil
|
||||||
case .view(let route):
|
case .view(let route):
|
||||||
@@ -111,20 +121,51 @@ extension SiteRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension DuctSizes: Content {}
|
||||||
|
|
||||||
@Sendable
|
@Sendable
|
||||||
private func siteHandler(
|
private func siteHandler(
|
||||||
request: Request,
|
request: Request,
|
||||||
route: SiteRoute
|
route: SiteRoute
|
||||||
) async throws -> any AsyncResponseEncodable {
|
) async throws -> any AsyncResponseEncodable {
|
||||||
@Dependency(\.apiController) var apiController
|
|
||||||
@Dependency(\.viewController) var viewController
|
@Dependency(\.viewController) var viewController
|
||||||
|
@Dependency(\.projectClient) var projectClient
|
||||||
|
|
||||||
switch route {
|
switch route {
|
||||||
case .api(let route):
|
|
||||||
return try await apiController.respond(route, request: request)
|
|
||||||
case .health:
|
case .health:
|
||||||
return HTTPStatus.ok
|
return HTTPStatus.ok
|
||||||
|
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
|
||||||
|
// need to handle it seperately.
|
||||||
|
case .view(.project(.detail(let projectID, .pdf))):
|
||||||
|
return try await projectClient.generatePdf(projectID)
|
||||||
case .view(let route):
|
case .view(let route):
|
||||||
return try await viewController.respond(route: route, request: request)
|
return try await viewController.respond(route: route, request: request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension EnvVars {
|
||||||
|
func postgresConfiguration() throws -> SQLPostgresConfiguration {
|
||||||
|
guard let hostname = postgresHostname,
|
||||||
|
let username = postgresUsername,
|
||||||
|
let password = postgresPassword,
|
||||||
|
let database = postgresDatabase
|
||||||
|
else {
|
||||||
|
throw EnvError("Missing environment variables for postgres connection.")
|
||||||
|
}
|
||||||
|
return .init(
|
||||||
|
hostname: hostname,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
database: database,
|
||||||
|
tls: .disable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EnvError: Error {
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
init(_ reason: String) {
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import DatabaseClient
|
import DatabaseClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
import EnvVars
|
||||||
import Logging
|
import Logging
|
||||||
import NIOCore
|
import NIOCore
|
||||||
import NIOPosix
|
import NIOPosix
|
||||||
@@ -17,11 +18,15 @@ enum Entrypoint {
|
|||||||
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
|
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
|
||||||
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
|
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
|
||||||
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
|
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
|
||||||
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
let executorTakeoverSuccess =
|
||||||
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
|
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||||
|
app.logger.debug(
|
||||||
|
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
|
||||||
|
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
|
||||||
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await configure(app)
|
try await configure(app, in: EnvVars.live())
|
||||||
} catch {
|
} catch {
|
||||||
app.logger.report(error: error)
|
app.logger.report(error: error)
|
||||||
try? await app.asyncShutdown()
|
try? await app.asyncShutdown()
|
||||||
|
|||||||
58
Sources/AuthClient/Interface.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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.session.authenticate(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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/CLI/Cli.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct DuctCalcCli: AsyncParsableCommand {
|
||||||
|
static let configuration: CommandConfiguration = .init(
|
||||||
|
commandName: "ductcalc",
|
||||||
|
abstract: "Perform duct calculations.",
|
||||||
|
subcommands: [
|
||||||
|
ConvertCommand.self,
|
||||||
|
SizeCommand.self,
|
||||||
|
],
|
||||||
|
defaultSubcommand: SizeCommand.self
|
||||||
|
)
|
||||||
|
}
|
||||||
35
Sources/CLI/Commands/Convert.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dependencies
|
||||||
|
import ManualDClient
|
||||||
|
|
||||||
|
struct ConvertCommand: AsyncParsableCommand {
|
||||||
|
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "convert",
|
||||||
|
abstract: "Convert to an equivalent recangular size."
|
||||||
|
)
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "The height"
|
||||||
|
)
|
||||||
|
var height: Int
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
// name: .shortAndLong,
|
||||||
|
help: "The round size."
|
||||||
|
)
|
||||||
|
var roundSize: Int
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let size = try await manualD.rectangularSize(
|
||||||
|
round: .init(roundSize),
|
||||||
|
height: .init(height)
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\(size.width) x \(height)")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
35
Sources/CLI/Commands/Size.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dependencies
|
||||||
|
import ManualDClient
|
||||||
|
|
||||||
|
struct SizeCommand: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "size",
|
||||||
|
abstract: "Calculate the required size of a duct."
|
||||||
|
)
|
||||||
|
|
||||||
|
@Option(
|
||||||
|
name: .shortAndLong,
|
||||||
|
help: "The design friction rate."
|
||||||
|
)
|
||||||
|
var frictionRate: Double = 0.06
|
||||||
|
|
||||||
|
@Argument(
|
||||||
|
help: "The required CFM for the duct."
|
||||||
|
)
|
||||||
|
var cfm: Int
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let size = try await manualD.ductSize(cfm: cfm, frictionRate: frictionRate)
|
||||||
|
print(
|
||||||
|
"""
|
||||||
|
Calculated: \(size.calculatedSize.string(digits: 2))
|
||||||
|
Final Size: \(size.finalSize)
|
||||||
|
Flex Size: \(size.flexSize)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Sources/CSVParser/Interface.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import ManualDCore
|
||||||
|
import Parsing
|
||||||
|
|
||||||
|
extension DependencyValues {
|
||||||
|
public var csvParser: CSVParser {
|
||||||
|
get { self[CSVParser.self] }
|
||||||
|
set { self[CSVParser.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct CSVParser: Sendable {
|
||||||
|
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.CSV.Row]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CSVParser: DependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static let liveValue = Self(
|
||||||
|
parseRooms: { csv in
|
||||||
|
guard let string = String(data: csv.file, encoding: .utf8) else {
|
||||||
|
throw CSVParsingError("Unreadable file data")
|
||||||
|
}
|
||||||
|
let rows = try RoomCSVParser().parse(string[...].utf8)
|
||||||
|
return rows.reduce(into: [Room.CSV.Row]()) {
|
||||||
|
if case .room(let room) = $1 {
|
||||||
|
$0.append(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CSVParsingError: Error {
|
||||||
|
let reason: String
|
||||||
|
|
||||||
|
public init(_ reason: String) {
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Sources/CSVParser/Internal/Room+parsing.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import ManualDCore
|
||||||
|
import Parsing
|
||||||
|
|
||||||
|
struct RoomCSVParser: Parser {
|
||||||
|
var body: some Parser<Substring.UTF8View, [RoomRowType]> {
|
||||||
|
Many {
|
||||||
|
RoomRowParser()
|
||||||
|
} separator: {
|
||||||
|
"\n".utf8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomRowParser: Parser {
|
||||||
|
|
||||||
|
var body: some Parser<Substring.UTF8View, RoomRowType> {
|
||||||
|
OneOf {
|
||||||
|
RoomCreateParser().map { RoomRowType.room($0) }
|
||||||
|
Prefix { $0 != UInt8(ascii: "\n") }
|
||||||
|
.map(.string)
|
||||||
|
.map { RoomRowType.header($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoomRowType {
|
||||||
|
case header(String)
|
||||||
|
case room(Room.CSV.Row)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomCreateParser: ParserPrinter {
|
||||||
|
|
||||||
|
// FIX: The delegated to field won't work here, as we potentially have not created
|
||||||
|
// the room yet, so we will need an intermediate representation for the csv data
|
||||||
|
// that uses a room's name or disregard and require user to delegate airflow in
|
||||||
|
// the ui.
|
||||||
|
var body: some ParserPrinter<Substring.UTF8View, Room.CSV.Row> {
|
||||||
|
ParsePrint {
|
||||||
|
Prefix { $0 != UInt8(ascii: ",") }.map(.string)
|
||||||
|
",".utf8
|
||||||
|
Optionally {
|
||||||
|
Int.parser()
|
||||||
|
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||||
|
}
|
||||||
|
",".utf8
|
||||||
|
Double.parser()
|
||||||
|
",".utf8
|
||||||
|
Optionally {
|
||||||
|
Double.parser()
|
||||||
|
}
|
||||||
|
",".utf8
|
||||||
|
Optionally {
|
||||||
|
Double.parser()
|
||||||
|
}
|
||||||
|
",".utf8
|
||||||
|
Int.parser()
|
||||||
|
",".utf8
|
||||||
|
Optionally {
|
||||||
|
Prefix { $0 != UInt8(ascii: "\n") }.map(.string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(Room.CSV.Row.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,134 @@ import FluentKit
|
|||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
extension DependencyValues {
|
extension DependencyValues {
|
||||||
|
/// The database dependency.
|
||||||
public var database: DatabaseClient {
|
public var database: DatabaseClient {
|
||||||
get { self[DatabaseClient.self] }
|
get { self[DatabaseClient.self] }
|
||||||
set { self[DatabaseClient.self] = newValue }
|
set { self[DatabaseClient.self] = newValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents the database interactions used by the application.
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct DatabaseClient: Sendable {
|
public struct DatabaseClient: Sendable {
|
||||||
|
/// Database migrations.
|
||||||
public var migrations: Migrations
|
public var migrations: Migrations
|
||||||
|
/// Interactions with the projects table.
|
||||||
public var projects: Projects
|
public var projects: Projects
|
||||||
|
/// Interactions with the rooms table.
|
||||||
public var rooms: Rooms
|
public var rooms: Rooms
|
||||||
|
/// Interactions with the equipment table.
|
||||||
public var equipment: Equipment
|
public var equipment: Equipment
|
||||||
public var componentLoss: ComponentLoss
|
/// Interactions with the component losses table.
|
||||||
public var effectiveLength: EffectiveLengthClient
|
public var componentLosses: ComponentLosses
|
||||||
// public var rectangularDuct: RectangularDuct
|
/// Interactions with the equivalent lengths table.
|
||||||
|
public var equivalentLengths: EquivalentLengths
|
||||||
|
/// Interactions with the users table.
|
||||||
public var users: Users
|
public var users: Users
|
||||||
|
/// Interactions with the user profiles table.
|
||||||
|
public var userProfiles: UserProfiles
|
||||||
|
/// Interactions with the trunk sizes table.
|
||||||
|
public var trunkSizes: TrunkSizes
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct ComponentLosses: Sendable {
|
||||||
|
public var create:
|
||||||
|
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
||||||
|
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
||||||
|
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
||||||
|
public var update:
|
||||||
|
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
||||||
|
ComponentPressureLoss
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct EquivalentLengths: Sendable {
|
||||||
|
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
|
||||||
|
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
|
||||||
|
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
|
||||||
|
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
|
||||||
|
public var update:
|
||||||
|
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Equipment: Sendable {
|
||||||
|
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
|
||||||
|
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
||||||
|
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
||||||
|
public var update:
|
||||||
|
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Migrations: Sendable {
|
||||||
|
public var all: @Sendable () async throws -> [any AsyncMigration]
|
||||||
|
|
||||||
|
public func callAsFunction() async throws -> [any AsyncMigration] {
|
||||||
|
try await self.all()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Projects: Sendable {
|
||||||
|
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
||||||
|
public var delete: @Sendable (Project.ID) async throws -> Void
|
||||||
|
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
|
||||||
|
public var get: @Sendable (Project.ID) async throws -> Project?
|
||||||
|
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
|
||||||
|
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
||||||
|
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
||||||
|
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Rooms: Sendable {
|
||||||
|
public var create: @Sendable (Project.ID, Room.Create) async throws -> Room
|
||||||
|
public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room]
|
||||||
|
public var createFromCSV: @Sendable (Project.ID, [Room.CSV.Row]) async throws -> [Room]
|
||||||
|
public var delete: @Sendable (Room.ID) async throws -> Void
|
||||||
|
public var deleteRectangularSize:
|
||||||
|
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
|
||||||
|
public var get: @Sendable (Room.ID) async throws -> Room?
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [Room]
|
||||||
|
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
|
||||||
|
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct TrunkSizes: Sendable {
|
||||||
|
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
|
||||||
|
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
|
||||||
|
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
|
||||||
|
public var update:
|
||||||
|
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
|
||||||
|
TrunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct UserProfiles: Sendable {
|
||||||
|
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
|
||||||
|
public var delete: @Sendable (User.Profile.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
|
||||||
|
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
|
||||||
|
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct Users: Sendable {
|
||||||
|
public var create: @Sendable (User.Create) async throws -> User
|
||||||
|
public var delete: @Sendable (User.ID) async throws -> Void
|
||||||
|
public var get: @Sendable (User.ID) async throws -> User?
|
||||||
|
public var login: @Sendable (User.Login) async throws -> User.Token
|
||||||
|
public var logout: @Sendable (User.Token.ID) async throws -> Void
|
||||||
|
// public var token: @Sendable (User.ID) async throws -> User.Token
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseClient: TestDependencyKey {
|
extension DatabaseClient: TestDependencyKey {
|
||||||
@@ -28,10 +140,11 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
projects: .testValue,
|
projects: .testValue,
|
||||||
rooms: .testValue,
|
rooms: .testValue,
|
||||||
equipment: .testValue,
|
equipment: .testValue,
|
||||||
componentLoss: .testValue,
|
componentLosses: .testValue,
|
||||||
effectiveLength: .testValue,
|
equivalentLengths: .testValue,
|
||||||
// rectangularDuct: .testValue,
|
users: .testValue,
|
||||||
users: .testValue
|
userProfiles: .testValue,
|
||||||
|
trunkSizes: .testValue
|
||||||
)
|
)
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
@@ -40,38 +153,11 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
projects: .live(database: database),
|
projects: .live(database: database),
|
||||||
rooms: .live(database: database),
|
rooms: .live(database: database),
|
||||||
equipment: .live(database: database),
|
equipment: .live(database: database),
|
||||||
componentLoss: .live(database: database),
|
componentLosses: .live(database: database),
|
||||||
effectiveLength: .live(database: database),
|
equivalentLengths: .live(database: database),
|
||||||
// rectangularDuct: .live(database: database),
|
users: .live(database: database),
|
||||||
users: .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(),
|
|
||||||
ComponentPressureLoss.Migrate(),
|
|
||||||
EquipmentInfo.Migrate(),
|
|
||||||
Room.Migrate(),
|
|
||||||
EffectiveLength.Migrate(),
|
|
||||||
// DuctSizing.RectangularDuct.Migrate(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
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 Fluent
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
import SQLKit
|
||||||
|
import Validations
|
||||||
|
|
||||||
extension DatabaseClient {
|
extension DatabaseClient.ComponentLosses: TestDependencyKey {
|
||||||
@DependencyClient
|
|
||||||
public struct ComponentLoss: Sendable {
|
|
||||||
public var create:
|
|
||||||
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
|
||||||
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
|
||||||
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
|
||||||
public var update:
|
|
||||||
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
|
||||||
ComponentPressureLoss
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.ComponentLoss: TestDependencyKey {
|
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseClient.ComponentLoss {
|
extension DatabaseClient.ComponentLosses {
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { request in
|
create: { request in
|
||||||
let model = try request.toModel()
|
let model = request.toModel()
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
},
|
},
|
||||||
delete: { id in
|
delete: { id in
|
||||||
@@ -48,13 +36,13 @@ extension DatabaseClient.ComponentLoss {
|
|||||||
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
update: { id, updates in
|
update: { id, updates in
|
||||||
try updates.validate()
|
// try updates.validate()
|
||||||
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
model.applyUpdates(updates)
|
model.applyUpdates(updates)
|
||||||
if model.hasChanges {
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
@@ -64,40 +52,9 @@ extension DatabaseClient.ComponentLoss {
|
|||||||
|
|
||||||
extension ComponentPressureLoss.Create {
|
extension ComponentPressureLoss.Create {
|
||||||
|
|
||||||
func toModel() throws(ValidationError) -> ComponentLossModel {
|
func toModel() -> ComponentLossModel {
|
||||||
try validate()
|
|
||||||
return .init(name: name, value: value, projectID: projectID)
|
return .init(name: name, value: value, projectID: projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Component loss name should not be empty.")
|
|
||||||
}
|
|
||||||
guard value > 0 else {
|
|
||||||
throw ValidationError("Component loss value should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard value < 1.0 else {
|
|
||||||
throw ValidationError("Component loss value should be less than 1.0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComponentPressureLoss.Update {
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
if let name {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Component loss name should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let value {
|
|
||||||
guard value > 0 else {
|
|
||||||
throw ValidationError("Component loss value should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard value < 1.0 else {
|
|
||||||
throw ValidationError("Component loss value should be less than 1.0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComponentPressureLoss {
|
extension ComponentPressureLoss {
|
||||||
@@ -109,8 +66,8 @@ extension ComponentPressureLoss {
|
|||||||
.id()
|
.id()
|
||||||
.field("name", .string, .required)
|
.field("name", .string, .required)
|
||||||
.field("value", .double, .required)
|
.field("value", .double, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .string)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .string)
|
||||||
.field(
|
.field(
|
||||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
)
|
)
|
||||||
@@ -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 Fluent
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
extension DatabaseClient {
|
|
||||||
@DependencyClient
|
|
||||||
public struct Equipment: Sendable {
|
|
||||||
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
|
|
||||||
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
|
||||||
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
|
||||||
public var update:
|
|
||||||
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Equipment: TestDependencyKey {
|
extension DatabaseClient.Equipment: TestDependencyKey {
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Equipment {
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { request in
|
create: { request in
|
||||||
let model = try request.toModel()
|
let model = request.toModel()
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
},
|
},
|
||||||
delete: { id in
|
delete: { id in
|
||||||
@@ -51,10 +38,9 @@ extension DatabaseClient.Equipment {
|
|||||||
guard let model = try await EquipmentModel.find(id, on: database) else {
|
guard let model = try await EquipmentModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
try updates.validate()
|
|
||||||
model.applyUpdates(updates)
|
model.applyUpdates(updates)
|
||||||
if model.hasChanges {
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
@@ -64,8 +50,7 @@ extension DatabaseClient.Equipment {
|
|||||||
|
|
||||||
extension EquipmentInfo.Create {
|
extension EquipmentInfo.Create {
|
||||||
|
|
||||||
func toModel() throws(ValidationError) -> EquipmentModel {
|
func toModel() -> EquipmentModel {
|
||||||
try validate()
|
|
||||||
return .init(
|
return .init(
|
||||||
staticPressure: staticPressure,
|
staticPressure: staticPressure,
|
||||||
heatingCFM: heatingCFM,
|
heatingCFM: heatingCFM,
|
||||||
@@ -74,44 +59,6 @@ extension EquipmentInfo.Create {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
guard staticPressure >= 0 else {
|
|
||||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard staticPressure <= 1.0 else {
|
|
||||||
throw ValidationError("Equipment info static pressure should be less than 1.0.")
|
|
||||||
}
|
|
||||||
guard heatingCFM >= 0 else {
|
|
||||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard coolingCFM >= 0 else {
|
|
||||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EquipmentInfo.Update {
|
|
||||||
var hasUpdates: Bool {
|
|
||||||
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
if let staticPressure {
|
|
||||||
guard staticPressure >= 0 else {
|
|
||||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let heatingCFM {
|
|
||||||
guard heatingCFM >= 0 else {
|
|
||||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let coolingCFM {
|
|
||||||
guard coolingCFM >= 0 else {
|
|
||||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EquipmentInfo {
|
extension EquipmentInfo {
|
||||||
@@ -126,8 +73,8 @@ extension EquipmentInfo {
|
|||||||
.field("staticPressure", .double, .required)
|
.field("staticPressure", .double, .required)
|
||||||
.field("heatingCFM", .int16, .required)
|
.field("heatingCFM", .int16, .required)
|
||||||
.field("coolingCFM", .int16, .required)
|
.field("coolingCFM", .int16, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .string)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .string)
|
||||||
.field(
|
.field(
|
||||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
)
|
)
|
||||||
@@ -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 Fluent
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
|
|
||||||
extension DatabaseClient {
|
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||||
@DependencyClient
|
|
||||||
public struct EffectiveLengthClient: Sendable {
|
|
||||||
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
|
||||||
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
|
|
||||||
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
|
|
||||||
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
|
|
||||||
public var update:
|
|
||||||
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { request in
|
create: { request in
|
||||||
let model = try request.toModel()
|
let model = try request.toModel()
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
},
|
},
|
||||||
delete: { id in
|
delete: { id in
|
||||||
@@ -66,7 +54,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
|||||||
}
|
}
|
||||||
try model.applyUpdates(updates)
|
try model.applyUpdates(updates)
|
||||||
if model.hasChanges {
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
@@ -74,10 +62,12 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EffectiveLength.Create {
|
extension EquivalentLength.Create {
|
||||||
|
|
||||||
func toModel() throws -> EffectiveLengthModel {
|
func toModel() throws -> EffectiveLengthModel {
|
||||||
try validate()
|
if groups.count > 0 {
|
||||||
|
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||||
|
}
|
||||||
return try .init(
|
return try .init(
|
||||||
name: name,
|
name: name,
|
||||||
type: type.rawValue,
|
type: type.rawValue,
|
||||||
@@ -86,15 +76,9 @@ extension EffectiveLength.Create {
|
|||||||
projectID: projectID
|
projectID: projectID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Effective length name can not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EffectiveLength {
|
extension EquivalentLength {
|
||||||
|
|
||||||
struct Migrate: AsyncMigration {
|
struct Migrate: AsyncMigration {
|
||||||
let name = "CreateEffectiveLength"
|
let name = "CreateEffectiveLength"
|
||||||
@@ -106,8 +90,8 @@ extension EffectiveLength {
|
|||||||
.field("type", .string, .required)
|
.field("type", .string, .required)
|
||||||
.field("straightLengths", .array(of: .int))
|
.field("straightLengths", .array(of: .int))
|
||||||
.field("groups", .data)
|
.field("groups", .data)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .string)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .string)
|
||||||
.field(
|
.field(
|
||||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
)
|
)
|
||||||
@@ -173,20 +157,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
|||||||
$project.id = projectID
|
$project.id = projectID
|
||||||
}
|
}
|
||||||
|
|
||||||
func toDTO() throws -> EffectiveLength {
|
func toDTO() throws -> EquivalentLength {
|
||||||
try .init(
|
try .init(
|
||||||
id: requireID(),
|
id: requireID(),
|
||||||
projectID: $project.id,
|
projectID: $project.id,
|
||||||
name: name,
|
name: name,
|
||||||
type: .init(rawValue: type)!,
|
type: .init(rawValue: type)!,
|
||||||
straightLengths: straightLengths,
|
straightLengths: straightLengths,
|
||||||
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups),
|
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpdates(_ updates: EffectiveLength.Update) throws {
|
func applyUpdates(_ updates: EquivalentLength.Update) throws {
|
||||||
if let name = updates.name, name != self.name {
|
if let name = updates.name, name != self.name {
|
||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
@@ -197,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
|||||||
self.straightLengths = straightLengths
|
self.straightLengths = straightLengths
|
||||||
}
|
}
|
||||||
if let groups = updates.groups {
|
if let groups = updates.groups {
|
||||||
|
if groups.count > 0 {
|
||||||
|
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||||
|
}
|
||||||
self.groups = try JSONEncoder().encode(groups)
|
self.groups = try JSONEncoder().encode(groups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension EffectiveLengthModel: Validatable {
|
||||||
|
|
||||||
|
var body: some Validation<EffectiveLengthModel> {
|
||||||
|
Validator.accumulating {
|
||||||
|
Validator.validate(\.name, with: .notEmpty())
|
||||||
|
.errorLabel("Name", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(
|
||||||
|
\.straightLengths,
|
||||||
|
with: [Int].empty().or(
|
||||||
|
ForEachValidator {
|
||||||
|
Int.greaterThan(0)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.errorLabel("Straight Lengths", inline: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EquivalentLength.FittingGroup: Validatable {
|
||||||
|
|
||||||
|
public var body: some Validation<Self> {
|
||||||
|
Validator.accumulating {
|
||||||
|
Validator.validate(\.group) {
|
||||||
|
Int.greaterThanOrEquals(1)
|
||||||
|
Int.lessThanOrEquals(12)
|
||||||
|
}
|
||||||
|
.errorLabel("Group", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
|
||||||
|
.errorLabel("Letter", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.value, with: .greaterThan(0))
|
||||||
|
.errorLabel("Value", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
|
||||||
|
.errorLabel("Quantity", inline: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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: {
|
||||||
|
[
|
||||||
|
User.Migrate(),
|
||||||
|
User.Token.Migrate(),
|
||||||
|
User.Profile.Migrate(),
|
||||||
|
Project.Migrate(),
|
||||||
|
ComponentPressureLoss.Migrate(),
|
||||||
|
EquipmentInfo.Migrate(),
|
||||||
|
Room.Migrate(),
|
||||||
|
EquivalentLength.Migrate(),
|
||||||
|
TrunkSize.Migrate(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
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 Fluent
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
extension DatabaseClient {
|
|
||||||
@DependencyClient
|
|
||||||
public struct Projects: Sendable {
|
|
||||||
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
|
||||||
public var delete: @Sendable (Project.ID) async throws -> Void
|
|
||||||
public var get: @Sendable (Project.ID) async throws -> Project?
|
|
||||||
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
|
|
||||||
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
|
||||||
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
|
||||||
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Projects: TestDependencyKey {
|
extension DatabaseClient.Projects: TestDependencyKey {
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
@@ -23,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { userID, request in
|
create: { userID, request in
|
||||||
let model = try request.toModel(userID: userID)
|
let model = request.toModel(userID: userID)
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
},
|
},
|
||||||
delete: { id in
|
delete: { id in
|
||||||
@@ -33,46 +21,51 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
}
|
}
|
||||||
try await model.delete(on: database)
|
try await model.delete(on: database)
|
||||||
},
|
},
|
||||||
|
detail: { id in
|
||||||
|
let model = try await ProjectModel.fetchDetail(for: id, on: database)
|
||||||
|
|
||||||
|
// TODO: Different error ??
|
||||||
|
guard let equipmentInfo = model.equipment else { return nil }
|
||||||
|
|
||||||
|
let trunks = try model.trunks.toDTO()
|
||||||
|
|
||||||
|
return try .init(
|
||||||
|
project: model.toDTO(),
|
||||||
|
componentLosses: model.componentLosses.map { try $0.toDTO() },
|
||||||
|
equipmentInfo: equipmentInfo.toDTO(),
|
||||||
|
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
|
||||||
|
rooms: model.rooms.map { try $0.toDTO() },
|
||||||
|
trunks: trunks
|
||||||
|
)
|
||||||
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
|
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
getCompletedSteps: { id in
|
getCompletedSteps: { id in
|
||||||
let roomsCount = try await RoomModel.query(on: database)
|
let model = try await ProjectModel.fetchDetail(for: id, on: database)
|
||||||
.with(\.$project)
|
|
||||||
.filter(\.$project.$id == id)
|
|
||||||
.count()
|
|
||||||
|
|
||||||
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
|
|
||||||
.with(\.$project)
|
|
||||||
.filter(\.$project.$id == id)
|
|
||||||
.all()
|
|
||||||
|
|
||||||
var equivalentLengthsCompleted = false
|
var equivalentLengthsCompleted = false
|
||||||
|
|
||||||
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
||||||
equivalentLengths.filter({ $0.type == "return" }).first != nil
|
model.equivalentLengths.filter({ $0.type == "return" }).first != nil
|
||||||
{
|
{
|
||||||
equivalentLengthsCompleted = true
|
equivalentLengthsCompleted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
let componentLosses = try await ComponentLossModel.query(on: database)
|
|
||||||
.with(\.$project)
|
|
||||||
.filter(\.$project.$id == id)
|
|
||||||
.count()
|
|
||||||
|
|
||||||
let equipmentInfo = try await EquipmentModel.query(on: database)
|
|
||||||
.with(\.$project)
|
|
||||||
.filter(\.$project.$id == id)
|
|
||||||
.first()
|
|
||||||
|
|
||||||
return .init(
|
return .init(
|
||||||
rooms: roomsCount > 0,
|
equipmentInfo: model.equipment != nil,
|
||||||
|
rooms: model.rooms.count > 0,
|
||||||
equivalentLength: equivalentLengthsCompleted,
|
equivalentLength: equivalentLengthsCompleted,
|
||||||
frictionRate: equipmentInfo != nil && componentLosses > 0
|
frictionRate: model.componentLosses.count > 0
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
getSensibleHeatRatio: { id in
|
getSensibleHeatRatio: { id in
|
||||||
guard let model = try await ProjectModel.find(id, on: database) else {
|
guard
|
||||||
|
let model = try await ProjectModel.query(on: database)
|
||||||
|
.field(\.$id)
|
||||||
|
.field(\.$sensibleHeatRatio)
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
return model.sensibleHeatRatio
|
return model.sensibleHeatRatio
|
||||||
@@ -89,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
guard let model = try await ProjectModel.find(id, on: database) else {
|
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
try updates.validate()
|
|
||||||
model.applyUpdates(updates)
|
model.applyUpdates(updates)
|
||||||
if model.hasChanges {
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.validateAndSave(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
@@ -102,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
|
|
||||||
extension Project.Create {
|
extension Project.Create {
|
||||||
|
|
||||||
func toModel(userID: User.ID) throws -> ProjectModel {
|
func toModel(userID: User.ID) -> ProjectModel {
|
||||||
try validate()
|
|
||||||
return .init(
|
return .init(
|
||||||
name: name,
|
name: name,
|
||||||
streetAddress: streetAddress,
|
streetAddress: streetAddress,
|
||||||
@@ -114,70 +105,6 @@ extension Project.Create {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Project name should not be empty.")
|
|
||||||
}
|
|
||||||
guard !streetAddress.isEmpty else {
|
|
||||||
throw ValidationError("Project street address should not be empty.")
|
|
||||||
}
|
|
||||||
guard !city.isEmpty else {
|
|
||||||
throw ValidationError("Project city should not be empty.")
|
|
||||||
}
|
|
||||||
guard !state.isEmpty else {
|
|
||||||
throw ValidationError("Project state should not be empty.")
|
|
||||||
}
|
|
||||||
guard !zipCode.isEmpty else {
|
|
||||||
throw ValidationError("Project zipCode should not be empty.")
|
|
||||||
}
|
|
||||||
if let sensibleHeatRatio {
|
|
||||||
guard sensibleHeatRatio >= 0 else {
|
|
||||||
throw ValidationError("Project sensible heat ratio should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard sensibleHeatRatio <= 1 else {
|
|
||||||
throw ValidationError("Project sensible heat ratio should be less than 1.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Project.Update {
|
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
if let name {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Project name should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let streetAddress {
|
|
||||||
guard !streetAddress.isEmpty else {
|
|
||||||
throw ValidationError("Project street address should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let city {
|
|
||||||
guard !city.isEmpty else {
|
|
||||||
throw ValidationError("Project city should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let state {
|
|
||||||
guard !state.isEmpty else {
|
|
||||||
throw ValidationError("Project state should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let zipCode {
|
|
||||||
guard !zipCode.isEmpty else {
|
|
||||||
throw ValidationError("Project zipCode should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let sensibleHeatRatio {
|
|
||||||
guard sensibleHeatRatio >= 0 else {
|
|
||||||
throw ValidationError("Project sensible heat ratio should be greater than 0.")
|
|
||||||
}
|
|
||||||
guard sensibleHeatRatio <= 1 else {
|
|
||||||
throw ValidationError("Project sensible heat ratio should be less than 1.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Project {
|
extension Project {
|
||||||
@@ -193,9 +120,9 @@ extension Project {
|
|||||||
.field("state", .string, .required)
|
.field("state", .string, .required)
|
||||||
.field("zipCode", .string, .required)
|
.field("zipCode", .string, .required)
|
||||||
.field("sensibleHeatRatio", .double)
|
.field("sensibleHeatRatio", .double)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .string)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .string)
|
||||||
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
|
.field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||||
.unique(on: "userID", "name")
|
.unique(on: "userID", "name")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -241,6 +168,18 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
@Children(for: \.$project)
|
@Children(for: \.$project)
|
||||||
var componentLosses: [ComponentLossModel]
|
var componentLosses: [ComponentLossModel]
|
||||||
|
|
||||||
|
@OptionalChild(for: \.$project)
|
||||||
|
var equipment: EquipmentModel?
|
||||||
|
|
||||||
|
@Children(for: \.$project)
|
||||||
|
var equivalentLengths: [EffectiveLengthModel]
|
||||||
|
|
||||||
|
@Children(for: \.$project)
|
||||||
|
var rooms: [RoomModel]
|
||||||
|
|
||||||
|
@Children(for: \.$project)
|
||||||
|
var trunks: [TrunkModel]
|
||||||
|
|
||||||
@Parent(key: "userID")
|
@Parent(key: "userID")
|
||||||
var user: UserModel
|
var user: UserModel
|
||||||
|
|
||||||
@@ -306,4 +245,67 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
self.sensibleHeatRatio = sensibleHeatRatio
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a ``ProjectModel`` with all the relations eagerly loaded.
|
||||||
|
static func fetchDetail(
|
||||||
|
for projectID: Project.ID,
|
||||||
|
on database: any Database
|
||||||
|
) async throws -> ProjectModel {
|
||||||
|
guard
|
||||||
|
let model =
|
||||||
|
try await ProjectModel.query(on: database)
|
||||||
|
.with(\.$componentLosses)
|
||||||
|
.with(\.$equipment)
|
||||||
|
.with(\.$equivalentLengths)
|
||||||
|
.with(\.$rooms)
|
||||||
|
.with(
|
||||||
|
\.$trunks,
|
||||||
|
{ trunk in
|
||||||
|
trunk.with(
|
||||||
|
\.$rooms,
|
||||||
|
{
|
||||||
|
$0.with(\.$room)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.filter(\.$id == projectID)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProjectModel: Validatable {
|
||||||
|
|
||||||
|
var body: some Validation<ProjectModel> {
|
||||||
|
Validator.accumulating {
|
||||||
|
Validator.validate(\.name, with: .notEmpty())
|
||||||
|
.errorLabel("Name", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.streetAddress, with: .notEmpty())
|
||||||
|
.errorLabel("Address", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.city, with: .notEmpty())
|
||||||
|
.errorLabel("City", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.state, with: .notEmpty())
|
||||||
|
.errorLabel("State", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.zipCode, with: .notEmpty())
|
||||||
|
.errorLabel("Zip", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.sensibleHeatRatio) {
|
||||||
|
Validator {
|
||||||
|
Double.greaterThan(0)
|
||||||
|
Double.lessThanOrEquals(1.0)
|
||||||
|
}
|
||||||
|
.optional()
|
||||||
|
}
|
||||||
|
.errorLabel("Sensible Heat Ratio", inline: true)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
396
Sources/DatabaseClient/Internal/Rooms.swift
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
|
|
||||||
|
extension DatabaseClient.Rooms: TestDependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { projectID, request in
|
||||||
|
let model = try request.toModel(projectID: projectID)
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
createMany: { projectID, rooms in
|
||||||
|
try await RoomModel.createMany(projectID: projectID, rooms: rooms, on: database)
|
||||||
|
},
|
||||||
|
createFromCSV: { projectID, rows in
|
||||||
|
|
||||||
|
database.logger.debug("\nCreate From CSV rows: \(rows)\n")
|
||||||
|
|
||||||
|
// Filter out rows that delegate their airflow / load to another room,
|
||||||
|
// these need to be created last.
|
||||||
|
let rowsThatDelegate = rows.filter({
|
||||||
|
$0.delegatedToName != nil && $0.delegatedToName != ""
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out the rest of the rooms that don't delegate their airflow / loads.
|
||||||
|
let initialRooms = rows.filter({
|
||||||
|
$0.delegatedToName == nil || $0.delegatedToName == ""
|
||||||
|
})
|
||||||
|
.map(\.createModel)
|
||||||
|
|
||||||
|
database.logger.debug("\nInitial rows: \(initialRooms)\n")
|
||||||
|
|
||||||
|
let initialCreated = try await RoomModel.createMany(
|
||||||
|
projectID: projectID,
|
||||||
|
rooms: initialRooms,
|
||||||
|
on: database
|
||||||
|
)
|
||||||
|
database.logger.debug("\nInitially created rows: \(initialCreated)\n")
|
||||||
|
|
||||||
|
let roomsThatDelegateModels = try rowsThatDelegate.reduce(into: [Room.Create]()) {
|
||||||
|
array, row in
|
||||||
|
database.logger.debug("\n\(row.name), delegating to: \(row.delegatedToName!)\n")
|
||||||
|
guard let created = initialCreated.first(where: { $0.name == row.delegatedToName }) else {
|
||||||
|
database.logger.debug(
|
||||||
|
"\nUnable to find created room with name: \(row.delegatedToName!)\n"
|
||||||
|
)
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
array.append(
|
||||||
|
Room.Create.init(
|
||||||
|
name: row.name,
|
||||||
|
level: row.level,
|
||||||
|
heatingLoad: row.heatingLoad,
|
||||||
|
coolingTotal: row.coolingTotal,
|
||||||
|
coolingSensible: row.coolingSensible,
|
||||||
|
registerCount: 0,
|
||||||
|
delegatedTo: created.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await RoomModel.createMany(
|
||||||
|
projectID: projectID,
|
||||||
|
rooms: roomsThatDelegateModels,
|
||||||
|
on: database
|
||||||
|
) + initialCreated
|
||||||
|
|
||||||
|
},
|
||||||
|
delete: { id in
|
||||||
|
guard let model = try await RoomModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try await model.delete(on: database)
|
||||||
|
},
|
||||||
|
deleteRectangularSize: { roomID, rectangularDuctID in
|
||||||
|
guard let model = try await RoomModel.find(roomID, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.rectangularSizes?.removeAll {
|
||||||
|
$0.id == rectangularDuctID
|
||||||
|
}
|
||||||
|
if model.rectangularSizes?.count == 0 {
|
||||||
|
model.rectangularSizes = nil
|
||||||
|
}
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
get: { id in
|
||||||
|
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
fetch: { projectID in
|
||||||
|
try await RoomModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
|
.sort(\.$name, .ascending)
|
||||||
|
.all()
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await RoomModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
updateRectangularSize: { id, size in
|
||||||
|
guard let model = try await RoomModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
var rectangularSizes = model.rectangularSizes ?? []
|
||||||
|
rectangularSizes.removeAll {
|
||||||
|
$0.id == size.id
|
||||||
|
}
|
||||||
|
rectangularSizes.append(size)
|
||||||
|
model.rectangularSizes = rectangularSizes
|
||||||
|
try await model.save(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room.CSV.Row {
|
||||||
|
fileprivate var createModel: Room.Create {
|
||||||
|
assert(delegatedToName == nil || delegatedToName == "")
|
||||||
|
return .init(
|
||||||
|
name: name,
|
||||||
|
level: level,
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingTotal: coolingTotal,
|
||||||
|
coolingSensible: coolingSensible,
|
||||||
|
registerCount: registerCount,
|
||||||
|
delegatedTo: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RoomModel {
|
||||||
|
fileprivate static func createMany(
|
||||||
|
projectID: Project.ID,
|
||||||
|
rooms: [Room.Create],
|
||||||
|
on database: any Database
|
||||||
|
) async throws -> [Room] {
|
||||||
|
try await rooms.asyncMap { request in
|
||||||
|
let model = try request.toModel(projectID: projectID)
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room.Create {
|
||||||
|
|
||||||
|
func toModel(projectID: Project.ID) throws -> RoomModel {
|
||||||
|
var registerCount = registerCount
|
||||||
|
// Set register count appropriately when delegatedTo is set / changes.
|
||||||
|
if delegatedTo != nil {
|
||||||
|
registerCount = 0
|
||||||
|
} else if registerCount == 0 {
|
||||||
|
registerCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
name: name,
|
||||||
|
level: level?.rawValue,
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingLoad: coolingLoad,
|
||||||
|
registerCount: registerCount,
|
||||||
|
delegetedToID: delegatedTo,
|
||||||
|
projectID: projectID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room {
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
let name = "CreateRoom"
|
||||||
|
|
||||||
|
func prepare(on database: any Database) async throws {
|
||||||
|
try await database.schema(RoomModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("name", .string, .required)
|
||||||
|
.field("level", .int8)
|
||||||
|
.field("heatingLoad", .double, .required)
|
||||||
|
.field("coolingLoad", .dictionary, .required)
|
||||||
|
.field("registerCount", .int8, .required)
|
||||||
|
.field("delegatedToID", .uuid, .references(RoomModel.schema, "id"))
|
||||||
|
.field("rectangularSizes", .array)
|
||||||
|
.field("createdAt", .string)
|
||||||
|
.field("updatedAt", .string)
|
||||||
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.unique(on: "projectID", "name")
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: any Database) async throws {
|
||||||
|
try await database.schema(RoomModel.schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RoomModel: Model, @unchecked Sendable, Validatable {
|
||||||
|
|
||||||
|
static let schema = "room"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Field(key: "name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Field(key: "level")
|
||||||
|
var level: Int?
|
||||||
|
|
||||||
|
@Field(key: "heatingLoad")
|
||||||
|
var heatingLoad: Double
|
||||||
|
|
||||||
|
@Field(key: "coolingLoad")
|
||||||
|
var coolingLoad: Room.CoolingLoad
|
||||||
|
|
||||||
|
@Field(key: "registerCount")
|
||||||
|
var registerCount: Int
|
||||||
|
|
||||||
|
@OptionalParent(key: "delegatedToID")
|
||||||
|
var room: RoomModel?
|
||||||
|
|
||||||
|
@Field(key: "rectangularSizes")
|
||||||
|
var rectangularSizes: [Room.RectangularSize]?
|
||||||
|
|
||||||
|
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
|
var createdAt: Date?
|
||||||
|
|
||||||
|
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
|
||||||
|
var updatedAt: Date?
|
||||||
|
|
||||||
|
@Parent(key: "projectID")
|
||||||
|
var project: ProjectModel
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
name: String,
|
||||||
|
level: Int? = nil,
|
||||||
|
heatingLoad: Double,
|
||||||
|
coolingLoad: Room.CoolingLoad,
|
||||||
|
registerCount: Int,
|
||||||
|
delegetedToID: UUID? = nil,
|
||||||
|
rectangularSizes: [Room.RectangularSize]? = nil,
|
||||||
|
createdAt: Date? = nil,
|
||||||
|
updatedAt: Date? = nil,
|
||||||
|
projectID: Project.ID
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.level = level
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
self.coolingLoad = coolingLoad
|
||||||
|
self.registerCount = registerCount
|
||||||
|
$room.id = delegetedToID
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
$project.id = projectID
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO() throws -> Room {
|
||||||
|
try .init(
|
||||||
|
id: requireID(),
|
||||||
|
projectID: $project.id,
|
||||||
|
name: name,
|
||||||
|
level: level.map(Room.Level.init(rawValue:)),
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingLoad: coolingLoad,
|
||||||
|
registerCount: registerCount,
|
||||||
|
delegatedTo: $room.id,
|
||||||
|
rectangularSizes: rectangularSizes,
|
||||||
|
createdAt: createdAt!,
|
||||||
|
updatedAt: updatedAt!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: Room.Update) {
|
||||||
|
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if let level = updates.level?.rawValue, level != self.level {
|
||||||
|
self.level = level
|
||||||
|
}
|
||||||
|
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
}
|
||||||
|
if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad {
|
||||||
|
self.coolingLoad = coolingLoad
|
||||||
|
}
|
||||||
|
if let registerCount = updates.registerCount, registerCount != self.registerCount {
|
||||||
|
self.registerCount = registerCount
|
||||||
|
}
|
||||||
|
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Validation<RoomModel> {
|
||||||
|
Validator.accumulating {
|
||||||
|
Validator.validate(\.name, with: .notEmpty())
|
||||||
|
.errorLabel("Name", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
|
||||||
|
.errorLabel("Heating Load", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.coolingLoad)
|
||||||
|
.errorLabel("Cooling Load", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.registerCount, with: .greaterThanOrEquals($room.id == nil ? 1 : 0))
|
||||||
|
.errorLabel("Register Count", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.rectangularSizes)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndSave(on database: Database) async throws {
|
||||||
|
try self.validate()
|
||||||
|
if let delegateTo = $room.id {
|
||||||
|
guard
|
||||||
|
let parent =
|
||||||
|
try await RoomModel
|
||||||
|
.query(on: database)
|
||||||
|
.with(\.$room)
|
||||||
|
.filter(\.$id == delegateTo)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw ValidationError("Can not find room: \(delegateTo), to delegate airflow to.")
|
||||||
|
}
|
||||||
|
guard parent.$room.id == nil else {
|
||||||
|
throw ValidationError(
|
||||||
|
"""
|
||||||
|
Attempting to delegate to: \(parent.name), that delegates to: \(parent.$room.name)
|
||||||
|
|
||||||
|
Unable to delegate airflow to a room that already delegates it's airflow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try await save(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room.CoolingLoad: Validatable {
|
||||||
|
|
||||||
|
public var body: some Validation<Self> {
|
||||||
|
Validator.accumulating {
|
||||||
|
// Ensure that at least one of the values is not nil.
|
||||||
|
Validator.oneOf {
|
||||||
|
Validator.validate(\.total, with: .notNil())
|
||||||
|
.errorLabel("Total or Sensible", inline: true)
|
||||||
|
Validator.validate(\.sensible, with: .notNil())
|
||||||
|
.errorLabel("Total or Sensible", inline: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Validator.validate(\.total, with: Double.greaterThan(0).optional())
|
||||||
|
Validator.validate(\.sensible, with: Double.greaterThan(0).optional())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room.RectangularSize: Validatable {
|
||||||
|
|
||||||
|
public var body: some Validation<Self> {
|
||||||
|
Validator.accumulating {
|
||||||
|
Validator.validate(\.register, with: Int.greaterThanOrEquals(1).optional())
|
||||||
|
.errorLabel("Register", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.height, with: Int.greaterThanOrEquals(1))
|
||||||
|
.errorLabel("Height", inline: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
Sources/DatabaseClient/Internal/TrunkSizes.swift
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
|
|
||||||
|
extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { request in
|
||||||
|
// try request.validate()
|
||||||
|
|
||||||
|
let trunk = request.toModel()
|
||||||
|
var roomProxies = [TrunkSize.RoomProxy]()
|
||||||
|
|
||||||
|
try await trunk.validateAndSave(on: database)
|
||||||
|
|
||||||
|
for (roomID, registers) in request.rooms {
|
||||||
|
guard let room = try await RoomModel.find(roomID, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
let model = try TrunkRoomModel(
|
||||||
|
trunkID: trunk.requireID(),
|
||||||
|
roomID: room.requireID(),
|
||||||
|
registers: registers,
|
||||||
|
type: request.type
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
height: trunk.height,
|
||||||
|
name: trunk.name
|
||||||
|
)
|
||||||
|
},
|
||||||
|
delete: { id in
|
||||||
|
guard let model = try await TrunkModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try await model.delete(on: database)
|
||||||
|
},
|
||||||
|
fetch: { projectID in
|
||||||
|
try await TrunkModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.with(\.$rooms, { $0.with(\.$room) })
|
||||||
|
.filter(\.$project.$id == projectID)
|
||||||
|
.all()
|
||||||
|
.toDTO()
|
||||||
|
},
|
||||||
|
get: { id in
|
||||||
|
guard
|
||||||
|
let model =
|
||||||
|
try await TrunkModel
|
||||||
|
.query(on: database)
|
||||||
|
.with(\.$rooms, { $0.with(\.$room) })
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard
|
||||||
|
let model =
|
||||||
|
try await TrunkModel
|
||||||
|
.query(on: database)
|
||||||
|
.with(\.$rooms, { $0.with(\.$room) })
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
// try updates.validate()
|
||||||
|
try await model.applyUpdates(updates, on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize.Create {
|
||||||
|
|
||||||
|
func toModel() -> TrunkModel {
|
||||||
|
.init(
|
||||||
|
projectID: projectID,
|
||||||
|
type: type,
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
let name = "CreateTrunkSize"
|
||||||
|
|
||||||
|
func prepare(on database: any Database) async throws {
|
||||||
|
try await database.schema(TrunkModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("height", .int8)
|
||||||
|
.field("name", .string)
|
||||||
|
.field("type", .string, .required)
|
||||||
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
try await database.schema(TrunkRoomModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("registers", .array(of: .int), .required)
|
||||||
|
.field("type", .string, .required)
|
||||||
|
.field(
|
||||||
|
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.unique(on: "trunkID", "roomID", "type")
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: any Database) async throws {
|
||||||
|
try await database.schema(TrunkRoomModel.schema).delete()
|
||||||
|
try await database.schema(TrunkModel.schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pivot table for associating rooms and trunks.
|
||||||
|
final class TrunkRoomModel: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
static let schema = "room+trunk"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "trunkID")
|
||||||
|
var trunk: TrunkModel
|
||||||
|
|
||||||
|
@Parent(key: "roomID")
|
||||||
|
var room: RoomModel
|
||||||
|
|
||||||
|
@Field(key: "registers")
|
||||||
|
var registers: [Int]
|
||||||
|
|
||||||
|
@Field(key: "type")
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
trunkID: TrunkModel.IDValue,
|
||||||
|
roomID: RoomModel.IDValue,
|
||||||
|
registers: [Int],
|
||||||
|
type: TrunkSize.TrunkType
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$trunk.id = trunkID
|
||||||
|
$room.id = roomID
|
||||||
|
self.registers = registers
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
|
static let schema = "trunk"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "projectID")
|
||||||
|
var project: ProjectModel
|
||||||
|
|
||||||
|
@OptionalField(key: "height")
|
||||||
|
var height: Int?
|
||||||
|
|
||||||
|
@Field(key: "type")
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
@OptionalField(key: "name")
|
||||||
|
var name: String?
|
||||||
|
|
||||||
|
@Children(for: \.$trunk)
|
||||||
|
var rooms: [TrunkRoomModel]
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: TrunkSize.TrunkType,
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$project.id = projectID
|
||||||
|
self.height = height
|
||||||
|
self.type = type.rawValue
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO() throws -> TrunkSize {
|
||||||
|
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||||
|
$0.append(try $1.toDTO())
|
||||||
|
}
|
||||||
|
|
||||||
|
return try .init(
|
||||||
|
id: requireID(),
|
||||||
|
projectID: $project.id,
|
||||||
|
type: .init(rawValue: type)!,
|
||||||
|
rooms: rooms,
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdates(
|
||||||
|
_ updates: TrunkSize.Update,
|
||||||
|
on database: any Database
|
||||||
|
) async throws {
|
||||||
|
if let type = updates.type, type.rawValue != self.type {
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
if let height = updates.height, height != self.height {
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if hasChanges {
|
||||||
|
try await self.validateAndSave(on: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let updateRooms = updates.rooms else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rooms.
|
||||||
|
let rooms = try await TrunkRoomModel.query(on: database)
|
||||||
|
.with(\.$room)
|
||||||
|
.filter(\.$trunk.$id == requireID())
|
||||||
|
.all()
|
||||||
|
|
||||||
|
for (roomID, registers) in updateRooms {
|
||||||
|
if let currRoom = rooms.first(where: { $0.$room.id == roomID }) {
|
||||||
|
database.logger.debug("CURRENT ROOM: \(currRoom.room.name)")
|
||||||
|
if registers != currRoom.registers {
|
||||||
|
database.logger.debug("Updating registers for: \(currRoom.room.name)")
|
||||||
|
currRoom.registers = registers
|
||||||
|
}
|
||||||
|
if currRoom.hasChanges {
|
||||||
|
try await currRoom.validateAndSave(on: database)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
database.logger.debug("CREATING NEW TrunkRoomModel")
|
||||||
|
let newModel = try TrunkRoomModel(
|
||||||
|
trunkID: requireID(),
|
||||||
|
roomID: roomID,
|
||||||
|
registers: registers,
|
||||||
|
type: .init(rawValue: type)!
|
||||||
|
)
|
||||||
|
try await newModel.save(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let roomsToDelete = rooms.filter {
|
||||||
|
!updateRooms.keys.contains($0.$room.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for room in roomsToDelete {
|
||||||
|
try await room.delete(on: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.logger.debug("DONE WITH UPDATES")
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkModel: Validatable {
|
||||||
|
|
||||||
|
var body: some Validation<TrunkModel> {
|
||||||
|
Validator.accumulating {
|
||||||
|
|
||||||
|
Validator.validate(\.height, with: Int.greaterThan(0).optional())
|
||||||
|
.errorLabel("Height", inline: true)
|
||||||
|
|
||||||
|
Validator.validate(\.name, with: String.notEmpty().optional())
|
||||||
|
.errorLabel("Name", inline: true)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == TrunkModel {
|
||||||
|
|
||||||
|
func toDTO() throws -> [TrunkSize] {
|
||||||
|
|
||||||
|
return try reduce(into: [TrunkSize]()) {
|
||||||
|
$0.append(try $1.toDTO())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234
Sources/DatabaseClient/Internal/UserProfiles.swift
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
import Validations
|
||||||
|
|
||||||
|
extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||||
|
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { profile in
|
||||||
|
let model = profile.toModel()
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
delete: { id in
|
||||||
|
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try await model.delete(on: database)
|
||||||
|
},
|
||||||
|
fetch: { userID in
|
||||||
|
try await UserProfileModel.query(on: database)
|
||||||
|
.with(\.$user)
|
||||||
|
.filter(\.$user.$id == userID)
|
||||||
|
.first()
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
get: { id in
|
||||||
|
try await UserProfileModel.find(id, on: database)
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.validateAndSave(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile.Create {
|
||||||
|
|
||||||
|
func toModel() -> UserProfileModel {
|
||||||
|
.init(
|
||||||
|
userID: userID,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
companyName: companyName,
|
||||||
|
streetAddress: streetAddress,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
zipCode: zipCode,
|
||||||
|
theme: theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
let name = "Create UserProfile"
|
||||||
|
|
||||||
|
func prepare(on database: any Database) async throws {
|
||||||
|
try await database.schema(UserProfileModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("firstName", .string, .required)
|
||||||
|
.field("lastName", .string, .required)
|
||||||
|
.field("companyName", .string, .required)
|
||||||
|
.field("streetAddress", .string, .required)
|
||||||
|
.field("city", .string, .required)
|
||||||
|
.field("state", .string, .required)
|
||||||
|
.field("zipCode", .string, .required)
|
||||||
|
.field("theme", .string)
|
||||||
|
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||||
|
.field("createdAt", .string)
|
||||||
|
.field("updatedAt", .string)
|
||||||
|
.unique(on: "userID")
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: any Database) async throws {
|
||||||
|
try await database.schema(UserProfileModel.schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UserProfileModel: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
static let schema = "user_profile"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "userID")
|
||||||
|
var user: UserModel
|
||||||
|
|
||||||
|
@Field(key: "firstName")
|
||||||
|
var firstName: String
|
||||||
|
|
||||||
|
@Field(key: "lastName")
|
||||||
|
var lastName: String
|
||||||
|
|
||||||
|
@Field(key: "companyName")
|
||||||
|
var companyName: String
|
||||||
|
|
||||||
|
@Field(key: "streetAddress")
|
||||||
|
var streetAddress: String
|
||||||
|
|
||||||
|
@Field(key: "city")
|
||||||
|
var city: String
|
||||||
|
|
||||||
|
@Field(key: "state")
|
||||||
|
var state: String
|
||||||
|
|
||||||
|
@Field(key: "zipCode")
|
||||||
|
var zipCode: String
|
||||||
|
|
||||||
|
@Field(key: "theme")
|
||||||
|
var theme: String?
|
||||||
|
|
||||||
|
@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,
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$user.id = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme?.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO() throws -> User.Profile {
|
||||||
|
try .init(
|
||||||
|
id: requireID(),
|
||||||
|
userID: $user.id,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
companyName: companyName,
|
||||||
|
streetAddress: streetAddress,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
zipCode: zipCode,
|
||||||
|
theme: self.theme.flatMap(Theme.init),
|
||||||
|
createdAt: createdAt!,
|
||||||
|
updatedAt: updatedAt!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: User.Profile.Update) {
|
||||||
|
if let firstName = updates.firstName, firstName != self.firstName {
|
||||||
|
self.firstName = firstName
|
||||||
|
}
|
||||||
|
if let lastName = updates.lastName, lastName != self.lastName {
|
||||||
|
self.lastName = lastName
|
||||||
|
}
|
||||||
|
if let companyName = updates.companyName, companyName != self.companyName {
|
||||||
|
self.companyName = companyName
|
||||||
|
}
|
||||||
|
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
}
|
||||||
|
if let city = updates.city, city != self.city {
|
||||||
|
self.city = city
|
||||||
|
}
|
||||||
|
if let state = updates.state, state != self.state {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
if let zipCode = updates.zipCode, zipCode != self.zipCode {
|
||||||
|
self.zipCode = zipCode
|
||||||
|
}
|
||||||
|
if let theme = updates.theme, theme.rawValue != self.theme {
|
||||||
|
self.theme = theme.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
import Fluent
|
import Fluent
|
||||||
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Vapor
|
import Vapor
|
||||||
|
|
||||||
extension DatabaseClient {
|
|
||||||
@DependencyClient
|
|
||||||
public struct Users: Sendable {
|
|
||||||
public var create: @Sendable (User.Create) async throws -> User
|
|
||||||
public var delete: @Sendable (User.ID) async throws -> Void
|
|
||||||
public var get: @Sendable (User.ID) async throws -> User?
|
|
||||||
public var login: @Sendable (User.Login) async throws -> User.Token
|
|
||||||
public var logout: @Sendable (User.Token.ID) async throws -> Void
|
|
||||||
// public var token: @Sendable (User.ID) async throws -> User.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Users: TestDependencyKey {
|
extension DatabaseClient.Users: TestDependencyKey {
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { request in
|
create: { request in
|
||||||
|
try request.validate()
|
||||||
let model = try request.toModel()
|
let model = try request.toModel()
|
||||||
try await model.save(on: database)
|
try await model.save(on: database)
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
@@ -46,6 +35,11 @@ extension DatabaseClient.Users: TestDependencyKey {
|
|||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the password matches the user's hashed password.
|
||||||
|
guard try user.verifyPassword(request.password) else {
|
||||||
|
throw Abort(.unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
let token: User.Token
|
let token: User.Token
|
||||||
|
|
||||||
// Check if there's a user token
|
// Check if there's a user token
|
||||||
@@ -80,12 +74,11 @@ extension User {
|
|||||||
func prepare(on database: any Database) async throws {
|
func prepare(on database: any Database) async throws {
|
||||||
try await database.schema(UserModel.schema)
|
try await database.schema(UserModel.schema)
|
||||||
.id()
|
.id()
|
||||||
.field("username", .string, .required)
|
|
||||||
.field("email", .string, .required)
|
.field("email", .string, .required)
|
||||||
.field("password_hash", .string, .required)
|
.field("password_hash", .string, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .string)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .string)
|
||||||
.unique(on: "email", "username")
|
.unique(on: "email")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +97,8 @@ extension User.Token {
|
|||||||
.id()
|
.id()
|
||||||
.field("value", .string, .required)
|
.field("value", .string, .required)
|
||||||
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
||||||
|
.field("createdAt", .string)
|
||||||
|
.field("updatedAt", .string)
|
||||||
.unique(on: "value")
|
.unique(on: "value")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -125,23 +120,7 @@ extension User {
|
|||||||
extension User.Create {
|
extension User.Create {
|
||||||
|
|
||||||
func toModel() throws -> UserModel {
|
func toModel() throws -> UserModel {
|
||||||
try validate()
|
return try .init(email: email, passwordHash: User.hashPassword(password))
|
||||||
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate() throws {
|
|
||||||
guard !username.isEmpty else {
|
|
||||||
throw ValidationError("Username should not be empty.")
|
|
||||||
}
|
|
||||||
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.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +131,6 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
@ID(key: .id)
|
@ID(key: .id)
|
||||||
var id: UUID?
|
var id: UUID?
|
||||||
|
|
||||||
@Field(key: "username")
|
|
||||||
var username: String
|
|
||||||
|
|
||||||
@Field(key: "email")
|
@Field(key: "email")
|
||||||
var email: String
|
var email: String
|
||||||
|
|
||||||
@@ -174,12 +150,10 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID? = nil,
|
id: UUID? = nil,
|
||||||
username: String,
|
|
||||||
email: String,
|
email: String,
|
||||||
passwordHash: String
|
passwordHash: String
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.passwordHash = passwordHash
|
self.passwordHash = passwordHash
|
||||||
}
|
}
|
||||||
@@ -188,7 +162,6 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
try .init(
|
try .init(
|
||||||
id: requireID(),
|
id: requireID(),
|
||||||
email: email,
|
email: email,
|
||||||
username: username,
|
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
@@ -237,7 +210,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable {
|
|||||||
|
|
||||||
extension User: Authenticatable {}
|
extension User: Authenticatable {}
|
||||||
extension User: SessionAuthenticatable {
|
extension User: SessionAuthenticatable {
|
||||||
public var sessionID: String { username }
|
public var sessionID: String { email }
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
||||||
@@ -248,7 +221,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
|||||||
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
|
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
|
||||||
guard
|
guard
|
||||||
let user = try await UserModel.query(on: request.db)
|
let user = try await UserModel.query(on: request.db)
|
||||||
.filter(\UserModel.$username == basic.username)
|
.filter(\UserModel.$email == basic.username)
|
||||||
.first(),
|
.first(),
|
||||||
try user.verifyPassword(basic.password)
|
try user.verifyPassword(basic.password)
|
||||||
else {
|
else {
|
||||||
@@ -284,7 +257,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
|
|||||||
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
|
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
|
||||||
guard
|
guard
|
||||||
let user = try await UserModel.query(on: request.db)
|
let user = try await UserModel.query(on: request.db)
|
||||||
.filter(\UserModel.$username == sessionID)
|
.filter(\UserModel.$email == sessionID)
|
||||||
.first()
|
.first()
|
||||||
else {
|
else {
|
||||||
throw Abort(.unauthorized)
|
throw Abort(.unauthorized)
|
||||||
@@ -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
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import Dependencies
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Rooms: TestDependencyKey {
|
|
||||||
public static let testValue = Self()
|
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
|
||||||
.init(
|
|
||||||
create: { request in
|
|
||||||
let model = try request.toModel()
|
|
||||||
try await model.save(on: database)
|
|
||||||
return try model.toDTO()
|
|
||||||
},
|
|
||||||
delete: { id in
|
|
||||||
guard let model = try await RoomModel.find(id, on: database) else {
|
|
||||||
throw NotFoundError()
|
|
||||||
}
|
|
||||||
try await model.delete(on: database)
|
|
||||||
},
|
|
||||||
deleteRectangularSize: { roomID, rectangularDuctID in
|
|
||||||
guard let model = try await RoomModel.find(roomID, on: database) else {
|
|
||||||
throw NotFoundError()
|
|
||||||
}
|
|
||||||
model.rectangularSizes?.removeAll {
|
|
||||||
$0.id == rectangularDuctID
|
|
||||||
}
|
|
||||||
if model.hasChanges {
|
|
||||||
try await model.save(on: database)
|
|
||||||
}
|
|
||||||
return try model.toDTO()
|
|
||||||
},
|
|
||||||
get: { id in
|
|
||||||
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
|
|
||||||
},
|
|
||||||
fetch: { projectID in
|
|
||||||
try await RoomModel.query(on: database)
|
|
||||||
.with(\.$project)
|
|
||||||
.filter(\.$project.$id, .equal, projectID)
|
|
||||||
.sort(\.$name, .ascending)
|
|
||||||
.all()
|
|
||||||
.map { try $0.toDTO() }
|
|
||||||
},
|
|
||||||
update: { id, updates in
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return try model.toDTO()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Room.Create {
|
|
||||||
|
|
||||||
func toModel() throws(ValidationError) -> RoomModel {
|
|
||||||
try validate()
|
|
||||||
return .init(
|
|
||||||
name: name,
|
|
||||||
heatingLoad: heatingLoad,
|
|
||||||
coolingTotal: coolingTotal,
|
|
||||||
coolingSensible: coolingSensible,
|
|
||||||
registerCount: registerCount,
|
|
||||||
projectID: projectID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Room name should not be empty.")
|
|
||||||
}
|
|
||||||
guard heatingLoad >= 0 else {
|
|
||||||
throw ValidationError("Room heating load should not be less than 0.")
|
|
||||||
}
|
|
||||||
guard coolingTotal >= 0 else {
|
|
||||||
throw ValidationError("Room cooling total should not be less than 0.")
|
|
||||||
}
|
|
||||||
if let coolingSensible {
|
|
||||||
guard coolingSensible >= 0 else {
|
|
||||||
throw ValidationError("Room cooling sensible should not be less than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard registerCount >= 1 else {
|
|
||||||
throw ValidationError("Room cooling sensible should not be less than 1.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Room.Update {
|
|
||||||
|
|
||||||
func validate() throws(ValidationError) {
|
|
||||||
if let name {
|
|
||||||
guard !name.isEmpty else {
|
|
||||||
throw ValidationError("Room name should not be empty.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let heatingLoad {
|
|
||||||
guard heatingLoad >= 0 else {
|
|
||||||
throw ValidationError("Room heating load should not be less than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let coolingTotal {
|
|
||||||
guard coolingTotal >= 0 else {
|
|
||||||
throw ValidationError("Room cooling total should not be less than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let coolingSensible {
|
|
||||||
guard coolingSensible >= 0 else {
|
|
||||||
throw ValidationError("Room cooling sensible should not be less than 0.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let registerCount {
|
|
||||||
guard registerCount >= 1 else {
|
|
||||||
throw ValidationError("Room cooling sensible should not be less than 1.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Room {
|
|
||||||
struct Migrate: AsyncMigration {
|
|
||||||
let name = "CreateRoom"
|
|
||||||
|
|
||||||
func prepare(on database: any Database) async throws {
|
|
||||||
try await database.schema(RoomModel.schema)
|
|
||||||
.id()
|
|
||||||
.field("name", .string, .required)
|
|
||||||
.field("heatingLoad", .double, .required)
|
|
||||||
.field("coolingTotal", .double, .required)
|
|
||||||
.field("coolingSensible", .double)
|
|
||||||
.field("registerCount", .int8, .required)
|
|
||||||
.field("rectangularSizes", .array)
|
|
||||||
.field("createdAt", .datetime)
|
|
||||||
.field("updatedAt", .datetime)
|
|
||||||
.field(
|
|
||||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
|
||||||
)
|
|
||||||
.unique(on: "projectID", "name")
|
|
||||||
.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
func revert(on database: any Database) async throws {
|
|
||||||
try await database.schema(RoomModel.schema).delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class RoomModel: Model, @unchecked Sendable {
|
|
||||||
|
|
||||||
static let schema = "room"
|
|
||||||
|
|
||||||
@ID(key: .id)
|
|
||||||
var id: UUID?
|
|
||||||
|
|
||||||
@Field(key: "name")
|
|
||||||
var name: String
|
|
||||||
|
|
||||||
@Field(key: "heatingLoad")
|
|
||||||
var heatingLoad: Double
|
|
||||||
|
|
||||||
@Field(key: "coolingTotal")
|
|
||||||
var coolingTotal: Double
|
|
||||||
|
|
||||||
@Field(key: "coolingSensible")
|
|
||||||
var coolingSensible: Double?
|
|
||||||
|
|
||||||
@Field(key: "registerCount")
|
|
||||||
var registerCount: Int
|
|
||||||
|
|
||||||
@Field(key: "rectangularSizes")
|
|
||||||
var rectangularSizes: [DuctSizing.RectangularDuct]?
|
|
||||||
|
|
||||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
|
||||||
var createdAt: Date?
|
|
||||||
|
|
||||||
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
|
|
||||||
var updatedAt: Date?
|
|
||||||
|
|
||||||
@Parent(key: "projectID")
|
|
||||||
var project: ProjectModel
|
|
||||||
|
|
||||||
init() {}
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: UUID? = nil,
|
|
||||||
name: String,
|
|
||||||
heatingLoad: Double,
|
|
||||||
coolingTotal: Double,
|
|
||||||
coolingSensible: Double? = nil,
|
|
||||||
registerCount: Int,
|
|
||||||
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
|
||||||
createdAt: Date? = nil,
|
|
||||||
updatedAt: Date? = nil,
|
|
||||||
projectID: Project.ID
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.name = name
|
|
||||||
self.heatingLoad = heatingLoad
|
|
||||||
self.coolingTotal = coolingTotal
|
|
||||||
self.coolingSensible = coolingSensible
|
|
||||||
self.registerCount = registerCount
|
|
||||||
self.rectangularSizes = rectangularSizes
|
|
||||||
self.createdAt = createdAt
|
|
||||||
self.updatedAt = updatedAt
|
|
||||||
$project.id = projectID
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDTO() throws -> Room {
|
|
||||||
try .init(
|
|
||||||
id: requireID(),
|
|
||||||
projectID: $project.id,
|
|
||||||
name: name,
|
|
||||||
heatingLoad: heatingLoad,
|
|
||||||
coolingTotal: coolingTotal,
|
|
||||||
coolingSensible: coolingSensible,
|
|
||||||
registerCount: registerCount,
|
|
||||||
rectangularSizes: rectangularSizes,
|
|
||||||
createdAt: createdAt!,
|
|
||||||
updatedAt: updatedAt!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyUpdates(_ updates: Room.Update) {
|
|
||||||
|
|
||||||
if let name = updates.name, name != self.name {
|
|
||||||
self.name = name
|
|
||||||
}
|
|
||||||
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
|
|
||||||
self.heatingLoad = heatingLoad
|
|
||||||
}
|
|
||||||
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
|
|
||||||
self.coolingTotal = coolingTotal
|
|
||||||
}
|
|
||||||
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
|
|
||||||
self.coolingSensible = coolingSensible
|
|
||||||
}
|
|
||||||
if let registerCount = updates.registerCount, registerCount != self.registerCount {
|
|
||||||
self.registerCount = registerCount
|
|
||||||
}
|
|
||||||
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
|
|
||||||
self.rectangularSizes = rectangularSizes
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
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_USER"
|
||||||
|
case postgresPassword = "POSTGRES_PASSWORD"
|
||||||
|
case postgresDatabase = "POSTGRES_DB"
|
||||||
|
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
@@ -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
@@ -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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,23 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
extension Room {
|
|
||||||
|
|
||||||
var heatingLoadPerRegister: Double {
|
|
||||||
|
|
||||||
heatingLoad / Double(registerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
|
|
||||||
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
|
|
||||||
return sensible / Double(registerCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComponentPressureLosses {
|
|
||||||
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == EffectiveLengthGroup {
|
extension Array where Element == EffectiveLengthGroup {
|
||||||
var totalEffectiveLength: Int {
|
var totalEffectiveLength: Int {
|
||||||
reduce(0) { $0 + $1.effectiveLength }
|
reduce(0) { $0 + $1.effectiveLength }
|
||||||
@@ -32,6 +15,8 @@ func roundSize(_ size: Double) throws -> Int {
|
|||||||
throw ManualDError(message: "Size should be less than 24.")
|
throw ManualDError(message: "Size should be less than 24.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// let size = size.rounded(.toNearestOrEven)
|
||||||
|
|
||||||
switch size {
|
switch size {
|
||||||
case 0..<4:
|
case 0..<4:
|
||||||
return 4
|
return 4
|
||||||
@@ -67,16 +52,16 @@ func roundSize(_ size: Double) throws -> Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func velocity(cfm: Int, roundSize: Int) -> Int {
|
func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
|
||||||
let cfm = Double(cfm)
|
let cfm = Double(cfm.rawValue)
|
||||||
let roundSize = Double(roundSize)
|
let roundSize = Double(roundSize)
|
||||||
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
|
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
|
||||||
return Int(round(velocity))
|
return Int(round(velocity))
|
||||||
}
|
}
|
||||||
|
|
||||||
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int {
|
func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
|
||||||
let cfm = pow(Double(request.designCFM), 0.4)
|
let cfm = pow(Double(cfm.rawValue), 0.4)
|
||||||
let fr = pow(request.frictionRate / 1.76, 0.2)
|
let fr = pow(frictionRate / 1.76, 0.2)
|
||||||
let size = 0.55 * (cfm / fr)
|
let size = 0.55 * (cfm / fr)
|
||||||
return try roundSize(size)
|
return try roundSize(size)
|
||||||
}
|
}
|
||||||
|
|||||||
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,57 +4,47 @@ import ManualDCore
|
|||||||
|
|
||||||
extension ManualDClient: DependencyKey {
|
extension ManualDClient: DependencyKey {
|
||||||
public static let liveValue: Self = .init(
|
public static let liveValue: Self = .init(
|
||||||
ductSize: { request in
|
ductSize: { cfm, frictionRate in
|
||||||
guard request.designCFM > 0 else {
|
guard cfm > 0 else {
|
||||||
throw ManualDError(message: "Design CFM should be greater than 0.")
|
throw ManualDError(message: "Design CFM should be greater than 0.")
|
||||||
}
|
}
|
||||||
let fr = pow(request.frictionRate, 0.5)
|
let fr = pow(frictionRate.rawValue, 0.5)
|
||||||
let ductulatorSize = pow(Double(request.designCFM) / (3.12 * fr), 0.38)
|
let ductulatorSize = pow(Double(cfm.rawValue) / (3.12 * fr), 0.38)
|
||||||
let finalSize = try roundSize(ductulatorSize)
|
let finalSize = try roundSize(ductulatorSize)
|
||||||
let flexSize = try flexSize(request)
|
let flexSize = try flexSize(cfm, frictionRate.rawValue)
|
||||||
return .init(
|
return .init(
|
||||||
ductulatorSize: ductulatorSize,
|
calculatedSize: ductulatorSize,
|
||||||
finalSize: finalSize,
|
finalSize: finalSize,
|
||||||
flexSize: flexSize,
|
flexSize: flexSize,
|
||||||
velocity: velocity(cfm: request.designCFM, roundSize: finalSize)
|
velocity: velocity(cfm: cfm, roundSize: finalSize)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
frictionRate: { request in
|
frictionRate: { request in
|
||||||
// Ensure the total effective length is greater than 0.
|
// Ensure the total effective length is greater than 0.
|
||||||
guard request.totalEffectiveLength > 0 else {
|
guard request.totalEquivalentLength > 0 else {
|
||||||
throw ManualDError(message: "Total Effective Length should be greater than 0.")
|
throw ManualDError(message: "Total Effective Length should be greater than 0.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalComponentLosses = request.componentPressureLosses.totalComponentPressureLoss
|
let totalComponentLosses = request.componentPressureLosses.total
|
||||||
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
|
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
|
||||||
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
|
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEquivalentLength)
|
||||||
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
|
return .init(
|
||||||
},
|
availableStaticPressure: availableStaticPressure,
|
||||||
totalEffectiveLength: { request in
|
value: frictionRate
|
||||||
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
)
|
||||||
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
},
|
||||||
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
// totalEquivalentLength: { request in
|
||||||
return trunkLengths + runoutLengths + groupLengths
|
// let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
||||||
},
|
// let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
||||||
equivalentRectangularDuct: { request in
|
// let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
||||||
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
// return trunkLengths + runoutLengths + groupLengths
|
||||||
// Round the width up or fail (really should never fail since we know the input is a number).
|
// },
|
||||||
guard let widthStr = numberFormatter.string(for: width),
|
rectangularSize: { round, height in
|
||||||
let widthInt = Int(widthStr)
|
let width = (Double.pi * (pow(Double(round.rawValue) / 2.0, 2.0))) / Double(height.rawValue)
|
||||||
else {
|
return .init(
|
||||||
throw ManualDError(
|
height: height.rawValue,
|
||||||
message: "Failed to convert to to rectangular duct size, width: \(width)"
|
width: Int(width.rounded(.toNearestOrEven))
|
||||||
)
|
)
|
||||||
}
|
|
||||||
return .init(height: request.height, width: widthInt)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let numberFormatter: NumberFormatter = {
|
|
||||||
let formatter = NumberFormatter()
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
formatter.minimumFractionDigits = 0
|
|
||||||
formatter.roundingMode = .ceiling
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|||||||
@@ -1,200 +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],
|
|
||||||
equipmentInfo: EquipmentInfo,
|
|
||||||
maxSupplyLength: EffectiveLength,
|
|
||||||
maxReturnLength: EffectiveLength,
|
|
||||||
designFrictionRate: Double,
|
|
||||||
projectSHR: Double,
|
|
||||||
logger: Logger? = nil
|
|
||||||
) async throws -> [DuctSizing.RoomContainer] {
|
|
||||||
|
|
||||||
var registerIDCount = 1
|
|
||||||
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(
|
|
||||||
registerID: "SR-\(registerIDCount)",
|
|
||||||
roomID: room.id,
|
|
||||||
roomName: "\(room.name)-\(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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registerIDCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents component pressure losses used in the friction rate worksheet.
|
||||||
|
///
|
||||||
|
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
|
||||||
|
/// need to be overcome by the system fan.
|
||||||
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
|
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
@@ -43,7 +48,7 @@ extension ComponentPressureLoss {
|
|||||||
self.value = value
|
self.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return's commonly used default component pressure losses.
|
/// Commonly used default component pressure losses.
|
||||||
public static func `default`(projectID: Project.ID) -> [Self] {
|
public static func `default`(projectID: Project.ID) -> [Self] {
|
||||||
[
|
[
|
||||||
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
|
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
|
||||||
@@ -69,27 +74,68 @@ extension ComponentPressureLoss {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == ComponentPressureLoss {
|
extension Array where Element == ComponentPressureLoss {
|
||||||
public var totalComponentPressureLoss: Double {
|
public var total: Double {
|
||||||
reduce(into: 0) { $0 += $1.value }
|
reduce(into: 0) { $0 += $1.value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public typealias ComponentPressureLosses = [String: Double]
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension ComponentPressureLosses {
|
|
||||||
public static var mock: Self {
|
extension Array where Element == ComponentPressureLoss {
|
||||||
[
|
public static func mock(projectID: Project.ID) -> Self {
|
||||||
"evaporator-coil": 0.2,
|
ComponentPressureLoss.mock(projectID: projectID)
|
||||||
"filter": 0.1,
|
|
||||||
"supply-outlet": 0.03,
|
|
||||||
"return-grille": 0.03,
|
|
||||||
"balancing-damper": 0.03,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComponentPressureLoss {
|
extension ComponentPressureLoss {
|
||||||
|
public static func mock(projectID: Project.ID) -> [Self] {
|
||||||
|
@Dependency(\.uuid) var uuid
|
||||||
|
@Dependency(\.date.now) var now
|
||||||
|
|
||||||
|
return [
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "evaporator-coil",
|
||||||
|
value: 0.2,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "filter",
|
||||||
|
value: 0.1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "supply-outlet",
|
||||||
|
value: 0.03,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "return-grille",
|
||||||
|
value: 0.03,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "balancing-damper",
|
||||||
|
value: 0.03,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
public static var mock: [Self] {
|
public static var mock: [Self] {
|
||||||
[
|
[
|
||||||
.init(
|
.init(
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
// import Foundation
|
||||||
|
|
||||||
public struct CoolingLoad: Codable, Equatable, Sendable {
|
//
|
||||||
public let total: Double
|
// public struct CoolingLoad: Codable, Equatable, Sendable {
|
||||||
public let sensible: Double
|
// public let total: Double
|
||||||
public var latent: Double { total - sensible }
|
// public let sensible: Double
|
||||||
public var shr: Double { sensible / total }
|
// public var latent: Double { total - sensible }
|
||||||
|
// public var shr: Double { sensible / total }
|
||||||
public init(total: Double, sensible: Double) {
|
//
|
||||||
self.total = total
|
// public init(total: Double, sensible: Double) {
|
||||||
self.sensible = sensible
|
// self.total = total
|
||||||
}
|
// self.sensible = sensible
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
|||||||
259
Sources/ManualDCore/DuctSizes.swift
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
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],
|
||||||
|
shr: Double
|
||||||
|
) -> Self {
|
||||||
|
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingLoad = try! rooms.totalCoolingLoad(shr: shr)
|
||||||
|
|
||||||
|
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),
|
||||||
|
shr: shr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
shr: Double
|
||||||
|
) -> [Self] {
|
||||||
|
var retval = [DuctSizes.RoomContainer]()
|
||||||
|
let heatingLoad = room.heatingLoad / Double(room.registerCount)
|
||||||
|
let heatingFraction = heatingLoad / totalHeatingLoad
|
||||||
|
let heatingCFM = totalHeatingCFM * heatingFraction
|
||||||
|
// Not really accurate, but works for mocks.
|
||||||
|
let coolingLoad = (try! room.coolingLoad.ensured(shr: shr).total) / 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,93 +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 RoomContainer: Codable, Equatable, Sendable {
|
|
||||||
|
|
||||||
public let registerID: String
|
|
||||||
public let roomID: Room.ID
|
|
||||||
public let roomName: String
|
|
||||||
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(
|
|
||||||
registerID: String,
|
|
||||||
roomID: Room.ID,
|
|
||||||
roomName: String,
|
|
||||||
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.registerID = registerID
|
|
||||||
self.roomID = roomID
|
|
||||||
self.roomName = roomName
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
import Foundation
|
||||||
|
|
||||||
|
// TODO: These are not used, should they be removed??
|
||||||
|
|
||||||
// TODO: Add other description / label for items that have same group & letter, but
|
// TODO: Add other description / label for items that have same group & letter, but
|
||||||
// different effective length.
|
// different effective length.
|
||||||
public struct EffectiveLengthGroup: Codable, Equatable, Sendable {
|
public struct EffectiveLengthGroup: Codable, Equatable, Sendable {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents the equipment information for a project.
|
||||||
|
///
|
||||||
|
/// This is used in the friction rate worksheet and sizing ducts. It holds on to items
|
||||||
|
/// such as the target static pressure, cooling CFM, and heating CFM for the project.
|
||||||
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
|
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
public let projectID: Project.ID
|
public let projectID: Project.ID
|
||||||
@@ -70,6 +74,21 @@ extension EquipmentInfo {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension EquipmentInfo {
|
extension EquipmentInfo {
|
||||||
|
|
||||||
|
public static func mock(projectID: Project.ID) -> Self {
|
||||||
|
@Dependency(\.uuid) var uuid
|
||||||
|
@Dependency(\.date.now) var now
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
heatingCFM: 900,
|
||||||
|
coolingCFM: 1000,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public static let mock = Self(
|
public static let mock = Self(
|
||||||
id: UUID(0),
|
id: UUID(0),
|
||||||
projectID: UUID(0),
|
projectID: UUID(0),
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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 Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a single duct design project / system.
|
||||||
|
///
|
||||||
|
/// Holds items such as project name and address.
|
||||||
public struct Project: Codable, Equatable, Identifiable, Sendable {
|
public struct Project: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
/// The unique ID of the project.
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
|
/// The name of the project.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
/// The street address of the project.
|
||||||
public let streetAddress: String
|
public let streetAddress: String
|
||||||
|
/// The city of the project.
|
||||||
public let city: String
|
public let city: String
|
||||||
|
/// The state of the project.
|
||||||
public let state: String
|
public let state: String
|
||||||
|
/// The zip code of the project.
|
||||||
public let zipCode: String
|
public let zipCode: String
|
||||||
|
/// The global sensible heat ratio for the project.
|
||||||
|
///
|
||||||
|
/// **NOTE:** This is used for calculating the sensible cooling load for rooms.
|
||||||
public let sensibleHeatRatio: Double?
|
public let sensibleHeatRatio: Double?
|
||||||
|
/// When the project was created in the database.
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
|
/// When the project was updated in the database.
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -37,14 +50,20 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Project {
|
extension Project {
|
||||||
|
/// Represents the data needed to create a new project.
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The name of the project.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
/// The street address of the project.
|
||||||
public let streetAddress: String
|
public let streetAddress: String
|
||||||
|
/// The city of the project.
|
||||||
public let city: String
|
public let city: String
|
||||||
|
/// The state of the project.
|
||||||
public let state: String
|
public let state: String
|
||||||
|
/// The zip code of the project.
|
||||||
public let zipCode: String
|
public let zipCode: String
|
||||||
|
/// The global sensible heat ratio for the project.
|
||||||
public let sensibleHeatRatio: Double?
|
public let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -64,26 +83,86 @@ extension Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents steps that are completed in order to calculate the duct sizes
|
||||||
|
/// for a project.
|
||||||
|
///
|
||||||
|
/// This is primarily used on the web pages to display errors or color of the
|
||||||
|
/// different steps of a project.
|
||||||
public struct CompletedSteps: Codable, Equatable, Sendable {
|
public struct CompletedSteps: Codable, Equatable, Sendable {
|
||||||
|
/// Whether there is ``EquipmentInfo`` for a project.
|
||||||
|
public let equipmentInfo: Bool
|
||||||
|
/// Whether there are ``Room``'s for a project.
|
||||||
public let rooms: Bool
|
public let rooms: Bool
|
||||||
|
/// Whether there are ``EquivalentLength``'s for a project.
|
||||||
public let equivalentLength: Bool
|
public let equivalentLength: Bool
|
||||||
|
/// Whether there is a ``FrictionRate`` for a project.
|
||||||
public let frictionRate: Bool
|
public let frictionRate: Bool
|
||||||
|
|
||||||
public init(rooms: Bool, equivalentLength: Bool, frictionRate: Bool) {
|
public init(
|
||||||
|
equipmentInfo: Bool,
|
||||||
|
rooms: Bool,
|
||||||
|
equivalentLength: Bool,
|
||||||
|
frictionRate: Bool
|
||||||
|
) {
|
||||||
|
self.equipmentInfo = equipmentInfo
|
||||||
self.rooms = rooms
|
self.rooms = rooms
|
||||||
self.equivalentLength = equivalentLength
|
self.equivalentLength = equivalentLength
|
||||||
self.frictionRate = frictionRate
|
self.frictionRate = frictionRate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents project details loaded from the database.
|
||||||
|
///
|
||||||
|
/// This is generally used to perform duct sizing calculations for the
|
||||||
|
/// project, once all the steps have been completed.
|
||||||
|
public struct Detail: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The project.
|
||||||
|
public let project: Project
|
||||||
|
/// The component pressure losses for the project.
|
||||||
|
public let componentLosses: [ComponentPressureLoss]
|
||||||
|
/// The equipment info for the project.
|
||||||
|
public let equipmentInfo: EquipmentInfo
|
||||||
|
/// The equivalent lengths for the project.
|
||||||
|
public let equivalentLengths: [EquivalentLength]
|
||||||
|
/// The rooms in the project.
|
||||||
|
public let rooms: [Room]
|
||||||
|
/// The trunk sizes in the project.
|
||||||
|
public let trunks: [TrunkSize]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
project: Project,
|
||||||
|
componentLosses: [ComponentPressureLoss],
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
equivalentLengths: [EquivalentLength],
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [TrunkSize]
|
||||||
|
) {
|
||||||
|
self.project = project
|
||||||
|
self.componentLosses = componentLosses
|
||||||
|
self.equipmentInfo = equipmentInfo
|
||||||
|
self.equivalentLengths = equivalentLengths
|
||||||
|
self.rooms = rooms
|
||||||
|
self.trunks = trunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents fields that can be updated for a project that has already been created.
|
||||||
|
///
|
||||||
|
/// Only fields that are supplied get updated in the database.
|
||||||
public struct Update: Codable, Equatable, Sendable {
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The name of the project.
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
/// The street address of the project.
|
||||||
public let streetAddress: String?
|
public let streetAddress: String?
|
||||||
|
/// The city of the project.
|
||||||
public let city: String?
|
public let city: String?
|
||||||
|
/// The state of the project.
|
||||||
public let state: String?
|
public let state: String?
|
||||||
|
/// The zip code of the project.
|
||||||
public let zipCode: String?
|
public let zipCode: String?
|
||||||
|
/// The global sensible heat ratio for the project.
|
||||||
public let sensibleHeatRatio: Double?
|
public let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -107,16 +186,22 @@ extension Project {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
||||||
extension Project {
|
extension Project {
|
||||||
public static let mock = Self(
|
|
||||||
id: UUID(0),
|
public static var mock: Self {
|
||||||
|
@Dependency(\.uuid) var uuid
|
||||||
|
@Dependency(\.date.now) var now
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
id: uuid(),
|
||||||
name: "Testy McTestface",
|
name: "Testy McTestface",
|
||||||
streetAddress: "1234 Sesame Street",
|
streetAddress: "1234 Sesame Street",
|
||||||
city: "Monroe",
|
city: "Monroe",
|
||||||
state: "OH",
|
state: "OH",
|
||||||
zipCode: "55555",
|
zipCode: "55555",
|
||||||
createdAt: Date(),
|
createdAt: now,
|
||||||
updatedAt: Date()
|
updatedAt: now
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,86 +1,284 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tagged
|
||||||
|
|
||||||
|
/// Represents a room in a project.
|
||||||
|
///
|
||||||
|
/// This contains data such as the heating and cooling load for the
|
||||||
|
/// room, the number of registers in the room, and any rectangular
|
||||||
|
/// duct size calculations stored for the room.
|
||||||
public struct Room: Codable, Equatable, Identifiable, Sendable {
|
public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
/// The unique id of the room.
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
|
|
||||||
|
/// The project this room is associated with.
|
||||||
public let projectID: Project.ID
|
public let projectID: Project.ID
|
||||||
|
|
||||||
|
/// A unique name for the room in the project.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
|
||||||
|
/// The level of the home the room is on.
|
||||||
|
public let level: Level?
|
||||||
|
|
||||||
|
/// The heating load required for the room (from Manual-J).
|
||||||
public let heatingLoad: Double
|
public let heatingLoad: Double
|
||||||
public let coolingTotal: Double
|
|
||||||
public let coolingSensible: Double?
|
/// The cooling load required for the room (from Manual-J).
|
||||||
|
public let coolingLoad: CoolingLoad
|
||||||
|
|
||||||
|
/// The number of registers for the room.
|
||||||
public let registerCount: Int
|
public let registerCount: Int
|
||||||
public let rectangularSizes: [DuctSizing.RectangularDuct]?
|
|
||||||
|
/// An optional room that the airflow is delegated to.
|
||||||
|
public let delegatedTo: Room.ID?
|
||||||
|
|
||||||
|
/// The rectangular duct size calculations for a room.
|
||||||
|
///
|
||||||
|
/// **NOTE:** These are optionally set after the round sizes have been calculate
|
||||||
|
/// for a room.
|
||||||
|
public let rectangularSizes: [RectangularSize]?
|
||||||
|
|
||||||
|
/// When the room was created in the database.
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
|
|
||||||
|
/// When the room was updated in the database.
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
name: String,
|
name: String,
|
||||||
|
level: Level? = nil,
|
||||||
heatingLoad: Double,
|
heatingLoad: Double,
|
||||||
coolingTotal: Double,
|
coolingLoad: CoolingLoad,
|
||||||
coolingSensible: Double? = nil,
|
|
||||||
registerCount: Int = 1,
|
registerCount: Int = 1,
|
||||||
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
delegatedTo: Room.ID? = nil,
|
||||||
|
rectangularSizes: [RectangularSize]? = nil,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.projectID = projectID
|
self.projectID = projectID
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.level = level
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
self.coolingTotal = coolingTotal
|
self.coolingLoad = coolingLoad
|
||||||
self.coolingSensible = coolingSensible
|
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.delegatedTo = delegatedTo
|
||||||
self.rectangularSizes = rectangularSizes
|
self.rectangularSizes = rectangularSizes
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents the cooling load of a room.
|
||||||
|
///
|
||||||
|
/// Generally only one of the values is provided by a Manual-J room x room
|
||||||
|
/// calculation.
|
||||||
|
///
|
||||||
|
public struct CoolingLoad: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let total: Double?
|
||||||
|
public let sensible: Double?
|
||||||
|
|
||||||
|
public init(total: Double? = nil, sensible: Double? = nil) {
|
||||||
|
self.total = total
|
||||||
|
self.sensible = sensible
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the cooling load based on the shr.
|
||||||
|
///
|
||||||
|
/// Generally Manual-J room x room loads provide either the total load or the
|
||||||
|
/// sensible load, so this allows us to calculate whichever is not provided.
|
||||||
|
public func ensured(shr: Double) throws -> (total: Double, sensible: Double) {
|
||||||
|
switch (total, sensible) {
|
||||||
|
case (.none, .none):
|
||||||
|
throw CoolingLoadError("Both the total and sensible loads are nil.")
|
||||||
|
case (.some(let total), .some(let sensible)):
|
||||||
|
return (total, sensible)
|
||||||
|
case (.some(let total), .none):
|
||||||
|
return (total, total * shr)
|
||||||
|
case (.none, .some(let sensible)):
|
||||||
|
return (sensible / shr, sensible)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LevelTag {}
|
||||||
|
|
||||||
|
public typealias Level = Tagged<LevelTag, Int>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Room {
|
extension Room {
|
||||||
|
/// Represents the data required to create a new room for a project.
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
public let projectID: Project.ID
|
/// A unique name for the room in the project.
|
||||||
public let name: String
|
public let name: String
|
||||||
|
|
||||||
|
/// An optional level of the home the room is on.
|
||||||
|
public let level: Room.Level?
|
||||||
|
|
||||||
|
/// The heating load required for the room (from Manual-J).
|
||||||
public let heatingLoad: Double
|
public let heatingLoad: Double
|
||||||
public let coolingTotal: Double
|
|
||||||
|
/// The total cooling load required for the room (from Manual-J).
|
||||||
|
public let coolingTotal: Double?
|
||||||
|
|
||||||
|
/// An optional sensible cooling load for the room.
|
||||||
public let coolingSensible: Double?
|
public let coolingSensible: Double?
|
||||||
|
|
||||||
|
/// The number of registers for the room.
|
||||||
public let registerCount: Int
|
public let registerCount: Int
|
||||||
|
|
||||||
|
/// An optional room that this room delegates it's airflow to.
|
||||||
|
public let delegatedTo: Room.ID?
|
||||||
|
|
||||||
|
public var coolingLoad: Room.CoolingLoad {
|
||||||
|
.init(total: coolingTotal, sensible: coolingSensible)
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
projectID: Project.ID,
|
|
||||||
name: String,
|
name: String,
|
||||||
|
level: Room.Level? = nil,
|
||||||
heatingLoad: Double,
|
heatingLoad: Double,
|
||||||
coolingTotal: Double,
|
coolingTotal: Double? = nil,
|
||||||
coolingSensible: Double? = nil,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int = 1
|
registerCount: Int = 1,
|
||||||
|
delegatedTo: Room.ID? = nil
|
||||||
) {
|
) {
|
||||||
self.projectID = projectID
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.level = level
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.delegatedTo = delegatedTo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Update: Codable, Equatable, Sendable {
|
public struct CSV: Equatable, Sendable {
|
||||||
public let name: String?
|
public let file: Data
|
||||||
public let heatingLoad: Double?
|
|
||||||
|
public init(file: Data) {
|
||||||
|
self.file = file
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a row in a CSV file.
|
||||||
|
///
|
||||||
|
/// This is similar to ``Room.Create``, but since the rooms are not yet
|
||||||
|
/// created, delegating to another room is done via the room's name
|
||||||
|
/// instead of id.
|
||||||
|
///
|
||||||
|
public struct Row: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// A unique name for the room in the project.
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
/// An optional level of the home the room is on.
|
||||||
|
public let level: Room.Level?
|
||||||
|
|
||||||
|
/// 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?
|
public let coolingTotal: Double?
|
||||||
|
|
||||||
|
/// An optional sensible cooling load for the room.
|
||||||
public let coolingSensible: Double?
|
public let coolingSensible: Double?
|
||||||
|
|
||||||
|
/// The number of registers for the room.
|
||||||
|
public let registerCount: Int
|
||||||
|
|
||||||
|
/// An optional room that this room delegates it's airflow to.
|
||||||
|
public let delegatedToName: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
level: Room.Level? = nil,
|
||||||
|
heatingLoad: Double,
|
||||||
|
coolingTotal: Double? = nil,
|
||||||
|
coolingSensible: Double? = nil,
|
||||||
|
registerCount: Int,
|
||||||
|
delegatedToName: String? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.level = level
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
self.coolingTotal = coolingTotal
|
||||||
|
self.coolingSensible = coolingSensible
|
||||||
|
self.registerCount = registerCount
|
||||||
|
// Treat empty strings as nil, as they are often empty
|
||||||
|
// when left blank in a CSV file.
|
||||||
|
self.delegatedToName = delegatedToName == "" ? nil : delegatedToName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Onlly fields that are supplied get updated.
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
/// A unique name for the room in the project.
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
/// An optional level of the home the room is on.
|
||||||
|
public let level: Room.Level?
|
||||||
|
|
||||||
|
/// 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 registerCount: Int?
|
||||||
public let rectangularSizes: [DuctSizing.RectangularDuct]?
|
/// The rectangular duct size calculations for a room.
|
||||||
|
public let rectangularSizes: [RectangularSize]?
|
||||||
|
|
||||||
|
public var coolingLoad: CoolingLoad? {
|
||||||
|
guard coolingTotal != nil || coolingSensible != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .init(total: coolingTotal, sensible: coolingSensible)
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String? = nil,
|
name: String? = nil,
|
||||||
|
level: Room.Level? = nil,
|
||||||
heatingLoad: Double? = nil,
|
heatingLoad: Double? = nil,
|
||||||
coolingTotal: Double? = nil,
|
coolingTotal: Double? = nil,
|
||||||
coolingSensible: Double? = nil,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int? = nil
|
registerCount: Int? = nil
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.level = level
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
@@ -89,9 +287,10 @@ extension Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
rectangularSizes: [DuctSizing.RectangularDuct]
|
rectangularSizes: [RectangularSize]
|
||||||
) {
|
) {
|
||||||
self.name = nil
|
self.name = nil
|
||||||
|
self.level = nil
|
||||||
self.heatingLoad = nil
|
self.heatingLoad = nil
|
||||||
self.coolingTotal = nil
|
self.coolingTotal = nil
|
||||||
self.coolingSensible = nil
|
self.coolingSensible = nil
|
||||||
@@ -103,57 +302,129 @@ extension Room {
|
|||||||
|
|
||||||
extension Array where Element == Room {
|
extension Array where Element == Room {
|
||||||
|
|
||||||
|
/// The sum of heating loads for an array of rooms.
|
||||||
public var totalHeatingLoad: Double {
|
public var totalHeatingLoad: Double {
|
||||||
reduce(into: 0) { $0 += $1.heatingLoad }
|
reduce(into: 0) { $0 += $1.heatingLoad }
|
||||||
}
|
}
|
||||||
|
|
||||||
public var totalCoolingLoad: Double {
|
/// The sum of total cooling loads for an array of rooms.
|
||||||
reduce(into: 0) { $0 += $1.coolingTotal }
|
public func totalCoolingLoad(shr: Double) throws -> Double {
|
||||||
|
try reduce(into: 0) { $0 += try $1.coolingLoad.ensured(shr: shr).total }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func totalCoolingSensible(shr: Double) -> Double {
|
/// The sum of sensible cooling loads for an array of rooms.
|
||||||
reduce(into: 0) {
|
///
|
||||||
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
/// - Parameters:
|
||||||
$0 += sensible
|
/// - shr: The project wide sensible heat ratio.
|
||||||
|
public func totalCoolingSensible(shr: Double) throws -> Double {
|
||||||
|
try reduce(into: 0) {
|
||||||
|
// let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||||
|
$0 += try $1.coolingLoad.ensured(shr: shr).sensible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct CoolingLoadError: Error, Equatable, Sendable {
|
||||||
|
public let reason: String
|
||||||
|
|
||||||
|
public init(_ reason: String) {
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room.Level {
|
||||||
|
/// The label for the level, i.e. 'Basement' or 'Level-1', etc.
|
||||||
|
public var label: String {
|
||||||
|
if rawValue <= 0 {
|
||||||
|
return "Basement"
|
||||||
|
}
|
||||||
|
return "Level-\(rawValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
||||||
extension Room {
|
extension Room {
|
||||||
public static let mocks = [
|
|
||||||
Room(
|
public static func mock(projectID: Project.ID) -> [Self] {
|
||||||
id: UUID(0),
|
@Dependency(\.uuid) var uuid
|
||||||
projectID: UUID(0),
|
@Dependency(\.date.now) var now
|
||||||
name: "Kitchen",
|
|
||||||
heatingLoad: 12345,
|
return [
|
||||||
coolingTotal: 1234,
|
.init(
|
||||||
registerCount: 2,
|
id: uuid(),
|
||||||
createdAt: Date(),
|
projectID: projectID,
|
||||||
updatedAt: Date()
|
name: "Bed-1",
|
||||||
),
|
heatingLoad: 3913,
|
||||||
Room(
|
coolingLoad: .init(total: 2472),
|
||||||
id: UUID(1),
|
// coolingSensible: nil,
|
||||||
projectID: UUID(1),
|
|
||||||
name: "Bedroom - 1",
|
|
||||||
heatingLoad: 12345,
|
|
||||||
coolingTotal: 1456,
|
|
||||||
registerCount: 1,
|
registerCount: 1,
|
||||||
createdAt: Date(),
|
rectangularSizes: nil,
|
||||||
updatedAt: Date()
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
),
|
),
|
||||||
Room(
|
.init(
|
||||||
id: UUID(2),
|
id: uuid(),
|
||||||
projectID: UUID(2),
|
projectID: projectID,
|
||||||
|
name: "Entry",
|
||||||
|
heatingLoad: 8284,
|
||||||
|
coolingLoad: .init(total: 2916),
|
||||||
|
// coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
name: "Family Room",
|
name: "Family Room",
|
||||||
heatingLoad: 12345,
|
heatingLoad: 9785,
|
||||||
coolingTotal: 1673,
|
coolingLoad: .init(total: 7446),
|
||||||
|
// coolingSensible: nil,
|
||||||
registerCount: 3,
|
registerCount: 3,
|
||||||
createdAt: Date(),
|
rectangularSizes: nil,
|
||||||
updatedAt: Date()
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Kitchen",
|
||||||
|
heatingLoad: 4518,
|
||||||
|
coolingLoad: .init(total: 5096),
|
||||||
|
// coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Living Room",
|
||||||
|
heatingLoad: 7553,
|
||||||
|
coolingLoad: .init(total: 6829),
|
||||||
|
// coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Master",
|
||||||
|
heatingLoad: 8202,
|
||||||
|
coolingLoad: .init(total: 2076),
|
||||||
|
// coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endif
|
#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 {
|
public enum SiteRoute: Equatable, Sendable {
|
||||||
|
|
||||||
case api(Self.Api)
|
|
||||||
case health
|
case health
|
||||||
case view(Self.View)
|
case view(Self.View)
|
||||||
|
|
||||||
public static let router = OneOf {
|
public static let router = OneOf {
|
||||||
Route(.case(Self.api)) {
|
|
||||||
SiteRoute.Api.router
|
|
||||||
}
|
|
||||||
Route(.case(Self.health)) {
|
Route(.case(Self.health)) {
|
||||||
Path { "health" }
|
Path { "health" }
|
||||||
Method.get
|
Method.get
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ extension SiteRoute {
|
|||||||
///
|
///
|
||||||
/// The routes return html.
|
/// The routes return html.
|
||||||
public enum View: Equatable, Sendable {
|
public enum View: Equatable, Sendable {
|
||||||
|
case home
|
||||||
|
case privacyPolicy
|
||||||
case login(LoginRoute)
|
case login(LoginRoute)
|
||||||
case signup(SignupRoute)
|
case signup(SignupRoute)
|
||||||
case project(ProjectRoute)
|
case project(ProjectRoute)
|
||||||
|
case ductulator(DuctulatorRoute)
|
||||||
|
case user(UserRoute)
|
||||||
//FIX: Remove.
|
//FIX: Remove.
|
||||||
case test
|
case test
|
||||||
|
|
||||||
@@ -19,6 +23,13 @@ extension SiteRoute {
|
|||||||
Path { "test" }
|
Path { "test" }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.home)) {
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.privacyPolicy)) {
|
||||||
|
Path { "privacy-policy" }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
Route(.case(Self.login)) {
|
Route(.case(Self.login)) {
|
||||||
SiteRoute.View.LoginRoute.router
|
SiteRoute.View.LoginRoute.router
|
||||||
}
|
}
|
||||||
@@ -28,6 +39,12 @@ extension SiteRoute {
|
|||||||
Route(.case(Self.project)) {
|
Route(.case(Self.project)) {
|
||||||
SiteRoute.View.ProjectRoute.router
|
SiteRoute.View.ProjectRoute.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.ductulator)) {
|
||||||
|
SiteRoute.View.DuctulatorRoute.router
|
||||||
|
}
|
||||||
|
Route(.case(Self.user)) {
|
||||||
|
SiteRoute.View.UserRoute.router
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +54,6 @@ extension SiteRoute.View {
|
|||||||
case create(Project.Create)
|
case create(Project.Create)
|
||||||
case delete(id: Project.ID)
|
case delete(id: Project.ID)
|
||||||
case detail(Project.ID, DetailRoute)
|
case detail(Project.ID, DetailRoute)
|
||||||
case form(id: Project.ID? = nil, dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
case page(PageRequest)
|
case page(PageRequest)
|
||||||
case update(Project.ID, Project.Update)
|
case update(Project.ID, Project.Update)
|
||||||
@@ -82,19 +98,6 @@ extension SiteRoute.View {
|
|||||||
}
|
}
|
||||||
DetailRoute.router
|
DetailRoute.router
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Optionally {
|
|
||||||
Field("id", default: nil) { Project.ID.parser() }
|
|
||||||
}
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
@@ -150,22 +153,18 @@ extension SiteRoute.View {
|
|||||||
extension SiteRoute.View.ProjectRoute {
|
extension SiteRoute.View.ProjectRoute {
|
||||||
|
|
||||||
public enum DetailRoute: Equatable, Sendable {
|
public enum DetailRoute: Equatable, Sendable {
|
||||||
case index(tab: Tab = .default)
|
case index
|
||||||
case componentLoss(ComponentLossRoute)
|
case componentLoss(ComponentLossRoute)
|
||||||
case ductSizing(DuctSizingRoute)
|
case ductSizing(DuctSizingRoute)
|
||||||
case equipment(EquipmentInfoRoute)
|
case equipment(EquipmentInfoRoute)
|
||||||
case equivalentLength(EquivalentLengthRoute)
|
case equivalentLength(EquivalentLengthRoute)
|
||||||
case frictionRate(FrictionRateRoute)
|
case frictionRate(FrictionRateRoute)
|
||||||
|
case pdf
|
||||||
case rooms(RoomRoute)
|
case rooms(RoomRoute)
|
||||||
|
|
||||||
static let router = OneOf {
|
static let router = OneOf {
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Method.get
|
Method.get
|
||||||
Query {
|
|
||||||
Field("tab", default: Tab.default) {
|
|
||||||
Tab.parser()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Route(.case(Self.componentLoss)) {
|
Route(.case(Self.componentLoss)) {
|
||||||
ComponentLossRoute.router
|
ComponentLossRoute.router
|
||||||
@@ -182,6 +181,10 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Route(.case(Self.frictionRate)) {
|
Route(.case(Self.frictionRate)) {
|
||||||
FrictionRateRoute.router
|
FrictionRateRoute.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.pdf)) {
|
||||||
|
Path { "pdf" }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
Route(.case(Self.rooms)) {
|
Route(.case(Self.rooms)) {
|
||||||
RoomRoute.router
|
RoomRoute.router
|
||||||
}
|
}
|
||||||
@@ -189,18 +192,17 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
|
|
||||||
public enum Tab: String, CaseIterable, Equatable, Sendable {
|
public enum Tab: String, CaseIterable, Equatable, Sendable {
|
||||||
case project
|
case project
|
||||||
|
case equipment
|
||||||
case rooms
|
case rooms
|
||||||
case equivalentLength
|
case equivalentLength
|
||||||
case frictionRate
|
case frictionRate
|
||||||
case ductSizing
|
case ductSizing
|
||||||
|
|
||||||
public static var `default`: Self { .rooms }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum RoomRoute: Equatable, Sendable {
|
public enum RoomRoute: Equatable, Sendable {
|
||||||
|
case csv(Room.CSV)
|
||||||
case delete(id: Room.ID)
|
case delete(id: Room.ID)
|
||||||
case form(id: Room.ID? = nil, dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
case submit(Room.Create)
|
case submit(Room.Create)
|
||||||
case update(Room.ID, Room.Update)
|
case update(Room.ID, Room.Update)
|
||||||
@@ -209,6 +211,17 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
static let rootPath = "rooms"
|
static let rootPath = "rooms"
|
||||||
|
|
||||||
public static let router = OneOf {
|
public static let router = OneOf {
|
||||||
|
Route(.case(Self.csv)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
"csv"
|
||||||
|
}
|
||||||
|
Headers {
|
||||||
|
Field("Content-Type") { "multipart/form-data" }
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body().map(.memberwise(Room.CSV.init))
|
||||||
|
}
|
||||||
Route(.case(Self.delete)) {
|
Route(.case(Self.delete)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
@@ -216,19 +229,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
Method.delete
|
Method.delete
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Optionally {
|
|
||||||
Field("id", default: nil) { Room.ID.parser() }
|
|
||||||
}
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
@@ -240,14 +240,24 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Method.post
|
Method.post
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("projectID") { Project.ID.parser() }
|
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
Field("heatingLoad") { Double.parser() }
|
|
||||||
Field("coolingTotal") { Double.parser() }
|
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("coolingSensible", default: nil) { Double.parser() }
|
Field("level") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||||
|
}
|
||||||
|
Field("heatingLoad") { Double.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("coolingTotal") { Double.parser() }
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("coolingSensible") { Double.parser() }
|
||||||
}
|
}
|
||||||
Field("registerCount") { Digits() }
|
Field("registerCount") { Digits() }
|
||||||
|
Optionally {
|
||||||
|
Field("delegatedTo") { Room.ID.parser() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.map(.memberwise(Room.Create.init))
|
.map(.memberwise(Room.Create.init))
|
||||||
}
|
}
|
||||||
@@ -263,6 +273,12 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Optionally {
|
Optionally {
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
}
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("level") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||||
|
}
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("heatingLoad") { Double.parser() }
|
Field("heatingLoad") { Double.parser() }
|
||||||
}
|
}
|
||||||
@@ -358,8 +374,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
|
|
||||||
public enum FrictionRateRoute: Equatable, Sendable {
|
public enum FrictionRateRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
// TODO: Remove form or move equipment / component losses routes here.
|
|
||||||
case form(FormType, dismiss: Bool = false)
|
|
||||||
|
|
||||||
static let rootPath = "friction-rate"
|
static let rootPath = "friction-rate"
|
||||||
|
|
||||||
@@ -368,28 +382,11 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("type") { FormType.parser() }
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum FormType: String, CaseIterable, Codable, Equatable, Sendable {
|
|
||||||
case equipmentInfo
|
|
||||||
case componentPressureLoss
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum EquipmentInfoRoute: Equatable, Sendable {
|
public enum EquipmentInfoRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case form(dismiss: Bool)
|
|
||||||
case submit(EquipmentInfo.Create)
|
case submit(EquipmentInfo.Create)
|
||||||
case update(EquipmentInfo.ID, EquipmentInfo.Update)
|
case update(EquipmentInfo.ID, EquipmentInfo.Update)
|
||||||
|
|
||||||
@@ -400,16 +397,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("dismiss", default: true) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.submit)) {
|
Route(.case(Self.submit)) {
|
||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.post
|
Method.post
|
||||||
@@ -448,12 +435,11 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum EquivalentLengthRoute: Equatable, Sendable {
|
public enum EquivalentLengthRoute: Equatable, Sendable {
|
||||||
case delete(id: EffectiveLength.ID)
|
case delete(id: EquivalentLength.ID)
|
||||||
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
|
case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
|
||||||
case form(dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
case submit(FormStep)
|
case submit(FormStep)
|
||||||
case update(EffectiveLength.ID, StepThree)
|
case update(EquivalentLength.ID, StepThree)
|
||||||
|
|
||||||
static let rootPath = "effective-lengths"
|
static let rootPath = "effective-lengths"
|
||||||
|
|
||||||
@@ -461,7 +447,7 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Route(.case(Self.delete(id:))) {
|
Route(.case(Self.delete(id:))) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
EffectiveLength.ID.parser()
|
EquivalentLength.ID.parser()
|
||||||
}
|
}
|
||||||
Method.delete
|
Method.delete
|
||||||
}
|
}
|
||||||
@@ -469,16 +455,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form(dismiss:))) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.field)) {
|
Route(.case(Self.field)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
@@ -489,7 +465,7 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Field("type") { FieldType.parser() }
|
Field("type") { FieldType.parser() }
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("style", default: nil) {
|
Field("style", default: nil) {
|
||||||
EffectiveLength.EffectiveLengthType.parser()
|
EquivalentLength.EffectiveLengthType.parser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,16 +478,16 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Route(.case(Self.update)) {
|
Route(.case(Self.update)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
EffectiveLength.ID.parser()
|
EquivalentLength.ID.parser()
|
||||||
}
|
}
|
||||||
Method.patch
|
Method.patch
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||||
}
|
}
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||||
Many {
|
Many {
|
||||||
Field("straightLengths") {
|
Field("straightLengths") {
|
||||||
Int.parser()
|
Int.parser()
|
||||||
@@ -555,10 +531,10 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||||
}
|
}
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||||
}
|
}
|
||||||
.map(.memberwise(StepOne.init))
|
.map(.memberwise(StepOne.init))
|
||||||
}
|
}
|
||||||
@@ -570,10 +546,10 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||||
}
|
}
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||||
Many {
|
Many {
|
||||||
Field("straightLengths") {
|
Field("straightLengths") {
|
||||||
Int.parser()
|
Int.parser()
|
||||||
@@ -590,10 +566,10 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||||
}
|
}
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||||
Many {
|
Many {
|
||||||
Field("straightLengths") {
|
Field("straightLengths") {
|
||||||
Int.parser()
|
Int.parser()
|
||||||
@@ -632,22 +608,22 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct StepOne: Codable, Equatable, Sendable {
|
public struct StepOne: Codable, Equatable, Sendable {
|
||||||
public let id: EffectiveLength.ID?
|
public let id: EquivalentLength.ID?
|
||||||
public let name: String
|
public let name: String
|
||||||
public let type: EffectiveLength.EffectiveLengthType
|
public let type: EquivalentLength.EffectiveLengthType
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct StepTwo: Codable, Equatable, Sendable {
|
public struct StepTwo: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let id: EffectiveLength.ID?
|
public let id: EquivalentLength.ID?
|
||||||
public let name: String
|
public let name: String
|
||||||
public let type: EffectiveLength.EffectiveLengthType
|
public let type: EquivalentLength.EffectiveLengthType
|
||||||
public let straightLengths: [Int]
|
public let straightLengths: [Int]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: EffectiveLength.ID? = nil,
|
id: EquivalentLength.ID? = nil,
|
||||||
name: String,
|
name: String,
|
||||||
type: EffectiveLength.EffectiveLengthType,
|
type: EquivalentLength.EffectiveLengthType,
|
||||||
straightLengths: [Int]
|
straightLengths: [Int]
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -658,9 +634,9 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct StepThree: Codable, Equatable, Sendable {
|
public struct StepThree: Codable, Equatable, Sendable {
|
||||||
public let id: EffectiveLength.ID?
|
public let id: EquivalentLength.ID?
|
||||||
public let name: String
|
public let name: String
|
||||||
public let type: EffectiveLength.EffectiveLengthType
|
public let type: EquivalentLength.EffectiveLengthType
|
||||||
public let straightLengths: [Int]
|
public let straightLengths: [Int]
|
||||||
public let groupGroups: [Int]
|
public let groupGroups: [Int]
|
||||||
public let groupLetters: [String]
|
public let groupLetters: [String]
|
||||||
@@ -677,9 +653,11 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
|
|
||||||
public enum DuctSizingRoute: Equatable, Sendable {
|
public enum DuctSizingRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case deleteRectangularSize(Room.ID, DuctSizing.RectangularDuct.ID)
|
case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
|
||||||
case roomRectangularForm(Room.ID, RoomRectangularForm)
|
case roomRectangularForm(Room.ID, RoomRectangularForm)
|
||||||
|
case trunk(TrunkRoute)
|
||||||
|
|
||||||
|
public static let roomPath = "room"
|
||||||
static let rootPath = "duct-sizing"
|
static let rootPath = "duct-sizing"
|
||||||
|
|
||||||
static let router = OneOf {
|
static let router = OneOf {
|
||||||
@@ -690,25 +668,27 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Route(.case(Self.deleteRectangularSize)) {
|
Route(.case(Self.deleteRectangularSize)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
"room"
|
roomPath
|
||||||
Room.ID.parser()
|
Room.ID.parser()
|
||||||
}
|
}
|
||||||
Method.delete
|
Method.delete
|
||||||
Query {
|
Query {
|
||||||
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
|
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
|
||||||
|
Field("register") { Int.parser() }
|
||||||
}
|
}
|
||||||
|
.map(.memberwise(DeleteRectangularDuct.init))
|
||||||
}
|
}
|
||||||
Route(.case(Self.roomRectangularForm)) {
|
Route(.case(Self.roomRectangularForm)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
"room"
|
roomPath
|
||||||
Room.ID.parser()
|
Room.ID.parser()
|
||||||
}
|
}
|
||||||
Method.post
|
Method.post
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
|
Field("id") { Room.RectangularSize.ID.parser() }
|
||||||
}
|
}
|
||||||
Field("register") { Int.parser() }
|
Field("register") { Int.parser() }
|
||||||
Field("height") { Int.parser() }
|
Field("height") { Int.parser() }
|
||||||
@@ -716,12 +696,125 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
.map(.memberwise(RoomRectangularForm.init))
|
.map(.memberwise(RoomRectangularForm.init))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.trunk)) {
|
||||||
|
Path { rootPath }
|
||||||
|
TrunkRoute.router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeleteRectangularDuct: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let rectangularSizeID: Room.RectangularSize.ID
|
||||||
|
public let register: Int
|
||||||
|
|
||||||
|
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
|
||||||
|
self.rectangularSizeID = rectangularSizeID
|
||||||
|
self.register = register
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TrunkRoute: Equatable, Sendable {
|
||||||
|
case delete(TrunkSize.ID)
|
||||||
|
case submit(TrunkSizeForm)
|
||||||
|
case update(TrunkSize.ID, TrunkSizeForm)
|
||||||
|
|
||||||
|
public static let rootPath = "trunk"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.delete)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
TrunkSize.ID.parser()
|
||||||
|
}
|
||||||
|
Method.delete
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("projectID") { Project.ID.parser() }
|
||||||
|
Field("type") { TrunkSize.TrunkType.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("name", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("rooms", .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(TrunkSizeForm.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
TrunkSize.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("projectID") { Project.ID.parser() }
|
||||||
|
Field("type") { TrunkSize.TrunkType.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("name", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("rooms", .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(TrunkSizeForm.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct RoomRectangularForm: Equatable, Sendable {
|
public struct RoomRectangularForm: Equatable, Sendable {
|
||||||
public let id: DuctSizing.RectangularDuct.ID?
|
|
||||||
|
public let id: Room.RectangularSize.ID?
|
||||||
public let register: Int
|
public let register: Int
|
||||||
public let height: Int
|
public let height: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Room.RectangularSize.ID? = nil,
|
||||||
|
register: Int,
|
||||||
|
height: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.register = register
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TrunkSizeForm: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let projectID: Project.ID
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,6 +863,7 @@ extension SiteRoute.View {
|
|||||||
public enum SignupRoute: Equatable, Sendable {
|
public enum SignupRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case submit(User.Create)
|
case submit(User.Create)
|
||||||
|
case submitProfile(User.Profile.Create)
|
||||||
|
|
||||||
static let rootPath = "signup"
|
static let rootPath = "signup"
|
||||||
|
|
||||||
@@ -783,7 +877,6 @@ extension SiteRoute.View {
|
|||||||
Method.post
|
Method.post
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("username", .string)
|
|
||||||
Field("email", .string)
|
Field("email", .string)
|
||||||
Field("password", .string)
|
Field("password", .string)
|
||||||
Field("confirmPassword", .string)
|
Field("confirmPassword", .string)
|
||||||
@@ -791,6 +884,162 @@ extension SiteRoute.View {
|
|||||||
.map(.memberwise(User.Create.init))
|
.map(.memberwise(User.Create.init))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.submitProfile)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
"profile"
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("userID") { User.ID.parser() }
|
||||||
|
Field("firstName", .string)
|
||||||
|
Field("lastName", .string)
|
||||||
|
Field("companyName", .string)
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
Field("city", .string)
|
||||||
|
Field("state", .string)
|
||||||
|
Field("zipCode", .string)
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Create.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View {
|
||||||
|
public enum UserRoute: Equatable, Sendable {
|
||||||
|
case profile(Profile)
|
||||||
|
case logout
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.profile)) {
|
||||||
|
Profile.router
|
||||||
|
}
|
||||||
|
Route(.case(Self.logout)) {
|
||||||
|
Path { "logout" }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View.UserRoute {
|
||||||
|
public enum Profile: Equatable, Sendable {
|
||||||
|
case index
|
||||||
|
case submit(User.Profile.Create)
|
||||||
|
case update(User.Profile.ID, User.Profile.Update)
|
||||||
|
|
||||||
|
static let rootPath = "profile"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("userID") { User.ID.parser() }
|
||||||
|
Field("firstName", .string)
|
||||||
|
Field("lastName", .string)
|
||||||
|
Field("companyName", .string)
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
Field("city", .string)
|
||||||
|
Field("state", .string)
|
||||||
|
Field("zipCode", .string)
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Create.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
User.Profile.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("firstName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("lastName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("companyName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("city", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("state", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("zipCode", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Update.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View {
|
||||||
|
public enum DuctulatorRoute: Equatable, Sendable {
|
||||||
|
case index
|
||||||
|
case submit(Form)
|
||||||
|
|
||||||
|
public static let rootPath = "duct-size"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("cfm") { Int.parser() }
|
||||||
|
Field("frictionRate") { Double.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(Form.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Form: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let cfm: Int
|
||||||
|
public let frictionRate: Double
|
||||||
|
public let height: Int?
|
||||||
|
|
||||||
|
public init(cfm: Int, frictionRate: Double, height: Int? = nil) {
|
||||||
|
self.cfm = cfm
|
||||||
|
self.frictionRate = frictionRate
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
Sources/ManualDCore/Theme.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents supported color themes for the website.
|
||||||
|
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case aqua
|
||||||
|
case cupcake
|
||||||
|
case cyberpunk
|
||||||
|
case dark
|
||||||
|
case `default`
|
||||||
|
case dracula
|
||||||
|
case light
|
||||||
|
case night
|
||||||
|
case nord
|
||||||
|
case retro
|
||||||
|
case synthwave
|
||||||
|
|
||||||
|
/// Represents dark color themes.
|
||||||
|
public static let darkThemes = [
|
||||||
|
Self.aqua,
|
||||||
|
Self.cyberpunk,
|
||||||
|
Self.dark,
|
||||||
|
Self.dracula,
|
||||||
|
Self.night,
|
||||||
|
Self.synthwave,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Represents light color themes.
|
||||||
|
public static let lightThemes = [
|
||||||
|
Self.cupcake,
|
||||||
|
Self.light,
|
||||||
|
Self.nord,
|
||||||
|
Self.retro,
|
||||||
|
]
|
||||||
|
}
|
||||||
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,23 +1,26 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a user of the site.
|
||||||
|
///
|
||||||
public struct User: Codable, Equatable, Identifiable, Sendable {
|
public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
/// The unique id of the user.
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
|
/// The user's email address.
|
||||||
public let email: String
|
public let email: String
|
||||||
public let username: String
|
/// When the user was created in the database.
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
|
/// When the user was updated in the database.
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
email: String,
|
email: String,
|
||||||
username: String,
|
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
@@ -25,29 +28,34 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension User {
|
extension User {
|
||||||
|
/// Represents the data required to create a new user.
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let username: String
|
/// The user's email address.
|
||||||
public let email: String
|
public let email: String
|
||||||
|
/// The password for the user.
|
||||||
public let password: String
|
public let password: String
|
||||||
|
/// The password confirmation, must match the password.
|
||||||
public let confirmPassword: String
|
public let confirmPassword: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
username: String,
|
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
confirmPassword: String
|
confirmPassword: String
|
||||||
) {
|
) {
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.password = password
|
self.password = password
|
||||||
self.confirmPassword = confirmPassword
|
self.confirmPassword = confirmPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents data required to login a user.
|
||||||
public struct Login: Codable, Equatable, Sendable {
|
public struct Login: Codable, Equatable, Sendable {
|
||||||
|
/// The user's email address.
|
||||||
public let email: String
|
public let email: String
|
||||||
|
/// The password for the user.
|
||||||
public let password: String
|
public let password: String
|
||||||
|
/// An optional page / route to navigate to after logging in the user.
|
||||||
public let next: String?
|
public let next: String?
|
||||||
|
|
||||||
public init(email: String, password: String, next: String? = nil) {
|
public init(email: String, password: String, next: String? = nil) {
|
||||||
@@ -57,10 +65,13 @@ extension User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents a user session token, for a logged in user.
|
||||||
public struct Token: Codable, Equatable, Identifiable, Sendable {
|
public struct Token: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
/// The unique id of the token.
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
|
/// The user id the token is for.
|
||||||
public let userID: User.ID
|
public let userID: User.ID
|
||||||
|
/// The token value.
|
||||||
public let value: String
|
public let value: String
|
||||||
|
|
||||||
public init(id: UUID, userID: User.ID, value: String) {
|
public init(id: UUID, userID: User.ID, value: String) {
|
||||||
@@ -70,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
|
||||||
|
|||||||
174
Sources/ManualDCore/UserProfile.swift
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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(
|
||||||
|
id: UUID,
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil,
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.userID = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.userID = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
firstName: String? = nil,
|
||||||
|
lastName: String? = nil,
|
||||||
|
companyName: String? = nil,
|
||||||
|
streetAddress: String? = nil,
|
||||||
|
city: String? = nil,
|
||||||
|
state: String? = nil,
|
||||||
|
zipCode: String? = nil,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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
|
||||||
168
Sources/PdfClient/Interface.swift
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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,
|
||||||
|
shr: project.sensibleHeatRatio ?? 0.83
|
||||||
|
),
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Sources/PdfClient/Views/RoomTable.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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 { try! room.coolingLoad.ensured(shr: projectSHR).total.string(digits: 0) }
|
||||||
|
td {
|
||||||
|
try! room.coolingLoad.ensured(shr: projectSHR).sensible.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")) {
|
||||||
|
try! rooms.totalCoolingLoad(shr: projectSHR).string(digits: 0)
|
||||||
|
}
|
||||||
|
td(.class("coolingSensible label")) {
|
||||||
|
try! rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
|
||||||
|
}
|
||||||
|
td {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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,220 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX: Need to add the loads for rooms that get delegated to other rooms here.
|
||||||
|
func calculateRoomSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
sharedRequest: DuctSizeSharedRequest,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> [DuctSizes.RoomContainer] {
|
||||||
|
|
||||||
|
var retval: [DuctSizes.RoomContainer] = []
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||||
|
let nonDelegatedRooms = rooms.filter { $0.delegatedTo == nil }
|
||||||
|
|
||||||
|
for room in nonDelegatedRooms {
|
||||||
|
// Get all the rooms that delegate their loads to this room.
|
||||||
|
let delegatedRooms = rooms.filter { $0.delegatedTo == room.id }
|
||||||
|
|
||||||
|
let heatingLoad = room.heatingLoadPerRegister(delegatedRooms: delegatedRooms)
|
||||||
|
let coolingLoad = try room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
|
||||||
|
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
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 = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||||
|
|
||||||
|
for trunk in trunks {
|
||||||
|
let heatingLoad = trunk.totalHeatingLoad
|
||||||
|
let coolingLoad = try trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
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 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) throws -> Double {
|
||||||
|
// try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// extension TrunkSize {
|
||||||
|
//
|
||||||
|
// var totalHeatingLoad: Double {
|
||||||
|
// rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func totalCoolingSensible(projectSHR: Double) throws -> Double {
|
||||||
|
// try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||||
|
// }
|
||||||
|
// }
|
||||||