Compare commits
78 Commits
1d155546ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
|
1446540109
|
|||
|
20065ebf10
|
|||
|
a356aa2a13
|
|||
|
07818d24ed
|
|||
|
7083178844
|
|||
|
30fddb9dce
|
|||
|
9356ccb1c9
|
|||
|
79b7892d9a
|
|||
|
f8bed40670
|
|||
|
dbf7e3b1b4
|
|||
|
8fb313fddc
|
|||
|
5fcc5b88fa
|
|||
|
fc12e47b5c
|
|||
|
4c8a23be94
|
|||
|
fb7cf9905c
|
|||
|
55a3adde25
|
|||
|
4aca134abd
|
|||
|
f159c3ab75
|
|||
|
a61c772f7b
|
|||
|
9f63b96c80
|
|||
|
1aeb6144d5
|
65
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# branches: ['main']
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Defines two custom environment variables for the workflow. These are used for the Container registry domain,
|
||||||
|
# and a name for the Docker image that this workflow builds.
|
||||||
|
env:
|
||||||
|
REGISTRY: git.housh.dev
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Uses the `docker/login-action` action to log in to the Container registry registry using the account
|
||||||
|
# and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
|
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels
|
||||||
|
# that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a
|
||||||
|
# subsequent step. The `images` value provides the base name for the tags and labels.
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=prod
|
||||||
|
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
|
||||||
|
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
|
||||||
|
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"
|
||||||
|
# in the README of the `docker/build-push-action` repository.
|
||||||
|
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
4
.gitignore
vendored
@@ -9,3 +9,7 @@ DerivedData/
|
|||||||
.swift-version
|
.swift-version
|
||||||
node_modules/
|
node_modules/
|
||||||
tailwindcss
|
tailwindcss
|
||||||
|
.envrc
|
||||||
|
*.pdf
|
||||||
|
.env
|
||||||
|
.env*
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2",
|
"originHash" : "c3efcfd33bc1490f59ae406e4e5292027b2d01cafee9fc625652213505df50fb",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "async-http-client",
|
"identity" : "async-http-client",
|
||||||
@@ -226,6 +226,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 +370,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",
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "App", targets: ["App"]),
|
.executable(name: "App", targets: ["App"]),
|
||||||
.library(name: "ApiController", targets: ["ApiController"]),
|
.library(name: "ApiController", targets: ["ApiController"]),
|
||||||
|
.library(name: "AuthClient", targets: ["AuthClient"]),
|
||||||
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
|
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
|
||||||
|
.library(name: "EnvClient", targets: ["EnvClient"]),
|
||||||
|
.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"]),
|
||||||
@@ -19,6 +25,7 @@ let package = Package(
|
|||||||
.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/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-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"),
|
||||||
@@ -31,6 +38,7 @@ let package = Package(
|
|||||||
name: "App",
|
name: "App",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.target(name: "ApiController"),
|
.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"),
|
||||||
@@ -53,6 +61,15 @@ let package = Package(
|
|||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "AuthClient",
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "DatabaseClient"),
|
||||||
|
.target(name: "ManualDCore"),
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "DatabaseClient",
|
name: "DatabaseClient",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -63,10 +80,66 @@ let package = Package(
|
|||||||
.product(name: "Vapor", package: "vapor"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
.target(
|
||||||
|
name: "EnvClient",
|
||||||
|
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: "EnvClient"),
|
||||||
|
.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: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "Fluent", package: "fluent"),
|
||||||
.product(name: "URLRouting", package: "swift-url-routing"),
|
.product(name: "URLRouting", package: "swift-url-routing"),
|
||||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||||
]
|
]
|
||||||
@@ -103,6 +176,11 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "ViewController",
|
name: "ViewController",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.target(name: "AuthClient"),
|
||||||
|
.target(name: "DatabaseClient"),
|
||||||
|
.target(name: "PdfClient"),
|
||||||
|
.target(name: "ProjectClient"),
|
||||||
|
.target(name: "ManualDClient"),
|
||||||
.target(name: "ManualDCore"),
|
.target(name: "ManualDCore"),
|
||||||
.target(name: "Styleguide"),
|
.target(name: "Styleguide"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
@@ -112,5 +190,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/files/ManD.Groups.pdf_original
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 |
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"}
|
||||||
@@ -31,6 +31,15 @@ extension SiteRoute.Api.ProjectRoute {
|
|||||||
case .delete(let id):
|
case .delete(let id):
|
||||||
try await database.projects.delete(id)
|
try await database.projects.delete(id)
|
||||||
return nil
|
return nil
|
||||||
|
case .detail(let id, let route):
|
||||||
|
switch route {
|
||||||
|
case .index:
|
||||||
|
return try await database.projects.detail(id)
|
||||||
|
case .completedSteps:
|
||||||
|
// FIX:
|
||||||
|
fatalError()
|
||||||
|
|
||||||
|
}
|
||||||
case .get(let id):
|
case .get(let id):
|
||||||
guard let project = try await database.projects.get(id) else {
|
guard let project = try await database.projects.get(id) else {
|
||||||
logger.error("Project not found for id: \(id)")
|
logger.error("Project not found for id: \(id)")
|
||||||
|
|||||||
@@ -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,12 +1,14 @@
|
|||||||
import ApiController
|
import ApiController
|
||||||
|
import AuthClient
|
||||||
import DatabaseClient
|
import DatabaseClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
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
|
||||||
@@ -29,9 +31,12 @@ struct DependenciesMiddleware: AsyncMiddleware {
|
|||||||
try await values.yield {
|
try await values.yield {
|
||||||
try await withDependencies {
|
try await withDependencies {
|
||||||
$0.apiController = apiController
|
$0.apiController = apiController
|
||||||
|
$0.authClient = .live(on: request)
|
||||||
$0.database = database
|
$0.database = database
|
||||||
// $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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,18 @@ import Vapor
|
|||||||
private let viewRouteMiddleware: [any Middleware] = [
|
private let viewRouteMiddleware: [any Middleware] = [
|
||||||
UserPasswordAuthenticator(),
|
UserPasswordAuthenticator(),
|
||||||
UserSessionAuthenticator(),
|
UserSessionAuthenticator(),
|
||||||
User.redirectMiddleware(path: "/login"),
|
User.redirectMiddleware { req in
|
||||||
|
"/login?next=\(req.url.string)"
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
extension SiteRoute.View {
|
extension SiteRoute.View {
|
||||||
var middleware: [any Middleware]? {
|
var middleware: [any Middleware]? {
|
||||||
switch self {
|
switch self {
|
||||||
case .project,
|
case .login, .signup, .test:
|
||||||
.frictionRate,
|
|
||||||
.effectiveLength,
|
|
||||||
.room:
|
|
||||||
return viewRouteMiddleware
|
|
||||||
case .login, .signup:
|
|
||||||
return nil
|
return nil
|
||||||
|
case .project, .user:
|
||||||
|
return viewRouteMiddleware
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Fluent
|
|||||||
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
|
||||||
@@ -65,7 +66,7 @@ private func setupDatabase(
|
|||||||
let databaseClient = makeDatabaseClient(app.db)
|
let databaseClient = makeDatabaseClient(app.db)
|
||||||
|
|
||||||
if app.environment != .testing {
|
if app.environment != .testing {
|
||||||
try await app.migrations.add(databaseClient.migrations.run())
|
try await app.migrations.add(databaseClient.migrations())
|
||||||
}
|
}
|
||||||
|
|
||||||
return databaseClient
|
return databaseClient
|
||||||
@@ -111,6 +112,8 @@ extension SiteRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension DuctSizes: Content {}
|
||||||
|
|
||||||
@Sendable
|
@Sendable
|
||||||
private func siteHandler(
|
private func siteHandler(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -118,12 +121,17 @@ private func siteHandler(
|
|||||||
) async throws -> any AsyncResponseEncodable {
|
) async throws -> any AsyncResponseEncodable {
|
||||||
@Dependency(\.apiController) var apiController
|
@Dependency(\.apiController) var apiController
|
||||||
@Dependency(\.viewController) var viewController
|
@Dependency(\.viewController) var viewController
|
||||||
|
@Dependency(\.projectClient) var projectClient
|
||||||
|
|
||||||
switch route {
|
switch route {
|
||||||
case .api(let route):
|
case .api(let route):
|
||||||
return try await apiController.respond(route, request: request)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
51
Sources/AuthClient/Interface.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import ManualDCore
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension DependencyValues {
|
||||||
|
public var authClient: AuthClient {
|
||||||
|
get { self[AuthClient.self] }
|
||||||
|
set { self[AuthClient.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
public struct AuthClient: Sendable {
|
||||||
|
public var createAndLogin: @Sendable (User.Create) async throws -> User
|
||||||
|
public var currentUser: @Sendable () throws -> User
|
||||||
|
public var login: @Sendable (User.Login) async throws -> User
|
||||||
|
public var logout: @Sendable () throws -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AuthClient: TestDependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(on request: Request) -> Self {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
createAndLogin: { createForm in
|
||||||
|
let user = try await database.users.create(createForm)
|
||||||
|
_ = try await database.users.login(
|
||||||
|
.init(email: createForm.email, password: createForm.password)
|
||||||
|
)
|
||||||
|
request.auth.login(user)
|
||||||
|
request.logger.debug("LOGGED IN: \(user.id)")
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
currentUser: {
|
||||||
|
try request.auth.require(User.self)
|
||||||
|
},
|
||||||
|
login: { loginForm in
|
||||||
|
let token = try await database.users.login(loginForm)
|
||||||
|
let user = try await database.users.get(token.userID)!
|
||||||
|
request.session.authenticate(user)
|
||||||
|
request.logger.debug("LOGGED IN: \(user.id)")
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
logout: { request.auth.logout(User.self) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ extension DatabaseClient {
|
|||||||
public var create:
|
public var create:
|
||||||
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
|
||||||
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> ComponentPressureLoss
|
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
||||||
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
||||||
|
public var update:
|
||||||
|
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
||||||
|
ComponentPressureLoss
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,18 +37,26 @@ extension DatabaseClient.ComponentLoss {
|
|||||||
try await model.delete(on: database)
|
try await model.delete(on: database)
|
||||||
},
|
},
|
||||||
fetch: { projectID in
|
fetch: { projectID in
|
||||||
guard
|
try await ComponentLossModel.query(on: database)
|
||||||
let model = try await ComponentLossModel.query(on: database)
|
.with(\.$project)
|
||||||
.filter("projectID", .equal, projectID)
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
.first()
|
.all()
|
||||||
else {
|
.map { try $0.toDTO() }
|
||||||
throw NotFoundError()
|
|
||||||
}
|
|
||||||
return try model.toDTO()
|
|
||||||
|
|
||||||
},
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
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
|
||||||
|
try updates.validate()
|
||||||
|
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,6 +82,24 @@ extension ComponentPressureLoss.Create {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
struct Migrate: AsyncMigration {
|
struct Migrate: AsyncMigration {
|
||||||
let name = "CreateComponentLoss"
|
let name = "CreateComponentLoss"
|
||||||
@@ -82,8 +111,10 @@ extension ComponentPressureLoss {
|
|||||||
.field("value", .double, .required)
|
.field("value", .double, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
|
.field(
|
||||||
.unique(on: "projectID", "name")
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
// .unique(on: "projectID", "name")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,4 +174,13 @@ final class ComponentLossModel: Model, @unchecked Sendable {
|
|||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: ComponentPressureLoss.Update) {
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if let value = updates.value, value != self.value {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ extension DatabaseClient {
|
|||||||
public struct EffectiveLengthClient: Sendable {
|
public struct EffectiveLengthClient: Sendable {
|
||||||
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
||||||
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> EffectiveLength?
|
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 get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
|
||||||
|
public var update:
|
||||||
|
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,18 +34,41 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
|||||||
try await model.delete(on: database)
|
try await model.delete(on: database)
|
||||||
},
|
},
|
||||||
fetch: { projectID in
|
fetch: { projectID in
|
||||||
guard
|
try await EffectiveLengthModel.query(on: database)
|
||||||
let model = try await EffectiveLengthModel.query(on: database)
|
.with(\.$project)
|
||||||
.filter("projectID", .equal, projectID)
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
.first()
|
.all()
|
||||||
else {
|
.map { try $0.toDTO() }
|
||||||
throw NotFoundError()
|
},
|
||||||
}
|
fetchMax: { projectID in
|
||||||
|
let effectiveLengths = try await EffectiveLengthModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
|
.all()
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
supply: effectiveLengths.filter({ $0.type == .supply })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first,
|
||||||
|
return: effectiveLengths.filter({ $0.type == .return })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first
|
||||||
|
)
|
||||||
|
|
||||||
return try model.toDTO()
|
|
||||||
},
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
|
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -82,7 +108,9 @@ extension EffectiveLength {
|
|||||||
.field("groups", .data)
|
.field("groups", .data)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
.unique(on: "projectID", "name", "type")
|
.unique(on: "projectID", "name", "type")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -157,4 +185,19 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
|||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: EffectiveLength.Update) throws {
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if let type = updates.type, type.rawValue != self.type {
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
|
||||||
|
self.straightLengths = straightLengths
|
||||||
|
}
|
||||||
|
if let groups = updates.groups {
|
||||||
|
self.groups = try JSONEncoder().encode(groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ extension DatabaseClient {
|
|||||||
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
||||||
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
||||||
|
public var update:
|
||||||
|
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +40,23 @@ extension DatabaseClient.Equipment {
|
|||||||
.filter("projectID", .equal, projectId)
|
.filter("projectID", .equal, projectId)
|
||||||
.first()
|
.first()
|
||||||
else {
|
else {
|
||||||
throw NotFoundError()
|
return nil
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
},
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
|
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await EquipmentModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try updates.validate()
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,6 +90,30 @@ extension EquipmentInfo.Create {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
||||||
struct Migrate: AsyncMigration {
|
struct Migrate: AsyncMigration {
|
||||||
@@ -91,7 +128,9 @@ extension EquipmentInfo {
|
|||||||
.field("coolingCFM", .int16, .required)
|
.field("coolingCFM", .int16, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
.unique(on: "projectID")
|
.unique(on: "projectID")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -159,4 +198,16 @@ final class EquipmentModel: Model, @unchecked Sendable {
|
|||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: EquipmentInfo.Update) {
|
||||||
|
if let staticPressure = updates.staticPressure {
|
||||||
|
self.staticPressure = staticPressure
|
||||||
|
}
|
||||||
|
if let heatingCFM = updates.heatingCFM {
|
||||||
|
self.heatingCFM = heatingCFM
|
||||||
|
}
|
||||||
|
if let coolingCFM = updates.coolingCFM {
|
||||||
|
self.coolingCFM = coolingCFM
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// TODO: Move to ManualDCore
|
||||||
public struct ValidationError: Error {
|
public struct ValidationError: Error {
|
||||||
public let message: String
|
public let message: String
|
||||||
|
|
||||||
@@ -8,4 +9,6 @@ public struct ValidationError: Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NotFoundError: Error {}
|
public struct NotFoundError: Error {
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public struct DatabaseClient: Sendable {
|
|||||||
public var componentLoss: ComponentLoss
|
public var componentLoss: ComponentLoss
|
||||||
public var effectiveLength: EffectiveLengthClient
|
public var effectiveLength: EffectiveLengthClient
|
||||||
public var users: Users
|
public var users: Users
|
||||||
|
public var userProfile: UserProfile
|
||||||
|
public var trunkSizes: TrunkSizes
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseClient: TestDependencyKey {
|
extension DatabaseClient: TestDependencyKey {
|
||||||
@@ -29,7 +31,9 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
equipment: .testValue,
|
equipment: .testValue,
|
||||||
componentLoss: .testValue,
|
componentLoss: .testValue,
|
||||||
effectiveLength: .testValue,
|
effectiveLength: .testValue,
|
||||||
users: .testValue
|
users: .testValue,
|
||||||
|
userProfile: .testValue,
|
||||||
|
trunkSizes: .testValue
|
||||||
)
|
)
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
@@ -40,7 +44,9 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
equipment: .live(database: database),
|
equipment: .live(database: database),
|
||||||
componentLoss: .live(database: database),
|
componentLoss: .live(database: database),
|
||||||
effectiveLength: .live(database: database),
|
effectiveLength: .live(database: database),
|
||||||
users: .live(database: database)
|
users: .live(database: database),
|
||||||
|
userProfile: .live(database: database),
|
||||||
|
trunkSizes: .live(database: database)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +55,10 @@ extension DatabaseClient {
|
|||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct Migrations: Sendable {
|
public struct Migrations: Sendable {
|
||||||
public var run: @Sendable () async throws -> [any AsyncMigration]
|
public var run: @Sendable () async throws -> [any AsyncMigration]
|
||||||
|
|
||||||
|
public func callAsFunction() async throws -> [any AsyncMigration] {
|
||||||
|
try await self.run()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +70,15 @@ extension DatabaseClient.Migrations: DependencyKey {
|
|||||||
public static let liveValue = Self(
|
public static let liveValue = Self(
|
||||||
run: {
|
run: {
|
||||||
[
|
[
|
||||||
|
Project.Migrate(),
|
||||||
User.Migrate(),
|
User.Migrate(),
|
||||||
User.Token.Migrate(),
|
User.Token.Migrate(),
|
||||||
Project.Migrate(),
|
User.Profile.Migrate(),
|
||||||
ComponentPressureLoss.Migrate(),
|
ComponentPressureLoss.Migrate(),
|
||||||
EquipmentInfo.Migrate(),
|
EquipmentInfo.Migrate(),
|
||||||
Room.Migrate(),
|
Room.Migrate(),
|
||||||
EffectiveLength.Migrate(),
|
EffectiveLength.Migrate(),
|
||||||
|
TrunkSize.Migrate(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ extension DatabaseClient {
|
|||||||
public struct Projects: Sendable {
|
public struct Projects: Sendable {
|
||||||
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
||||||
public var delete: @Sendable (Project.ID) async throws -> Void
|
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 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 fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
||||||
|
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +34,95 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
}
|
}
|
||||||
try await model.delete(on: database)
|
try await model.delete(on: database)
|
||||||
},
|
},
|
||||||
|
detail: { id in
|
||||||
|
guard
|
||||||
|
let model = try await ProjectModel.query(on: database)
|
||||||
|
.with(\.$componentLosses)
|
||||||
|
.with(\.$equipment)
|
||||||
|
.with(\.$equivalentLengths)
|
||||||
|
.with(\.$rooms)
|
||||||
|
.with(
|
||||||
|
\.$trunks,
|
||||||
|
{ trunk in
|
||||||
|
trunk.with(
|
||||||
|
\.$rooms,
|
||||||
|
{
|
||||||
|
$0.with(\.$room)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let roomsCount = try await RoomModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.count()
|
||||||
|
|
||||||
|
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
var equivalentLengthsCompleted = false
|
||||||
|
|
||||||
|
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
||||||
|
equivalentLengths.filter({ $0.type == "return" }).first != nil
|
||||||
|
{
|
||||||
|
equivalentLengthsCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let componentLosses = try await ComponentLossModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.count()
|
||||||
|
|
||||||
|
let equipmentInfo = try await EquipmentModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
equipmentInfo: equipmentInfo != nil,
|
||||||
|
rooms: roomsCount > 0,
|
||||||
|
equivalentLength: equivalentLengthsCompleted,
|
||||||
|
frictionRate: componentLosses > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
getSensibleHeatRatio: { id in
|
||||||
|
guard
|
||||||
|
let shr = try await ProjectModel.query(on: database)
|
||||||
|
.field(\.$id)
|
||||||
|
.field(\.$sensibleHeatRatio)
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
return shr.sensibleHeatRatio
|
||||||
|
},
|
||||||
fetch: { userID, request in
|
fetch: { userID, request in
|
||||||
try await ProjectModel.query(on: database)
|
try await ProjectModel.query(on: database)
|
||||||
.sort(\.$createdAt, .descending)
|
.sort(\.$createdAt, .descending)
|
||||||
@@ -40,6 +130,17 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
.filter(\.$user.$id == userID)
|
.filter(\.$user.$id == userID)
|
||||||
.paginate(request)
|
.paginate(request)
|
||||||
.map { try $0.toDTO() }
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try updates.validate()
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -75,6 +176,53 @@ extension Project.Create {
|
|||||||
guard !zipCode.isEmpty else {
|
guard !zipCode.isEmpty else {
|
||||||
throw ValidationError("Project zipCode should not be empty.")
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +238,7 @@ extension Project {
|
|||||||
.field("city", .string, .required)
|
.field("city", .string, .required)
|
||||||
.field("state", .string, .required)
|
.field("state", .string, .required)
|
||||||
.field("zipCode", .string, .required)
|
.field("zipCode", .string, .required)
|
||||||
|
.field("sensibleHeatRatio", .double)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
|
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
|
||||||
@@ -126,6 +275,9 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
@Field(key: "zipCode")
|
@Field(key: "zipCode")
|
||||||
var zipCode: String
|
var zipCode: String
|
||||||
|
|
||||||
|
@Field(key: "sensibleHeatRatio")
|
||||||
|
var sensibleHeatRatio: Double?
|
||||||
|
|
||||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
var createdAt: Date?
|
var createdAt: Date?
|
||||||
|
|
||||||
@@ -135,6 +287,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
|
||||||
|
|
||||||
@@ -147,6 +311,7 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
city: String,
|
city: String,
|
||||||
state: String,
|
state: String,
|
||||||
zipCode: String,
|
zipCode: String,
|
||||||
|
sensibleHeatRatio: Double? = nil,
|
||||||
userID: User.ID,
|
userID: User.ID,
|
||||||
createdAt: Date? = nil,
|
createdAt: Date? = nil,
|
||||||
updatedAt: Date? = nil
|
updatedAt: Date? = nil
|
||||||
@@ -155,9 +320,9 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.streetAddress = streetAddress
|
self.streetAddress = streetAddress
|
||||||
self.city = city
|
self.city = city
|
||||||
self.city = city
|
|
||||||
self.state = state
|
self.state = state
|
||||||
self.zipCode = zipCode
|
self.zipCode = zipCode
|
||||||
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
$user.id = userID
|
$user.id = userID
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
@@ -171,8 +336,32 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
city: city,
|
city: city,
|
||||||
state: state,
|
state: state,
|
||||||
zipCode: zipCode,
|
zipCode: zipCode,
|
||||||
|
sensibleHeatRatio: sensibleHeatRatio,
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: Project.Update) {
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
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 sensibleHeatRatio = updates.sensibleHeatRatio,
|
||||||
|
sensibleHeatRatio != self.sensibleHeatRatio
|
||||||
|
{
|
||||||
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
Sources/DatabaseClient/RectangularDuct.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// 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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -9,15 +9,18 @@ extension DatabaseClient {
|
|||||||
public struct Rooms: Sendable {
|
public struct Rooms: Sendable {
|
||||||
public var create: @Sendable (Room.Create) async throws -> Room
|
public var create: @Sendable (Room.Create) async throws -> Room
|
||||||
public var delete: @Sendable (Room.ID) async throws -> Void
|
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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseClient.Rooms: TestDependencyKey {
|
extension DatabaseClient.Rooms: TestDependencyKey {
|
||||||
public static let testValue = Self()
|
public static let testValue = Self()
|
||||||
}
|
|
||||||
|
|
||||||
extension DatabaseClient.Rooms {
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
.init(
|
.init(
|
||||||
create: { request in
|
create: { request in
|
||||||
@@ -31,8 +34,53 @@ extension DatabaseClient.Rooms {
|
|||||||
}
|
}
|
||||||
try await model.delete(on: database)
|
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
|
get: { id in
|
||||||
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
|
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()
|
||||||
|
},
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,8 +110,10 @@ extension Room.Create {
|
|||||||
guard coolingTotal >= 0 else {
|
guard coolingTotal >= 0 else {
|
||||||
throw ValidationError("Room cooling total should not be less than 0.")
|
throw ValidationError("Room cooling total should not be less than 0.")
|
||||||
}
|
}
|
||||||
guard coolingSensible >= 0 else {
|
if let coolingSensible {
|
||||||
throw ValidationError("Room cooling sensible should not be less than 0.")
|
guard coolingSensible >= 0 else {
|
||||||
|
throw ValidationError("Room cooling sensible should not be less than 0.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard registerCount >= 1 else {
|
guard registerCount >= 1 else {
|
||||||
throw ValidationError("Room cooling sensible should not be less than 1.")
|
throw ValidationError("Room cooling sensible should not be less than 1.")
|
||||||
@@ -71,6 +121,37 @@ extension Room.Create {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Room.Update {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let name {
|
||||||
|
guard !name.isEmpty else {
|
||||||
|
throw ValidationError("Room name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let heatingLoad {
|
||||||
|
guard heatingLoad >= 0 else {
|
||||||
|
throw ValidationError("Room heating load should not be less than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let coolingTotal {
|
||||||
|
guard coolingTotal >= 0 else {
|
||||||
|
throw ValidationError("Room cooling total should not be less than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let coolingSensible {
|
||||||
|
guard coolingSensible >= 0 else {
|
||||||
|
throw ValidationError("Room cooling sensible should not be less than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let registerCount {
|
||||||
|
guard registerCount >= 1 else {
|
||||||
|
throw ValidationError("Room cooling sensible should not be less than 1.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Room {
|
extension Room {
|
||||||
struct Migrate: AsyncMigration {
|
struct Migrate: AsyncMigration {
|
||||||
let name = "CreateRoom"
|
let name = "CreateRoom"
|
||||||
@@ -81,9 +162,14 @@ extension Room {
|
|||||||
.field("name", .string, .required)
|
.field("name", .string, .required)
|
||||||
.field("heatingLoad", .double, .required)
|
.field("heatingLoad", .double, .required)
|
||||||
.field("coolingTotal", .double, .required)
|
.field("coolingTotal", .double, .required)
|
||||||
.field("coolingSensible", .double, .required)
|
.field("coolingSensible", .double)
|
||||||
.field("registerCount", .int8, .required)
|
.field("registerCount", .int8, .required)
|
||||||
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
|
.field("rectangularSizes", .array)
|
||||||
|
.field("createdAt", .datetime)
|
||||||
|
.field("updatedAt", .datetime)
|
||||||
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
.unique(on: "projectID", "name")
|
.unique(on: "projectID", "name")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -111,11 +197,14 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
var coolingTotal: Double
|
var coolingTotal: Double
|
||||||
|
|
||||||
@Field(key: "coolingSensible")
|
@Field(key: "coolingSensible")
|
||||||
var coolingSensible: Double
|
var coolingSensible: Double?
|
||||||
|
|
||||||
@Field(key: "registerCount")
|
@Field(key: "registerCount")
|
||||||
var registerCount: Int
|
var registerCount: Int
|
||||||
|
|
||||||
|
@Field(key: "rectangularSizes")
|
||||||
|
var rectangularSizes: [Room.RectangularSize]?
|
||||||
|
|
||||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
var createdAt: Date?
|
var createdAt: Date?
|
||||||
|
|
||||||
@@ -132,8 +221,9 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
name: String,
|
name: String,
|
||||||
heatingLoad: Double,
|
heatingLoad: Double,
|
||||||
coolingTotal: Double,
|
coolingTotal: Double,
|
||||||
coolingSensible: Double,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int,
|
registerCount: Int,
|
||||||
|
rectangularSizes: [Room.RectangularSize]? = nil,
|
||||||
createdAt: Date? = nil,
|
createdAt: Date? = nil,
|
||||||
updatedAt: Date? = nil,
|
updatedAt: Date? = nil,
|
||||||
projectID: Project.ID
|
projectID: Project.ID
|
||||||
@@ -144,6 +234,7 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
$project.id = projectID
|
$project.id = projectID
|
||||||
@@ -155,11 +246,36 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
projectID: $project.id,
|
projectID: $project.id,
|
||||||
name: name,
|
name: name,
|
||||||
heatingLoad: heatingLoad,
|
heatingLoad: heatingLoad,
|
||||||
coolingLoad: .init(total: coolingTotal, sensible: coolingSensible),
|
coolingTotal: coolingTotal,
|
||||||
|
coolingSensible: coolingSensible,
|
||||||
registerCount: registerCount,
|
registerCount: registerCount,
|
||||||
|
rectangularSizes: rectangularSizes,
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
369
Sources/DatabaseClient/TrunkSizes.swift
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
@DependencyClient
|
||||||
|
public struct TrunkSizes: Sendable {
|
||||||
|
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
|
||||||
|
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
|
||||||
|
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
|
||||||
|
public var update:
|
||||||
|
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
|
||||||
|
TrunkSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.save(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.save(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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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 validate() throws(ValidationError) {
|
||||||
|
guard rooms.count > 0 else {
|
||||||
|
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||||
|
}
|
||||||
|
if let height {
|
||||||
|
guard height > 0 else {
|
||||||
|
throw ValidationError("Trunk size height should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModel() -> TrunkModel {
|
||||||
|
.init(
|
||||||
|
projectID: projectID,
|
||||||
|
type: type,
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize.Update {
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let rooms {
|
||||||
|
guard rooms.count > 0 else {
|
||||||
|
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let height {
|
||||||
|
guard height > 0 else {
|
||||||
|
throw ValidationError("Trunk size height should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
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 {
|
||||||
|
// guard let room = try await RoomModel.find($room.id, on: database) else {
|
||||||
|
// throw NotFoundError()
|
||||||
|
// }
|
||||||
|
return .init(
|
||||||
|
room: try room.toDTO(),
|
||||||
|
registers: registers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
|
||||||
|
// for room in self.rooms {
|
||||||
|
// group.addTask {
|
||||||
|
// try await room.toDTO(on: database)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||||
|
// $0.append($1)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|
||||||
|
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||||
|
$0.append(try $1.toDTO())
|
||||||
|
}
|
||||||
|
|
||||||
|
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.save(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.save(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 Array where Element == TrunkModel {
|
||||||
|
|
||||||
|
func toDTO() throws -> [TrunkSize] {
|
||||||
|
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
|
||||||
|
// for model in self {
|
||||||
|
// group.addTask {
|
||||||
|
// try await model.toDTO(on: database)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return try reduce(into: [TrunkSize]()) {
|
||||||
|
$0.append(try $1.toDTO())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
283
Sources/DatabaseClient/UserProfile.swift
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import ManualDCore
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
@DependencyClient
|
||||||
|
public struct UserProfile: Sendable {
|
||||||
|
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
|
||||||
|
public var delete: @Sendable (User.Profile.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
|
||||||
|
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
|
||||||
|
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DatabaseClient.UserProfile: TestDependencyKey {
|
||||||
|
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { profile in
|
||||||
|
try profile.validate()
|
||||||
|
let model = profile.toModel()
|
||||||
|
try await model.save(on: database)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
try updates.validate()
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile.Create {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
guard !firstName.isEmpty else {
|
||||||
|
throw ValidationError("User first name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !lastName.isEmpty else {
|
||||||
|
throw ValidationError("User last name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !companyName.isEmpty else {
|
||||||
|
throw ValidationError("User company name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !streetAddress.isEmpty else {
|
||||||
|
throw ValidationError("User street address should not be empty.")
|
||||||
|
}
|
||||||
|
guard !city.isEmpty else {
|
||||||
|
throw ValidationError("User city should not be empty.")
|
||||||
|
}
|
||||||
|
guard !state.isEmpty else {
|
||||||
|
throw ValidationError("User state should not be empty.")
|
||||||
|
}
|
||||||
|
guard !zipCode.isEmpty else {
|
||||||
|
throw ValidationError("User zip code should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModel() -> UserProfileModel {
|
||||||
|
.init(
|
||||||
|
userID: userID,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
companyName: companyName,
|
||||||
|
streetAddress: streetAddress,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
zipCode: zipCode,
|
||||||
|
theme: theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile.Update {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let firstName {
|
||||||
|
guard !firstName.isEmpty else {
|
||||||
|
throw ValidationError("User first name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let lastName {
|
||||||
|
guard !lastName.isEmpty else {
|
||||||
|
throw ValidationError("User last name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let companyName {
|
||||||
|
guard !companyName.isEmpty else {
|
||||||
|
throw ValidationError("User company name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let streetAddress {
|
||||||
|
guard !streetAddress.isEmpty else {
|
||||||
|
throw ValidationError("User street address should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let city {
|
||||||
|
guard !city.isEmpty else {
|
||||||
|
throw ValidationError("User city should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let state {
|
||||||
|
guard !state.isEmpty else {
|
||||||
|
throw ValidationError("User state should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let zipCode {
|
||||||
|
guard !zipCode.isEmpty else {
|
||||||
|
throw ValidationError("User zip code should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
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", .datetime)
|
||||||
|
.field("updatedAt", .datetime)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -80,12 +80,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", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.unique(on: "email", "username")
|
.unique(on: "email")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +103,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", .datetime)
|
||||||
|
.field("updatedAt", .datetime)
|
||||||
.unique(on: "value")
|
.unique(on: "value")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -126,13 +127,10 @@ extension User.Create {
|
|||||||
|
|
||||||
func toModel() throws -> UserModel {
|
func toModel() throws -> UserModel {
|
||||||
try validate()
|
try validate()
|
||||||
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
|
return try .init(email: email, passwordHash: User.hashPassword(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
guard !username.isEmpty else {
|
|
||||||
throw ValidationError("Username should not be empty.")
|
|
||||||
}
|
|
||||||
guard !email.isEmpty else {
|
guard !email.isEmpty else {
|
||||||
throw ValidationError("Email should not be empty")
|
throw ValidationError("Email should not be empty")
|
||||||
}
|
}
|
||||||
@@ -152,9 +150,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 +169,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 +181,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 +229,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 +240,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 +276,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)
|
||||||
|
|||||||
74
Sources/EnvClient/Interface.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension DependencyValues {
|
||||||
|
|
||||||
|
/// Holds values defined in the process environment that are needed.
|
||||||
|
///
|
||||||
|
/// These are generally loaded from a `.env` file, but also have default values,
|
||||||
|
/// if not found.
|
||||||
|
public var env: @Sendable () throws -> EnvVars {
|
||||||
|
get { self[EnvClient.self].env }
|
||||||
|
set { self[EnvClient.self].env = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DependencyClient
|
||||||
|
struct EnvClient: Sendable {
|
||||||
|
public var env: @Sendable () throws -> EnvVars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds values defined in the process environment that are needed.
|
||||||
|
///
|
||||||
|
/// These are generally loaded from a `.env` file, but also have default values,
|
||||||
|
/// if not found.
|
||||||
|
public struct EnvVars: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// The path to the pandoc executable on the system, used to generate pdf's.
|
||||||
|
public let pandocPath: String
|
||||||
|
|
||||||
|
/// The pdf engine to use with pandoc when creating pdf's.
|
||||||
|
public let pdfEngine: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
pandocPath: String = "/usr/bin/pandoc",
|
||||||
|
pdfEngine: String = "weasyprint"
|
||||||
|
) {
|
||||||
|
self.pandocPath = pandocPath
|
||||||
|
self.pdfEngine = pdfEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case pandocPath = "PANDOC_PATH"
|
||||||
|
case pdfEngine = "PDF_ENGINE"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvClient: DependencyKey {
|
||||||
|
static let testValue = Self()
|
||||||
|
|
||||||
|
static let liveValue = Self(env: {
|
||||||
|
// Convert default values into a dictionary.
|
||||||
|
let defaults =
|
||||||
|
(try? encoder.encode(EnvVars()))
|
||||||
|
.flatMap { try? decoder.decode([String: String].self, from: $0) }
|
||||||
|
?? [:]
|
||||||
|
|
||||||
|
// Merge the default values with values found in process environment.
|
||||||
|
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
|
||||||
|
|
||||||
|
return (try? JSONSerialization.data(withJSONObject: assigned))
|
||||||
|
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
|
||||||
|
?? .init()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private let encoder: JSONEncoder = {
|
||||||
|
JSONEncoder()
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let decoder: JSONDecoder = {
|
||||||
|
JSONDecoder()
|
||||||
|
}()
|
||||||
40
Sources/FileClient/Interface.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension DependencyValues {
|
||||||
|
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
|
||||||
|
|
||||||
|
public var writeFile: @Sendable (String, String) async throws -> Void
|
||||||
|
public var removeFile: @Sendable (String) async throws -> Void
|
||||||
|
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +1,51 @@
|
|||||||
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 TrunkSize.RoomProxy {
|
||||||
|
|
||||||
|
// We need to make sure if registers got removed after a trunk
|
||||||
|
// was already made / saved that we do not include registers that
|
||||||
|
// no longer exist.
|
||||||
|
private var actualRegisterCount: Int {
|
||||||
|
guard registers.count <= room.registerCount else {
|
||||||
|
return room.registerCount
|
||||||
|
}
|
||||||
|
return registers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
room.heatingLoadPerRegister * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize {
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension ComponentPressureLosses {
|
extension ComponentPressureLosses {
|
||||||
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
|
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
|
||||||
}
|
}
|
||||||
@@ -19,6 +64,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
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
|
import Logging
|
||||||
import ManualDCore
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ManualDClient: TestDependencyKey {
|
|
||||||
public static let testValue = Self()
|
|
||||||
}
|
|
||||||
|
|
||||||
extension DependencyValues {
|
extension DependencyValues {
|
||||||
public var manualD: ManualDClient {
|
public var manualD: ManualDClient {
|
||||||
get { self[ManualDClient.self] }
|
get { self[ManualDClient.self] }
|
||||||
@@ -22,8 +10,25 @@ extension DependencyValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Duct Size
|
/// Performs manual-d duct sizing calculations.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
@DependencyClient
|
||||||
|
public struct ManualDClient: Sendable {
|
||||||
|
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
|
||||||
|
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
|
||||||
|
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
|
||||||
|
public var rectangularSize:
|
||||||
|
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ManualDClient: TestDependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
}
|
||||||
|
|
||||||
extension ManualDClient {
|
extension ManualDClient {
|
||||||
|
|
||||||
public struct DuctSizeRequest: Codable, Equatable, Sendable {
|
public struct DuctSizeRequest: Codable, Equatable, Sendable {
|
||||||
public let designCFM: Int
|
public let designCFM: Int
|
||||||
public let frictionRate: Double
|
public let frictionRate: Double
|
||||||
@@ -39,36 +44,33 @@ extension ManualDClient {
|
|||||||
|
|
||||||
public struct DuctSizeResponse: Codable, Equatable, Sendable {
|
public struct DuctSizeResponse: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let ductulatorSize: Double
|
public let calculatedSize: Double
|
||||||
public let finalSize: Int
|
public let finalSize: Int
|
||||||
public let flexSize: Int
|
public let flexSize: Int
|
||||||
public let velocity: Int
|
public let velocity: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ductulatorSize: Double,
|
calculatedSize: Double,
|
||||||
finalSize: Int,
|
finalSize: Int,
|
||||||
flexSize: Int,
|
flexSize: Int,
|
||||||
velocity: Int
|
velocity: Int
|
||||||
) {
|
) {
|
||||||
self.ductulatorSize = ductulatorSize
|
self.calculatedSize = calculatedSize
|
||||||
self.finalSize = finalSize
|
self.finalSize = finalSize
|
||||||
self.flexSize = flexSize
|
self.flexSize = flexSize
|
||||||
self.velocity = velocity
|
self.velocity = velocity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Friction Rate
|
|
||||||
extension ManualDClient {
|
|
||||||
public struct FrictionRateRequest: Codable, Equatable, Sendable {
|
public struct FrictionRateRequest: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let externalStaticPressure: Double
|
public let externalStaticPressure: Double
|
||||||
public let componentPressureLosses: ComponentPressureLosses
|
public let componentPressureLosses: [ComponentPressureLoss]
|
||||||
public let totalEffectiveLength: Int
|
public let totalEffectiveLength: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
externalStaticPressure: Double,
|
externalStaticPressure: Double,
|
||||||
componentPressureLosses: ComponentPressureLosses,
|
componentPressureLosses: [ComponentPressureLoss],
|
||||||
totalEffectiveLength: Int
|
totalEffectiveLength: Int
|
||||||
) {
|
) {
|
||||||
self.externalStaticPressure = externalStaticPressure
|
self.externalStaticPressure = externalStaticPressure
|
||||||
@@ -87,11 +89,8 @@ extension ManualDClient {
|
|||||||
self.frictionRate = frictionRate
|
self.frictionRate = frictionRate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Total Effective Length
|
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
|
||||||
extension ManualDClient {
|
|
||||||
public struct TotalEffectiveLengthRequest: Codable, Equatable, Sendable {
|
|
||||||
|
|
||||||
public let trunkLengths: [Int]
|
public let trunkLengths: [Int]
|
||||||
public let runoutLengths: [Int]
|
public let runoutLengths: [Int]
|
||||||
@@ -107,11 +106,8 @@ extension ManualDClient {
|
|||||||
self.effectiveLengthGroups = effectiveLengthGroups
|
self.effectiveLengthGroups = effectiveLengthGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Equivalent Rectangular Duct
|
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
|
||||||
extension ManualDClient {
|
|
||||||
public struct EquivalentRectangularDuctRequest: Codable, Equatable, Sendable {
|
|
||||||
public let roundSize: Int
|
public let roundSize: Int
|
||||||
public let height: Int
|
public let height: Int
|
||||||
|
|
||||||
@@ -121,7 +117,7 @@ extension ManualDClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct EquivalentRectangularDuctResponse: Codable, Equatable, Sendable {
|
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
|
||||||
public let height: Int
|
public let height: Int
|
||||||
public let width: Int
|
public let width: Int
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ extension ManualDClient: DependencyKey {
|
|||||||
let finalSize = try roundSize(ductulatorSize)
|
let finalSize = try roundSize(ductulatorSize)
|
||||||
let flexSize = try flexSize(request)
|
let flexSize = try flexSize(request)
|
||||||
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: request.designCFM, roundSize: finalSize)
|
||||||
@@ -25,36 +25,20 @@ extension ManualDClient: DependencyKey {
|
|||||||
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.totalLosses
|
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.totalEffectiveLength)
|
||||||
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
|
return .init(availableStaticPressure: availableStaticPressure, value: frictionRate)
|
||||||
},
|
},
|
||||||
totalEffectiveLength: { request in
|
totalEquivalentLength: { request in
|
||||||
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
|
||||||
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
|
||||||
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
|
||||||
return trunkLengths + runoutLengths + groupLengths
|
return trunkLengths + runoutLengths + groupLengths
|
||||||
},
|
},
|
||||||
equivalentRectangularDuct: { request in
|
rectangularSize: { request in
|
||||||
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
||||||
// Round the width up or fail (really should never fail since we know the input is a number).
|
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
|
||||||
guard let widthStr = numberFormatter.string(for: width),
|
|
||||||
let widthInt = Int(widthStr)
|
|
||||||
else {
|
|
||||||
throw ManualDError(
|
|
||||||
message: "Failed to convert to to rectangular duct size, width: \(width)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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,3 +1,4 @@
|
|||||||
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
|
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
|
||||||
@@ -42,6 +43,35 @@ extension ComponentPressureLoss {
|
|||||||
self.name = name
|
self.name = name
|
||||||
self.value = value
|
self.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return's commonly used default component pressure losses.
|
||||||
|
public static func `default`(projectID: Project.ID) -> [Self] {
|
||||||
|
[
|
||||||
|
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
|
||||||
|
.init(projectID: projectID, name: "return-grille", value: 0.03),
|
||||||
|
.init(projectID: projectID, name: "balancing-damper", value: 0.03),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let name: String?
|
||||||
|
public let value: Double?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String? = nil,
|
||||||
|
value: Double? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == ComponentPressureLoss {
|
||||||
|
public var total: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +90,61 @@ public typealias ComponentPressureLosses = [String: Double]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array where Element == ComponentPressureLoss {
|
||||||
|
public static func mock(projectID: Project.ID) -> Self {
|
||||||
|
ComponentPressureLoss.mock(projectID: projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
256
Sources/ManualDCore/DuctSizes.swift
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DuctSizes: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let rooms: [RoomContainer]
|
||||||
|
public let trunks: [TrunkContainer]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rooms: [DuctSizes.RoomContainer],
|
||||||
|
trunks: [DuctSizes.TrunkContainer]
|
||||||
|
) {
|
||||||
|
self.rooms = rooms
|
||||||
|
self.trunks = trunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizes {
|
||||||
|
|
||||||
|
public struct SizeContainer: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let rectangularID: Room.RectangularSize.ID?
|
||||||
|
public let designCFM: DesignCFM
|
||||||
|
public let roundSize: Double
|
||||||
|
public let finalSize: Int
|
||||||
|
public let velocity: Int
|
||||||
|
public let flexSize: Int
|
||||||
|
public let height: Int?
|
||||||
|
public let width: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rectangularID: Room.RectangularSize.ID? = nil,
|
||||||
|
designCFM: DuctSizes.DesignCFM,
|
||||||
|
roundSize: Double,
|
||||||
|
finalSize: Int,
|
||||||
|
velocity: Int,
|
||||||
|
flexSize: Int,
|
||||||
|
height: Int? = nil,
|
||||||
|
width: Int? = nil
|
||||||
|
) {
|
||||||
|
self.rectangularID = rectangularID
|
||||||
|
self.designCFM = designCFM
|
||||||
|
self.roundSize = roundSize
|
||||||
|
self.finalSize = finalSize
|
||||||
|
self.velocity = velocity
|
||||||
|
self.flexSize = flexSize
|
||||||
|
self.height = height
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct RoomContainer: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let roomID: Room.ID
|
||||||
|
public let roomName: String
|
||||||
|
public let roomRegister: Int
|
||||||
|
public let heatingLoad: Double
|
||||||
|
public let coolingLoad: Double
|
||||||
|
public let heatingCFM: Double
|
||||||
|
public let coolingCFM: Double
|
||||||
|
public let ductSize: SizeContainer
|
||||||
|
|
||||||
|
public init(
|
||||||
|
roomID: Room.ID,
|
||||||
|
roomName: String,
|
||||||
|
roomRegister: Int,
|
||||||
|
heatingLoad: Double,
|
||||||
|
coolingLoad: Double,
|
||||||
|
heatingCFM: Double,
|
||||||
|
coolingCFM: Double,
|
||||||
|
ductSize: SizeContainer
|
||||||
|
) {
|
||||||
|
self.roomID = roomID
|
||||||
|
self.roomName = roomName
|
||||||
|
self.roomRegister = roomRegister
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
self.coolingLoad = coolingLoad
|
||||||
|
self.heatingCFM = heatingCFM
|
||||||
|
self.coolingCFM = coolingCFM
|
||||||
|
self.ductSize = ductSize
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
|
||||||
|
ductSize[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DesignCFM: Codable, Equatable, Sendable {
|
||||||
|
case heating(Double)
|
||||||
|
case cooling(Double)
|
||||||
|
|
||||||
|
public init(heating: Double, cooling: Double) {
|
||||||
|
if heating >= cooling {
|
||||||
|
self = .heating(heating)
|
||||||
|
} else {
|
||||||
|
self = .cooling(cooling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var value: Double {
|
||||||
|
switch self {
|
||||||
|
case .heating(let value): return value
|
||||||
|
case .cooling(let value): return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Represents the database model that the duct sizes have been calculated
|
||||||
|
// for.
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
public var id: TrunkSize.ID { trunk.id }
|
||||||
|
|
||||||
|
public let trunk: TrunkSize
|
||||||
|
public let ductSize: SizeContainer
|
||||||
|
|
||||||
|
public init(
|
||||||
|
trunk: TrunkSize,
|
||||||
|
ductSize: SizeContainer
|
||||||
|
) {
|
||||||
|
self.trunk = trunk
|
||||||
|
self.ductSize = ductSize
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<TrunkSize, T>) -> T {
|
||||||
|
trunk[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
|
||||||
|
ductSize[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func registerIDS(rooms: [RoomContainer]) -> [String] {
|
||||||
|
trunk.rooms.reduce(into: []) { array, room in
|
||||||
|
array = room.registers.reduce(into: array) { array, register in
|
||||||
|
if let room =
|
||||||
|
rooms
|
||||||
|
.first(where: { $0.roomID == room.id && $0.roomRegister == register })
|
||||||
|
{
|
||||||
|
array.append(room.roomName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension DuctSizes {
|
||||||
|
public static func mock(
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [TrunkSize]
|
||||||
|
) -> Self {
|
||||||
|
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingLoad = rooms.totalCoolingLoad
|
||||||
|
|
||||||
|
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
|
||||||
|
array += RoomContainer.mock(
|
||||||
|
room: room,
|
||||||
|
totalHeatingLoad: totalHeatingLoad,
|
||||||
|
totalCoolingLoad: totalCoolingLoad,
|
||||||
|
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
|
||||||
|
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
rooms: roomContainers,
|
||||||
|
trunks: TrunkContainer.mock(
|
||||||
|
trunks: trunks,
|
||||||
|
totalHeatingLoad: totalHeatingLoad,
|
||||||
|
totalCoolingLoad: totalCoolingLoad,
|
||||||
|
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
|
||||||
|
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizes.RoomContainer {
|
||||||
|
public static func mock(
|
||||||
|
room: Room,
|
||||||
|
totalHeatingLoad: Double,
|
||||||
|
totalCoolingLoad: Double,
|
||||||
|
totalHeatingCFM: Double,
|
||||||
|
totalCoolingCFM: Double
|
||||||
|
) -> [Self] {
|
||||||
|
var retval = [DuctSizes.RoomContainer]()
|
||||||
|
let heatingLoad = room.heatingLoad / Double(room.registerCount)
|
||||||
|
let heatingFraction = heatingLoad / totalHeatingLoad
|
||||||
|
let heatingCFM = totalHeatingCFM * heatingFraction
|
||||||
|
// Not really accurate, but works for mocks.
|
||||||
|
let coolingLoad = room.coolingTotal / Double(room.registerCount)
|
||||||
|
let coolingFraction = coolingLoad / totalCoolingLoad
|
||||||
|
let coolingCFM = totalCoolingCFM * coolingFraction
|
||||||
|
|
||||||
|
for n in 1...room.registerCount {
|
||||||
|
|
||||||
|
retval.append(
|
||||||
|
.init(
|
||||||
|
roomID: room.id,
|
||||||
|
roomName: room.name,
|
||||||
|
roomRegister: n,
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingLoad: coolingLoad,
|
||||||
|
heatingCFM: heatingCFM,
|
||||||
|
coolingCFM: coolingCFM,
|
||||||
|
ductSize: .init(
|
||||||
|
rectangularID: nil,
|
||||||
|
designCFM: .init(heating: heatingCFM, cooling: coolingCFM),
|
||||||
|
roundSize: 7,
|
||||||
|
finalSize: 8,
|
||||||
|
velocity: 489,
|
||||||
|
flexSize: 8,
|
||||||
|
height: nil,
|
||||||
|
width: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizes.TrunkContainer {
|
||||||
|
|
||||||
|
public static func mock(
|
||||||
|
trunks: [TrunkSize],
|
||||||
|
totalHeatingLoad: Double,
|
||||||
|
totalCoolingLoad: Double,
|
||||||
|
totalHeatingCFM: Double,
|
||||||
|
totalCoolingCFM: Double
|
||||||
|
) -> [Self] {
|
||||||
|
trunks.reduce(into: []) { array, trunk in
|
||||||
|
array.append(
|
||||||
|
.init(
|
||||||
|
trunk: trunk,
|
||||||
|
ductSize: .init(
|
||||||
|
designCFM: .init(heating: totalHeatingCFM, cooling: totalCoolingCFM),
|
||||||
|
roundSize: 18,
|
||||||
|
finalSize: 20,
|
||||||
|
velocity: 987,
|
||||||
|
flexSize: 20
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -61,6 +61,26 @@ extension EffectiveLength {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
|
||||||
case `return`
|
case `return`
|
||||||
case supply
|
case supply
|
||||||
@@ -85,11 +105,81 @@ extension EffectiveLength {
|
|||||||
self.quantity = quantity
|
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
|
#if DEBUG
|
||||||
|
|
||||||
extension EffectiveLength {
|
extension EffectiveLength {
|
||||||
|
|
||||||
|
public static func mock(projectID: Project.ID) -> [Self] {
|
||||||
|
@Dependency(\.uuid) var uuid
|
||||||
|
@Dependency(\.date.now) var now
|
||||||
|
|
||||||
|
return [
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Supply - 1",
|
||||||
|
type: .supply,
|
||||||
|
straightLengths: [10, 25],
|
||||||
|
groups: [
|
||||||
|
.init(group: 1, letter: "a", value: 20),
|
||||||
|
.init(group: 2, letter: "b", value: 30, quantity: 1),
|
||||||
|
.init(group: 3, letter: "a", value: 10, quantity: 1),
|
||||||
|
.init(group: 12, letter: "a", value: 10, quantity: 1),
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Return - 1",
|
||||||
|
type: .return,
|
||||||
|
straightLengths: [10, 20, 5],
|
||||||
|
groups: [
|
||||||
|
.init(group: 5, letter: "a", value: 10),
|
||||||
|
.init(group: 6, letter: "a", value: 15, quantity: 1),
|
||||||
|
.init(group: 7, letter: "a", value: 20, quantity: 1),
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
public static let mocks: [Self] = [
|
public static let mocks: [Self] = [
|
||||||
.init(
|
.init(
|
||||||
id: UUID(0),
|
id: UUID(0),
|
||||||
|
|||||||
@@ -50,10 +50,41 @@ extension EquipmentInfo {
|
|||||||
self.coolingCFM = coolingCFM
|
self.coolingCFM = coolingCFM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
public let staticPressure: Double?
|
||||||
|
public let heatingCFM: Int?
|
||||||
|
public let coolingCFM: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
staticPressure: Double? = nil,
|
||||||
|
heatingCFM: Int? = nil,
|
||||||
|
coolingCFM: Int? = nil
|
||||||
|
) {
|
||||||
|
self.staticPressure = staticPressure
|
||||||
|
self.heatingCFM = heatingCFM
|
||||||
|
self.coolingCFM = coolingCFM
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#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),
|
||||||
|
|||||||
57
Sources/ManualDCore/FrictionRate.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/// Holds onto values returned when calculating the design
|
||||||
|
/// friction rate for a project.
|
||||||
|
public struct FrictionRate: Codable, Equatable, Sendable {
|
||||||
|
public let availableStaticPressure: Double
|
||||||
|
public let value: Double
|
||||||
|
public var hasErrors: Bool { error != nil }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
availableStaticPressure: Double,
|
||||||
|
value: Double
|
||||||
|
) {
|
||||||
|
self.availableStaticPressure = availableStaticPressure
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FrictionRateError: Error, Equatable, Sendable {
|
||||||
|
public let reason: String
|
||||||
|
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
|
||||||
24
Sources/ManualDCore/Numbers+string.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
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/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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public let city: String
|
public let city: String
|
||||||
public let state: String
|
public let state: String
|
||||||
public let zipCode: String
|
public let zipCode: String
|
||||||
|
public let sensibleHeatRatio: Double?
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
|
|||||||
city: String,
|
city: String,
|
||||||
state: String,
|
state: String,
|
||||||
zipCode: String,
|
zipCode: String,
|
||||||
|
sensibleHeatRatio: Double? = nil,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
@@ -28,6 +30,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.city = city
|
self.city = city
|
||||||
self.state = state
|
self.state = state
|
||||||
self.zipCode = zipCode
|
self.zipCode = zipCode
|
||||||
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,7 @@ extension Project {
|
|||||||
public let city: String
|
public let city: String
|
||||||
public let state: String
|
public let state: String
|
||||||
public let zipCode: String
|
public let zipCode: String
|
||||||
|
public let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -49,12 +53,86 @@ extension Project {
|
|||||||
city: String,
|
city: String,
|
||||||
state: String,
|
state: String,
|
||||||
zipCode: String,
|
zipCode: String,
|
||||||
|
sensibleHeatRatio: Double? = nil,
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.streetAddress = streetAddress
|
self.streetAddress = streetAddress
|
||||||
self.city = city
|
self.city = city
|
||||||
self.state = state
|
self.state = state
|
||||||
self.zipCode = zipCode
|
self.zipCode = zipCode
|
||||||
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CompletedSteps: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let equipmentInfo: Bool
|
||||||
|
public let rooms: Bool
|
||||||
|
public let equivalentLength: Bool
|
||||||
|
public let frictionRate: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
equipmentInfo: Bool,
|
||||||
|
rooms: Bool,
|
||||||
|
equivalentLength: Bool,
|
||||||
|
frictionRate: Bool
|
||||||
|
) {
|
||||||
|
self.equipmentInfo = equipmentInfo
|
||||||
|
self.rooms = rooms
|
||||||
|
self.equivalentLength = equivalentLength
|
||||||
|
self.frictionRate = frictionRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Detail: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let project: Project
|
||||||
|
public let componentLosses: [ComponentPressureLoss]
|
||||||
|
public let equipmentInfo: EquipmentInfo
|
||||||
|
public let equivalentLengths: [EffectiveLength]
|
||||||
|
public let rooms: [Room]
|
||||||
|
public let trunks: [TrunkSize]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
project: Project,
|
||||||
|
componentLosses: [ComponentPressureLoss],
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
equivalentLengths: [EffectiveLength],
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [TrunkSize]
|
||||||
|
) {
|
||||||
|
self.project = project
|
||||||
|
self.componentLosses = componentLosses
|
||||||
|
self.equipmentInfo = equipmentInfo
|
||||||
|
self.equivalentLengths = equivalentLengths
|
||||||
|
self.rooms = rooms
|
||||||
|
self.trunks = trunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let name: String?
|
||||||
|
public let streetAddress: String?
|
||||||
|
public let city: String?
|
||||||
|
public let state: String?
|
||||||
|
public let zipCode: String?
|
||||||
|
public let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String? = nil,
|
||||||
|
streetAddress: String? = nil,
|
||||||
|
city: String? = nil,
|
||||||
|
state: String? = nil,
|
||||||
|
zipCode: String? = nil,
|
||||||
|
sensibleHeatRatio: Double? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,16 +140,22 @@ extension Project {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|
||||||
extension Project {
|
extension Project {
|
||||||
public static let mock = Self(
|
|
||||||
id: UUID(0),
|
public static var mock: Self {
|
||||||
name: "Testy McTestface",
|
@Dependency(\.uuid) var uuid
|
||||||
streetAddress: "1234 Sesame Street",
|
@Dependency(\.date.now) var now
|
||||||
city: "Monroe",
|
|
||||||
state: "OH",
|
return .init(
|
||||||
zipCode: "55555",
|
id: uuid(),
|
||||||
createdAt: Date(),
|
name: "Testy McTestface",
|
||||||
updatedAt: Date()
|
streetAddress: "1234 Sesame Street",
|
||||||
)
|
city: "Monroe",
|
||||||
|
state: "OH",
|
||||||
|
zipCode: "55555",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public let projectID: Project.ID
|
public let projectID: Project.ID
|
||||||
public let name: String
|
public let name: String
|
||||||
public let heatingLoad: Double
|
public let heatingLoad: Double
|
||||||
public let coolingLoad: CoolingLoad
|
public let coolingTotal: Double
|
||||||
|
public let coolingSensible: Double?
|
||||||
public let registerCount: Int
|
public let registerCount: Int
|
||||||
|
public let rectangularSizes: [RectangularSize]?
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
@@ -16,8 +18,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
name: String,
|
name: String,
|
||||||
heatingLoad: Double,
|
heatingLoad: Double,
|
||||||
coolingLoad: CoolingLoad,
|
coolingTotal: Double,
|
||||||
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int = 1,
|
registerCount: Int = 1,
|
||||||
|
rectangularSizes: [RectangularSize]? = nil,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
@@ -25,8 +29,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.projectID = projectID
|
self.projectID = projectID
|
||||||
self.name = name
|
self.name = name
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
self.coolingLoad = coolingLoad
|
self.coolingTotal = coolingTotal
|
||||||
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -34,13 +40,12 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
|
|
||||||
extension Room {
|
extension Room {
|
||||||
|
|
||||||
// TODO: Maybe remove project ID, and make dependencies that retrieves current project id??
|
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
public let projectID: Project.ID
|
public let projectID: Project.ID
|
||||||
public let name: String
|
public let name: String
|
||||||
public let heatingLoad: Double
|
public let heatingLoad: Double
|
||||||
public let coolingTotal: Double
|
public let coolingTotal: Double
|
||||||
public let coolingSensible: Double
|
public let coolingSensible: Double?
|
||||||
public let registerCount: Int
|
public let registerCount: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -48,7 +53,7 @@ extension Room {
|
|||||||
name: String,
|
name: String,
|
||||||
heatingLoad: Double,
|
heatingLoad: Double,
|
||||||
coolingTotal: Double,
|
coolingTotal: Double,
|
||||||
coolingSensible: Double,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int = 1
|
registerCount: Int = 1
|
||||||
) {
|
) {
|
||||||
self.projectID = projectID
|
self.projectID = projectID
|
||||||
@@ -59,6 +64,76 @@ extension Room {
|
|||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct RectangularSize: 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 Update: Codable, Equatable, Sendable {
|
||||||
|
public let name: String?
|
||||||
|
public let heatingLoad: Double?
|
||||||
|
public let coolingTotal: Double?
|
||||||
|
public let coolingSensible: Double?
|
||||||
|
public let registerCount: Int?
|
||||||
|
public let rectangularSizes: [RectangularSize]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String? = nil,
|
||||||
|
heatingLoad: Double? = nil,
|
||||||
|
coolingTotal: Double? = nil,
|
||||||
|
coolingSensible: Double? = nil,
|
||||||
|
registerCount: Int? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
self.coolingTotal = coolingTotal
|
||||||
|
self.coolingSensible = coolingSensible
|
||||||
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rectangularSizes: [RectangularSize]
|
||||||
|
) {
|
||||||
|
self.name = nil
|
||||||
|
self.heatingLoad = nil
|
||||||
|
self.coolingTotal = nil
|
||||||
|
self.coolingSensible = nil
|
||||||
|
self.registerCount = nil
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Room {
|
||||||
|
|
||||||
|
public var totalHeatingLoad: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.heatingLoad }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var totalCoolingLoad: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.coolingTotal }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func totalCoolingSensible(shr: Double) -> Double {
|
||||||
|
reduce(into: 0) {
|
||||||
|
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||||
|
$0 += sensible
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -70,7 +145,7 @@ extension Room {
|
|||||||
projectID: UUID(0),
|
projectID: UUID(0),
|
||||||
name: "Kitchen",
|
name: "Kitchen",
|
||||||
heatingLoad: 12345,
|
heatingLoad: 12345,
|
||||||
coolingLoad: .init(total: 12345, sensible: 12345),
|
coolingTotal: 1234,
|
||||||
registerCount: 2,
|
registerCount: 2,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
@@ -80,7 +155,7 @@ extension Room {
|
|||||||
projectID: UUID(1),
|
projectID: UUID(1),
|
||||||
name: "Bedroom - 1",
|
name: "Bedroom - 1",
|
||||||
heatingLoad: 12345,
|
heatingLoad: 12345,
|
||||||
coolingLoad: .init(total: 12345, sensible: 12345),
|
coolingTotal: 1456,
|
||||||
registerCount: 1,
|
registerCount: 1,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
@@ -90,12 +165,92 @@ extension Room {
|
|||||||
projectID: UUID(2),
|
projectID: UUID(2),
|
||||||
name: "Family Room",
|
name: "Family Room",
|
||||||
heatingLoad: 12345,
|
heatingLoad: 12345,
|
||||||
coolingLoad: .init(total: 12345, sensible: 12345),
|
coolingTotal: 1673,
|
||||||
registerCount: 3,
|
registerCount: 3,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
public static func mock(projectID: Project.ID) -> [Self] {
|
||||||
|
@Dependency(\.uuid) var uuid
|
||||||
|
@Dependency(\.date.now) var now
|
||||||
|
|
||||||
|
return [
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Bed-1",
|
||||||
|
heatingLoad: 3913,
|
||||||
|
coolingTotal: 2472,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 1,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Entry",
|
||||||
|
heatingLoad: 8284,
|
||||||
|
coolingTotal: 2916,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Family Room",
|
||||||
|
heatingLoad: 9785,
|
||||||
|
coolingTotal: 7446,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 3,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Kitchen",
|
||||||
|
heatingLoad: 4518,
|
||||||
|
coolingTotal: 5096,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Living Room",
|
||||||
|
heatingLoad: 7553,
|
||||||
|
coolingTotal: 6829,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: uuid(),
|
||||||
|
projectID: projectID,
|
||||||
|
name: "Master",
|
||||||
|
heatingLoad: 8202,
|
||||||
|
coolingTotal: 2076,
|
||||||
|
coolingSensible: nil,
|
||||||
|
registerCount: 2,
|
||||||
|
rectangularSizes: nil,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ extension SiteRoute.Api {
|
|||||||
public enum ProjectRoute: Sendable, Equatable {
|
public enum ProjectRoute: Sendable, Equatable {
|
||||||
case create(Project.Create)
|
case create(Project.Create)
|
||||||
case delete(id: Project.ID)
|
case delete(id: Project.ID)
|
||||||
|
case detail(id: Project.ID, route: DetailRoute)
|
||||||
case get(id: Project.ID)
|
case get(id: Project.ID)
|
||||||
case index
|
case index
|
||||||
|
|
||||||
@@ -74,6 +75,36 @@ extension SiteRoute.Api {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.detail(id:route:))) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
Project.ID.parser()
|
||||||
|
}
|
||||||
|
DetailRoute.router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.Api.ProjectRoute {
|
||||||
|
public enum DetailRoute: Equatable, Sendable {
|
||||||
|
case index
|
||||||
|
case completedSteps
|
||||||
|
|
||||||
|
static let rootPath = "details"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.completedSteps)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
"completed"
|
||||||
|
}
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import CasePathsCore
|
import CasePathsCore
|
||||||
|
import FluentKit
|
||||||
import Foundation
|
import Foundation
|
||||||
@preconcurrency import URLRouting
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
|
|||||||
31
Sources/ManualDCore/Theme.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
public static let darkThemes = [
|
||||||
|
Self.aqua,
|
||||||
|
Self.cyberpunk,
|
||||||
|
Self.dark,
|
||||||
|
Self.dracula,
|
||||||
|
Self.night,
|
||||||
|
Self.synthwave,
|
||||||
|
]
|
||||||
|
|
||||||
|
public static let lightThemes = [
|
||||||
|
Self.cupcake,
|
||||||
|
Self.light,
|
||||||
|
Self.nord,
|
||||||
|
Self.retro,
|
||||||
|
]
|
||||||
|
}
|
||||||
114
Sources/ManualDCore/TrunkSize.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Represents the database model.
|
||||||
|
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let projectID: Project.ID
|
||||||
|
public let type: TrunkType
|
||||||
|
public let rooms: [RoomProxy]
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: 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 {
|
||||||
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let projectID: Project.ID
|
||||||
|
public let type: TrunkType
|
||||||
|
public let rooms: [Room.ID: [Int]]
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: TrunkType,
|
||||||
|
rooms: [Room.ID: [Int]],
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.projectID = projectID
|
||||||
|
self.type = type
|
||||||
|
self.rooms = rooms
|
||||||
|
self.height = height
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let type: TrunkType?
|
||||||
|
public let rooms: [Room.ID: [Int]]?
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
type: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public var id: Room.ID { room.id }
|
||||||
|
public let room: Room
|
||||||
|
public let registers: [Int]
|
||||||
|
|
||||||
|
public init(room: Room, registers: [Int]) {
|
||||||
|
self.room = room
|
||||||
|
self.registers = registers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case `return`
|
||||||
|
case supply
|
||||||
|
|
||||||
|
public static let allCases = [Self.supply, .return]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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,21 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// FIX: Remove username.
|
||||||
public struct User: Codable, Equatable, Identifiable, Sendable {
|
public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
public let email: String
|
public let email: String
|
||||||
public let username: String
|
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
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
|
||||||
@@ -27,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
|
|||||||
extension User {
|
extension User {
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let username: String
|
|
||||||
public let email: String
|
public let email: String
|
||||||
public let password: String
|
public let password: String
|
||||||
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
|
||||||
@@ -48,10 +43,12 @@ extension User {
|
|||||||
public struct Login: Codable, Equatable, Sendable {
|
public struct Login: Codable, Equatable, Sendable {
|
||||||
public let email: String
|
public let email: String
|
||||||
public let password: String
|
public let password: String
|
||||||
|
public let next: String?
|
||||||
|
|
||||||
public init(email: String, password: String) {
|
public init(email: String, password: String, next: String? = nil) {
|
||||||
self.email = email
|
self.email = email
|
||||||
self.password = password
|
self.password = password
|
||||||
|
self.next = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,3 +65,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
|
||||||
|
|||||||
140
Sources/ManualDCore/UserProfile.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension User {
|
||||||
|
public struct Profile: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let userID: User.ID
|
||||||
|
public let firstName: String
|
||||||
|
public let lastName: String
|
||||||
|
public let companyName: String
|
||||||
|
public let streetAddress: String
|
||||||
|
public let city: String
|
||||||
|
public let state: String
|
||||||
|
public let zipCode: String
|
||||||
|
public let theme: Theme?
|
||||||
|
public let createdAt: Date
|
||||||
|
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 {
|
||||||
|
|
||||||
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
public let userID: User.ID
|
||||||
|
public let firstName: String
|
||||||
|
public let lastName: String
|
||||||
|
public let companyName: String
|
||||||
|
public let streetAddress: String
|
||||||
|
public let city: String
|
||||||
|
public let state: String
|
||||||
|
public let zipCode: String
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
public let firstName: String?
|
||||||
|
public let lastName: String?
|
||||||
|
public let companyName: String?
|
||||||
|
public let streetAddress: String?
|
||||||
|
public let city: String?
|
||||||
|
public let state: String?
|
||||||
|
public let zipCode: String?
|
||||||
|
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
|
||||||
153
Sources/PdfClient/Interface.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Elementary
|
||||||
|
import EnvClient
|
||||||
|
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 {
|
||||||
|
let html = try await self.html(request)
|
||||||
|
return try await self.generatePdf(request.project.id, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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(\.env) var env
|
||||||
|
|
||||||
|
let envVars = try env()
|
||||||
|
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: envVars.pandocPath)
|
||||||
|
process.arguments = [
|
||||||
|
"\(baseUrl).html",
|
||||||
|
"--pdf-engine=\(envVars.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 {
|
||||||
|
/// Container for the data required to generate a pdf for a given project.
|
||||||
|
public struct Request: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let project: Project
|
||||||
|
public let rooms: [Room]
|
||||||
|
public let componentLosses: [ComponentPressureLoss]
|
||||||
|
public let ductSizes: DuctSizes
|
||||||
|
public let equipmentInfo: EquipmentInfo
|
||||||
|
public let maxSupplyTEL: EffectiveLength
|
||||||
|
public let maxReturnTEL: EffectiveLength
|
||||||
|
public let frictionRate: FrictionRate
|
||||||
|
public let projectSHR: Double
|
||||||
|
|
||||||
|
var totalEquivalentLength: Double {
|
||||||
|
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
project: Project,
|
||||||
|
rooms: [Room],
|
||||||
|
componentLosses: [ComponentPressureLoss],
|
||||||
|
ductSizes: DuctSizes,
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
maxSupplyTEL: EffectiveLength,
|
||||||
|
maxReturnTEL: EffectiveLength,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Response: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let htmlPath: String
|
||||||
|
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 = EffectiveLength.mock(projectID: project.id)
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
project: project,
|
||||||
|
rooms: rooms,
|
||||||
|
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
|
||||||
|
ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks),
|
||||||
|
equipmentInfo: equipmentInfo,
|
||||||
|
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
|
||||||
|
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,
|
||||||
|
frictionRate: .mock,
|
||||||
|
projectSHR: 0.83
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
107
Sources/PdfClient/Request+html.swift
Normal file
@@ -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: [EffectiveLength]
|
||||||
|
|
||||||
|
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: [EffectiveLength.Group]
|
||||||
|
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Sources/PdfClient/Views/RoomTable.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import Elementary
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
struct RoomsTable: HTML, Sendable {
|
||||||
|
let rooms: [Room]
|
||||||
|
let projectSHR: Double
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.table> {
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr(.class("bg-green")) {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Heating BTU" }
|
||||||
|
th { "Cooling Total BTU" }
|
||||||
|
th { "Cooling Sensible BTU" }
|
||||||
|
th { "Register Count" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for room in rooms {
|
||||||
|
tr {
|
||||||
|
td { room.name }
|
||||||
|
td { room.heatingLoad.string(digits: 0) }
|
||||||
|
td { room.coolingTotal.string(digits: 0) }
|
||||||
|
td {
|
||||||
|
(room.coolingSensible
|
||||||
|
?? (room.coolingTotal * projectSHR)).string(digits: 0)
|
||||||
|
}
|
||||||
|
td { room.registerCount.string() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Totals
|
||||||
|
// tr(.class("table-footer")) {
|
||||||
|
tr {
|
||||||
|
td(.class("label")) { "Totals" }
|
||||||
|
td(.class("heating label")) {
|
||||||
|
rooms.totalHeatingLoad.string(digits: 0)
|
||||||
|
}
|
||||||
|
td(.class("coolingTotal label")) {
|
||||||
|
rooms.totalCoolingLoad.string(digits: 0)
|
||||||
|
}
|
||||||
|
td(.class("coolingSensible label")) {
|
||||||
|
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
|
||||||
|
}
|
||||||
|
td {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Sources/PdfClient/Views/TrunkTable.swift
Normal file
@@ -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() ?? "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Sources/ProjectClient/DuctCalcClientError.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ProjectClientError: Error {
|
||||||
|
public let reason: String
|
||||||
|
|
||||||
|
public init(_ reason: String) {
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Sources/ProjectClient/Interface.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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 client to render views.
|
||||||
|
@DependencyClient
|
||||||
|
public struct ProjectClient: Sendable {
|
||||||
|
public var calculateDuctSizes: @Sendable (Project.ID) async throws -> DuctSizes
|
||||||
|
public var calculateRoomDuctSizes:
|
||||||
|
@Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer]
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: EffectiveLength.MaxContainer
|
||||||
|
public let frictionRate: FrictionRate?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
componentLosses: [ComponentPressureLoss],
|
||||||
|
equivalentLengths: EffectiveLength.MaxContainer,
|
||||||
|
frictionRate: FrictionRate? = nil
|
||||||
|
) {
|
||||||
|
self.componentLosses = componentLosses
|
||||||
|
self.equivalentLengths = equivalentLengths
|
||||||
|
self.frictionRate = frictionRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension DatabaseClient.ComponentLoss {
|
||||||
|
|
||||||
|
func createDefaults(projectID: Project.ID) async throws {
|
||||||
|
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
|
||||||
|
for loss in defaults {
|
||||||
|
_ = try await create(loss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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)
|
||||||
|
let (trunks, _) = try await calculateTrunkDuctSizes(details: details)
|
||||||
|
return (.init(rooms: rooms, trunks: trunks), shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateDuctSizes(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let shared = try await sharedDuctRequest(projectID)
|
||||||
|
let rooms = try await rooms.fetch(projectID)
|
||||||
|
|
||||||
|
return try await (
|
||||||
|
manualD.calculateDuctSizes(
|
||||||
|
rooms: rooms,
|
||||||
|
trunks: trunkSizes.fetch(projectID),
|
||||||
|
sharedRequest: shared
|
||||||
|
),
|
||||||
|
shared,
|
||||||
|
rooms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateRoomDuctSizes(
|
||||||
|
details: Project.Detail
|
||||||
|
) async throws -> ([DuctSizes.RoomContainer], 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 calculateRoomDuctSizes(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let shared = try await sharedDuctRequest(projectID)
|
||||||
|
|
||||||
|
return try await (
|
||||||
|
manualD.calculateRoomSizes(
|
||||||
|
rooms: rooms.fetch(projectID),
|
||||||
|
sharedRequest: shared
|
||||||
|
),
|
||||||
|
shared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateTrunkDuctSizes(
|
||||||
|
details: Project.Detail
|
||||||
|
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let shared = try sharedDuctRequest(details: details)
|
||||||
|
let trunks = try await manualD.calculateTrunkSizes(
|
||||||
|
rooms: details.rooms,
|
||||||
|
trunks: details.trunks,
|
||||||
|
sharedRequest: shared
|
||||||
|
)
|
||||||
|
return (trunks, shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateTrunkDuctSizes(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
let shared = try await sharedDuctRequest(projectID)
|
||||||
|
|
||||||
|
return try await (
|
||||||
|
manualD.calculateTrunkSizes(
|
||||||
|
rooms: rooms.fetch(projectID),
|
||||||
|
trunks: trunkSizes.fetch(projectID),
|
||||||
|
sharedRequest: shared
|
||||||
|
),
|
||||||
|
shared
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
|
||||||
|
guard
|
||||||
|
let dfrResponse = designFrictionRate(
|
||||||
|
componentLosses: details.componentLosses,
|
||||||
|
equipmentInfo: details.equipmentInfo,
|
||||||
|
equivalentLengths: details.maxContainer
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw ProjectClientError("Project not complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let projectSHR = details.project.sensibleHeatRatio else {
|
||||||
|
throw ProjectClientError("Project sensible heat ratio not set.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ensuredTEL = try dfrResponse.ensureMaxContainer()
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
equipmentInfo: dfrResponse.equipmentInfo,
|
||||||
|
maxSupplyLength: ensuredTEL.supply,
|
||||||
|
maxReturnLenght: ensuredTEL.return,
|
||||||
|
designFrictionRate: dfrResponse.designFrictionRate,
|
||||||
|
projectSHR: projectSHR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest {
|
||||||
|
|
||||||
|
guard let dfrResponse = try await designFrictionRate(projectID: projectID) else {
|
||||||
|
throw ProjectClientError("Project not complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ensuredTEL = try dfrResponse.ensureMaxContainer()
|
||||||
|
|
||||||
|
return try await .init(
|
||||||
|
equipmentInfo: dfrResponse.equipmentInfo,
|
||||||
|
maxSupplyLength: ensuredTEL.supply,
|
||||||
|
maxReturnLenght: ensuredTEL.return,
|
||||||
|
designFrictionRate: dfrResponse.designFrictionRate,
|
||||||
|
projectSHR: ensuredSHR(projectID)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the project sensible heat ratio or throws an error if it's nil.
|
||||||
|
func ensuredSHR(_ projectID: Project.ID) async throws -> Double {
|
||||||
|
guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else {
|
||||||
|
throw ProjectClientError("Project sensible heat ratio not set.")
|
||||||
|
}
|
||||||
|
return projectSHR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal container.
|
||||||
|
struct DesignFrictionRateResponse: Equatable, Sendable {
|
||||||
|
|
||||||
|
typealias EnsuredTEL = (supply: EffectiveLength, return: EffectiveLength)
|
||||||
|
|
||||||
|
let designFrictionRate: Double
|
||||||
|
let equipmentInfo: EquipmentInfo
|
||||||
|
let telMaxContainer: EffectiveLength.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: EffectiveLength.MaxContainer
|
||||||
|
) -> DesignFrictionRateResponse? {
|
||||||
|
guard let tel = equivalentLengths.total,
|
||||||
|
componentLosses.count > 0
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
designFrictionRate: (availableStaticPressure * 100) / tel,
|
||||||
|
equipmentInfo: equipmentInfo,
|
||||||
|
telMaxContainer: equivalentLengths
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func designFrictionRate(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> DesignFrictionRateResponse? {
|
||||||
|
|
||||||
|
guard let equipmentInfo = try await equipment.fetch(projectID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await designFrictionRate(
|
||||||
|
componentLosses: componentLoss.fetch(projectID),
|
||||||
|
equipmentInfo: equipmentInfo,
|
||||||
|
equivalentLengths: effectiveLength.fetchMax(projectID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import Logging
|
||||||
|
import ManualDClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
struct DuctSizeSharedRequest {
|
||||||
|
let equipmentInfo: EquipmentInfo
|
||||||
|
let maxSupplyLength: EffectiveLength
|
||||||
|
let maxReturnLenght: EffectiveLength
|
||||||
|
let designFrictionRate: Double
|
||||||
|
let projectSHR: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove Logger and use depedency logger.
|
||||||
|
|
||||||
|
extension ManualDClient {
|
||||||
|
|
||||||
|
func calculateDuctSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [TrunkSize],
|
||||||
|
sharedRequest: DuctSizeSharedRequest,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> DuctSizes {
|
||||||
|
try await .init(
|
||||||
|
rooms: calculateRoomSizes(
|
||||||
|
rooms: rooms,
|
||||||
|
sharedRequest: sharedRequest
|
||||||
|
),
|
||||||
|
trunks: calculateTrunkSizes(
|
||||||
|
rooms: rooms,
|
||||||
|
trunks: trunks,
|
||||||
|
sharedRequest: sharedRequest
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateRoomSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
sharedRequest: DuctSizeSharedRequest,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> [DuctSizes.RoomContainer] {
|
||||||
|
|
||||||
|
var retval: [DuctSizes.RoomContainer] = []
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||||
|
|
||||||
|
for room in rooms {
|
||||||
|
let heatingLoad = room.heatingLoadPerRegister
|
||||||
|
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
.init(designCFM: Int(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(
|
||||||
|
.init(round: sizes.finalSize, height: rectangularSize.height)
|
||||||
|
)
|
||||||
|
rectangularWidth = response.width
|
||||||
|
}
|
||||||
|
|
||||||
|
retval.append(
|
||||||
|
.init(
|
||||||
|
roomID: room.id,
|
||||||
|
roomName: "\(room.name)-\(n)",
|
||||||
|
roomRegister: n,
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingLoad: coolingLoad,
|
||||||
|
heatingCFM: heatingCFM,
|
||||||
|
coolingCFM: coolingCFM,
|
||||||
|
ductSize: .init(
|
||||||
|
designCFM: designCFM,
|
||||||
|
sizes: sizes,
|
||||||
|
rectangularSize: rectangularSize,
|
||||||
|
width: rectangularWidth
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateTrunkSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [TrunkSize],
|
||||||
|
sharedRequest: DuctSizeSharedRequest,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> [DuctSizes.TrunkContainer] {
|
||||||
|
|
||||||
|
var retval = [DuctSizes.TrunkContainer]()
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||||
|
|
||||||
|
for trunk in trunks {
|
||||||
|
let heatingLoad = trunk.totalHeatingLoad
|
||||||
|
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
|
||||||
|
)
|
||||||
|
var width: Int? = nil
|
||||||
|
if let height = trunk.height {
|
||||||
|
let rectangularSize = try await self.rectangularSize(
|
||||||
|
.init(round: sizes.finalSize, height: height)
|
||||||
|
)
|
||||||
|
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.DuctSizeResponse,
|
||||||
|
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.DuctSizeResponse,
|
||||||
|
rectangularSize: Room.RectangularSize?,
|
||||||
|
width: Int?
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
rectangularID: rectangularSize?.id,
|
||||||
|
designCFM: designCFM,
|
||||||
|
roundSize: sizes.calculatedSize,
|
||||||
|
finalSize: sizes.finalSize,
|
||||||
|
velocity: sizes.velocity,
|
||||||
|
flexSize: sizes.flexSize,
|
||||||
|
height: rectangularSize?.height,
|
||||||
|
width: width
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Room {
|
||||||
|
|
||||||
|
var heatingLoadPerRegister: Double {
|
||||||
|
|
||||||
|
heatingLoad / Double(registerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
|
||||||
|
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
|
||||||
|
return sensible / Double(registerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize.RoomProxy {
|
||||||
|
|
||||||
|
// We need to make sure if registers got removed after a trunk
|
||||||
|
// was already made / saved that we do not include registers that
|
||||||
|
// no longer exist.
|
||||||
|
private var actualRegisterCount: Int {
|
||||||
|
guard registers.count <= room.registerCount else {
|
||||||
|
return room.registerCount
|
||||||
|
}
|
||||||
|
return registers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
room.heatingLoadPerRegister * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrunkSize {
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import Dependencies
|
||||||
|
import ManualDClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension ManualDClient {
|
||||||
|
|
||||||
|
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
|
||||||
|
|
||||||
|
let maxContainer = details.maxContainer
|
||||||
|
guard let totalEquivalentLength = maxContainer.total else {
|
||||||
|
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await .init(
|
||||||
|
componentLosses: details.componentLosses,
|
||||||
|
equivalentLengths: maxContainer,
|
||||||
|
frictionRate: frictionRate(
|
||||||
|
.init(
|
||||||
|
externalStaticPressure: details.equipmentInfo.staticPressure,
|
||||||
|
componentPressureLosses: details.componentLosses,
|
||||||
|
totalEffectiveLength: Int(totalEquivalentLength)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
let componentLosses = try await database.componentLoss.fetch(projectID)
|
||||||
|
let lengths = try await database.effectiveLength.fetchMax(projectID)
|
||||||
|
|
||||||
|
let equipmentInfo = try await database.equipment.fetch(projectID)
|
||||||
|
guard let staticPressure = equipmentInfo?.staticPressure else {
|
||||||
|
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let totalEquivalentLength = lengths.total else {
|
||||||
|
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await .init(
|
||||||
|
componentLosses: componentLosses,
|
||||||
|
equivalentLengths: lengths,
|
||||||
|
frictionRate: frictionRate(
|
||||||
|
.init(
|
||||||
|
externalStaticPressure: staticPressure,
|
||||||
|
componentPressureLosses: database.componentLoss.fetch(projectID),
|
||||||
|
totalEffectiveLength: Int(totalEquivalentLength)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension Project.Detail {
|
||||||
|
var maxContainer: EffectiveLength.MaxContainer {
|
||||||
|
.init(
|
||||||
|
supply: equivalentLengths.filter({ $0.type == .supply })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first,
|
||||||
|
return: equivalentLengths.filter({ $0.type == .return })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
Sources/ProjectClient/Live.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import Dependencies
|
||||||
|
import FileClient
|
||||||
|
import Logging
|
||||||
|
import ManualDClient
|
||||||
|
import ManualDCore
|
||||||
|
import PdfClient
|
||||||
|
|
||||||
|
extension ProjectClient: DependencyKey {
|
||||||
|
|
||||||
|
public static var liveValue: Self {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
@Dependency(\.pdfClient) var pdfClient
|
||||||
|
@Dependency(\.fileClient) var fileClient
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
calculateDuctSizes: { projectID in
|
||||||
|
try await database.calculateDuctSizes(projectID: projectID).0
|
||||||
|
},
|
||||||
|
calculateRoomDuctSizes: { projectID in
|
||||||
|
try await database.calculateRoomDuctSizes(projectID: projectID).0
|
||||||
|
},
|
||||||
|
calculateTrunkDuctSizes: { projectID in
|
||||||
|
try await database.calculateTrunkDuctSizes(projectID: projectID).0
|
||||||
|
},
|
||||||
|
createProject: { userID, request in
|
||||||
|
let project = try await database.projects.create(userID, request)
|
||||||
|
try await database.componentLoss.createDefaults(projectID: project.id)
|
||||||
|
return try await .init(
|
||||||
|
projectID: project.id,
|
||||||
|
rooms: database.rooms.fetch(project.id),
|
||||||
|
sensibleHeatRatio: database.projects.getSensibleHeatRatio(project.id),
|
||||||
|
completedSteps: database.projects.getCompletedSteps(project.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
frictionRate: { projectID in
|
||||||
|
try await manualD.frictionRate(projectID: projectID)
|
||||||
|
},
|
||||||
|
generatePdf: { projectID in
|
||||||
|
let pdfResponse = try await pdfClient.generatePdf(
|
||||||
|
request: database.makePdfRequest(projectID)
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try await fileClient.streamFile(
|
||||||
|
pdfResponse.pdfPath,
|
||||||
|
{
|
||||||
|
try await fileClient.removeFile(pdfResponse.htmlPath)
|
||||||
|
try await fileClient.removeFile(pdfResponse.pdfPath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
|
||||||
|
response.headers.replaceOrAdd(
|
||||||
|
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
|
||||||
|
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
guard let projectDetails = try await projects.detail(projectID) else {
|
||||||
|
throw ProjectClientError("Project not found. id: \(projectID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
|
||||||
|
|
||||||
|
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
|
||||||
|
guard let frictionRate = frictionRateResponse.frictionRate else {
|
||||||
|
throw ProjectClientError("Friction rate not found. id: \(projectID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
details: projectDetails,
|
||||||
|
ductSizes: ductSizes,
|
||||||
|
shared: shared,
|
||||||
|
frictionRate: frictionRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PdfClient.Request {
|
||||||
|
init(
|
||||||
|
details: Project.Detail,
|
||||||
|
ductSizes: DuctSizes,
|
||||||
|
shared: DuctSizeSharedRequest,
|
||||||
|
frictionRate: FrictionRate
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
project: details.project,
|
||||||
|
rooms: details.rooms,
|
||||||
|
componentLosses: details.componentLosses,
|
||||||
|
ductSizes: ductSizes,
|
||||||
|
equipmentInfo: details.equipmentInfo,
|
||||||
|
maxSupplyTEL: shared.maxSupplyLength,
|
||||||
|
maxReturnTEL: shared.maxReturnLenght,
|
||||||
|
frictionRate: frictionRate,
|
||||||
|
projectSHR: shared.projectSHR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/Styleguide/Alert.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct Alert<Content: HTML>: HTML {
|
||||||
|
|
||||||
|
let inner: Content
|
||||||
|
|
||||||
|
public init(@HTMLBuilder content: () -> Content) {
|
||||||
|
self.inner = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(.class("flex space-x-2")) {
|
||||||
|
SVG(.triangleAlert)
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Alert: Sendable where Content: Sendable {}
|
||||||
|
|
||||||
|
extension Alert where Content == p<HTMLText> {
|
||||||
|
|
||||||
|
public init(_ description: String) {
|
||||||
|
self.init {
|
||||||
|
p { description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/Styleguide/Badge.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
|
||||||
|
|
||||||
|
let inner: Inner
|
||||||
|
|
||||||
|
public init(
|
||||||
|
@HTMLBuilder inner: () -> Inner
|
||||||
|
) {
|
||||||
|
self.inner = inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(.class("badge badge-lg badge-outline")) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Badge where Inner == Number {
|
||||||
|
public init(number: Int) {
|
||||||
|
self.inner = Number(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(number: Double, digits: Int = 2) {
|
||||||
|
self.inner = Number(number, digits: digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public struct SubmitButton: HTML, Sendable {
|
|||||||
button(
|
button(
|
||||||
.class(
|
.class(
|
||||||
"""
|
"""
|
||||||
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
|
btn btn-secondary
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
.type(type)
|
.type(type)
|
||||||
@@ -53,11 +53,11 @@ public struct CancelButton: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct EditButton: HTML, Sendable {
|
public struct EditButton: HTML, Sendable {
|
||||||
let title: String
|
let title: String?
|
||||||
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String = "Edit",
|
title: String? = nil,
|
||||||
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
|
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -65,16 +65,11 @@ public struct EditButton: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.button> {
|
public var body: some HTML<HTMLTag.button> {
|
||||||
button(
|
button(.class("btn"), .type(type)) {
|
||||||
.class(
|
|
||||||
"""
|
|
||||||
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
.type(type)
|
|
||||||
) {
|
|
||||||
div(.class("flex")) {
|
div(.class("flex")) {
|
||||||
span(.class("pe-2")) { title }
|
if let title {
|
||||||
|
span(.class("pe-2")) { title }
|
||||||
|
}
|
||||||
SVG(.squarePen)
|
SVG(.squarePen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +81,22 @@ public struct PlusButton: HTML, Sendable {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.button> {
|
public var body: some HTML<HTMLTag.button> {
|
||||||
button(.type(.button)) { SVG(.circlePlus) }
|
button(
|
||||||
|
.type(.button),
|
||||||
|
.class("btn")
|
||||||
|
) { SVG(.circlePlus) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TrashButton: HTML, Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.button> {
|
||||||
|
button(
|
||||||
|
.type(.button),
|
||||||
|
.class("btn btn-error")
|
||||||
|
) {
|
||||||
|
SVG(.trash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
Sources/Styleguide/Date.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DateView: HTML, Sendable {
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
var formatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MM/dd/yyyy"
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ date: Date) {
|
||||||
|
self.date = date
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.span> {
|
||||||
|
span { formatter.string(from: date) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
||||||
@@ -7,3 +8,49 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
|||||||
href(SiteRoute.View.router.path(for: route))
|
href(SiteRoute.View.router.path(for: route))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HTMLAttribute where Tag == HTMLTag.form {
|
||||||
|
|
||||||
|
public static func action(route: SiteRoute.View) -> Self {
|
||||||
|
action(SiteRoute.View.router.path(for: route))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTMLAttribute where Tag == HTMLTag.input {
|
||||||
|
|
||||||
|
public static func value(_ string: String?) -> Self {
|
||||||
|
value(string ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func value(_ int: Int?) -> Self {
|
||||||
|
value(int == nil ? "" : "\(int!)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func value(_ double: Double?) -> Self {
|
||||||
|
value(double == nil ? "" : "\(double!)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func value(_ uuid: UUID?) -> Self {
|
||||||
|
value(uuid?.uuidString ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTMLAttribute where Tag == HTMLTag.button {
|
||||||
|
public static func showModal(id: String) -> Self {
|
||||||
|
.on(.click, "\(id).showModal()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTML where Tag: HTMLTrait.Attributes.Global {
|
||||||
|
public func badge() -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("badge badge-lg badge-outline"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("hidden"), when: shouldHide)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bold(when shouldBeBold: Bool = true) -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("font-bold"), when: shouldBeBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ extension HTMLAttribute.hx {
|
|||||||
put(SiteRoute.View.router.path(for: route))
|
put(SiteRoute.View.router.path(for: route))
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Sendable
|
@Sendable
|
||||||
// static func delete(route: SiteRoute.Api) -> HTMLAttribute {
|
public static func delete(route: SiteRoute.View) -> HTMLAttribute {
|
||||||
// delete(SiteRoute.Api.router.path(for: route))
|
delete(SiteRoute.View.router.path(for: route))
|
||||||
// }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTMLAttribute.hx {
|
||||||
|
@Sendable
|
||||||
|
public static func indicator() -> HTMLAttribute {
|
||||||
|
indicator(".htmx-indicator")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
|
||||||
|
// TODO: Remove, using svg's.
|
||||||
public struct Icon: HTML, Sendable {
|
public struct Icon: HTML, Sendable {
|
||||||
|
|
||||||
let icon: String
|
let icon: String
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
|
||||||
|
public struct LabeledInput: HTML, Sendable {
|
||||||
|
|
||||||
|
let labelText: String
|
||||||
|
let inputAttributes: [HTMLAttribute<HTMLTag.input>]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ label: String,
|
||||||
|
_ attributes: HTMLAttribute<HTMLTag.input>...
|
||||||
|
) {
|
||||||
|
self.labelText = label
|
||||||
|
self.inputAttributes = attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.label> {
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { labelText }
|
||||||
|
input(attributes: inputAttributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Input: HTML, Sendable {
|
public struct Input: HTML, Sendable {
|
||||||
|
|
||||||
let id: String?
|
let id: String?
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public struct Label: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.span> {
|
public var body: some HTML<HTMLTag.span> {
|
||||||
span(.class("text-xl text-gray-400 font-bold")) {
|
span(.class("text-lg label font-bold")) {
|
||||||
title
|
title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
Sources/Styleguide/LabeledContent.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct LabeledContent<Label: HTML, Content: HTML>: HTML {
|
||||||
|
let label: @Sendable () -> Label
|
||||||
|
let content: @Sendable () -> Content
|
||||||
|
let position: LabelPosition
|
||||||
|
|
||||||
|
public init(
|
||||||
|
position: LabelPosition = .default,
|
||||||
|
@HTMLBuilder label: @escaping @Sendable () -> Label,
|
||||||
|
@HTMLBuilder content: @escaping @Sendable () -> Content
|
||||||
|
) {
|
||||||
|
self.position = position
|
||||||
|
self.label = label
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div {
|
||||||
|
switch position {
|
||||||
|
case .leading:
|
||||||
|
label()
|
||||||
|
content()
|
||||||
|
case .trailing:
|
||||||
|
content()
|
||||||
|
label()
|
||||||
|
case .top:
|
||||||
|
label()
|
||||||
|
content()
|
||||||
|
case .bottom:
|
||||||
|
content()
|
||||||
|
label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attributes(.class("flex space-x-4"), when: position.isHorizontal)
|
||||||
|
.attributes(.class("space-y-4"), when: position.isVertical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Merge / use TooltipPosition
|
||||||
|
public enum LabelPosition: String, CaseIterable, Equatable, Sendable {
|
||||||
|
case leading
|
||||||
|
case trailing
|
||||||
|
case top
|
||||||
|
case bottom
|
||||||
|
|
||||||
|
var isHorizontal: Bool {
|
||||||
|
self == .leading || self == .trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVertical: Bool {
|
||||||
|
self == .top || self == .bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = Self.leading
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LabeledContent: Sendable where Label: Sendable, Content: Sendable {}
|
||||||
|
|
||||||
|
extension LabeledContent where Label == Styleguide.Label {
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ label: String, position: LabelPosition = .default,
|
||||||
|
@HTMLBuilder content: @escaping @Sendable () -> Content
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
position: position,
|
||||||
|
label: { Label(label) },
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,36 +2,37 @@ import Elementary
|
|||||||
|
|
||||||
public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
|
public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
|
||||||
|
|
||||||
|
let closeButton: Bool
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
let id: String
|
let id: String
|
||||||
let inner: T
|
let inner: T
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
|
closeButton: Bool = true,
|
||||||
dismiss: Bool,
|
dismiss: Bool,
|
||||||
@HTMLBuilder inner: () -> T
|
@HTMLBuilder inner: () -> T
|
||||||
) {
|
) {
|
||||||
|
self.closeButton = closeButton
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
self.id = id
|
self.id = id
|
||||||
self.inner = inner()
|
self.inner = inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML {
|
public var body: some HTML<HTMLTag.dialog> {
|
||||||
if dismiss {
|
dialog(.id(id), .class("modal")) {
|
||||||
div(.id(id)) {}
|
div(.class("modal-box")) {
|
||||||
} else {
|
if closeButton {
|
||||||
div(
|
button(
|
||||||
.id(id),
|
.class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
|
||||||
.class(
|
.on(.click, "\(id).close()")
|
||||||
"""
|
) {
|
||||||
fixed top-40 left-[25vw] w-1/2 z-50 text-gray-800
|
SVG(.close)
|
||||||
bg-gray-200 border border-gray-400
|
}
|
||||||
rounded-lg shadow-lg mx-10
|
}
|
||||||
"""
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.attributes(.class("modal-open"), when: dismiss == false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
public struct Number: HTML, Sendable {
|
public struct Number: HTML, Sendable {
|
||||||
let fractionDigits: Int
|
let fractionDigits: Int
|
||||||
let value: Double
|
let value: Double
|
||||||
|
|
||||||
private var formatter: NumberFormatter {
|
// private var formatter: NumberFormatter {
|
||||||
let formatter = NumberFormatter()
|
// let formatter = NumberFormatter()
|
||||||
formatter.maximumFractionDigits = fractionDigits
|
// formatter.maximumFractionDigits = fractionDigits
|
||||||
formatter.numberStyle = .decimal
|
// formatter.numberStyle = .decimal
|
||||||
return formatter
|
// formatter.groupingSize = 3
|
||||||
}
|
// formatter.groupingSeparator = ","
|
||||||
|
// return formatter
|
||||||
|
// }
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
_ value: Double,
|
_ value: Double,
|
||||||
@@ -27,6 +30,6 @@ public struct Number: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.span> {
|
public var body: some HTML<HTMLTag.span> {
|
||||||
span { formatter.string(for: value) ?? "N/A" }
|
span { value.string(digits: fractionDigits) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
Sources/Styleguide/PageTitle.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct PageTitleRow<Content: HTML>: HTML, Sendable where Content: Sendable {
|
||||||
|
|
||||||
|
let inner: Content
|
||||||
|
|
||||||
|
public init(@HTMLBuilder content: () -> Content) {
|
||||||
|
self.inner = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
|
||||||
|
p-6 w-full
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PageTitle: HTML, Sendable {
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
public init(_ title: String) {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ title: () -> String) {
|
||||||
|
self.title = title()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.h1> {
|
||||||
|
h1(.class("text-3xl font-bold")) { title }
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Sources/Styleguide/ResultView.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
|
||||||
|
|
||||||
|
let result: Result<ValueView, any Error>
|
||||||
|
let errorView: @Sendable (any Error) -> ErrorView
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ content: @escaping @Sendable () async throws -> ValueView,
|
||||||
|
onError: @escaping @Sendable (any Error) -> ErrorView
|
||||||
|
) async {
|
||||||
|
self.result = await Result(catching: content)
|
||||||
|
self.errorView = onError
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML {
|
||||||
|
switch result {
|
||||||
|
case .success(let view):
|
||||||
|
view
|
||||||
|
case .failure(let error):
|
||||||
|
errorView(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ResultView where ErrorView == Styleguide.ErrorView {
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ content: @escaping @Sendable () async throws -> ValueView
|
||||||
|
) async {
|
||||||
|
await self.init(
|
||||||
|
content,
|
||||||
|
onError: { Styleguide.ErrorView(error: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init<V: Sendable>(
|
||||||
|
catching: @escaping @Sendable () async throws -> V,
|
||||||
|
onSuccess content: @escaping @Sendable (V) -> ValueView
|
||||||
|
) async where ValueView: Sendable {
|
||||||
|
await self.init(
|
||||||
|
{
|
||||||
|
try await content(catching())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
catching: @escaping @Sendable () async throws -> Void
|
||||||
|
) async where ValueView == EmptyHTML {
|
||||||
|
await self.init(
|
||||||
|
catching: catching,
|
||||||
|
onSuccess: { EmptyHTML() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ResultView: Sendable where ValueView: Sendable, ErrorView: Sendable {}
|
||||||
|
|
||||||
|
public struct ErrorView: HTML, Sendable {
|
||||||
|
let error: any Error
|
||||||
|
|
||||||
|
public init(error: any Error) {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div {
|
||||||
|
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
|
||||||
|
p {
|
||||||
|
"\(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,23 +15,66 @@ public struct SVG: HTML, Sendable {
|
|||||||
|
|
||||||
extension SVG {
|
extension SVG {
|
||||||
public enum Key: Sendable {
|
public enum Key: Sendable {
|
||||||
|
case badgeCheck
|
||||||
|
case ban
|
||||||
|
case chevronDown
|
||||||
|
case chevronRight
|
||||||
|
case chevronsLeft
|
||||||
case circlePlus
|
case circlePlus
|
||||||
|
case circleUser
|
||||||
case close
|
case close
|
||||||
|
case doorClosed
|
||||||
case email
|
case email
|
||||||
|
case fan
|
||||||
case key
|
case key
|
||||||
|
case mapPin
|
||||||
|
case rulerDimensionLine
|
||||||
|
case sidebarToggle
|
||||||
|
case squareFunction
|
||||||
case squarePen
|
case squarePen
|
||||||
|
case trash
|
||||||
|
case triangleAlert
|
||||||
case user
|
case user
|
||||||
|
case wind
|
||||||
|
|
||||||
var svg: String {
|
var svg: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .badgeCheck:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||||
|
"""
|
||||||
|
case .ban:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronDown:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronRight:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronsLeft:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>
|
||||||
|
"""
|
||||||
case .circlePlus:
|
case .circlePlus:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||||
"""
|
"""
|
||||||
|
case .circleUser:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
|
||||||
|
"""
|
||||||
case .close:
|
case .close:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
"""
|
"""
|
||||||
|
case .doorClosed:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg>
|
||||||
|
"""
|
||||||
case .email:
|
case .email:
|
||||||
return """
|
return """
|
||||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -47,6 +90,10 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .fan:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg>
|
||||||
|
"""
|
||||||
case .key:
|
case .key:
|
||||||
return """
|
return """
|
||||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -64,9 +111,34 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .mapPin:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
"""
|
||||||
|
case .rulerDimensionLine:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg>
|
||||||
|
"""
|
||||||
|
case .sidebarToggle:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
|
||||||
|
"""
|
||||||
|
case .squareFunction:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg>
|
||||||
|
"""
|
||||||
case .squarePen:
|
case .squarePen:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
||||||
|
|
||||||
|
"""
|
||||||
|
case .trash:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
"""
|
||||||
|
case .triangleAlert:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||||
"""
|
"""
|
||||||
case .user:
|
case .user:
|
||||||
return """
|
return """
|
||||||
@@ -83,6 +155,10 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .wind:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
Sources/Styleguide/Tooltip.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
extension HTML {
|
||||||
|
|
||||||
|
public func tooltip(
|
||||||
|
_ tip: String,
|
||||||
|
position: TooltipPosition = .default
|
||||||
|
) -> Tooltip<Self> {
|
||||||
|
Tooltip(tip, position: position) {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Tooltip<Inner: HTML>: HTML {
|
||||||
|
|
||||||
|
let tooltip: String
|
||||||
|
let position: TooltipPosition
|
||||||
|
let inner: Inner
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ tooltip: String,
|
||||||
|
position: TooltipPosition = .default,
|
||||||
|
@HTMLBuilder inner: () -> Inner
|
||||||
|
) {
|
||||||
|
self.tooltip = tooltip
|
||||||
|
self.position = position
|
||||||
|
self.inner = inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(
|
||||||
|
.class("tooltip tooltip-\(position.rawValue)"),
|
||||||
|
.data("tip", value: tooltip)
|
||||||
|
) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Tooltip: Sendable where Inner: Sendable {}
|
||||||
|
|
||||||
|
public enum TooltipPosition: String, CaseIterable, Sendable {
|
||||||
|
|
||||||
|
public static let `default` = Self.left
|
||||||
|
|
||||||
|
case bottom
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
case top
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
guard groupGroups.count == groupLengths.count,
|
||||||
|
groupGroups.count == groupLetters.count,
|
||||||
|
groupGroups.count == groupQuantities.count
|
||||||
|
else {
|
||||||
|
throw ValidationError("Equivalent length form group counts are not equal.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups: [EffectiveLength.Group] {
|
||||||
|
var groups = [EffectiveLength.Group]()
|
||||||
|
for (n, group) in groupGroups.enumerated() {
|
||||||
|
groups.append(
|
||||||
|
.init(
|
||||||
|
group: group,
|
||||||
|
letter: groupLetters[n],
|
||||||
|
value: Double(groupLengths[n]),
|
||||||
|
quantity: groupQuantities[n]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EffectiveLength.Create {
|
||||||
|
|
||||||
|
init(
|
||||||
|
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||||
|
projectID: Project.ID
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
projectID: projectID,
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
straightLengths: form.straightLengths,
|
||||||
|
groups: form.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EffectiveLength.Update {
|
||||||
|
init(
|
||||||
|
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||||
|
projectID: Project.ID
|
||||||
|
) throws {
|
||||||
|
self.init(
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
straightLengths: form.straightLengths,
|
||||||
|
groups: form.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Sources/ViewController/Extensions/String+extensions.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
func appendingPath(_ string: String) -> Self {
|
||||||
|
guard string.starts(with: "/") else {
|
||||||
|
return self.appending("/\(string)")
|
||||||
|
}
|
||||||
|
return self.appending(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendingPath(_ id: UUID?) -> Self {
|
||||||
|
guard let id else { return self }
|
||||||
|
return appendingPath(id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendingPath(_ id: UUID) -> Self {
|
||||||
|
return appendingPath(id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var idString: Self {
|
||||||
|
replacing("-", with: "")
|
||||||
|
.replacing(" ", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
|
||||||
|
|
||||||
|
func toCreate(logger: Logger? = nil) throws -> TrunkSize.Create {
|
||||||
|
try .init(
|
||||||
|
projectID: projectID,
|
||||||
|
type: type,
|
||||||
|
rooms: makeRooms(logger: logger),
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUpdate(logger: Logger? = nil) throws -> TrunkSize.Update {
|
||||||
|
try .init(
|
||||||
|
type: type,
|
||||||
|
rooms: makeRooms(logger: logger),
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRooms(logger: Logger?) throws -> [Room.ID: [Int]] {
|
||||||
|
var retval = [Room.ID: [Int]]()
|
||||||
|
for room in rooms {
|
||||||
|
let split = room.split(separator: "_")
|
||||||
|
guard let idString = split.first,
|
||||||
|
let id = UUID(uuidString: String(idString))
|
||||||
|
else {
|
||||||
|
logger?.error("Could not parse id from: \(room)")
|
||||||
|
throw RoomError()
|
||||||
|
}
|
||||||
|
guard let registerString = split.last,
|
||||||
|
let register = Int(registerString)
|
||||||
|
else {
|
||||||
|
logger?.error("Could not register number from: \(room)")
|
||||||
|
throw RoomError()
|
||||||
|
}
|
||||||
|
if var currRegisters = retval[id] {
|
||||||
|
currRegisters.append(register)
|
||||||
|
retval[id] = currRegisters
|
||||||
|
} else {
|
||||||
|
retval[id] = [register]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomError: Error {}
|
||||||
7
Sources/ViewController/Extensions/UUID+idString.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension UUID {
|
||||||
|
var idString: String {
|
||||||
|
uuidString.idString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AuthClient
|
||||||
import Dependencies
|
import Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
import Elementary
|
import Elementary
|
||||||
@@ -15,10 +16,6 @@ public typealias AnySendableHTML = (any HTML & Sendable)
|
|||||||
|
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct ViewController: Sendable {
|
public struct ViewController: Sendable {
|
||||||
|
|
||||||
public typealias AuthenticateHandler = @Sendable (User) -> Void
|
|
||||||
public typealias CurrentUserHandler = @Sendable () throws -> User
|
|
||||||
|
|
||||||
public var view: @Sendable (Request) async throws -> AnySendableHTML
|
public var view: @Sendable (Request) async throws -> AnySendableHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,26 +26,17 @@ extension ViewController {
|
|||||||
public let route: SiteRoute.View
|
public let route: SiteRoute.View
|
||||||
public let isHtmxRequest: Bool
|
public let isHtmxRequest: Bool
|
||||||
public let logger: Logger
|
public let logger: Logger
|
||||||
public let authenticateUser: AuthenticateHandler
|
|
||||||
public let currentUser: CurrentUserHandler
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
route: SiteRoute.View,
|
route: SiteRoute.View,
|
||||||
isHtmxRequest: Bool,
|
isHtmxRequest: Bool,
|
||||||
logger: Logger,
|
logger: Logger
|
||||||
authenticateUser: @escaping AuthenticateHandler,
|
|
||||||
currentUser: @escaping CurrentUserHandler
|
|
||||||
) {
|
) {
|
||||||
self.route = route
|
self.route = route
|
||||||
self.isHtmxRequest = isHtmxRequest
|
self.isHtmxRequest = isHtmxRequest
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.authenticateUser = authenticateUser
|
|
||||||
self.currentUser = currentUser
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func authenticate(_ user: User) {
|
|
||||||
self.authenticateUser(user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +46,30 @@ extension ViewController: DependencyKey {
|
|||||||
// FIX: Fix.
|
// FIX: Fix.
|
||||||
public static let liveValue = Self(
|
public static let liveValue = Self(
|
||||||
view: { request in
|
view: { request in
|
||||||
try await request.render()
|
await request.render()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ViewController.Request {
|
||||||
|
|
||||||
|
func currentUser() throws -> User {
|
||||||
|
@Dependency(\.authClient.currentUser) var currentUser
|
||||||
|
return try currentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticate(
|
||||||
|
_ login: User.Login
|
||||||
|
) async throws -> User {
|
||||||
|
@Dependency(\.authClient) var auth
|
||||||
|
return try await auth.login(login)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createAndAuthenticate(
|
||||||
|
_ signup: User.Create
|
||||||
|
) async throws -> User {
|
||||||
|
@Dependency(\.authClient) var auth
|
||||||
|
return try await auth.createAndLogin(signup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||