44 Commits

Author SHA1 Message Date
2e2c424850 Merge branch 'feat-pdf' 2026-01-29 09:34:41 -05:00
93894e4c25 feat: Adds pdf client tests, updates view controller snapshots that have changed. 2026-01-29 09:33:43 -05:00
bab031f241 feat: Adds pdf support to docker images. 2026-01-28 16:41:12 -05:00
c82f20bb60 WIP: Mostly done with pdf client, need to add tests. 2026-01-28 15:47:56 -05:00
458b3bd644 feat: Adds EnvClient 2026-01-28 11:34:52 -05:00
58023c4dbc feat: Adds view snapshot tests 2026-01-28 10:26:59 -05:00
30241fec60 feat: Updates pdf css to a smaller font size. 2026-01-27 14:16:25 -05:00
273da46db2 feat: Adds file client. 2026-01-27 13:50:42 -05:00
6064b5267a WIP: Initial pdf generation and download, needs improvement and put somewhere different. 2026-01-27 12:53:55 -05:00
69e8acc5d8 WIP: Cleans up ResultView, fixes creating a new trunk size after changes to the database client, pdf button shows html now. 2026-01-27 10:12:51 -05:00
066b3003d0 WIP: Using project detail for duct size calculations, fix trunk sizes querying database for room data, it's now eagerly loaded. 2026-01-27 08:59:36 -05:00
1663c0a514 WIP: Working on a project detail database request to minimize database calls. 2026-01-26 16:43:16 -05:00
e08d896758 feat: Adds better mock support for models to aid in testing / viewing a mock project for the pdf client. 2026-01-26 13:39:27 -05:00
b3c6c27a96 feat: Updates database client migrations to be called as a function. 2026-01-26 11:13:29 -05:00
5fa11ae584 WIP: Style updates for pdf view. 2026-01-18 18:49:20 -05:00
04a7405ca4 WIP: Html view that prints to pdf ok. 2026-01-17 20:40:49 -05:00
0fe80d05c6 WIP: Begins work on pdf client. 2026-01-17 12:59:46 -05:00
3ec1ee2814 feat: Begins creating an auth client and integrates into view controller routes. 2026-01-16 17:04:05 -05:00
761ba29c1e feat: Cleans up / renames some manual-d client routes. 2026-01-16 12:10:33 -05:00
13c4bb33b5 feat: Reorganizes / creates duct sizes container, uses it in views and projectClient. 2026-01-16 11:40:21 -05:00
146baa7815 feat: Moves TrunkSize to be it's own namespace rather than being under DuctSizing, as it's got it's own database model, etc. 2026-01-16 10:48:07 -05:00
b5436c2073 feat: Moves rectangular size to room namespace instead of under duct sizing, since it's stored on the room database model. 2026-01-16 10:26:11 -05:00
59c1c9ec4a feat: Uses shared duct size container for both room and trunk duct sizing containers. 2026-01-16 10:16:31 -05:00
65fc8565b6 feat: More cleanup / renaming for project client. 2026-01-16 09:40:15 -05:00
d14477e97a feat: Cleans up / moves helpers for view controller to project client. 2026-01-16 09:31:35 -05:00
dbec7fb920 WIP: Attempt at breaking out some logic / middleware between database and view layer, to remove some code from the view controller. Not complete, maybe revert. 2026-01-15 23:02:36 -05:00
6b8cb73434 feat: Adds TODO's. 2026-01-15 19:51:26 -05:00
c6a29313aa fix: Fixes setting theme upon signup not working upon submitting the signup form.
All checks were successful
Create and publish a Docker image / build-and-push-image (push) Successful in 6m52s
2026-01-15 19:23:34 -05:00
f5afc6e32b feat: Adds release workflow.
All checks were successful
Create and publish a Docker image / build-and-push-image (push) Successful in 8m45s
2026-01-15 15:33:31 -05:00
9709eaaf8e feat: Removes register-id in favor of using the room name with register number in duct sizing forms / tables. 2026-01-15 15:18:42 -05:00
4ecd4dba7b feat: Style updates, begins adding name/label to trunk sizes. Need to remove register id. 2026-01-15 13:00:46 -05:00
7471e11bd2 fix: Fixes some layout issues with footer and sidebar, makes size column in duct-sizing views to be a fixed width, so the tables line up properly. 2026-01-15 09:17:27 -05:00
1b88f81b5f feat: Adds page header styles, starts an Alert component. 2026-01-14 23:09:28 -05:00
86307dfa05 feat: Uses room names for trunk sizing in the form and table, prep for removing register-id's in favor of only using the room names. 2026-01-14 19:24:56 -05:00
356e020e3b fix: Fixes trunk / runout rectangular size badge not matching color of room rectangular size. 2026-01-14 19:05:49 -05:00
9b5b891744 feat: Adds footer with copyright info. 2026-01-14 19:02:10 -05:00
658ea9f12e feat: Adds meta tags for og / twitter links. 2026-01-14 18:29:19 -05:00
7f734e912b fix: Fixes rectangular duct rounding. 2026-01-14 17:04:27 -05:00
b5d1f87380 feat: Adds manual-d group pdf while working on better picker for groups, fixes issues with trunk table not always rendering properly with certain themes. 2026-01-14 16:53:05 -05:00
450791b37e fix: Fixes duct sizing rooms table not showing forms correctly, updates the table styles. 2026-01-14 10:32:57 -05:00
71848c607a WIP: Rooms table style updates in duct sizing tab, but room form is not working properly on all rows for some reason. 2026-01-13 22:47:50 -05:00
62a82ed674 fix: Fixes height / width not working for trunk sizes. 2026-01-13 20:36:52 -05:00
dfee50de8e feat-WIP: Adds update to trunk-size form, currently height / width is not working though. 2026-01-13 20:23:25 -05:00
f990c4b6db WIP: Begin cleaning up duct sizing routes. 2026-01-13 17:01:44 -05:00
119 changed files with 9962 additions and 1667 deletions

View 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
View File

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

View File

@@ -1,5 +1,5 @@
{
"originHash" : "5d6dad57209ac74e3c47d8e8eb162768b81c9e63e15df87d29019d46a13cfec2",
"originHash" : "c3efcfd33bc1490f59ae406e4e5292027b2d01cafee9fc625652213505df50fb",
"pins" : [
{
"identity" : "async-http-client",
@@ -226,6 +226,15 @@
"version" : "4.2.0"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "93a8aa4937030b606de42f44b17870249f49af0b",
"version" : "1.3.4"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
@@ -361,6 +370,15 @@
"version" : "2.9.1"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b",
"version" : "1.18.7"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",

View File

@@ -7,7 +7,13 @@ let package = Package(
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.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: "ManualDClient", targets: ["ManualDClient"]),
.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/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
@@ -31,6 +38,7 @@ let package = Package(
name: "App",
dependencies: [
.target(name: "ApiController"),
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -53,6 +61,15 @@ let package = Package(
.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(
name: "DatabaseClient",
dependencies: [
@@ -63,6 +80,61 @@ let package = Package(
.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(
name: "ManualDCore",
dependencies: [
@@ -104,7 +176,10 @@ let package = Package(
.target(
name: "ViewController",
dependencies: [
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),
@@ -115,5 +190,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.testTarget(
name: "ViewControllerTests",
dependencies: [
.target(name: "ViewController"),
.target(name: "HTMLSnapshotTesting"),
],
resources: [
.copy("__Snapshots__")
]
),
]
)

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

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

View File

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

View File

@@ -20,8 +20,6 @@
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
@@ -30,9 +28,8 @@
--text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--font-weight-bold: 700;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
@@ -4287,6 +4284,12 @@
}
}
}
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.left-0 {
left: calc(var(--spacing) * 0);
}
.join {
display: inline-flex;
align-items: stretch;
@@ -4804,6 +4807,9 @@
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-3 {
grid-column: span 3 / span 3;
}
.timeline-end {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -5273,9 +5279,6 @@
}
}
}
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-auto {
margin-inline: auto;
}
@@ -5365,9 +5368,6 @@
}
}
}
.-my-2 {
margin-block: calc(var(--spacing) * -2);
}
.my-1\.5 {
margin-block: calc(var(--spacing) * 1.5);
}
@@ -5586,8 +5586,11 @@
.me-4 {
margin-inline-end: calc(var(--spacing) * 4);
}
.me-8 {
margin-inline-end: calc(var(--spacing) * 8);
.me-6 {
margin-inline-end: calc(var(--spacing) * 6);
}
.me-\[140px\] {
margin-inline-end: 140px;
}
.modal-action {
@layer daisyui.l1.l2.l3 {
@@ -5628,15 +5631,15 @@
}
}
}
.-mt-2 {
margin-top: calc(var(--spacing) * -2);
}
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
.mt-6 {
margin-top: calc(var(--spacing) * 6);
}
.mt-auto {
margin-top: auto;
}
.breadcrumbs {
@layer daisyui.l1.l2.l3 {
max-width: 100%;
@@ -5719,6 +5722,9 @@
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-auto {
margin-bottom: auto;
}
.carousel-item {
@layer daisyui.l1.l2.l3 {
box-sizing: content-box;
@@ -6440,11 +6446,8 @@
.h-full {
height: 100%;
}
.h-screen {
height: 100vh;
}
.min-h-full {
min-height: 100%;
.min-h-screen {
min-height: 100vh;
}
.btn-wide {
@layer daisyui.l1.l2 {
@@ -6574,14 +6577,23 @@
width: calc(var(--size-selector, 0.25rem) * 4);
}
}
.w-\[330px\] {
width: 330px;
}
.w-fit {
width: fit-content;
}
.w-full {
width: 100%;
}
.max-w-\[300px\] {
max-width: 300px;
.min-w-\[200px\] {
min-width: 200px;
}
.min-w-\[220px\] {
min-width: 220px;
}
.min-w-full {
min-width: 100%;
}
.flex-1 {
flex: 1;
@@ -6782,18 +6794,12 @@
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-baseline {
align-items: baseline;
}
.items-center {
align-items: center;
}
@@ -6824,6 +6830,20 @@
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.space-y-1 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-2 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-4 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -6859,8 +6879,11 @@
margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse)));
}
}
.overflow-x-auto {
overflow-x: auto;
.gap-y-4 {
row-gap: calc(var(--spacing) * 4);
}
.overflow-auto {
overflow: auto;
}
.timeline-box {
@layer daisyui.l1.l2.l3 {
@@ -6968,6 +6991,9 @@
.rounded-selector {
border-radius: var(--radius-selector);
}
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-t-box {
border-top-left-radius: var(--radius-box);
border-top-right-radius: var(--radius-box);
@@ -7140,9 +7166,9 @@
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-b-1 {
border-bottom-style: var(--tw-border-style);
@@ -7257,9 +7283,6 @@
border-color: currentColor;
}
}
.border-base-300 {
border-color: var(--color-base-300);
}
.border-error {
border-color: var(--color-error);
}
@@ -7269,9 +7292,6 @@
.border-primary {
border-color: var(--color-primary);
}
.border-secondary {
border-color: var(--color-secondary);
}
.menu-active {
:where(:not(ul, details, .menu-title, .btn))& {
@layer daisyui.l1.l2 {
@@ -7423,15 +7443,18 @@
}
}
}
.bg-base-100 {
background-color: var(--color-base-100);
}
.bg-base-300 {
background-color: var(--color-base-300);
}
.bg-error {
background-color: var(--color-error);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-secondary {
background-color: var(--color-secondary);
}
.bg-white {
background-color: var(--color-white);
}
@@ -7723,6 +7746,9 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.menu-title {
@layer daisyui.l1.l2.l3 {
padding-inline: calc(0.25rem * 3);
@@ -7869,14 +7895,8 @@
.pe-2 {
padding-inline-end: calc(var(--spacing) * 2);
}
.pt-4 {
padding-top: calc(var(--spacing) * 4);
}
.pt-6 {
padding-top: calc(var(--spacing) * 6);
}
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
.pt-2 {
padding-top: calc(var(--spacing) * 2);
}
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
@@ -8495,12 +8515,6 @@
color: var(--color-warning);
}
}
.text-base-100 {
color: var(--color-base-100);
}
.text-base-300 {
color: var(--color-base-300);
}
.text-base-content {
color: var(--color-base-content);
}
@@ -8516,21 +8530,6 @@
.text-info {
color: var(--color-info);
}
.text-neutral {
color: var(--color-neutral);
}
.text-neutral-content {
color: var(--color-neutral-content);
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-secondary-content {
color: var(--color-secondary-content);
}
.text-slate-900 {
color: var(--color-slate-900);
}
@@ -9417,13 +9416,6 @@
border-color: var(--color-red-500);
}
}
.hover\:rounded-lg {
&:hover {
@media (hover: hover) {
border-radius: var(--radius-lg);
}
}
}
.hover\:bg-neutral {
&:hover {
@media (hover: hover) {
@@ -9445,16 +9437,6 @@
}
}
}
.hover\:btn-success {
&:hover {
@media (hover: hover) {
@layer daisyui.l1.l2.l3 {
--btn-color: var(--color-success);
--btn-fg: var(--color-success-content);
}
}
}
}
.focus\:outline {
&:focus {
outline-style: var(--tw-outline-style);
@@ -9481,11 +9463,26 @@
color: var(--color-white);
}
}
.sm\:footer-horizontal {
@media (width >= 40rem) {
@layer daisyui.l1.l2 {
grid-auto-flow: column;
&.footer-center {
grid-auto-flow: row dense;
}
}
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.md\:grid-cols-3 {
@media (width >= 48rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:drawer-open {
@media (width >= 64rem) {
@layer daisyui.l1.l2.l3 {
@@ -9539,14 +9536,9 @@
}
}
}
.lg\:grid-cols-3 {
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.\32 xl\:table-cell {
@media (width >= 96rem) {
display: table-cell;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.is-drawer-close\:mx-auto {

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

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

BIN
Public/files/ManD.Groups.pdf Executable file

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -33,6 +33,8 @@ extension SiteRoute.Api.ProjectRoute {
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()

View File

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

View File

@@ -1,12 +1,14 @@
import ApiController
import AuthClient
import DatabaseClient
import Dependencies
import ManualDCore
import PdfClient
import Vapor
import ViewController
// Taken from discussions page on `swift-dependencies`.
// FIX: Use live view controller.
struct DependenciesMiddleware: AsyncMiddleware {
private let values: DependencyValues.Continuation
@@ -29,9 +31,12 @@ struct DependenciesMiddleware: AsyncMiddleware {
try await values.yield {
try await withDependencies {
$0.apiController = apiController
$0.authClient = .live(on: request)
$0.database = database
// $0.dateFormatter = .liveValue
$0.viewController = viewController
$0.pdfClient = .liveValue
$0.fileClient = .live(fileIO: request.fileio)
} operation: {
try await next.respond(to: request)
}

View File

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

View File

@@ -5,6 +5,7 @@ import Fluent
import FluentSQLiteDriver
import ManualDCore
import NIOSSL
import ProjectClient
import Vapor
import VaporElementary
@preconcurrency import VaporRouting
@@ -65,7 +66,7 @@ private func setupDatabase(
let databaseClient = makeDatabaseClient(app.db)
if app.environment != .testing {
try await app.migrations.add(databaseClient.migrations.run())
try await app.migrations.add(databaseClient.migrations())
}
return databaseClient
@@ -111,6 +112,8 @@ extension SiteRoute {
}
}
extension DuctSizes: Content {}
@Sendable
private func siteHandler(
request: Request,
@@ -118,12 +121,17 @@ private func siteHandler(
) async throws -> any AsyncResponseEncodable {
@Dependency(\.apiController) var apiController
@Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient
switch route {
case .api(let route):
return try await apiController.respond(route, request: request)
case .health:
return HTTPStatus.ok
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
// need to handle it seperately.
case .view(.project(.detail(let projectID, .pdf))):
return try await projectClient.generatePdf(projectID)
case .view(let route):
return try await viewController.respond(route: route, request: request)
}

View 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) }
)
}
}

View File

@@ -55,6 +55,10 @@ extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.run()
}
}
}
@@ -74,7 +78,7 @@ extension DatabaseClient.Migrations: DependencyKey {
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
DuctSizing.TrunkSize.Migrate(),
TrunkSize.Migrate(),
]
}
)

View File

@@ -9,6 +9,7 @@ extension DatabaseClient {
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
@@ -33,6 +34,44 @@ extension DatabaseClient.Projects: TestDependencyKey {
}
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
@@ -73,10 +112,16 @@ extension DatabaseClient.Projects: TestDependencyKey {
)
},
getSensibleHeatRatio: { id in
guard let model = try await ProjectModel.find(id, on: database) else {
guard
let shr = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return model.sensibleHeatRatio
return shr.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
@@ -242,6 +287,18 @@ final class ProjectModel: Model, @unchecked Sendable {
@Children(for: \.$project)
var componentLosses: [ComponentLossModel]
@OptionalChild(for: \.$project)
var equipment: EquipmentModel?
@Children(for: \.$project)
var equivalentLengths: [EffectiveLengthModel]
@Children(for: \.$project)
var rooms: [RoomModel]
@Children(for: \.$project)
var trunks: [TrunkModel]
@Parent(key: "userID")
var user: UserModel

View File

@@ -10,10 +10,11 @@ extension DatabaseClient {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct.ID) async throws -> Room
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
}
@@ -67,6 +68,19 @@ extension DatabaseClient.Rooms: TestDependencyKey {
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()
}
)
}
@@ -189,7 +203,7 @@ final class RoomModel: Model, @unchecked Sendable {
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]?
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -209,7 +223,7 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID

View File

@@ -1,212 +0,0 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
}
}
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 = [DuctSizing.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
)
try await model.save(on: database)
try roomProxies.append(model.toDTO())
}
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(\.$rooms)
.with(\.$project)
.filter(\.$project.$id == projectID)
.all()
.map { try $0.toDTO() }
},
get: { id in
try await TrunkModel.find(id, on: database)
.map { try $0.toDTO() }
}
)
}
}
extension DuctSizing.TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
func toModel() -> TrunkModel {
.init(
projectID: projectID,
type: type,
height: height
)
}
}
extension DuctSizing.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("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(
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
)
.field(
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
)
.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]
init() {}
init(
id: UUID? = nil,
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int]
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
}
func toDTO() throws -> DuctSizing.TrunkSize.RoomProxy {
.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
@Field(key: "height")
var height: Int?
@Field(key: "type")
var type: String
@Children(for: \.$trunk)
var rooms: [TrunkRoomModel]
init() {}
init(
id: UUID? = nil,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
height: Int? = nil,
) {
self.id = id
$project.id = projectID
self.height = height
self.type = type.rawValue
}
func toDTO() throws -> DuctSizing.TrunkSize {
try .init(
id: requireID(),
projectID: $project.id,
type: .init(rawValue: type)!,
rooms: rooms.map { try $0.toDTO() },
height: height
)
}
}

View 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())
}
}
// }
}

View 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()
}()

View 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()
}
}
)
}
}

View File

@@ -0,0 +1,22 @@
import Elementary
import SnapshotTesting
extension Snapshotting where Value == (any HTML), Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { (html: any HTML) in html.renderFormatted() }
snapshotting.pathExtension = "html"
return snapshotting
}
}
extension Snapshotting where Value == String, Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { $0 }
snapshotting.pathExtension = "html"
return snapshotting
}
}

View File

@@ -14,6 +14,38 @@ extension Room {
}
}
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 {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
@@ -32,6 +64,8 @@ func roundSize(_ size: Double) throws -> Int {
throw ManualDError(message: "Size should be less than 24.")
}
// let size = size.rounded(.toNearestOrEven)
switch size {
case 0..<4:
return 4

View File

@@ -0,0 +1,129 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
/// 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 {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
public init(
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let calculatedSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.calculatedSize = calculatedSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

@@ -13,7 +13,7 @@ extension ManualDClient: DependencyKey {
let finalSize = try roundSize(ductulatorSize)
let flexSize = try flexSize(request)
return .init(
ductulatorSize: ductulatorSize,
calculatedSize: ductulatorSize,
finalSize: finalSize,
flexSize: flexSize,
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.")
}
let totalComponentLosses = request.componentPressureLosses.totalComponentPressureLoss
let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
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 runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
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)
// Round the width up or fail (really should never fail since we know the input is a number).
guard let widthStr = numberFormatter.string(for: width),
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)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
}
)
}
private let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
formatter.roundingMode = .ceiling
return formatter
}()

View File

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

View File

@@ -1,3 +1,4 @@
import Dependencies
import Foundation
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
@@ -69,7 +70,7 @@ extension ComponentPressureLoss {
}
extension Array where Element == ComponentPressureLoss {
public var totalComponentPressureLoss: Double {
public var total: Double {
reduce(into: 0) { $0 += $1.value }
}
}
@@ -89,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 {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "evaporator-coil",
value: 0.2,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "filter",
value: 0.1,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "supply-outlet",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "return-grille",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "balancing-damper",
value: 0.03,
createdAt: now,
updatedAt: now
),
]
}
public static var mock: [Self] {
[
.init(

View File

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

View File

@@ -1,160 +0,0 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct RoomContainer: Codable, Equatable, Sendable {
public let registerID: String
public let roomID: Room.ID
public let roomName: String
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let rectangularSize: RectangularDuct?
public let rectangularWidth: Int?
public init(
registerID: String,
roomID: Room.ID,
roomName: String,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.registerID = registerID
self.roomID = roomID
self.roomName = roomName
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}
extension DuctSizing {
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 init(
id: UUID,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [DuctSizing.TrunkSize.RoomProxy],
height: Int? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
}
}
}
extension DuctSizing.TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public init(
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
}
}
// TODO: Make registers non-optional
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]?
public init(room: Room, registers: [Int]? = nil) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, Codable, Equatable, Sendable {
case `return`
case supply
}
}

View File

@@ -142,6 +142,44 @@ extension Array where Element == EffectiveLength.Group {
#if DEBUG
extension EffectiveLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
public static let mocks: [Self] = [
.init(
id: UUID(0),

View File

@@ -70,6 +70,21 @@ extension EquipmentInfo {
#if DEBUG
extension EquipmentInfo {
public static func mock(projectID: Project.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
projectID: projectID,
heatingCFM: 900,
coolingCFM: 1000,
createdAt: now,
updatedAt: now
)
}
public static let mock = Self(
id: UUID(0),
projectID: UUID(0),

View 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

View 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)!
}

View File

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

View File

@@ -84,6 +84,32 @@ extension Project {
}
}
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?
@@ -114,16 +140,22 @@ extension Project {
#if DEBUG
extension Project {
public static let mock = Self(
id: UUID(0),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: Date(),
updatedAt: Date()
)
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -9,7 +9,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let coolingTotal: Double
public let coolingSensible: Double?
public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public let createdAt: Date
public let updatedAt: Date
@@ -21,7 +21,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -65,13 +65,30 @@ extension Room {
}
}
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: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public init(
name: String? = nil,
@@ -89,7 +106,7 @@ extension Room {
}
public init(
rectangularSizes: [DuctSizing.RectangularDuct]
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.heatingLoad = nil
@@ -154,6 +171,86 @@ extension Array where Element == Room {
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

View File

@@ -88,11 +88,16 @@ extension SiteRoute.Api {
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

View File

@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute)
static let router = OneOf {
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) {
FrictionRateRoute.router
}
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) {
RoomRoute.router
}
@@ -607,9 +612,11 @@ extension SiteRoute.View.ProjectRoute {
public enum DuctSizingRoute: Equatable, Sendable {
case index
case deleteRectangularSize(Room.ID, DuctSizing.RectangularDuct.ID)
case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
case roomRectangularForm(Room.ID, RoomRectangularForm)
case trunk(TrunkRoute)
public static let roomPath = "room"
static let rootPath = "duct-sizing"
static let router = OneOf {
@@ -620,25 +627,27 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.deleteRectangularSize)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
}
Route(.case(Self.roomRectangularForm)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.post
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
Field("id") { Room.RectangularSize.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
@@ -646,12 +655,125 @@ extension SiteRoute.View.ProjectRoute {
.map(.memberwise(RoomRectangularForm.init))
}
}
Route(.case(Self.trunk)) {
Path { rootPath }
TrunkRoute.router
}
}
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: Room.RectangularSize.ID
public let register: Int
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(TrunkSize.ID)
case submit(TrunkSizeForm)
case update(TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
static let router = OneOf {
Route(.case(Self.delete)) {
Path {
rootPath
TrunkSize.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path {
rootPath
}
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
}
}
public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let id: Room.RectangularSize.ID?
public let register: Int
public let height: Int
public init(
id: Room.RectangularSize.ID? = nil,
register: Int,
height: Int
) {
self.id = id
self.register = register
self.height = height
}
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
public init(
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil,
rooms: [String]
) {
self.projectID = projectID
self.type = type
self.height = height
self.name = name
self.rooms = rooms
}
}
}
}

View 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

View File

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

View File

@@ -1,3 +1,4 @@
import Dependencies
import Foundation
extension User {
@@ -113,3 +114,27 @@ extension User.Profile {
}
}
}
#if DEBUG
extension User.Profile {
public static func mock(userID: User.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
userID: userID,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "1234 Sesame St",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import Elementary
import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [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) }
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.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)
)
)
)
}
}

View File

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

View 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
)
}
}

View 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 }
}
}
}

View File

@@ -11,7 +11,7 @@ public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
}
public var body: some HTML<HTMLTag.div> {
div(.class("badge badge-lg badge-outline font-bold")) {
div(.class("badge badge-lg badge-outline")) {
inner
}
}

View File

@@ -65,7 +65,7 @@ public struct EditButton: HTML, Sendable {
}
public var body: some HTML<HTMLTag.button> {
button(.class("btn hover:btn-success"), .type(type)) {
button(.class("btn"), .type(type)) {
div(.class("flex")) {
if let title {
span(.class("pe-2")) { title }
@@ -83,7 +83,7 @@ public struct PlusButton: HTML, Sendable {
public var body: some HTML<HTMLTag.button> {
button(
.type(.button),
.class("btn btn-primary btn-circle text-xl")
.class("btn")
) { SVG(.circlePlus) }
}
}

View File

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

View File

@@ -43,6 +43,14 @@ extension HTMLAttribute where Tag == HTMLTag.button {
extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline font-bold"))
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)
}
}

View File

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

View File

@@ -19,7 +19,7 @@ public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
self.inner = inner()
}
public var body: some HTML {
public var body: some HTML<HTMLTag.dialog> {
dialog(.id(id), .class("modal")) {
div(.class("modal-box")) {
if closeButton {

View File

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

View File

@@ -1,5 +1,27 @@
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

View File

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

View File

@@ -33,6 +33,7 @@ extension SVG {
case squareFunction
case squarePen
case trash
case triangleAlert
case user
case wind
@@ -135,6 +136,10 @@ extension SVG {
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:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">

View File

@@ -1,6 +1,18 @@
import Elementary
public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
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
@@ -26,6 +38,8 @@ public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
}
}
extension Tooltip: Sendable where Inner: Sendable {}
public enum TooltipPosition: String, CaseIterable, Sendable {
public static let `default` = Self.left

View File

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

View File

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

View File

@@ -17,4 +17,9 @@ extension String {
func appendingPath(_ id: UUID) -> Self {
return appendingPath(id.uuidString)
}
var idString: Self {
replacing("-", with: "")
.replacing(" ", with: "")
}
}

View File

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

View File

@@ -2,6 +2,6 @@ import Foundation
extension UUID {
var idString: String {
uuidString.replacing("-", with: "")
uuidString.idString
}
}

View File

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

View File

@@ -3,6 +3,8 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import PdfClient
import ProjectClient
import Styleguide
extension ViewController.Request {
@@ -10,12 +12,28 @@ extension ViewController.Request {
func render() async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
@Dependency(\.pdfClient) var pdfClient
switch route {
case .test:
return await view {
TestPage()
}
// let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
// return await view {
// await ResultView {
//
// // return (
// // try await database.projects.getCompletedSteps(projectID),
// // try await projectClient.calculateDuctSizes(projectID)
// // )
// return try await pdfClient.html(.mock())
// } onSuccess: {
// $0
// // TestPage()
// // TestPage(trunks: result.trunks, rooms: result.rooms)
// }
// }
// return try! await pdfClient.html(.mock())
return EmptyHTML()
case .login(let route):
switch route {
case .index(let next):
@@ -57,10 +75,13 @@ extension ViewController.Request {
// let user = try currentUser()
return (
userID,
try await database.projects.fetch(userID, .init(page: 1, per: 25))
try await database.projects.fetch(userID, .init(page: 1, per: 25)),
profile.theme
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
} onSuccess: { (userID, projects, theme) in
MainPage(displayFooter: true, theme: theme) {
ProjectsTable(userID: userID, projects: projects)
}
}
}
}
@@ -78,7 +99,7 @@ extension ViewController.Request {
let inner = await inner()
let theme = await self.theme
return MainPage(theme: theme) {
return MainPage(displayFooter: displayFooter, theme: theme) {
inner
}
}
@@ -90,12 +111,22 @@ extension ViewController.Request {
return try? await database.userProfile.fetch(user.id)?.theme
}
}
var displayFooter: Bool {
switch route {
case .login, .signup:
return false
default:
return true
}
}
}
extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -104,7 +135,7 @@ extension SiteRoute.View.ProjectRoute {
let user = try request.currentUser()
return try await (
user.id,
database.projects.fetchPage(userID: user.id)
database.projects.fetch(user.id, .first)
)
} onSuccess: { (userID, projects) in
@@ -123,21 +154,18 @@ extension SiteRoute.View.ProjectRoute {
}
case .create(let form):
return await ResultView {
let user = try request.currentUser()
let project = try await database.projects.create(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id)
let rooms = try await database.rooms.fetch(project.id)
let shr = try await database.projects.getSensibleHeatRatio(project.id)
let completedSteps = try await database.projects.getCompletedSteps(project.id)
return (project.id, rooms, shr, completedSteps)
} onSuccess: { (projectID, rooms, shr, completedSteps) in
ProjectView(
projectID: projectID,
activeTab: .rooms,
completedSteps: completedSteps
) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
return await request.view {
await ResultView {
let user = try request.currentUser()
return try await projectClient.createProject(user.id, form)
} onSuccess: { response in
ProjectView(
projectID: response.projectID,
activeTab: .rooms,
completedSteps: response.completedSteps
) {
RoomsView(rooms: response.rooms, sensibleHeatRatio: response.sensibleHeatRatio)
}
}
}
@@ -165,6 +193,12 @@ extension SiteRoute.View.ProjectRoute {
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route):
return await route.renderView(on: request, projectID: projectID)
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
// return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route):
return await route.renderView(on: request, projectID: projectID)
}
@@ -350,7 +384,7 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
frictionRate: frictionRate
)
}
@@ -394,32 +428,21 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
return await request.view {
await ResultView {
try await catching()
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
try await projectClient.frictionRate(projectID)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
} onSuccess: { (steps, response) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
componentLosses: response.componentLosses,
equivalentLengths: response.equivalentLengths,
frictionRate: response.frictionRate
)
}
@@ -533,36 +556,52 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
return await view(on: request, projectID: projectID)
case .deleteRectangularSize(let roomID, let rectangularSizeID):
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .roomRectangularForm(let roomID, let form):
return await ResultView {
let room = try await database.rooms.update(
let room = try await database.rooms.updateRectangularSize(
roomID,
.init(
rectangularSizes: [
.init(id: form.id ?? .init(), register: form.register, height: form.height)
]
)
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .trunk(let route):
switch route {
case .delete(let id):
return await ResultView {
try await database.trunkSizes.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.create(
form.toCreate(logger: request.logger)
)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.update(id, form.toUpdate())
}
}
}
}
@@ -573,17 +612,18 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var project
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
try await project.calculateDuctSizes(projectID)
)
} onSuccess: { (steps, rooms) in
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: rooms)
DuctSizingView(ductSizes: ducts)
}
}
}

View File

@@ -3,52 +3,46 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Load component losses when view appears??
struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss]
let projectID: Project.ID
private var total: Double {
componentPressureLosses.reduce(into: 0) { $0 += $1.value }
componentPressureLosses.total
}
private var sortedLosses: [ComponentPressureLoss] {
componentPressureLosses.sorted {
$0.value > $1.value
}
}
var body: some HTML {
div(.class("space-y-4")) {
Row {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
LabeledContent("Total") {
Badge(number: total)
}
PlusButton()
.attributes(
.class("btn-primary text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
.tooltip("Add component loss")
}
.attributes(.class("px-4"))
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl font-bold")) {
th { "Name" }
th { "Value" }
th {
div(.class("flex justify-end mx-auto")) {
Tooltip("Add Component Loss") {
PlusButton()
.attributes(
.class("btn-ghost text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
}
}
}
}
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl font-bold")) {
th { "Name" }
th { "Value" }
th(.class("min-w-[200px]")) {}
}
tbody {
for row in componentPressureLosses {
TableRow(row: row)
}
}
tbody {
for row in sortedLosses {
TableRow(row: row)
}
}
}
}

View File

@@ -3,151 +3,69 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Add error text if prior steps are not completed.
struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
let ductSizes: DuctSizes
var body: some HTML {
div(.class("space-y-4")) {
PageTitle { "Duct Sizes" }
if rooms.count == 0 {
p(.class("text-error italic")) {
"Must complete all the previous sections to display duct sizing calculations."
PageTitleRow {
div {
PageTitle("Duct Sizes")
Alert(
"""
Must complete all the previous sections to display duct sizing calculations.
"""
)
.hidden(when: ductSizes.rooms.count > 0)
.attributes(.class("text-error font-bold italic mt-4"))
}
} else {
RoomsTable(projectID: projectID, rooms: rooms)
div {
button(
.class("btn btn-primary"),
.hx.get(route: .project(.detail(projectID, .pdf))),
.hx.ext("htmx-download"),
.hx.swap(.none),
.hx.indicator()
) {
span { "PDF" }
Indicator()
}
// div {
// Indicator()
// }
}
}
if ductSizes.rooms.count != 0 {
RoomsTable(rooms: ductSizes.rooms)
PageTitleRow {
PageTitle {
"Trunk / Runout Sizes"
}
PlusButton()
.attributes(
.class("btn-primary"),
.showModal(id: TrunkSizeForm.id())
)
.tooltip("Add trunk / runout")
}
if ductSizes.trunks.count > 0 {
TrunkTable(ductSizes: ductSizes)
}
}
TrunkSizeForm(rooms: ductSizes.rooms, dismiss: true)
}
}
struct RoomsTable: HTML, Sendable {
let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
var body: some HTML<HTMLTag.div> {
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl text-gray-400 font-bold")) {
th { "ID" }
th { "Name" }
th { "H-BTU" }
th { "C-BTU" }
th(.class("hidden 2xl:table-cell")) { "Htg CFM" }
th(.class("hidden 2xl:table-cell")) { "Clg CFM" }
th { "Dsn CFM" }
th(.class("hidden 2xl:table-cell")) { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Width" }
th { "Height" }
}
}
tbody {
for room in rooms {
RoomRow(projectID: projectID, room: room)
}
}
}
}
}
}
struct RoomRow: HTML, Sendable {
let projectID: Project.ID
let room: DuctSizing.RoomContainer
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .ductSizing(.index)))
)
.appendingPath("room")
.appendingPath(room.roomID)
}
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg items-baseline"), .id(room.roomID.idString)) {
td { room.registerID }
td { room.roomName }
td { Number(room.heatingLoad, digits: 0) }
td { Number(room.coolingLoad, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.heatingCFM, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.coolingCFM, digits: 0) }
td {
Badge(number: room.designCFM.value, digits: 0)
.attributes(.class("badge-\(room.designCFM.color)"))
}
td(.class("hidden 2xl:table-cell")) { Number(room.roundSize, digits: 1) }
td { Number(room.velocity) }
td {
Badge(number: room.finalSize)
.attributes(.class("badge-secondary"))
}
td {
Number(room.flexSize)
.attributes(.class("badge badge-outline badge-primary text-xl font-bold"))
}
td {
if let width = room.rectangularWidth {
Number(width)
}
}
td {
div(.class("flex justify-between items-center space-x-4")) {
div(.id("height_\(room.roomID.idString)"), .class("h-full my-auto")) {
if let height = room.rectangularSize?.height {
Number(height)
}
}
div {
div(.class("join")) {
// FIX: Delete rectangular size from room.
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(
route: .project(
.detail(
projectID,
.ductSizing(
.deleteRectangularSize(
room.roomID,
room.rectangularSize?.id ?? .init())
)
)
)
),
.hx.target("closest tr"),
.hx.swap(.outerHTML),
when: room.rectangularSize != nil
)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RectangularSizeForm.id(room))
)
}
}
}
RectangularSizeForm(projectID: projectID, room: room)
}
}
}
}
}
extension DuctSizing.DesignCFM {
var color: String {
switch self {
case .heating: return "error"
case .cooling: return "info"
}
}
}

View File

@@ -5,80 +5,56 @@ import Styleguide
struct RectangularSizeForm: HTML, Sendable {
static func id(_ roomID: Room.ID? = nil) -> String {
let base = "rectangularSizeForm"
guard let roomID else { return base }
return "\(base)_\(roomID.idString)"
static func id(_ room: DuctSizes.RoomContainer) -> String {
let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)"
}
static func id(_ room: DuctSizing.RoomContainer) -> String {
return id(room.roomID)
}
@Environment(ProjectViewValue.$projectID) var projectID
let projectID: Project.ID
let roomID: Room.ID
let rectangularSizeID: DuctSizing.RectangularDuct.ID?
let register: Int
let height: Int?
let id: String
let room: DuctSizes.RoomContainer
let dismiss: Bool
init(
projectID: Project.ID,
roomID: Room.ID,
rectangularSizeID: DuctSizing.RectangularDuct.ID? = nil,
register: Int,
height: Int? = nil,
id: String? = nil,
room: DuctSizes.RoomContainer,
dismiss: Bool = true
) {
self.projectID = projectID
self.roomID = roomID
self.rectangularSizeID = rectangularSizeID
self.register = register
self.height = height
self.id = Self.id(room)
self.room = room
self.dismiss = dismiss
}
init(
projectID: Project.ID,
room: DuctSizing.RoomContainer,
dismiss: Bool = true
) {
let register =
room.rectangularSize?.register
?? (Int("\(room.roomName.last!)") ?? 1)
self.init(
projectID: projectID,
roomID: room.roomID,
rectangularSizeID: room.rectangularSize?.id,
register: register,
height: room.rectangularSize?.height,
dismiss: dismiss
)
}
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .ductSizing(.index)))
)
.appendingPath("room")
.appendingPath(roomID)
.appendingPath(room.roomID)
}
var body: some HTML {
ModalForm(id: Self.id(roomID), dismiss: dismiss) {
var rowID: String {
DuctSizingView.RoomRow.id(room)
}
var height: Int? {
room.ductSize.height
}
var body: some HTML<HTMLTag.dialog> {
ModalForm(id: id, dismiss: dismiss) {
h1(.class("text-lg pb-6")) { "Rectangular Size" }
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("closest tr"),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(register))
input(.class("hidden"), .name("id"), .value(rectangularSizeID))
input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.ductSize.rectangularID))
LabeledInput(
"Height",

View File

@@ -0,0 +1,167 @@
import Elementary
import ElementaryHTMX
import Foundation
import ManualDCore
import Styleguide
extension DuctSizingView {
// TODO: Remove register ID.
struct RoomsTable: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Name" }
th { "BTU" }
th { "CFM" }
th { "Velocity" }
th(.class("w-[330px]")) { "Size" }
}
}
tbody {
for room in rooms {
RoomRow(room: room)
}
}
}
}
}
struct RoomRow: HTML, Sendable {
static func id(_ room: DuctSizes.RoomContainer) -> String {
"roomRow_\(room.roomName.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let room: DuctSizes.RoomContainer
let formID = UUID().idString
var deleteRoute: String {
guard let id = room.rectangularID else { return "" }
return SiteRoute.View.router.path(
for: .project(
.detail(
projectID,
.ductSizing(
.deleteRectangularSize(
room.roomID,
.init(rectangularSizeID: id, register: room.roomRegister)
))
)
)
)
}
var rowID: String { Self.id(room) }
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg"), .id(rowID)) {
td { room.roomName }
td {
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
span(.class("label")) { "Heating" }
Number(room.heatingLoad, digits: 0)
span(.class("label")) { "Cooling" }
Number(room.coolingLoad, digits: 0)
}
}
td {
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
span(.class("label")) { "Design" }
div(.class("flex justify-center")) {
Badge(number: room.ductSize.designCFM.value, digits: 0)
}
span(.class("label")) { "Heating" }
div(.class("flex justify-center")) {
Number(room.heatingCFM, digits: 0)
}
span(.class("label")) { "Cooling" }
div(.class("flex justify-center")) {
Number(room.coolingCFM, digits: 0)
}
}
}
td { Number(room.velocity) }
td {
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: room.ductSize.roundSize, digits: 2)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: room.ductSize.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: room.ductSize.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = room.ductSize.width,
let height = room.ductSize.height
{
Badge {
span { "\(width) x \(height)" }
}
.attributes(.class("badge-info"))
}
}
div(.class("flex justify-end")) {
div(.class("join")) {
if room.ductSize.width != nil {
Tooltip("Delete Size", position: .bottom) {
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(deleteRoute),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML),
when: room.ductSize.width != nil
)
}
}
Tooltip("Edit Size", position: .bottom) {
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RectangularSizeForm.id(room))
)
}
}
}
RectangularSizeForm(room: room)
}
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct TrunkSizeForm: HTML, Sendable {
static func id(_ trunk: DuctSizes.TrunkContainer? = nil) -> String {
let base = "trunkSizeForm"
guard let trunk else { return base }
return "\(base)_\(trunk.id.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let container: DuctSizes.TrunkContainer?
let rooms: [DuctSizes.RoomContainer]
let dismiss: Bool
var trunk: TrunkSize? {
container?.trunk
}
init(
trunk: DuctSizes.TrunkContainer? = nil,
rooms: [DuctSizes.RoomContainer],
dismiss: Bool = true
) {
self.container = trunk
self.rooms = rooms
self.dismiss = dismiss
}
var route: String {
SiteRoute.View.router
.path(for: .project(.detail(projectID, .ductSizing(.index))))
.appendingPath(SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkRoute.rootPath)
.appendingPath(trunk?.id)
}
var body: some HTML {
ModalForm(id: Self.id(container), dismiss: dismiss) {
h1(.class("text-lg font-bold mb-4")) { "Trunk / Runout Size" }
form(
.class("space-y-4"),
trunk == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value(projectID))
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type")) {
for type in TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized }
.attributes(.selected, when: trunk?.type == type)
}
}
}
LabeledInput(
"Height",
.type(.text),
.name("height"),
.value(trunk?.height),
.placeholder("8 (Optional)"),
)
}
LabeledInput(
"Name",
.type(.text),
.name("name"),
.value(trunk?.name),
.placeholder("Trunk-1 (Optional)")
)
div {
h2(.class("label font-bold col-span-3 mb-6")) { "Associated Supply Runs" }
div(
.class(
"""
grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4
"""
)
) {
for room in rooms {
div(.class("block grow")) {
div(.class("grid grid-cols-1 space-y-1")) {
div(.class("flex justify-center")) {
p(.class("label")) { room.roomName }
}
div(.class("flex justify-center")) {
input(
.class("checkbox"),
.type(.checkbox),
.name("rooms"),
.value("\(room.roomID)_\(room.roomRegister)")
)
.attributes(
.checked,
when: trunk == nil ? false : trunk!.rooms.hasRoom(room)
)
}
}
}
}
}
}
SubmitButton()
.attributes(.class("btn-block mt-6"))
}
}
}
}
extension Array where Element == TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizes.RoomContainer) -> Bool {
first {
$0.id == room.roomID
&& $0.registers.contains(room.roomRegister)
} != nil
}
}

View File

@@ -0,0 +1,152 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
extension DuctSizingView {
struct TrunkTable: HTML, Sendable {
let ductSizes: DuctSizes
private var sortedTrunks: [DuctSizes.TrunkContainer] {
ductSizes.trunks
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
}
var body: some HTML {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Name / Type" }
th { "Associated Supplies" }
th { "Dsn CFM" }
th { "Velocity" }
th(.class("w-[330px]")) { "Size" }
}
}
tbody {
for trunk in sortedTrunks {
TrunkRow(trunk: trunk, rooms: ductSizes.rooms)
}
}
}
}
}
struct TrunkRow: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let trunk: DuctSizes.TrunkContainer
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.tr> {
tr {
td {
div(.class("grid grid-cols-1 space-y-2")) {
if let name = trunk.name {
p(.class("w-fit")) { name }
}
Badge {
trunk.trunk.type.rawValue
}
.attributes(.class("badge-info"), when: trunk.type == .supply)
.attributes(.class("badge-error"), when: trunk.type == .return)
}
}
td {
div(.class("flex flex-wrap space-x-2 space-y-2")) {
for id in registerIDS {
Badge { id }
}
}
}
td {
Number(trunk.designCFM.value, digits: 0)
}
td {
Number(trunk.velocity)
}
td {
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: trunk.roundSize, digits: 1)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: trunk.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: trunk.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = trunk.width,
let height = trunk.ductSize.height
{
Badge {
span { "\(width) x \(height)" }
}
.attributes(.class("badge-info"))
}
}
div(.class("flex justify-end items-end")) {
div(.class("join")) {
if trunk.width != nil {
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(route: deleteRoute),
.hx.target("closest tr"),
.hx.swap(.outerHTML)
)
}
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: TrunkSizeForm.id(trunk))
)
}
}
}
TrunkSizeForm(trunk: trunk, rooms: rooms, dismiss: true)
}
}
}
private var deleteRoute: SiteRoute.View {
.project(.detail(projectID, .ductSizing(.trunk(.delete(trunk.id)))))
}
private var registerIDS: [String] {
trunk.registerIDS(rooms: rooms)
// trunk.rooms.reduce(into: []) { array, room in
// array = room.registers.reduce(into: array) { array, register in
// if let room =
// rooms
// .first(where: { $0.roomID == room.id && $0.roomRegister == register })
// {
// array.append(room.roomName)
// }
// }
// }
// .sorted()
}
}
}

View File

@@ -200,6 +200,14 @@ struct EffectiveLengthForm: HTML, Sendable {
}
}
a(
.href("/files/ManD.Groups.pdf"),
.target(.blank),
.class("btn btn-link")
) {
"Click here for Manual-D groups reference."
}
div(.id("groups"), .class("space-y-4")) {
if let effectiveLength {
for group in effectiveLength.groups {
@@ -323,7 +331,7 @@ struct GroupTypeSelect: HTML, Sendable {
let selected: EffectiveLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> {
label(.class("select")) {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases {
@@ -346,7 +354,7 @@ extension EffectiveLength.EffectiveLengthType {
case .return:
return [5, 6, 7, 8, 10, 11, 12]
case .supply:
return [1, 2, 4, 8, 9, 11, 12]
return [1, 2, 3, 4, 8, 9, 11, 12]
}
}
}

View File

@@ -0,0 +1,139 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
private var sortedLengths: [EffectiveLength] {
effectiveLengths.sorted {
$0.totalEquivalentLength > $1.totalEquivalentLength
}
.sorted {
$0.type.rawValue > $1.type.rawValue
}
}
var body: some HTML<HTMLTag.table> {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Type" }
th { "Name" }
th { "Straight Lengths" }
th {
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
div(.class("flex justify-center col-span-3")) {
"Groups"
}
div { "Group" }
div(.class("flex justify-center")) {
"T.E.L."
}
div(.class("flex justify-end")) {
"Quantity"
}
}
}
th {
div(.class("flex justify-end me-[140px]")) {
"T.E.L."
}
}
}
}
tbody {
for row in sortedLengths {
EffectiveLenghtRow(effectiveLength: row)
}
}
}
}
struct EffectiveLenghtRow: HTML, Sendable {
let effectiveLength: EffectiveLength
private var deleteRoute: SiteRoute.View {
.project(
.detail(
effectiveLength.projectID,
.equivalentLength(.delete(id: effectiveLength.id))
)
)
}
var body: some HTML<HTMLTag.tr> {
tr(.id(effectiveLength.id.idString)) {
td {
// Type
Badge {
span { effectiveLength.type.rawValue }
}
.attributes(.class("badge-info"), when: effectiveLength.type == .supply)
.attributes(.class("badge-error"), when: effectiveLength.type == .return)
}
td { effectiveLength.name }
td {
// Lengths
div(.class("grid grid-cols-1 gap-2")) {
for length in effectiveLength.straightLengths {
Number(length)
}
}
}
td {
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
for group in effectiveLength.groups {
span { "\(group.group)-\(group.letter)" }
div(.class("flex justify-center")) {
Number(group.value)
}
div(.class("flex justify-end")) {
Number(group.quantity)
}
}
}
}
td {
// Total
// Row {
div(.class("flex justify-end mx-auto space-x-4")) {
Badge(number: effectiveLength.totalEquivalentLength, digits: 0)
.attributes(.class("badge-primary badge-lg pt-2"))
// Buttons
div(.class("flex justify-end -mt-2")) {
div(.class("join")) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(route: deleteRoute),
.hx.confirm("Are you sure?"),
.hx.target("#\(effectiveLength.id.idString)"),
.hx.swap(.outerHTML)
)
.tooltip("Delete", position: .bottom)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: EffectiveLengthForm.id(effectiveLength))
)
.tooltip("Edit", position: .bottom)
}
}
}
EffectiveLengthForm(effectiveLength: effectiveLength)
}
}
}
}
}

View File

@@ -21,136 +21,21 @@ struct EffectiveLengthsView: HTML, Sendable {
var body: some HTML {
div(.class("space-y-4")) {
Row {
PageTitleRow {
PageTitle { "Equivalent Lengths" }
PlusButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.showModal(id: EffectiveLengthForm.id(nil))
)
.tooltip("Add equivalent length")
}
.attributes(.class("pb-6"))
EffectiveLengthForm(projectID: projectID, dismiss: true)
div {
h2(.class("text-xl font-bold pb-4")) { "Supplies" }
.attributes(.class("hidden"), when: supplies.count == 0)
EffectiveLengthsTable(effectiveLengths: effectiveLengths)
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) {
for row in supplies {
EffectiveLengthView(effectiveLength: row)
}
}
}
div {
h2(.class("text-xl font-bold pb-4")) { "Returns" }
.attributes(.class("hidden"), when: returns.count == 0)
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 space-x-4 space-y-4")) {
for row in returns {
EffectiveLengthView(effectiveLength: row)
}
}
}
}
}
private struct EffectiveLengthView: HTML, Sendable {
let effectiveLength: EffectiveLength
var straightLengthsTotal: Int {
effectiveLength.straightLengths
.reduce(into: 0) { $0 += $1 }
}
var groupsTotal: Double {
effectiveLength.groups.totalEquivalentLength
}
var id: String {
return "effectiveLenghtCard_\(effectiveLength.id.uuidString.replacing("-", with: ""))"
}
var body: some HTML<HTMLTag.div> {
div(
.class("card h-full bg-base-100 shadow-sm border rounded-lg"),
.id(id)
) {
div(.class("card-body text-lg")) {
Row {
h2 { effectiveLength.name }
div(
.class("space-x-4")
) {
span(.class("text-primary text-sm italic")) {
"Total"
}
Number(self.effectiveLength.totalEquivalentLength, digits: 0)
.attributes(.class("badge badge-outline badge-primary text-lg"))
}
}
.attributes(.class("card-title pb-6"))
Row {
Label { "Straight Lengths" }
ul {
for length in effectiveLength.straightLengths {
li {
Number(length)
}
}
}
}
.attributes(.class("pb-6"))
Row {
span { "Groups" }
span { "Equivalent Length" }
span { "Quantity" }
}
.attributes(.class("label font-bold border-b border-label"))
for group in effectiveLength.groups {
Row {
span { "\(group.group)-\(group.letter)" }
Number(group.value)
Number(group.quantity)
}
}
div(.class("card-actions justify-end pt-6 space-y-4 mt-auto")) {
div(.class("join")) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(
route: .project(
.detail(
effectiveLength.projectID,
.equivalentLength(.delete(id: effectiveLength.id))
)
)
),
.hx.confirm("Are you sure?"),
.hx.target("#\(id)"),
.hx.swap(.outerHTML)
)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: EffectiveLengthForm.id(effectiveLength))
)
}
}
EffectiveLengthForm(effectiveLength: effectiveLength)
}
}
}
}
}

View File

@@ -7,8 +7,9 @@ struct EquipmentInfoForm: HTML, Sendable {
static let id = "equipmentForm"
@Environment(ProjectViewValue.$projectID) var projectID
let dismiss: Bool
let projectID: Project.ID
let equipmentInfo: EquipmentInfo?
var staticPressure: String {
@@ -33,7 +34,7 @@ struct EquipmentInfoForm: HTML, Sendable {
equipmentInfo != nil
? .hx.patch(route)
: .hx.post(route),
.hx.target("#equipmentInfo"),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))

View File

@@ -12,16 +12,15 @@ struct EquipmentInfoView: HTML, Sendable {
.id("equipmentInfo")
) {
Row {
PageTitle { "Equipment Info" }
PageTitleRow {
PageTitle { "Equipment Details" }
Tooltip("Edit equipment info") {
EditButton()
.attributes(
.class("btn-ghost"),
.showModal(id: EquipmentInfoForm.id)
)
}
EditButton()
.attributes(
.class("btn-primary"),
.showModal(id: EquipmentInfoForm.id)
)
.tooltip("Edit equipment details")
}
if let equipmentInfo {
@@ -56,7 +55,8 @@ struct EquipmentInfoView: HTML, Sendable {
}
}
EquipmentInfoForm(
dismiss: true, projectID: projectID, equipmentInfo: equipmentInfo
dismiss: equipmentInfo != nil,
equipmentInfo: equipmentInfo
)
}
}

View File

@@ -3,99 +3,142 @@ import ManualDClient
import ManualDCore
import Styleguide
// FIX: Need to update available static, etc. when equipment info is submitted.
struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer
let frictionRateResponse: ManualDClient.FrictionRateResponse?
let frictionRate: FrictionRate?
var availableStaticPressure: Double? {
frictionRateResponse?.availableStaticPressure
private var availableStaticPressure: Double? {
frictionRate?.availableStaticPressure
}
var frictionRateDesignValue: Double? {
frictionRateResponse?.frictionRate
private var shouldShowBadges: Bool {
frictionRate != nil
}
var badgeColor: String {
let base = "badge-primary"
guard let frictionRateDesignValue else { return base }
if frictionRateDesignValue >= 0.18 || frictionRateDesignValue <= 0.02 {
private var badgeColor: String {
let base = "badge-info"
guard let frictionRate = frictionRate?.value else { return base }
if frictionRate >= 0.18 || frictionRate <= 0.02 {
return "badge-error"
}
return base
}
var showHighErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue >= 0.18
private var showHighErrors: Bool {
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate >= 0.18
}
var showLowErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue <= 0.02
private var showLowErrors: Bool {
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate <= 0.02
}
private var showNoComponentLossesError: Bool {
componentLosses.count == 0
}
private var showIncompleteSectionsError: Bool {
availableStaticPressure == nil || frictionRate?.value == nil
}
private var hasAlerts: Bool {
showLowErrors
|| showHighErrors
|| showNoComponentLossesError
|| showIncompleteSectionsError
}
var body: some HTML {
div(.class("space-y-6")) {
div(.class("grid grid-cols-2 px-4")) {
PageTitleRow {
div(.class("grid grid-cols-2 px-4 w-full")) {
PageTitle { "Friction Rate" }
PageTitle { "Friction Rate" }
div(.class("space-y-4 justify-end")) {
div(.class("space-y-2 justify-end font-bold text-lg")) {
if shouldShowBadges {
if let frictionRateDesignValue {
LabeledContent("Friction Rate Design Value") {
Badge(number: frictionRateDesignValue, digits: 2)
.attributes(.class("\(badgeColor)"))
if let frictionRate = frictionRate?.value {
LabeledContent {
span { "Friction Rate Design Value" }
} content: {
Badge(number: frictionRate, digits: 2)
.attributes(.class("\(badgeColor) badge-lg"))
.bold()
}
.attributes(.class("justify-end mx-auto"))
}
if let availableStaticPressure {
LabeledContent {
span { "Available Static Pressure" }
} content: {
Badge(number: availableStaticPressure, digits: 2)
}
.attributes(.class("justify-end mx-auto"))
}
LabeledContent {
span { "Component Pressure Losses" }
} content: {
Badge(number: componentLosses.total, digits: 2)
}
.attributes(.class("justify-end mx-auto"))
}
.attributes(.class("justify-end"))
}
if let availableStaticPressure {
LabeledContent("Available Static Pressure") {
Badge(number: availableStaticPressure, digits: 2)
div(.class("text-error font-bold italic col-span-2")) {
Alert {
p {
"Must complete previous sections."
}
}
.attributes(.class("justify-end"))
.hidden(when: !showIncompleteSectionsError)
Alert {
p {
"No component pressures losses"
}
}
.hidden(when: !showNoComponentLossesError)
Alert {
p(.class("block")) {
"Calculated friction rate is below 0.02. The fan may not deliver the required CFM."
br()
" * Increase the blower speed"
br()
" * Increase the blower size"
br()
" * Decrease the Total Effective Length (TEL)"
}
}
.hidden(when: !showLowErrors)
Alert {
p(.class("block")) {
"Calculated friction rate is above 0.18. The fan may deliver too many CFM."
br()
" * Decrease the blower speed"
br()
" * Decreae the blower size"
br()
" * Increase the Total Effective Length (TEL)"
}
}
.hidden(when: !showHighErrors)
}
.attributes(.class("mt-4"), when: hasAlerts)
}
}
div(.class("text-error italic")) {
p {
"No component pressures losses"
}
.attributes(.class("hidden"), when: componentLosses.totalComponentPressureLoss > 0)
p {
"Calculated friction rate is below 0.02. The fan may not deliver the required CFM."
br()
" * Increase the blower speed"
br()
" * Increase the blower size"
br()
" * Decrease the Total Effective Length (TEL)"
}
.attributes(.class("hidden"), when: !showLowErrors)
p {
"Calculated friction rate is above 0.18. The fan may deliver too many CFM."
br()
" * Decrease the blower speed"
br()
" * Decreae the blower size"
br()
" * Increase the Total Effective Length (TEL)"
}
.attributes(.class("hidden"), when: !showHighErrors)
}
div(.class("divider")) {}
ComponentPressureLossesView(
componentPressureLosses: componentLosses, projectID: projectID
)

View File

@@ -1,5 +1,6 @@
import Elementary
import ElementaryHTMX
import Foundation
import ManualDCore
import Styleguide
@@ -10,21 +11,49 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
let inner: Inner
let theme: Theme?
let displayFooter: Bool
init(
displayFooter: Bool = true,
theme: Theme? = nil,
_ inner: () -> Inner
) {
self.displayFooter = displayFooter
self.theme = theme
self.inner = inner()
}
private var summary: String {
"""
Duct sizing based on ACCA, Manual-D.
"""
}
private var keywords: String {
"""
duct, hvac, duct-design, duct design, manual-d, manual d, design
"""
}
public var head: some HTML {
meta(.charset(.utf8))
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
meta(.content("ductcalc.com"), .name("og:site_name"))
meta(.content("Duct Calc"), .name("og:title"))
meta(.content(summary), .name("description"))
meta(.content(summary), .name("og:description"))
meta(.content("/images/mand_logo.png"), .name("og:image"))
meta(.content("/images/mand_logo.png"), .name("twitter:image"))
meta(.content("Duct Calc"), .name("twitter:image:alt"))
meta(.content("summary_large_image"), .name("twitter:card"))
meta(.content("1536"), .name("og:image:width"))
meta(.content("1024"), .name("og:image:height"))
meta(.content(keywords), .name(.keywords))
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
script(.src("/js/htmx-download.js")) {}
script(.src("/js/main.js")) {}
link(.rel(.stylesheet), .href("/css/output.css"))
link(.rel(.stylesheet), .href("/css/htmx.css"))
link(
.rel(.icon),
.href("/images/favicon.ico"),
@@ -54,8 +83,29 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
}
public var body: some HTML {
div(.class("h-screen w-full")) {
inner
div(.class("flex flex-col min-h-screen min-w-full justify-between")) {
main(.class("flex flex-col min-h-screen min-w-full grow mb-auto")) {
inner
}
div(.class("bottom-0 left-0 bg-error")) {
if displayFooter {
footer(
.class(
"""
footer sm:footer-horizontal footer-center
bg-base-300 text-base-content p-4
"""
)
) {
aside {
p {
"Copyright © \(Date().description.prefix(4)) - All rights reserved by Michael Housh"
}
}
}
}
}
}
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
}

View File

@@ -24,31 +24,28 @@ struct Navbar: HTML, Sendable {
) {
div(.class("flex flex-1 space-x-4 items-center")) {
if sidebarToggle {
Tooltip("Open sidebar", position: .right) {
label(
.for("my-drawer-1"),
.class("size-7"),
.init(name: "aria-label", value: "open sidebar")
) {
SVG(.sidebarToggle)
}
.navButton()
}
}
Tooltip("Home", position: .right) {
a(
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
.href(route: .project(.index))
label(
.for("my-drawer-1"),
.class("size-7"),
.init(name: "aria-label", value: "open sidebar")
) {
img(
.src("/images/mand_logo_sm.webp"),
)
span { "Duct Calc" }
SVG(.sidebarToggle)
}
.navButton()
.tooltip("Open sidebar", position: .right)
}
a(
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
.href(route: .project(.index))
) {
img(
.src("/images/mand_logo_sm.webp"),
)
span { "Duct Calc" }
}
.navButton()
.tooltip("Home", position: .right)
}
if userProfile {
// TODO: Make dropdown
@@ -59,34 +56,7 @@ struct Navbar: HTML, Sendable {
SVG(.circleUser)
}
.navButton()
// details(.class("dropdown dropdown-left dropdown-bottom")) {
// summary(.class("btn w-fit px-4 py-2")) {
// SVG(.circleUser)
// }
// .navButton()
//
// ul(
// .class(
// """
// menu dropdown-content bg-base-100
// rounded-box z-1 w-fit p-2 shadow-sm
// """
// )
// ) {
// li(.class("w-full")) {
// // TODO: Save theme to user profile ??
// div(.class("flex justify-between p-4 space-x-6")) {
// Label("Theme")
// input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
// }
// }
// }
//
// // button(.class("w-fit px-4 py-2")) {
// // SVG(.circleUser)
// // }
// // .navButton()
// }
.tooltip("Profile")
}
}
}

View File

@@ -8,56 +8,56 @@ struct ProjectDetail: HTML, Sendable {
var body: some HTML {
div {
Row {
h1(.class("text-3xl font-bold")) { "Project" }
PageTitleRow {
PageTitle { "Project" }
EditButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.on(.click, "projectForm.showModal()")
)
.tooltip("Edit project", position: .left)
}
div(.class("overflow-x-auto")) {
table(.class("table table-zebra text-lg")) {
tbody {
tr {
td(.class("label font-bold")) { "Name" }
td {
div(.class("flex justify-end")) {
project.name
}
table(.class("table table-zebra text-lg")) {
tbody {
tr {
td(.class("label font-bold")) { "Name" }
td {
div(.class("flex justify-end")) {
project.name
}
}
tr {
td(.class("label font-bold")) { "Street Address" }
td {
div(.class("flex justify-end")) {
project.streetAddress
}
}
tr {
td(.class("label font-bold")) { "Street Address" }
td {
div(.class("flex justify-end")) {
project.streetAddress
}
}
tr {
td(.class("label font-bold")) { "City" }
td {
div(.class("flex justify-end")) {
project.city
}
}
tr {
td(.class("label font-bold")) { "City" }
td {
div(.class("flex justify-end")) {
project.city
}
}
tr {
td(.class("label font-bold")) { "State" }
td {
div(.class("flex justify-end")) {
project.state
}
}
tr {
td(.class("label font-bold")) { "State" }
td {
div(.class("flex justify-end")) {
project.state
}
}
tr {
td(.class("label font-bold")) { "Zip" }
td {
div(.class("flex justify-end")) {
project.zipCode
}
}
tr {
td(.class("label font-bold")) { "Zip" }
td {
div(.class("flex justify-end")) {
project.zipCode
}
}
}

View File

@@ -31,25 +31,22 @@ struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
}
var body: some HTML {
div(.class("h-screen w-full")) {
div(.class("drawer lg:drawer-open h-full")) {
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
div(.class("drawer lg:drawer-open")) {
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
div(.class("drawer-content")) {
Navbar(sidebarToggle: true)
div(.class("p-4")) {
inner
.environment(ProjectViewValue.$projectID, projectID)
}
div(.class("drawer-content overflow-auto")) {
Navbar(sidebarToggle: true)
div(.class("p-4")) {
inner
.environment(ProjectViewValue.$projectID, projectID)
}
Sidebar(
active: activeTab,
projectID: projectID,
completedSteps: completedSteps
)
}
Sidebar(
active: activeTab,
projectID: projectID,
completedSteps: completedSteps
)
}
}
@@ -65,7 +62,7 @@ extension ProjectView {
var body: some HTML {
div(.class("drawer-side is-drawer-close:overflow-visible")) {
div(.class("drawer-side is-drawer-close:overflow-visible grow")) {
label(
.for("my-drawer-1"), .init(name: "aria-label", value: "close sidebar"),
.class("drawer-overlay")
@@ -74,14 +71,13 @@ extension ProjectView {
div(
.class(
"""
flex min-h-full flex-col items-start bg-base-300 text-base-content
flex grow h-full flex-col items-start bg-base-300 text-base-content
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]
"""
)
) {
ul(.class("w-full")) {
ul(.class("w-full grow")) {
li(.class("flex w-full")) {
row(
@@ -238,7 +234,7 @@ extension ManualDClient {
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
) async throws -> FrictionRateResponse? {
) async throws -> FrictionRate? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}

View File

@@ -19,36 +19,34 @@ struct ProjectsTable: HTML, Sendable {
div {
Navbar(sidebarToggle: false)
div(.class("m-6")) {
Row {
PageTitleRow {
PageTitle { "Projects" }
Tooltip("Add project") {
PlusButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.showModal(id: ProjectForm.id)
)
}
}
.attributes(.class("pb-6"))
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
Rows(projects: projects)
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
Rows(projects: projects)
}
}
ProjectForm(dismiss: true)
}
ProjectForm(dismiss: true)
}
}
}

View File

@@ -13,108 +13,105 @@ struct RoomsView: HTML, Sendable {
var body: some HTML {
div(.class("flex w-full flex-col")) {
Row {
PageTitle { "Room Loads" }
PageTitleRow {
div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
div(.class("flex justify-end items-end -my-2")) {
Tooltip("Project wide sensible heat ratio", position: .left) {
button(
.class(
"""
justify-end items-end p-4
hover:bg-neutral hover:text-white hover:rounded-lg
"""
),
.showModal(id: SHRForm.id)
) {
LabeledContent {
div(.class("flex justify-end items-end space-x-4")) {
Label {
div(.class("col-span-2")) {
PageTitle { "Room Loads" }
}
div(.class("flex justify-end grow")) {
Tooltip("Set sensible heat ratio", position: .left) {
button(
.class(
"""
btn btn-primary text-lg font-bold py-2
"""
),
.showModal(id: SHRForm.id)
) {
div(.class("flex grow justify-end items-end space-x-4")) {
span {
"Sensible Heat Ratio"
}
.attributes(.class("me-8"), when: sensibleHeatRatio == nil)
}
} content: {
if let sensibleHeatRatio {
Badge(number: sensibleHeatRatio)
} else {
SVG(.squarePen)
}
}
}
.attributes(.class("border rounded-lg border-error"), when: sensibleHeatRatio == nil)
}
}
}
div(.class("flex flex-wrap justify-between mt-6")) {
div(.class("flex items-end space-x-4")) {
Label { "Heating Total" }
Badge(number: rooms.heatingTotal, digits: 0)
.attributes(.class("badge-error"))
}
div(.class("flex items-end space-x-4")) {
Label { "Cooling Total" }
Badge(number: rooms.coolingTotal, digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex justify-end items-end space-x-4 me-4")) {
Label { "Cooling Sensible" }
Badge(number: rooms.coolingSensible(shr: sensibleHeatRatio), digits: 0)
.attributes(.class("badge-info"))
}
}
// .attributes(.class("mt-6 me-4"))
div(.class("divider")) {}
SHRForm(projectID: projectID, sensibleHeatRatio: sensibleHeatRatio)
div(.class("overflow-x-auto")) {
table(.class("table table-zebra text-lg"), .id("roomsTable")) {
thead {
tr(.class("text-lg font-bold")) {
th { "Name" }
th {
div(.class("flex justify-center")) {
"Heating Load"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Total"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Sensible"
}
}
th {
div(.class("flex justify-center")) {
"Register Count"
}
}
th {
div(.class("flex justify-end me-2")) {
Tooltip("Add Room") {
PlusButton()
.attributes(
.class("btn-ghost mx-auto"),
.showModal(id: RoomForm.id())
)
.attributes(.class("tooltip-left"))
if let sensibleHeatRatio {
Badge(number: sensibleHeatRatio)
} else {
Badge { "set" }
}
}
}
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
.attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil)
}
div(.class("flex items-end space-x-4 font-bold")) {
span(.class("text-lg")) { "Heating Total" }
Badge(number: rooms.totalHeatingLoad, digits: 0)
.attributes(.class("badge-error"))
}
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Total" }
Badge(number: rooms.totalCoolingLoad, digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Sensible" }
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-info"))
}
}
}
SHRForm(
sensibleHeatRatio: sensibleHeatRatio,
dismiss: true
)
table(.class("table table-zebra text-lg"), .id("roomsTable")) {
thead {
tr(.class("text-lg font-bold")) {
th { "Name" }
th {
div(.class("flex justify-center")) {
"Heating Load"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Total"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Sensible"
}
}
th {
div(.class("flex justify-center")) {
"Register Count"
}
}
th {
div(.class("flex justify-end me-2")) {
Tooltip("Add Room") {
PlusButton()
.attributes(
.class("btn-primary mx-auto"),
.showModal(id: RoomForm.id())
)
.attributes(.class("tooltip-left"))
}
}
}
}
tbody {
for room in rooms {
RoomRow(room: room, shr: sensibleHeatRatio)
}
}
tbody {
for room in rooms {
RoomRow(room: room, shr: sensibleHeatRatio)
}
}
}
@@ -201,8 +198,9 @@ struct RoomsView: HTML, Sendable {
struct SHRForm: HTML, Sendable {
static let id = "shrForm"
let projectID: Project.ID
@Environment(ProjectViewValue.$projectID) var projectID
let sensibleHeatRatio: Double?
let dismiss: Bool
var route: String {
SiteRoute.View.router
@@ -211,7 +209,7 @@ struct RoomsView: HTML, Sendable {
}
var body: some HTML {
ModalForm(id: Self.id, dismiss: true) {
ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-xl font-bold mb-6")) {
"Sensible Heat Ratio"
}
@@ -240,22 +238,3 @@ struct RoomsView: HTML, Sendable {
}
}
}
extension Array where Element == Room {
var heatingTotal: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
var coolingTotal: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
func coolingSensible(shr: Double?) -> Double {
let shr = shr ?? 1.0
return reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
}
}
}

View File

@@ -2,9 +2,30 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import Styleguide
struct TestPage: HTML, Sendable {
// let ductSizes: DuctSizes
var body: some HTML {
UserProfileForm(userID: UUID(0), profile: nil, dismiss: false)
div {}
// div(.class("overflow-auto")) {
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
//
// Row {
// h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
//
// PlusButton()
// .attributes(
// .class("me-6"),
// .showModal(id: TrunkSizeForm.id())
// )
// }
// .attributes(.class("mt-6"))
//
// div(.class("divider -mt-2")) {}
//
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
// }
}
}

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